feat: заявление на отпуск — по файлу на каждую компанию совместителя одним запросом в ЭДО

Сотрудник заполняет заявку один раз, а в ЭДО уходит по документу на каждую компанию, где он
работает: своя шапка (компания, должность) и свой БИН в массиве file_company_bins. Подписант один —
сам сотрудник, от имени физлица. Запрос один на все файлы: принимается целиком или отклоняется
целиком, повтор безопасен. Компания без БИН в справочнике пропускается и показывается в сообщении.

Удалять согласующих при составлении может редактор маршрута (во всех формах, не только в трёх).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-02 14:48:33 +05:00
parent 8cdee83496
commit 7f5f59fc81
8 changed files with 241 additions and 122 deletions
@@ -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 };
}