Заявление в ЭДО с брендированной шапкой: логотип компании вшит в DOCX (у компаний без логотипа — без шапки)
Tests / frontend-test (pull_request) Successful in 1m19s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-05 12:46:24 +05:00
parent 03f48af48e
commit 94b31b3a20
5 changed files with 118 additions and 5 deletions
@@ -42,6 +42,9 @@
- **Кто подписывает** — сотрудник, указанный в заявке (поле «Сотрудник»); ему уходит приглашение. - **Кто подписывает** — сотрудник, указанный в заявке (поле «Сотрудник»); ему уходит приглашение.
**Инициатор** (`initiator_email`, письма о подписях) — исполнитель заявки, первый в фазе исполнения; нет **Инициатор** (`initiator_email`, письма о подписях) — исполнитель заявки, первый в фазе исполнения; нет
исполнителя — уведомлений о подписях нет. исполнителя — уведомлений о подписях нет.
- **Брендированная шапка** — логотип компании первой строкой документа (тот же, что при печати,
`shared/letterheadLogo.ts` → `shared/orgLogos.ts`). Логотипы вшиты в бандл, поэтому у компании, для
которой логотип есть, документ без шапки уйти не может. У компании без логотипа шапки нет — как и в печати.
- **Документ** — заявление, собранное в браузере как DOCX (`vacationDocument.ts`, JSZip); ЭДО сам - **Документ** — заявление, собранное в браузере как DOCX (`vacationDocument.ts`, JSZip); ЭДО сам
переводит его в PDF. Текст — рабочий вариант, финальную формулировку утверждает кадровая служба. переводит его в PDF. Текст — рабочий вариант, финальную формулировку утверждает кадровая служба.
- **Совместители.** Заявку сотрудник заполняет одну, а подписывает заявление по каждой компании, где - **Совместители.** Заявку сотрудник заполняет одну, а подписывает заявление по каждой компании, где
@@ -1,4 +1,5 @@
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { letterheadLogoFor } from '../../shared/letterheadLogo';
import { getCompanies, getEmployeeOrganizations } from '../../shared/dictionary'; import { getCompanies, getEmployeeOrganizations } from '../../shared/dictionary';
import { submitSigningRequest } from '../../shared/edo/edo.service'; import { submitSigningRequest } from '../../shared/edo/edo.service';
import { ISigningCompany, ISkippedCompany, resolveSigningTargets } from '../../shared/edo/signingTarget'; import { ISigningCompany, ISkippedCompany, resolveSigningTargets } from '../../shared/edo/signingTarget';
@@ -99,7 +100,9 @@ export async function sendVacationForSigning(
daysCount: days, daysCount: days,
substituteName: substitute, substituteName: substitute,
requestCode: vacation.code || String(vacation.itemId), requestCode: vacation.code || String(vacation.itemId),
documentDate documentDate,
// Брендированная шапка — тот же логотип, что при печати; у компаний без логотипа её нет.
logo: letterheadLogoFor(company.name)
}) })
})) }))
); );
@@ -7,6 +7,7 @@ import {
escapeXml, escapeXml,
vacationFileName vacationFileName
} from './vacationDocument'; } from './vacationDocument';
import { letterheadLogoFor, pngSize } from '../../shared/letterheadLogo';
const INPUT: IVacationDocumentInput = { const INPUT: IVacationDocumentInput = {
companyName: 'ТОО TANSU Construction', companyName: 'ТОО TANSU Construction',
@@ -16,7 +17,8 @@ const INPUT: IVacationDocumentInput = {
daysCount: 14, daysCount: 14,
substituteName: 'Петров Пётр Петрович', substituteName: 'Петров Пётр Петрович',
requestCode: 'HR-0042', requestCode: 'HR-0042',
documentDate: '30.09.2026' documentDate: '30.09.2026',
logo: letterheadLogoFor('ТОО TANSU Construction')
}; };
/** Blob → байты через FileReader: в окружении Jest (jsdom) нет ни Response, ни blob.arrayBuffer(). */ /** Blob → байты через FileReader: в окружении Jest (jsdom) нет ни Response, ни blob.arrayBuffer(). */
@@ -80,8 +82,41 @@ describe('заявление на отпуск', () => {
const blob = await createVacationDocx(INPUT); const blob = await createVacationDocx(INPUT);
const zip = await JSZip.loadAsync(await readBlob(blob)); 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'); const document = await zip.file('word/document.xml')!.async('string');
expect(document).toContain('ЗАЯВЛЕНИЕ'); 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 JSZip from 'jszip';
import type { ILetterheadLogo } from '../../shared/letterheadLogo';
/** Данные заявления на отпуск. Всё, что идёт в текст, уже подготовлено вызывающим. */ /** Данные заявления на отпуск. Всё, что идёт в текст, уже подготовлено вызывающим. */
export interface IVacationDocumentInput { export interface IVacationDocumentInput {
@@ -18,6 +19,8 @@ export interface IVacationDocumentInput {
requestCode: string; requestCode: string;
/** Дата составления заявления, `дд.мм.гггг`. */ /** Дата составления заявления, `дд.мм.гггг`. */
documentDate: string; documentDate: string;
/** Логотип компании — брендированная шапка документа; нет у компании логотипа — без шапки. */
logo?: ILetterheadLogo;
} }
export const DOCX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; export const DOCX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
@@ -95,16 +98,40 @@ export function buildVacationParagraphs(input: IVacationDocumentInput): IParagra
return paragraphs; 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 — чтобы проверять разметку без архива. */ /** Тело `word/document.xml`. Отдельно от упаковки в zip — чтобы проверять разметку без архива. */
export function buildDocumentXml(input: IVacationDocumentInput): string { 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 см справа — как у канцелярских бланков. // A4, поля 2 см сверху/снизу/слева и 1,5 см справа — как у канцелярских бланков.
const section = const section =
'<w:sectPr><w:pgSz w:w="11906" w:h="16838"/>' + '<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>'; '<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 ( return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' + '<?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>` `<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">' + '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' + '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' + '<Default Extension="xml" ContentType="application/xml"/>' +
'<Default Extension="png" ContentType="image/png"/>' +
'<Override PartName="/word/document.xml" ' + '<Override PartName="/word/document.xml" ' +
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' + 'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
'</Types>'; '</Types>';
@@ -129,6 +157,13 @@ const ROOT_RELS_XML =
* Собирает заявление как DOCX. Файл строится прямо в браузере — отдельного сервиса * Собирает заявление как DOCX. Файл строится прямо в браузере — отдельного сервиса
* документов нет, а ЭДО сам переводит DOCX в PDF перед подписанием. * документов нет, а ЭДО сам переводит 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)); const FIXED_ZIP_DATE = new Date(Date.UTC(2020, 0, 1));
export async function createVacationDocx(input: IVacationDocumentInput): Promise<Blob> { 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('[Content_Types].xml', CONTENT_TYPES_XML, options);
zip.file('_rels/.rels', ROOT_RELS_XML, options); zip.file('_rels/.rels', ROOT_RELS_XML, options);
zip.file('word/document.xml', buildDocumentXml(input), 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' }); 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) };
}