Заявление в ЭДО с брендированной шапкой: логотип компании вшит в DOCX (у компаний без логотипа — без шапки)
Tests / frontend-test (pull_request) Successful in 1m19s
Tests / frontend-test (pull_request) Successful in 1m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,9 @@
|
||||
- **Кто подписывает** — сотрудник, указанный в заявке (поле «Сотрудник»); ему уходит приглашение.
|
||||
**Инициатор** (`initiator_email`, письма о подписях) — исполнитель заявки, первый в фазе исполнения; нет
|
||||
исполнителя — уведомлений о подписях нет.
|
||||
- **Брендированная шапка** — логотип компании первой строкой документа (тот же, что при печати,
|
||||
`shared/letterheadLogo.ts` → `shared/orgLogos.ts`). Логотипы вшиты в бандл, поэтому у компании, для
|
||||
которой логотип есть, документ без шапки уйти не может. У компании без логотипа шапки нет — как и в печати.
|
||||
- **Документ** — заявление, собранное в браузере как DOCX (`vacationDocument.ts`, JSZip); ЭДО сам
|
||||
переводит его в PDF. Текст — рабочий вариант, финальную формулировку утверждает кадровая служба.
|
||||
- **Совместители.** Заявку сотрудник заполняет одну, а подписывает заявление по каждой компании, где
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { letterheadLogoFor } from '../../shared/letterheadLogo';
|
||||
import { getCompanies, getEmployeeOrganizations } from '../../shared/dictionary';
|
||||
import { submitSigningRequest } from '../../shared/edo/edo.service';
|
||||
import { ISigningCompany, ISkippedCompany, resolveSigningTargets } from '../../shared/edo/signingTarget';
|
||||
@@ -99,7 +100,9 @@ export async function sendVacationForSigning(
|
||||
daysCount: days,
|
||||
substituteName: substitute,
|
||||
requestCode: vacation.code || String(vacation.itemId),
|
||||
documentDate
|
||||
documentDate,
|
||||
// Брендированная шапка — тот же логотип, что при печати; у компаний без логотипа её нет.
|
||||
logo: letterheadLogoFor(company.name)
|
||||
})
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
escapeXml,
|
||||
vacationFileName
|
||||
} from './vacationDocument';
|
||||
import { letterheadLogoFor, pngSize } from '../../shared/letterheadLogo';
|
||||
|
||||
const INPUT: IVacationDocumentInput = {
|
||||
companyName: 'ТОО TANSU Construction',
|
||||
@@ -16,7 +17,8 @@ const INPUT: IVacationDocumentInput = {
|
||||
daysCount: 14,
|
||||
substituteName: 'Петров Пётр Петрович',
|
||||
requestCode: 'HR-0042',
|
||||
documentDate: '30.09.2026'
|
||||
documentDate: '30.09.2026',
|
||||
logo: letterheadLogoFor('ТОО TANSU Construction')
|
||||
};
|
||||
|
||||
/** Blob → байты через FileReader: в окружении Jest (jsdom) нет ни Response, ни blob.arrayBuffer(). */
|
||||
@@ -80,8 +82,41 @@ describe('заявление на отпуск', () => {
|
||||
const blob = await createVacationDocx(INPUT);
|
||||
const zip = await JSZip.loadAsync(await readBlob(blob));
|
||||
|
||||
expect(Object.keys(zip.files).sort()).toEqual(['[Content_Types].xml', '_rels/.rels', 'word/document.xml']);
|
||||
expect(Object.keys(zip.files).sort()).toEqual([
|
||||
'[Content_Types].xml',
|
||||
'_rels/.rels',
|
||||
'word/_rels/document.xml.rels',
|
||||
'word/document.xml',
|
||||
'word/media/logo.png'
|
||||
]);
|
||||
const document = await zip.file('word/document.xml')!.async('string');
|
||||
expect(document).toContain('ЗАЯВЛЕНИЕ');
|
||||
// Шапка — первая в документе, раньше текста.
|
||||
expect(document.indexOf('r:embed="rIdLogo"')).toBeLessThan(document.indexOf('ЗАЯВЛЕНИЕ'));
|
||||
expect(await zip.file('word/media/logo.png')!.async('base64')).toBe(INPUT.logo!.base64);
|
||||
});
|
||||
|
||||
it('одно и то же заявление собирается байт в байт одинаково — ЭДО узнаёт повтор', async () => {
|
||||
const [first, second] = await Promise.all([createVacationDocx(INPUT), createVacationDocx(INPUT)]);
|
||||
|
||||
expect(new Uint8Array(await readBlob(first))).toEqual(new Uint8Array(await readBlob(second)));
|
||||
});
|
||||
|
||||
it('у каждой компании с печатной шапкой есть логотип для документа, размеры из PNG', () => {
|
||||
for (const company of ['ТОО TANSU Construction', 'ТОО АСТЕЛ-К', 'ТОО СТ Global', 'ТОО KazPromService', 'ТОО QAZGEOMAX']) {
|
||||
const logo = letterheadLogoFor(company);
|
||||
expect(logo?.width).toBeGreaterThan(0);
|
||||
expect(logo?.height).toBe(300);
|
||||
}
|
||||
expect(pngSize(INPUT.logo!.base64)).toEqual({ width: 212, height: 300 });
|
||||
});
|
||||
|
||||
it('у компании без логотипа документ собирается без шапки', async () => {
|
||||
expect(letterheadLogoFor('ТОО Монтажник-Т')).toBeUndefined();
|
||||
|
||||
const zip = await JSZip.loadAsync(await readBlob(await createVacationDocx({ ...INPUT, logo: undefined })));
|
||||
|
||||
expect(Object.keys(zip.files).sort()).toEqual(['[Content_Types].xml', '_rels/.rels', 'word/document.xml']);
|
||||
expect(await zip.file('word/document.xml')!.async('string')).not.toContain('rIdLogo');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import JSZip from 'jszip';
|
||||
import type { ILetterheadLogo } from '../../shared/letterheadLogo';
|
||||
|
||||
/** Данные заявления на отпуск. Всё, что идёт в текст, уже подготовлено вызывающим. */
|
||||
export interface IVacationDocumentInput {
|
||||
@@ -18,6 +19,8 @@ export interface IVacationDocumentInput {
|
||||
requestCode: string;
|
||||
/** Дата составления заявления, `дд.мм.гггг`. */
|
||||
documentDate: string;
|
||||
/** Логотип компании — брендированная шапка документа; нет у компании логотипа — без шапки. */
|
||||
logo?: ILetterheadLogo;
|
||||
}
|
||||
|
||||
export const DOCX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
@@ -95,16 +98,40 @@ export function buildVacationParagraphs(input: IVacationDocumentInput): IParagra
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
/** Логотип в шапке — высота 2,5 см, ширина по пропорции картинки. EMU: 1 см = 360 000. */
|
||||
const LOGO_HEIGHT_EMU = 900000;
|
||||
const LOGO_REL_ID = 'rIdLogo';
|
||||
|
||||
function logoParagraph(logo: ILetterheadLogo): string {
|
||||
const cy = LOGO_HEIGHT_EMU;
|
||||
const cx = Math.round((cy * logo.width) / logo.height);
|
||||
return (
|
||||
'<w:p><w:pPr><w:spacing w:after="360"/><w:jc w:val="left"/></w:pPr><w:r><w:drawing>' +
|
||||
'<wp:inline distT="0" distB="0" distL="0" distR="0">' +
|
||||
`<wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="1" name="Логотип"/>` +
|
||||
'<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic>' +
|
||||
'<pic:nvPicPr><pic:cNvPr id="0" name="logo.png"/><pic:cNvPicPr/></pic:nvPicPr>' +
|
||||
`<pic:blipFill><a:blip r:embed="${LOGO_REL_ID}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>` +
|
||||
`<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm>` +
|
||||
'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>' +
|
||||
'</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>'
|
||||
);
|
||||
}
|
||||
|
||||
/** Тело `word/document.xml`. Отдельно от упаковки в zip — чтобы проверять разметку без архива. */
|
||||
export function buildDocumentXml(input: IVacationDocumentInput): string {
|
||||
const body = buildVacationParagraphs(input).map(paragraph).join('');
|
||||
const body = (input.logo ? logoParagraph(input.logo) : '') + buildVacationParagraphs(input).map(paragraph).join('');
|
||||
// A4, поля 2 см сверху/снизу/слева и 1,5 см справа — как у канцелярских бланков.
|
||||
const section =
|
||||
'<w:sectPr><w:pgSz w:w="11906" w:h="16838"/>' +
|
||||
'<w:pgMar w:top="1134" w:right="850" w:bottom="1134" w:left="1701" w:header="709" w:footer="709" w:gutter="0"/></w:sectPr>';
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
|
||||
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"' +
|
||||
' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"' +
|
||||
' xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"' +
|
||||
' xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"' +
|
||||
' xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">' +
|
||||
`<w:body>${body}${section}</w:body></w:document>`
|
||||
);
|
||||
}
|
||||
@@ -114,6 +141,7 @@ const CONTENT_TYPES_XML =
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
|
||||
'<Default Extension="xml" ContentType="application/xml"/>' +
|
||||
'<Default Extension="png" ContentType="image/png"/>' +
|
||||
'<Override PartName="/word/document.xml" ' +
|
||||
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
|
||||
'</Types>';
|
||||
@@ -129,6 +157,13 @@ const ROOT_RELS_XML =
|
||||
* Собирает заявление как DOCX. Файл строится прямо в браузере — отдельного сервиса
|
||||
* документов нет, а ЭДО сам переводит DOCX в PDF перед подписанием.
|
||||
*/
|
||||
const DOCUMENT_RELS_XML =
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
||||
`<Relationship Id="${LOGO_REL_ID}" ` +
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" ' +
|
||||
'Target="media/logo.png"/></Relationships>';
|
||||
|
||||
const FIXED_ZIP_DATE = new Date(Date.UTC(2020, 0, 1));
|
||||
|
||||
export async function createVacationDocx(input: IVacationDocumentInput): Promise<Blob> {
|
||||
@@ -141,6 +176,10 @@ export async function createVacationDocx(input: IVacationDocumentInput): Promise
|
||||
zip.file('[Content_Types].xml', CONTENT_TYPES_XML, options);
|
||||
zip.file('_rels/.rels', ROOT_RELS_XML, options);
|
||||
zip.file('word/document.xml', buildDocumentXml(input), options);
|
||||
if (input.logo) {
|
||||
zip.file('word/_rels/document.xml.rels', DOCUMENT_RELS_XML, options);
|
||||
zip.file('word/media/logo.png', input.logo.base64, { ...options, base64: true });
|
||||
}
|
||||
return zip.generateAsync({ type: 'blob', mimeType: DOCX_CONTENT_TYPE, compression: 'DEFLATE' });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ORG_LOGOS } from './orgLogos';
|
||||
|
||||
/** Логотип для шапки документа: PNG в base64 и его размеры в пикселях (по ним считается пропорция). */
|
||||
export interface ILetterheadLogo {
|
||||
base64: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Ширина и высота PNG из заголовка IHDR (байты 16–23). */
|
||||
export function pngSize(base64: string): { width: number; height: number } {
|
||||
const head = atob(base64.slice(0, 44));
|
||||
const read = (offset: number): number =>
|
||||
((head.charCodeAt(offset) << 24) |
|
||||
(head.charCodeAt(offset + 1) << 16) |
|
||||
(head.charCodeAt(offset + 2) << 8) |
|
||||
head.charCodeAt(offset + 3)) >>>
|
||||
0;
|
||||
return { width: read(16), height: read(20) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Логотип компании — тот же, что в печатной шапке (shared/ui/OrgLetterhead). Логотипы вшиты в бандл,
|
||||
* поэтому у компании, для которой он есть, шапка в документе будет всегда. Нет логотипа у компании
|
||||
* вовсе — undefined, документ уходит без шапки, как и печатается.
|
||||
*/
|
||||
export function letterheadLogoFor(company: string, logos: Record<string, string> = ORG_LOGOS): ILetterheadLogo | undefined {
|
||||
const dataUri = logos[company];
|
||||
const prefix = 'data:image/png;base64,';
|
||||
if (!dataUri?.startsWith(prefix)) return undefined;
|
||||
const base64 = dataUri.slice(prefix.length);
|
||||
return { base64, ...pngSize(base64) };
|
||||
}
|
||||
Reference in New Issue
Block a user