Feature/vacation edo signing #23

Merged
gorbenko.pavel merged 27 commits from feature/vacation-edo-signing into main 2026-10-05 08:53:19 +00:00
8 changed files with 241 additions and 122 deletions
Showing only changes of commit 7f5f59fc81 - Show all commits
@@ -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`): подписание подключено только к этой форме.
- **Неудача отправки** показывается сообщением над панелью решения и согласование не отменяет; рядом
кнопка «Повторить отправку на подпись». Признака «уже отправлено» в заявке нет, поэтому после
перезагрузки страницы повтор недоступен.
@@ -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<IVacationViewProps> = ({ context, itemId, formKey,
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintSplitButton, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
// Итог отправки согласованного заявления на подпись в ЭДО (см. sendToSigning).
const [signingInfo, setSigningInfo] = useState<string>('');
// Итог отправки согласованного заявления на подпись в ЭДО (см. sendToSigning): по каким
// компаниям ушло, по каким нельзя, либо ошибка всего запроса.
const [signingResult, setSigningResult] = useState<IVacationSigningResult | undefined>(undefined);
const [signingError, setSigningError] = useState<string>('');
const [signingBusy, setSigningBusy] = useState<boolean>(false);
@@ -85,6 +86,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ 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<IVacationViewProps> = ({ context, itemId, formKey,
}).catch(() => undefined);
};
// Все согласовали → отправляем заявление на подпись сотруднику, на которого оформлен отпуск.
// Решение к этому моменту уже применено, поэтому неудача отправки показывается отдельным
// сообщением и согласование не отменяет. Повторить можно кнопкой, пока страница не закрыта:
// признака «уже отправлено» в заявке нет, и после перезагрузки повтор был бы дублем.
// Все согласовали → отправляем заявление на подпись сотруднику, на которого оформлен отпуск, —
// по документу на каждую его компанию, одним запросом. Решение к этому моменту уже применено,
// поэтому неудача показывается отдельным сообщением и согласование не отменяет. Повторить можно
// кнопкой: запрос принимается целиком или отклоняется целиком, а повтор уже принятого ЭДО
// отклонит как дубль — признака «уже отправлено» в заявке нет.
const sendToSigning = (): Promise<void> => {
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<IVacationViewProps> = ({ context, itemId, formKey,
</div>
<div className="rp-no-print">
{signingInfo && <Alert variant="ok">{signingInfo}</Alert>}
{signingError && (
{signingBusy && <Alert variant="ok">Отправляем заявление на подпись в ЭДО…</Alert>}
{!signingBusy && signingResult && (
<Alert variant="ok">
{`Заявление отправлено на подпись (${signingResult.sent.join(', ')}): письма со ссылкой ушли на ${data.Employee}.`}
</Alert>
)}
{!signingBusy && signingResult && signingResult.skipped.length > 0 && (
<Alert variant="err">
{`Не отправлено по компаниям: ${signingResult.skipped.map((c) => `${c.name} — ${c.reason}`).join('; ')}.`}
</Alert>
)}
{!signingBusy && signingError && (
<>
<Alert variant="err">{signingError}</Alert>
<Button variant="secondary" onClick={() => sendToSigning().catch(() => undefined)} disabled={signingBusy}>
<Button variant="secondary" onClick={() => sendToSigning().catch(() => undefined)}>
Повторить отправку на подпись
</Button>
</>
@@ -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<s
}
/**
* Отправляет согласованное заявление на отпуск на подпись в ЭДО.
* Отправляет согласованное заявление на отпуск на подпись в ЭДО — отдельным файлом на каждую
* компанию, где работает сотрудник: заявку он заполняет одну, а подписывает заявление в каждой
* компании. У каждого файла своя шапка (компания, должность) и свой БИН в `file_company_bins`;
* подписант один — сам сотрудник, от имени физического лица. БИН нужны ЭДО только для выбора
* сертификата Sigex (mTLS) нужной компании.
*
* Подписывает сотрудник, указанный в заявке (он же «инициатор»): ему уходит приглашение, а на
* его же почту ЭДО сообщит, что документ подписан. ИИН и БИН компании портал берёт из
* справочников api.tnsu.kz, в самой заявке их нет.
* Запрос один на все файлы: ЭДО принимает его целиком или отклоняет целиком (например, если у
* одной из компаний нет сертификата), поэтому «часть ушла, часть нет» не бывает и повтор безопасен.
*
* Подписывает сотрудник, указанный в заявке (он же «инициатор»): ему уходят приглашения, и на его
* почту ЭДО сообщит о подписи. ИИН и БИН берутся из справочников api.tnsu.kz.
*/
export async function sendVacationForSigning(
context: WebPartContext,
vacation: IVacationToSign
): Promise<IEdoSigningResult> {
): Promise<IVacationSigningResult> {
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 };
}
@@ -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 () => {
@@ -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`;
}
@@ -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 {
@@ -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('не указана компания');
});
});
@@ -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<string>();
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 };
}