diff --git a/src/webparts/requestPortal/forms/Vacation/README.md b/src/webparts/requestPortal/forms/Vacation/README.md
index 8377be7..58f7694 100644
--- a/src/webparts/requestPortal/forms/Vacation/README.md
+++ b/src/webparts/requestPortal/forms/Vacation/README.md
@@ -42,6 +42,9 @@
- **Кто подписывает** — сотрудник, указанный в заявке (поле «Сотрудник»); ему уходит приглашение.
**Инициатор** (`initiator_email`, письма о подписях) — исполнитель заявки, первый в фазе исполнения; нет
исполнителя — уведомлений о подписях нет.
+- **Брендированная шапка** — логотип компании первой строкой документа (тот же, что при печати,
+ `shared/letterheadLogo.ts` → `shared/orgLogos.ts`). Логотипы вшиты в бандл, поэтому у компании, для
+ которой логотип есть, документ без шапки уйти не может. У компании без логотипа шапки нет — как и в печати.
- **Документ** — заявление, собранное в браузере как DOCX (`vacationDocument.ts`, JSZip); ЭДО сам
переводит его в PDF. Текст — рабочий вариант, финальную формулировку утверждает кадровая служба.
- **Совместители.** Заявку сотрудник заполняет одну, а подписывает заявление по каждой компании, где
diff --git a/src/webparts/requestPortal/forms/Vacation/sendToSign.ts b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts
index 325bf8b..50398a1 100644
--- a/src/webparts/requestPortal/forms/Vacation/sendToSign.ts
+++ b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts
@@ -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)
})
}))
);
diff --git a/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts b/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts
index 996d436..80b1635 100644
--- a/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts
+++ b/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts
@@ -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');
});
});
diff --git a/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts b/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts
index 4ffe580..3598f29 100644
--- a/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts
+++ b/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts
@@ -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 (
+ '' +
+ '' +
+ `` +
+ '' +
+ '' +
+ `` +
+ `` +
+ '' +
+ ''
+ );
+}
+
/** Тело `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 =
'' +
'';
return (
'' +
- '' +
+ '' +
`${body}${section}`
);
}
@@ -114,6 +141,7 @@ const CONTENT_TYPES_XML =
'' +
'' +
'' +
+ '' +
'' +
'';
@@ -129,6 +157,13 @@ const ROOT_RELS_XML =
* Собирает заявление как DOCX. Файл строится прямо в браузере — отдельного сервиса
* документов нет, а ЭДО сам переводит DOCX в PDF перед подписанием.
*/
+const DOCUMENT_RELS_XML =
+ '' +
+ '' +
+ `';
+
const FIXED_ZIP_DATE = new Date(Date.UTC(2020, 0, 1));
export async function createVacationDocx(input: IVacationDocumentInput): Promise {
@@ -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' });
}
diff --git a/src/webparts/requestPortal/shared/letterheadLogo.ts b/src/webparts/requestPortal/shared/letterheadLogo.ts
new file mode 100644
index 0000000..b9b71d8
--- /dev/null
+++ b/src/webparts/requestPortal/shared/letterheadLogo.ts
@@ -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 = 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) };
+}