diff --git a/src/webparts/requestPortal/forms/Vacation/README.md b/src/webparts/requestPortal/forms/Vacation/README.md index 7d4bd6c..e0f4c63 100644 --- a/src/webparts/requestPortal/forms/Vacation/README.md +++ b/src/webparts/requestPortal/forms/Vacation/README.md @@ -35,11 +35,18 @@ получатель, и как `initiator_email`: ему уходит приглашение, и он же получает письмо, когда документ подписан. - **Документ** — заявление, собранное в браузере как DOCX (`vacationDocument.ts`, JSZip); ЭДО сам переводит его в PDF. Текст — рабочий вариант, финальную формулировку утверждает кадровая служба. -- **ИИН** берётся из `GET /Employee/ByEmail` (поле `Iin`), **БИН компании** — из `GET /Dictionary/Companies` - по `CompanyId` сотрудника (`shared/edo/signingTarget.ts`). Если сотрудник состоит в нескольких - организациях, берётся первая, у которой в справочнике есть БИН. +- **Совместители.** Заявку сотрудник заполняет одну, а подписывает заявление по каждой компании, где + работает: в ЭДО уходит по файлу на компанию (своя шапка — компания и должность в ней), БИН файлов — в + массиве `file_company_bins`, общий `company_bin` не передаётся. Подписант один — сам сотрудник, от + имени физического лица; БИН нужны ЭДО только чтобы выбрать сертификат Sigex (mTLS) нужной компании. + Запрос один на все файлы: ЭДО принимает его целиком или отклоняет целиком (например, если у какой-то + компании нет сертификата), поэтому повтор безопасен. +- **ИИН** берётся из `GET /Employee/ByEmail` (поле `Iin`), **БИН компаний** — из `GET /Dictionary/Companies` + по `CompanyId` каждой записи сотрудника (`shared/edo/signingTarget.ts`). Компания без БИН в справочнике + пропускается и показывается в сообщении; остальные уходят. - **С каким ЭДО работать** — свойство веб-части «С каким ЭДО работать» (`edoApiBaseUrl`): стейдж или локальный бэкенд. По умолчанию стейдж. Токен берётся для того же AAD-приложения, что у approval-route. +- **Отпуск без содержания в ЭДО не подписывается** (`UnpaidLeave`): подписание подключено только к этой форме. - **Неудача отправки** показывается сообщением над панелью решения и согласование не отменяет; рядом кнопка «Повторить отправку на подпись». Признака «уже отправлено» в заявке нет, поэтому после перезагрузки страницы повтор недоступен. diff --git a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx index 31b875e..129c33c 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx @@ -27,7 +27,7 @@ import { import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { loadVacationRequest } from './load'; import { vacationApprovalService } from './submit'; -import { sendVacationForSigning } from './sendToSign'; +import { IVacationSigningResult, sendVacationForSigning } from './sendToSign'; import { RequestSection } from './sections/RequestSection'; import { useStableCallback } from '../../shared/useStableCallback'; @@ -64,8 +64,9 @@ const VacationView: React.FC = ({ context, itemId, formKey, // Какая из организаций сотрудника (см. OrganizationField) идёт в печать — // выбор в тулбаре через PrintSplitButton, по умолчанию первая. const [printOrgIndex, setPrintOrgIndex] = useState(0); - // Итог отправки согласованного заявления на подпись в ЭДО (см. sendToSigning). - const [signingInfo, setSigningInfo] = useState(''); + // Итог отправки согласованного заявления на подпись в ЭДО (см. sendToSigning): по каким + // компаниям ушло, по каким нельзя, либо ошибка всего запроса. + const [signingResult, setSigningResult] = useState(undefined); const [signingError, setSigningError] = useState(''); const [signingBusy, setSigningBusy] = useState(false); @@ -85,6 +86,7 @@ const VacationView: React.FC = ({ context, itemId, formKey, const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); + // Исполнители (фаза «Исполнение») — отдельная таблица, если они есть в маршруте. const executors = useViewExecutors(context, approval, itemId, decisionSeq); useEffect(() => { @@ -138,20 +140,20 @@ const VacationView: React.FC = ({ context, itemId, formKey, }).catch(() => undefined); }; - // Все согласовали → отправляем заявление на подпись сотруднику, на которого оформлен отпуск. - // Решение к этому моменту уже применено, поэтому неудача отправки показывается отдельным - // сообщением и согласование не отменяет. Повторить можно кнопкой, пока страница не закрыта: - // признака «уже отправлено» в заявке нет, и после перезагрузки повтор был бы дублем. + // Все согласовали → отправляем заявление на подпись сотруднику, на которого оформлен отпуск, — + // по документу на каждую его компанию, одним запросом. Решение к этому моменту уже применено, + // поэтому неудача показывается отдельным сообщением и согласование не отменяет. Повторить можно + // кнопкой: запрос принимается целиком или отклоняется целиком, а повтор уже принятого ЭДО + // отклонит как дубль — признака «уже отправлено» в заявке нет. const sendToSigning = (): Promise => { setSigningError(''); - setSigningInfo('Отправляем заявление на подпись в ЭДО…'); + setSigningResult(undefined); setSigningBusy(true); return sendVacationForSigning(context, { data, employeeName, itemId, code }) - .then(() => { - setSigningInfo(`Заявление отправлено на подпись: письмо со ссылкой ушло на ${data.Employee}.`); + .then((result) => { + setSigningResult(result); }) .catch((e) => { - setSigningInfo(''); setSigningError( `Заявка согласована, но отправить заявление на подпись не удалось: ${friendlyErrorMessage(e)}` ); @@ -195,11 +197,21 @@ const VacationView: React.FC = ({ context, itemId, formKey,
- {signingInfo && {signingInfo}} - {signingError && ( + {signingBusy && Отправляем заявление на подпись в ЭДО…} + {!signingBusy && signingResult && ( + + {`Заявление отправлено на подпись (${signingResult.sent.join(', ')}): письма со ссылкой ушли на ${data.Employee}.`} + + )} + {!signingBusy && signingResult && signingResult.skipped.length > 0 && ( + + {`Не отправлено по компаниям: ${signingResult.skipped.map((c) => `${c.name} — ${c.reason}`).join('; ')}.`} + + )} + {!signingBusy && signingError && ( <> {signingError} - diff --git a/src/webparts/requestPortal/forms/Vacation/sendToSign.ts b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts index f3b9ea2..42b8d48 100644 --- a/src/webparts/requestPortal/forms/Vacation/sendToSign.ts +++ b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts @@ -1,7 +1,7 @@ import { WebPartContext } from '@microsoft/sp-webpart-base'; import { getCompanies, getEmployeeOrganizations } from '../../shared/dictionary'; -import { IEdoSigningResult, submitSigningRequest } from '../../shared/edo/edo.service'; -import { resolveSigningTarget } from '../../shared/edo/signingTarget'; +import { submitSigningRequest } from '../../shared/edo/edo.service'; +import { ISigningCompany, ISkippedCompany, resolveSigningTargets } from '../../shared/edo/signingTarget'; import { computeEndDate, formatRuDate } from './dates'; import { IFormData } from './model'; import { createVacationDocx, vacationFileName } from './vacationDocument'; @@ -17,6 +17,14 @@ export interface IVacationToSign { itemId: number; } +/** Итог отправки: по каким компаниям документ ушёл и по каким нельзя. */ +export interface IVacationSigningResult { + /** Названия компаний, по которым заявление отправлено на подпись. */ + sent: string[]; + /** Компании сотрудника, по которым документ не собрать (нет БИН в справочнике), и почему. */ + skipped: ISkippedCompany[]; +} + /** Сегодняшняя дата `дд.мм.гггг` по местному времени — дата составления заявления. */ function todayRu(): string { const now = new Date(); @@ -37,46 +45,66 @@ async function substituteName(context: WebPartContext, email: string): Promise { +): Promise { const { data } = vacation; if (!data.Employee) { throw new Error('в заявке не указан сотрудник'); } const [records, companies] = await Promise.all([getEmployeeOrganizations(context, data.Employee), getCompanies(context)]); - const target = resolveSigningTarget(records, companies); + const targets = resolveSigningTargets(records, companies); + + if (targets.companies.length === 0) { + const reasons = targets.skipped.map((c) => `${c.name} — ${c.reason}`).join('; '); + throw new Error(`ни по одной компании сотрудника заявление не отправить: ${reasons}`); + } const days = Number(data.DaysCount); const documentDate = todayRu(); const employeeName = - [target.name.lastName, target.name.firstName, target.name.middleName].filter((p) => p).join(' ') || + [targets.name.lastName, targets.name.firstName, targets.name.middleName].filter((p) => p).join(' ') || vacation.employeeName; + const substitute = await substituteName(context, data.Substitute); - const file = await createVacationDocx({ - companyName: target.companyName, - position: data.Position, - employeeName, - startDate: formatRuDate(data.StartDate), - endDate: formatRuDate(computeEndDate(data.StartDate, days)), - daysCount: days, - substituteName: await substituteName(context, data.Substitute), - requestCode: vacation.code || String(vacation.itemId), - documentDate - }); + const files = await Promise.all( + targets.companies.map(async (company: ISigningCompany) => ({ + name: vacationFileName(employeeName, company.name, documentDate), + companyBin: company.bin, + content: await createVacationDocx({ + companyName: company.name, + // В каждой компании своя должность; нет её в справочнике — та, что в заявке. + position: company.position || data.Position, + employeeName, + startDate: formatRuDate(data.StartDate), + endDate: formatRuDate(computeEndDate(data.StartDate, days)), + daysCount: days, + substituteName: substitute, + requestCode: vacation.code || String(vacation.itemId), + documentDate + }) + })) + ); - return submitSigningRequest(context, { - recipient: { iin: target.iin, email: data.Employee, name: target.name }, - companyBin: target.companyBin, + await submitSigningRequest(context, { + recipient: { iin: targets.iin, email: data.Employee, name: targets.name }, initiatorEmail: data.Employee, - file: { name: vacationFileName(employeeName, documentDate), content: file } + files }); + + return { sent: targets.companies.map((c) => c.name), skipped: targets.skipped }; } diff --git a/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts b/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts index 7bd35c0..6b86ed3 100644 --- a/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts +++ b/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts @@ -81,8 +81,10 @@ describe('заявление на отпуск', () => { }); it('имя файла без недопустимых символов', () => { - expect(vacationFileName('Иванов И.И.', '30.09.2026')).toBe('Заявление на отпуск — Иванов И.И. — 30.09.2026.docx'); - expect(vacationFileName('A/B:C', '01.01.2026')).not.toMatch(/[\\/:*?"<>|]/); + expect(vacationFileName('Иванов И.И.', 'ТОО TANSU Construction', '30.09.2026')).toBe( + 'Заявление на отпуск — Иванов И.И. — ТОО TANSU Construction — 30.09.2026.docx' + ); + expect(vacationFileName('A/B:C', 'ТОО "X"', '01.01.2026')).not.toMatch(/[\\/:*?"<>|]/); }); it('DOCX — это zip с обязательными частями пакета', async () => { diff --git a/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts b/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts index ac64c5c..dca7833 100644 --- a/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts +++ b/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts @@ -141,9 +141,9 @@ export async function createVacationDocx(input: IVacationDocumentInput): Promise return zip.generateAsync({ type: 'blob', mimeType: DOCX_CONTENT_TYPE, compression: 'DEFLATE' }); } -/** Имя файла для ЭДО: по нему человек узнает документ в письме и кабинете. */ -export function vacationFileName(employeeName: string, documentDate: string): string { - // Недопустимые в имени файла символы заменяем, а не удаляем: ФИО остаётся читаемым. - const safeName = employeeName.replace(/[\\/:*?"<>|]/g, ' ').replace(/\s+/g, ' ').trim(); - return `Заявление на отпуск — ${safeName} — ${documentDate}.docx`; +/** Имя файла для ЭДО: по нему человек узнает документ в письме и кабинете — и отличит компании. */ +export function vacationFileName(employeeName: string, companyName: string, documentDate: string): string { + // Недопустимые в имени файла символы заменяем, а не удаляем: ФИО и компания остаются читаемыми. + const safe = (text: string): string => text.replace(/[\\/:*?"<>|]/g, ' ').replace(/\s+/g, ' ').trim(); + return `Заявление на отпуск — ${safe(employeeName)} — ${safe(companyName)} — ${documentDate}.docx`; } diff --git a/src/webparts/requestPortal/shared/edo/edo.service.ts b/src/webparts/requestPortal/shared/edo/edo.service.ts index 3020dde..2fc4d0f 100644 --- a/src/webparts/requestPortal/shared/edo/edo.service.ts +++ b/src/webparts/requestPortal/shared/edo/edo.service.ts @@ -7,15 +7,21 @@ import { IFioParts } from './fio'; /** Путь приёма документов на подпись от интеграций (TANSU ЭДО, multipart/form-data). */ const SIGNING_REQUESTS_PATH = '/api/v1/integration/signing-requests'; -/** Что отправляем в ЭДО: одного получателя, он же подписант, и один файл. */ +/** Файл на подпись и компания, от имени которой он идёт (по её сертификату Sigex в ЭДО). */ +export interface IEdoSigningFile { + name: string; + content: Blob; + /** БИН компании файла. У сотрудника-совместителя файлы одного запроса идут от разных компаний. */ + companyBin: string; +} + +/** Что отправляем в ЭДО: одного получателя, он же подписант (физлицо), и файлы. */ export interface IEdoSigningRequest { /** Подписывающий: ЭДО заводит его по ИИН, если нет, и зовёт подписать письмом. */ recipient: { iin: string; email: string; name: IFioParts }; - /** БИН компании, от имени которой документ идёт на подпись (нужен сертификат Sigex этой компании в ЭДО). */ - companyBin: string; /** Кому сообщать о подписи. */ initiatorEmail: string; - file: { name: string; content: Blob }; + files: IEdoSigningFile[]; } /** Ответ ЭДО: то, что нужно показать пользователю. */ @@ -32,7 +38,7 @@ interface IEdoResponseBody { } /** - * Отправляет документ на подпись в ЭДО. + * Отправляет документы на подпись в ЭДО одним запросом: либо принимаются все, либо ни один. * * Токен берётся для приложения ЭДО текущего пользователя (AadHttpClient), поэтому в ЭДО * запрос приходит от того, кто нажал «Согласовать», — он заводится там автоматически при @@ -52,9 +58,12 @@ export async function submitSigningRequest( form.append('last_name', request.recipient.name.lastName); form.append('first_name', request.recipient.name.firstName); if (request.recipient.name.middleName) form.append('middle_name', request.recipient.name.middleName); - form.append('company_bin', request.companyBin); form.append('initiator_email', request.initiatorEmail); - form.append('files', request.file.content, request.file.name); + // БИН файлов — массивом, по порядку файлов: общий company_bin не нужен, подписант один (физлицо). + for (const file of request.files) { + form.append('files', file.content, file.name); + form.append('file_company_bins', file.companyBin); + } let response: HttpClientResponse; try { diff --git a/src/webparts/requestPortal/shared/edo/edo.test.ts b/src/webparts/requestPortal/shared/edo/edo.test.ts index 09c6d3e..4174a45 100644 --- a/src/webparts/requestPortal/shared/edo/edo.test.ts +++ b/src/webparts/requestPortal/shared/edo/edo.test.ts @@ -2,7 +2,7 @@ import { ICompany } from '../dictionary/models/ICompany'; import { IEmployeeOrganization } from '../dictionary/models/IEmployeeOrganization'; import { describeEdoError } from './edoErrors'; import { splitFio } from './fio'; -import { resolveSigningTarget } from './signingTarget'; +import { resolveSigningTargets } from './signingTarget'; const TANSU_ID = '9de064a4-44c8-88ef-11ec-8284930fad96'; @@ -50,53 +50,80 @@ describe('Ф.И.О. по частям для ЭДО', () => { }); }); -describe('кому и от какой компании отправлять на подпись', () => { - it('берёт ИИН из справочника сотрудников и БИН из справочника компаний', () => { - const target = resolveSigningTarget([record()], COMPANIES); +const STROY_ID = '0a1b2c3d-0000-4000-8000-000000000003'; +const NO_BIN_ID = '0a1b2c3d-0000-4000-8000-000000000002'; - expect(target.iin).toBe('950515123452'); - expect(target.companyBin).toBe('021040005165'); - expect(target.companyName).toBe('ТОО TANSU Construction'); - expect(target.name.lastName).toBe('Иванов'); +describe('кому и по каким компаниям отправлять на подпись', () => { + const companies: ICompany[] = [...COMPANIES, { Id: STROY_ID, Name: 'ТОО TANSU Строй', Bin: '021040005166' }]; + + it('ИИН из справочника сотрудников, БИН из справочника компаний', () => { + const targets = resolveSigningTargets([record()], companies); + + expect(targets.iin).toBe('950515123452'); + expect(targets.name.lastName).toBe('Иванов'); + expect(targets.companies).toEqual([{ bin: '021040005165', name: 'ТОО TANSU Construction', position: 'Инженер' }]); + expect(targets.skipped).toEqual([]); + }); + + // Заявку заполняют одну, а подписывают по каждой компании, где человек работает. + it('по документу на каждую компанию, со своей должностью', () => { + const targets = resolveSigningTargets( + [record(), record({ company: 'ТОО TANSU Строй', companyId: STROY_ID, position: 'Главный инженер' })], + companies + ); + + expect(targets.companies).toEqual([ + { bin: '021040005165', name: 'ТОО TANSU Construction', position: 'Инженер' }, + { bin: '021040005166', name: 'ТОО TANSU Строй', position: 'Главный инженер' } + ]); + }); + + it('одна и та же компания дважды не попадает', () => { + const targets = resolveSigningTargets([record(), record({ position: 'Совместитель' })], companies); + + expect(targets.companies).toHaveLength(1); }); it('идентификаторы компаний сравниваются без учёта регистра', () => { - const target = resolveSigningTarget([record({ companyId: TANSU_ID.toUpperCase() })], COMPANIES); - - expect(target.companyBin).toBe('021040005165'); - }); - - it('при нескольких организациях берёт первую, где у компании есть БИН', () => { - const records = [ - record({ company: 'ТОО Без БИН', companyId: '0a1b2c3d-0000-4000-8000-000000000002' }), - record() - ]; - - expect(resolveSigningTarget(records, COMPANIES).companyBin).toBe('021040005165'); - }); - - it('нет записи о сотруднике — понятная причина', () => { - expect(() => resolveSigningTarget([], COMPANIES)).toThrow('нет в кадровом справочнике'); - }); - - it('у компании нет БИН — называет компанию', () => { - const records = [record({ company: 'ТОО Без БИН', companyId: '0a1b2c3d-0000-4000-8000-000000000002' })]; - - expect(() => resolveSigningTarget(records, COMPANIES)).toThrow('нет БИН для «ТОО Без БИН»'); - }); - - it('компании нет в справочнике', () => { - expect(() => resolveSigningTarget([record({ companyId: 'ffffffff-0000-4000-8000-000000000000' })], COMPANIES)).toThrow( - 'нет БИН' + expect(resolveSigningTargets([record({ companyId: TANSU_ID.toUpperCase() })], companies).companies[0].bin).toBe( + '021040005165' ); }); + it('компания без БИН не мешает остальным — уходит в пропущенные с причиной', () => { + const targets = resolveSigningTargets( + [record({ company: 'ТОО Без БИН', companyId: NO_BIN_ID }), record()], + companies + ); + + expect(targets.companies.map((c) => c.bin)).toEqual(['021040005165']); + expect(targets.skipped).toEqual([{ name: 'ТОО Без БИН', reason: 'в справочнике компаний нет БИН' }]); + }); + + it('компании нет в справочнике — тоже пропущена', () => { + const targets = resolveSigningTargets( + [record({ company: 'ТОО Неизвестная', companyId: 'ffffffff-0000-4000-8000-000000000000' })], + companies + ); + + expect(targets.companies).toEqual([]); + expect(targets.skipped[0].name).toBe('ТОО Неизвестная'); + }); + + it('нет записи о сотруднике — понятная причина', () => { + expect(() => resolveSigningTargets([], companies)).toThrow('нет в кадровом справочнике'); + }); + it('нет ИИН в карточке', () => { - expect(() => resolveSigningTarget([record({ iin: ' ' })], COMPANIES)).toThrow('не указан ИИН'); + expect(() => resolveSigningTargets([record({ iin: ' ' })], companies)).toThrow('не указан ИИН'); }); it('ФИО из одного слова не годится', () => { - expect(() => resolveSigningTarget([record({ fio: 'Иванов' })], COMPANIES)).toThrow('Ф.И.О.'); + expect(() => resolveSigningTargets([record({ fio: 'Иванов' })], companies)).toThrow('Ф.И.О.'); + }); + + it('ни в одной записи нет компании', () => { + expect(() => resolveSigningTargets([record({ companyId: '' })], companies)).toThrow('не указана компания'); }); }); diff --git a/src/webparts/requestPortal/shared/edo/signingTarget.ts b/src/webparts/requestPortal/shared/edo/signingTarget.ts index a846b52..9853295 100644 --- a/src/webparts/requestPortal/shared/edo/signingTarget.ts +++ b/src/webparts/requestPortal/shared/edo/signingTarget.ts @@ -2,52 +2,86 @@ import { ICompany } from '../dictionary/models/ICompany'; import { IEmployeeOrganization } from '../dictionary/models/IEmployeeOrganization'; import { IFioParts, splitFio } from './fio'; -/** Кому и от имени какой компании отправляется документ на подпись. */ -export interface ISigningTarget { +/** Одна компания, от имени которой подписывается документ. */ +export interface ISigningCompany { + /** БИН компании: от её имени документ идёт в Sigex. */ + bin: string; + name: string; + /** Должность сотрудника именно в этой компании. */ + position: string; +} + +/** Компания сотрудника, по которой документ отправить нельзя, и почему. */ +export interface ISkippedCompany { + name: string; + reason: string; +} + +/** Кто подписывает и по каким компаниям. */ +export interface ISigningTargets { iin: string; name: IFioParts; - /** БИН компании, от имени которой документ идёт в Sigex. */ - companyBin: string; - companyName: string; + /** По документу на каждую компанию, где у сотрудника есть БИН в справочнике. */ + companies: ISigningCompany[]; + /** Компании, по которым документ не отправить (нет БИН в справочнике и т. п.). */ + skipped: ISkippedCompany[]; } /** - * Определяет подписанта и компанию по данным справочников api.tnsu.kz: - * ИИН и ФИО — из Employee/ByEmail, БИН — из Dictionary/Companies по CompanyId сотрудника. + * Определяет подписанта и компании по справочникам api.tnsu.kz: ИИН и ФИО — из Employee/ByEmail, + * БИН — из Dictionary/Companies по CompanyId. * - * Сотрудник может состоять в нескольких организациях (на один e-mail — несколько записей). - * Берём первую запись, у которой компания известна справочнику и в нём есть БИН: заявление - * от имени компании без БИН в ЭДО не принять. Каждая невозможность — отдельное сообщение, - * чтобы по тексту было ясно, что поправить: карточку сотрудника или справочник компаний. + * Сотрудник может работать в нескольких компаниях (на один e-mail — несколько записей), и заявление + * подписывается по каждой из них отдельно: у каждой компании свой документ со своей должностью. + * Одна и та же компания дважды не попадает. Компания без БИН не роняет остальные — она уходит в + * skipped с причиной. Ошибка бросается, только если подписать нельзя вовсе: нет человека, ИИН, ФИО + * или ни одной компании. */ -export function resolveSigningTarget(records: IEmployeeOrganization[], companies: ICompany[]): ISigningTarget { +export function resolveSigningTargets(records: IEmployeeOrganization[], companies: ICompany[]): ISigningTargets { if (records.length === 0) { throw new Error('сотрудника нет в кадровом справочнике — ИИН для подписания взять неоткуда'); } - const withCompany = records.filter((r) => r.companyId); - if (withCompany.length === 0) { - throw new Error('в карточке сотрудника не указана компания'); - } - - const byId = new Map(companies.map((c) => [c.Id.toLowerCase(), c])); - const match = withCompany - .map((record) => ({ record, company: byId.get(record.companyId.toLowerCase()) })) - .find((m) => m.company && m.company.Bin && m.company.Bin.trim().length > 0); - - if (!match || !match.company) { - throw new Error(`в справочнике компаний нет БИН для «${withCompany[0].company}»`); - } - - const iin = (match.record.iin || '').trim(); + // Человек один на все записи: ИИН и ФИО берём из первой, где они есть. + const iin = (records.find((r) => (r.iin || '').trim())?.iin || '').trim(); if (!iin) { throw new Error('в кадровом справочнике у сотрудника не указан ИИН'); } - const name = splitFio(match.record.fio); + const fio = records.find((r) => (r.fio || '').trim())?.fio || ''; + const name = splitFio(fio); if (!name) { - throw new Error(`не удалось разобрать Ф.И.О. «${match.record.fio}» на фамилию и имя`); + throw new Error(`не удалось разобрать Ф.И.О. «${fio}» на фамилию и имя`); } - return { iin, name, companyBin: (match.company.Bin as string).trim(), companyName: match.company.Name }; + const byId = new Map(companies.map((c) => [c.Id.toLowerCase(), c])); + const result: ISigningCompany[] = []; + const skipped: ISkippedCompany[] = []; + const seen = new Set(); + + for (const record of records) { + if (!record.companyId) continue; + + const company = byId.get(record.companyId.toLowerCase()); + const bin = (company?.Bin || '').trim(); + const companyName = company?.Name || record.company; + + if (!bin) { + if (!seen.has(`name:${companyName}`)) { + seen.add(`name:${companyName}`); + skipped.push({ name: companyName, reason: 'в справочнике компаний нет БИН' }); + } + continue; + } + + if (seen.has(bin)) continue; + seen.add(bin); + result.push({ bin, name: companyName, position: record.position }); + } + + if (result.length === 0 && skipped.length === 0) { + throw new Error('в карточке сотрудника не указана компания'); + } + + return { iin, name, companies: result, skipped }; }