Feature/vacation edo signing #23
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user