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) }; +}