Compare commits
15 Commits
80caa59301
...
e51d2fd98c
| Author | SHA1 | Date | |
|---|---|---|---|
| e51d2fd98c | |||
| 16a478053c | |||
| 7aacc1730f | |||
| cdb7408dda | |||
| 1fd759732e | |||
| 911ccfbcf4 | |||
| 5fd8bfe453 | |||
| cce4dfa4b3 | |||
| 7f5f59fc81 | |||
| 8cdee83496 | |||
| 9e6c56c2e7 | |||
| 58e4d3c868 | |||
| 2009535f1a | |||
| d2d2f9f647 | |||
| 3337aeda6c |
@@ -18,6 +18,7 @@
|
||||
|---|---|---|---|---|
|
||||
| Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова, Павел Горбенко | email в коде | Подбор, Приём, Реферальная рекомендация: и при составлении, и в поданной заявке | `shared/approval/routeEditRights.ts` → `canEditRoute` |
|
||||
| Удалять любых согласующих **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все остальные формы с маршрутом согласования (отпуск, увольнение, 1С и т. д.); в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `composeRemoveRowRule` |
|
||||
| Добавлять и удалять исполнителей **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все формы, где в маршруте из админки есть фаза исполнения; в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `canEditExecutors` |
|
||||
| Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` |
|
||||
| Править **все поля** заявки на своём активном шаге согласования (без вложений) | Алтынай Бултурукова | email в коде | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
|
||||
| Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` |
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import {
|
||||
composeRemoveRowRule,
|
||||
ApprovalMatrix,
|
||||
ExecutorsMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
@@ -25,7 +26,11 @@ import {
|
||||
REQUEST_STATUS,
|
||||
isEditableStatus,
|
||||
IApproverToWrite,
|
||||
unassignedStepMessage
|
||||
unassignedStepMessage,
|
||||
useFormExecutors,
|
||||
approvalPhaseRows,
|
||||
executorsToWrite,
|
||||
initialSubmitStatus
|
||||
} from '../../shared/approval';
|
||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||
import { IRequestFormProps } from '../../models/types';
|
||||
@@ -171,7 +176,7 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
||||
const loadMatrix = useCallback(
|
||||
() =>
|
||||
editId
|
||||
? approval.getMatrixForRequest(editId)
|
||||
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
|
||||
: getPlannedMatrix(context, requestTypeId as number, data.SiteChiefEmail, approval, 'Approval', null, undefined, projectId),
|
||||
[context, approval, editId, data.SiteChiefEmail, requestTypeId, projectId]
|
||||
);
|
||||
@@ -183,6 +188,18 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
||||
(login) => approval.getJobTitle(login)
|
||||
);
|
||||
|
||||
// Исполнители — из маршрута админки (заявитель их не правит); те же параметры
|
||||
// резолва, что и у согласующих.
|
||||
const executors = useFormExecutors({
|
||||
readOnly,
|
||||
context,
|
||||
approval,
|
||||
requestTypeId,
|
||||
userEmail: data.SiteChiefEmail,
|
||||
editId,
|
||||
projectId
|
||||
});
|
||||
|
||||
const hasItems = data.Items.some((i) => i.mixClass.trim().length > 0 && i.volume.trim().length > 0);
|
||||
const isValid = !!(data.RequestDate && data.SiteChiefEmail && data.Project.trim() && hasItems);
|
||||
const validate = (): string | undefined => {
|
||||
@@ -224,6 +241,7 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
||||
rtId,
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
executorsToWrite(executors.rows),
|
||||
status,
|
||||
writeListId
|
||||
);
|
||||
@@ -238,7 +256,7 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
|
||||
// не текущей: поток ищет строку маршрута именно в НЕЙ.
|
||||
if (status === REQUEST_STATUS.onApproval) {
|
||||
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||
await notifyActiveStep(context, approval, id, {
|
||||
requestTypeId: rtId,
|
||||
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||
@@ -260,11 +278,21 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||
);
|
||||
if (unassignedError) return unassignedError;
|
||||
const unassignedExecutorError = unassignedStepMessage(
|
||||
executors.rows,
|
||||
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||
);
|
||||
if (unassignedExecutorError) return unassignedExecutorError;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
|
||||
// Без исполнителей — прежний статус «На согласовании»; с ними — как у рефералки
|
||||
// (initialSubmitStatus учитывает маршрут без согласующих).
|
||||
const submitStatus = executors.rows.length > 0
|
||||
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
|
||||
: REQUEST_STATUS.onApproval;
|
||||
const handleSubmit = (): Promise<void> => submit.run('submit', save(submitStatus), validateSubmit);
|
||||
|
||||
const sectionProps = {
|
||||
data,
|
||||
@@ -331,6 +359,8 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
||||
waitingText="Укажите начальника участка и проект, чтобы построить маршрут согласования."
|
||||
/>
|
||||
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
|
||||
@@ -8,10 +8,13 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ExecutorsMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId
|
||||
useRequestTypeId,
|
||||
useViewExecutors,
|
||||
approvalPhaseRows
|
||||
} from '../../shared/approval';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import {
|
||||
@@ -71,10 +74,11 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
|
||||
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
||||
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
|
||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||
approval.getJobTitle(l)
|
||||
);
|
||||
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -153,6 +157,7 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
|
||||
</FormCard>
|
||||
|
||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
|
||||
@@ -47,7 +47,7 @@ export type ISaveResult = ISaveItemResult;
|
||||
/**
|
||||
* Сохранить заявку на бетон: содержимое + маршрут согласования.
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей.
|
||||
* Исполнители (фаза «Исполнение») приходят из маршрута админки и пишутся тем же writeRoute.
|
||||
* Возвращает Id заявки (+ код при создании).
|
||||
*
|
||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadConcreteRequest). Имеет смысл
|
||||
@@ -59,12 +59,13 @@ export async function saveConcreteRequest(
|
||||
requestTypeId: number,
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
executors: IApproverToWrite[],
|
||||
status: string,
|
||||
listId?: string
|
||||
): Promise<ISaveResult> {
|
||||
const item = buildItem(data, status);
|
||||
|
||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||
await writeRoute(concreteRequestApprovalService(listId), result.id, approvers, [], status);
|
||||
await writeRoute(concreteRequestApprovalService(listId), result.id, approvers, executors, status);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import {
|
||||
composeRemoveRowRule,
|
||||
ApprovalMatrix,
|
||||
ExecutorsMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
@@ -25,7 +26,11 @@ import {
|
||||
isEditableStatus,
|
||||
IApproverToWrite,
|
||||
unassignedStepMessage,
|
||||
resolveWriteListId
|
||||
resolveWriteListId,
|
||||
approvalPhaseRows,
|
||||
executorsToWrite,
|
||||
initialSubmitStatus,
|
||||
useFormExecutors
|
||||
} from '../../shared/approval';
|
||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||
import { IRequestFormProps } from '../../models/types';
|
||||
@@ -159,7 +164,7 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
||||
const loadMatrix = useCallback(
|
||||
() =>
|
||||
editId
|
||||
? approval.getMatrixForRequest(editId)
|
||||
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
|
||||
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, [
|
||||
{ fieldName: 'Position', value: primaryPosition(data.Organization) }
|
||||
]),
|
||||
@@ -173,6 +178,18 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
||||
(login) => approval.getJobTitle(login)
|
||||
);
|
||||
|
||||
// Исполнители самого заявления (фаза «Исполнение» маршрута из админки) — по
|
||||
// тому же сотруднику и тем же fieldValues, что и согласующие выше.
|
||||
const executors = useFormExecutors({
|
||||
readOnly,
|
||||
context,
|
||||
approval,
|
||||
requestTypeId,
|
||||
userEmail: data.Employee,
|
||||
editId,
|
||||
fieldValues: [{ fieldName: 'Position', value: primaryPosition(data.Organization) }]
|
||||
});
|
||||
|
||||
// Убрать компанию из заявления (совместитель, реально не увольняется из
|
||||
// ВСЕХ) — см. OrganizationsTable. Снимает компанию из data.Organization И, по
|
||||
// возможности, соответствующего согласующего (директора/должностного
|
||||
@@ -245,6 +262,7 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
||||
rtId,
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
executorsToWrite(executors.rows),
|
||||
status,
|
||||
writeListId
|
||||
);
|
||||
@@ -259,7 +277,7 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
|
||||
// не текущей: поток ищет строку маршрута именно в НЕЙ.
|
||||
if (status === REQUEST_STATUS.onApproval) {
|
||||
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||
await notifyActiveStep(context, approval, id, {
|
||||
requestTypeId: rtId,
|
||||
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||
@@ -281,11 +299,27 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||
);
|
||||
if (unassignedError) return unassignedError;
|
||||
const unassignedExecutorError = unassignedStepMessage(
|
||||
executors.rows,
|
||||
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||
);
|
||||
if (unassignedExecutorError) return unassignedExecutorError;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
|
||||
// Исполнители в маршруте есть → статус стартует с учётом фаз (initialSubmitStatus:
|
||||
// без согласующих сразу «Выполнение»); иначе прежний «На согласовании».
|
||||
const handleSubmit = (): Promise<void> =>
|
||||
submit.run(
|
||||
'submit',
|
||||
save(
|
||||
executors.rows.length > 0
|
||||
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
|
||||
: REQUEST_STATUS.onApproval
|
||||
),
|
||||
validateSubmit
|
||||
);
|
||||
|
||||
const sectionProps = {
|
||||
data,
|
||||
@@ -348,6 +382,8 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
||||
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||
/>
|
||||
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили, — поэтому история и в форме, а не только в просмотре.
|
||||
Только у уже существующей заявки: у новой истории нет. */}
|
||||
|
||||
@@ -8,11 +8,14 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ExecutorsMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
REQUEST_STATUS
|
||||
REQUEST_STATUS,
|
||||
approvalPhaseRows,
|
||||
useViewExecutors
|
||||
} from '../../shared/approval';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import { getManagerEmail } from '../../shared/dictionary';
|
||||
@@ -87,10 +90,12 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
||||
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
|
||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||
approval.getJobTitle(l)
|
||||
);
|
||||
// Исполнители — отдельная фаза тех же строк маршрута; перечитываются вместе с согласующими.
|
||||
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -282,6 +287,8 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
</FormCard>
|
||||
|
||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
</div>
|
||||
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
|
||||
@@ -96,7 +96,9 @@ export type ISaveResult = ISaveItemResult;
|
||||
/**
|
||||
* Сохранить заявление на увольнение: содержимое + маршрут согласования.
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей.
|
||||
* executors — исполнители самого заявления (фаза «Исполнение» маршрута из
|
||||
* админки; пусто, если в маршруте их нет). Это не маршрут связанной заявки
|
||||
* Clearance (см. createClearance.ts).
|
||||
* Возвращает Id заявки (+ код при создании).
|
||||
*
|
||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadDismissalRequest). Имеет смысл
|
||||
@@ -109,6 +111,7 @@ export async function saveDismissalRequest(
|
||||
requestTypeId: number,
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
executors: IApproverToWrite[],
|
||||
status: string,
|
||||
listId?: string
|
||||
): Promise<ISaveResult> {
|
||||
@@ -116,7 +119,7 @@ export async function saveDismissalRequest(
|
||||
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee);
|
||||
|
||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||
await writeRoute(dismissalApprovalService(listId), result.id, approvers, [], status);
|
||||
await writeRoute(dismissalApprovalService(listId), result.id, approvers, executors, status);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
useFormExecutors,
|
||||
ExecutorsMatrix,
|
||||
approvalPhaseRows,
|
||||
executorsToWrite,
|
||||
initialSubmitStatus,
|
||||
getPlannedMatrix,
|
||||
resolveWriteListId,
|
||||
notifyActiveStep,
|
||||
@@ -155,7 +160,7 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
|
||||
const loadMatrix = useCallback(
|
||||
() =>
|
||||
editId
|
||||
? approval.getMatrixForRequest(editId)
|
||||
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
|
||||
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'),
|
||||
[context, approval, editId, data.Employee, requestTypeId]
|
||||
);
|
||||
@@ -167,6 +172,17 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
|
||||
(login) => approval.getJobTitle(login)
|
||||
);
|
||||
|
||||
// Исполнители — по тому же сотруднику, что и маршрут согласования. Только чтение:
|
||||
// их назначает маршрут из админки, не заявитель.
|
||||
const executors = useFormExecutors({
|
||||
readOnly,
|
||||
context,
|
||||
approval,
|
||||
requestTypeId,
|
||||
userEmail: data.Employee,
|
||||
editId
|
||||
});
|
||||
|
||||
const isValid = !!(
|
||||
data.Employee &&
|
||||
data.Position.trim() &&
|
||||
@@ -213,6 +229,7 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
|
||||
rtId,
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
executorsToWrite(executors.rows),
|
||||
status,
|
||||
writeListId
|
||||
);
|
||||
@@ -227,7 +244,7 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
|
||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
|
||||
// не текущей: поток ищет строку маршрута именно в НЕЙ.
|
||||
if (status === REQUEST_STATUS.onApproval) {
|
||||
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||
await notifyActiveStep(context, approval, id, {
|
||||
requestTypeId: rtId,
|
||||
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||
@@ -249,11 +266,27 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
|
||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||
);
|
||||
if (unassignedError) return unassignedError;
|
||||
const unassignedExecutorError = unassignedStepMessage(
|
||||
executors.rows,
|
||||
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||
);
|
||||
if (unassignedExecutorError) return unassignedExecutorError;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
|
||||
// Есть исполнители — initialSubmitStatus учитывает маршрут без согласующих
|
||||
// (сразу «Выполнение»); иначе прежний статус «На согласовании».
|
||||
const handleSubmit = (): Promise<void> =>
|
||||
submit.run(
|
||||
'submit',
|
||||
save(
|
||||
executors.rows.length > 0
|
||||
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
|
||||
: REQUEST_STATUS.onApproval
|
||||
),
|
||||
validateSubmit
|
||||
);
|
||||
|
||||
const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange };
|
||||
|
||||
@@ -310,6 +343,8 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
|
||||
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||
/>
|
||||
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
useViewExecutors,
|
||||
ExecutorsMatrix,
|
||||
approvalPhaseRows,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId
|
||||
} from '../../shared/approval';
|
||||
@@ -70,10 +73,11 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
||||
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
||||
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
|
||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||
approval.getJobTitle(l)
|
||||
);
|
||||
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -150,6 +154,7 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
||||
</FormCard>
|
||||
|
||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
|
||||
@@ -43,7 +43,8 @@ export type ISaveResult = ISaveItemResult;
|
||||
/**
|
||||
* Сохранить заявку на перемещение: содержимое + маршрут согласования.
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей.
|
||||
* executors — исполнители из маршрута (фаза «Исполнение»); пустой список, если в
|
||||
* маршруте их нет.
|
||||
* Возвращает Id заявки (+ код при создании).
|
||||
*
|
||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadEmployeeTransferRequest). Имеет
|
||||
@@ -55,6 +56,7 @@ export async function saveEmployeeTransferRequest(
|
||||
requestTypeId: number,
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
executors: IApproverToWrite[],
|
||||
status: string,
|
||||
listId?: string
|
||||
): Promise<ISaveResult> {
|
||||
@@ -62,6 +64,6 @@ export async function saveEmployeeTransferRequest(
|
||||
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee);
|
||||
|
||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||
await writeRoute(employeeTransferApprovalService(listId), result.id, approvers, [], status);
|
||||
await writeRoute(employeeTransferApprovalService(listId), result.id, approvers, executors, status);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
canEditExecutors,
|
||||
getPlannedMatrix,
|
||||
resolveWriteListId,
|
||||
notifyActiveStep,
|
||||
@@ -204,7 +205,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||
const executors = useMatrixEditor(
|
||||
loadExecutors,
|
||||
`${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`,
|
||||
false,
|
||||
canEditExecutors(context, readOnly),
|
||||
matrixReady,
|
||||
(login) => approval.getJobTitle(login),
|
||||
resolveApproverOrgInfo(context)
|
||||
@@ -460,12 +461,14 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||
canRemoveRow={canRemoveApprover ? () => true : undefined}
|
||||
/>
|
||||
|
||||
{!executors.loading && executors.rows.length > 0 && (
|
||||
{!executors.loading && (executors.editable || executors.rows.length > 0) && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title="Исполнение (после согласования)"
|
||||
roleLabel="Исполнитель"
|
||||
canRemoveRow={executors.editable ? () => true : undefined}
|
||||
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
|
||||
emptyText="Исполнители не заданы по маршруту."
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
canEditExecutors,
|
||||
getPlannedMatrix,
|
||||
resolveWriteListId,
|
||||
notifyActiveStep,
|
||||
@@ -233,7 +234,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
||||
const executors = useMatrixEditor(
|
||||
loadExecutors,
|
||||
`${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`,
|
||||
false,
|
||||
canEditExecutors(context, readOnly),
|
||||
matrixReady,
|
||||
(login) => approval.getJobTitle(login),
|
||||
resolveApproverOrgInfo(context)
|
||||
@@ -450,12 +451,14 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
||||
canRemoveRow={canRemoveApprover ? () => true : undefined}
|
||||
/>
|
||||
|
||||
{!executors.loading && executors.rows.length > 0 && (
|
||||
{!executors.loading && (executors.editable || executors.rows.length > 0) && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title="Исполнение (после согласования)"
|
||||
roleLabel="Исполнитель"
|
||||
canRemoveRow={executors.editable ? () => true : undefined}
|
||||
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
|
||||
emptyText="Исполнители не заданы по маршруту."
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
canEditExecutors,
|
||||
getPlannedMatrix,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -240,7 +241,7 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
|
||||
const executors = useMatrixEditor(
|
||||
loadExecutors,
|
||||
`${data.Referrer}-${editId ?? 'new'}`,
|
||||
false,
|
||||
canEditExecutors(context, readOnly),
|
||||
!!editId || (!!data.Referrer && !!requestTypeId),
|
||||
(login) => approval.getJobTitle(login),
|
||||
resolveApproverOrgInfo(context)
|
||||
@@ -469,12 +470,14 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
|
||||
canRemoveRow={canRemoveApprover ? () => true : undefined}
|
||||
/>
|
||||
|
||||
{!executors.loading && executors.rows.length > 0 && (
|
||||
{!executors.loading && (executors.editable || executors.rows.length > 0) && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title={noApprovalPhase ? 'Исполнение' : 'Исполнение (после согласования)'}
|
||||
roleLabel="Исполнитель"
|
||||
canRemoveRow={executors.editable ? () => true : undefined}
|
||||
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
|
||||
emptyText="Исполнители не заданы по маршруту."
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,12 @@ import {
|
||||
isEditableStatus,
|
||||
IApproverToWrite,
|
||||
unassignedStepMessage,
|
||||
resolveWriteListId
|
||||
resolveWriteListId,
|
||||
useFormExecutors,
|
||||
ExecutorsMatrix,
|
||||
approvalPhaseRows,
|
||||
executorsToWrite,
|
||||
initialSubmitStatus
|
||||
} from '../../shared/approval';
|
||||
import {
|
||||
uploadAttachments,
|
||||
@@ -181,13 +186,24 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
|
||||
const loadMatrix = useCallback(
|
||||
() =>
|
||||
editId
|
||||
? approval.getMatrixForRequest(editId)
|
||||
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
|
||||
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, undefined, projectId),
|
||||
[context, userEmail, approval, editId, projectId, requestTypeId]
|
||||
);
|
||||
const matrix = useMatrixEditor(loadMatrix, `${userEmail}-${editId ?? 'new'}-${projectId}`, !readOnly, matrixReady, (login) =>
|
||||
approval.getJobTitle(login)
|
||||
);
|
||||
// Исполнители резолвятся тем же маршрутом и с тем же проектом, что и согласующие:
|
||||
// пока проект не выбран, e-mail пустой — useFormExecutors не грузит маршрут (404).
|
||||
const executors = useFormExecutors({
|
||||
readOnly,
|
||||
context,
|
||||
approval,
|
||||
requestTypeId,
|
||||
userEmail: matrixReady ? userEmail : '',
|
||||
editId,
|
||||
projectId
|
||||
});
|
||||
|
||||
const hasCosts = hasAnyCostItem(data.Costs);
|
||||
const isValid =
|
||||
@@ -273,7 +289,7 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
|
||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||
}
|
||||
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||
const { id, code } = await saveServiceRequest(requestId, rtId, data, toApprovers(matrix.rows), status, writeListId);
|
||||
const { id, code } = await saveServiceRequest(requestId, rtId, data, toApprovers(matrix.rows), executorsToWrite(executors.rows), status, writeListId);
|
||||
setRequestId(id);
|
||||
setRequestStatus(status);
|
||||
if (code) {
|
||||
@@ -294,7 +310,7 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
|
||||
if (onSaved) {
|
||||
onSaved();
|
||||
}
|
||||
if (status === REQUEST_STATUS.onApproval) {
|
||||
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||
await notifyActiveStep(context, approval, id, {
|
||||
requestTypeId: rtId,
|
||||
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||
@@ -314,11 +330,26 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
|
||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||
);
|
||||
if (unassignedError) return unassignedError;
|
||||
const unassignedExecutorError = unassignedStepMessage(
|
||||
executors.rows,
|
||||
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||
);
|
||||
if (unassignedExecutorError) return unassignedExecutorError;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
|
||||
const handleSubmit = (): Promise<void> => submit.run(
|
||||
'submit',
|
||||
// Есть исполнители — initialSubmitStatus сам выбирает стартовый статус (согласующих
|
||||
// может не быть вовсе), иначе прежнее «На согласовании».
|
||||
save(
|
||||
executors.rows.length > 0
|
||||
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
|
||||
: REQUEST_STATUS.onApproval
|
||||
),
|
||||
validateSubmit
|
||||
);
|
||||
|
||||
const sectionProps = {
|
||||
data,
|
||||
@@ -390,6 +421,8 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
|
||||
waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования."
|
||||
/>
|
||||
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
|
||||
@@ -20,12 +20,16 @@ import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
useViewExecutors,
|
||||
ExecutorsMatrix,
|
||||
approvalPhaseRows,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
isEditableStatus
|
||||
} from '../../shared/approval';
|
||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import { useStableCallback } from '../../shared/useStableCallback';
|
||||
import {
|
||||
approvalListTitle,
|
||||
approvalListTitleForListId,
|
||||
@@ -54,6 +58,9 @@ interface IServiceRequestViewProps {
|
||||
|
||||
// Заявка «Заявка на услуги» только для просмотра (режим согласующего).
|
||||
const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||
// onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта
|
||||
// загрузки идёт стабильная обёртка, иначе заявка перезапрашивалась бы при каждом рендере родителя.
|
||||
const notifyCode = useStableCallback(onCodeLoaded);
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
@@ -77,10 +84,11 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
const loadApprovers = React.useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
|
||||
const loadApprovers = React.useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||
approval.getJobTitle(l)
|
||||
);
|
||||
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -91,7 +99,7 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
||||
setData(loaded.data);
|
||||
setStatus(loaded.status);
|
||||
setCode(loaded.code);
|
||||
onCodeLoaded?.(loaded.code);
|
||||
notifyCode(loaded.code);
|
||||
setAttachments(files);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
@@ -106,7 +114,7 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [itemId, listId]);
|
||||
}, [itemId, listId, notifyCode]);
|
||||
|
||||
// Ссылки на скачивание для файлов строк «Приложения» (см. AnnexesSection).
|
||||
// Считаем ДО ранних return'ов ниже: хук, вызванный только на части рендеров,
|
||||
@@ -180,6 +188,7 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
||||
</FormCard>
|
||||
|
||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
</div>
|
||||
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
|
||||
@@ -43,7 +43,7 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
||||
export type ISaveResult = ISaveItemResult;
|
||||
|
||||
/**
|
||||
* Сохранить «Заявку на услуги»: контент + маршрут согласования.
|
||||
* Сохранить «Заявку на услуги»: контент + маршрут согласования и исполнения.
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём
|
||||
* новую. Возвращает Id заявки (+ уникальный код при создании).
|
||||
*
|
||||
@@ -56,14 +56,13 @@ export async function saveServiceRequest(
|
||||
requestTypeId: number,
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
executors: IApproverToWrite[],
|
||||
status: string,
|
||||
listId?: string
|
||||
): Promise<ISaveResult> {
|
||||
const item = buildItem(data, status);
|
||||
|
||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||
// Фазы исполнения у заявки на услуги нет — договор дальше ведут вне портала,
|
||||
// поэтому исполнителей не пишем (пустой массив).
|
||||
await writeRoute(serviceRequestApprovalService(listId), result.id, approvers, [], status);
|
||||
await writeRoute(serviceRequestApprovalService(listId), result.id, approvers, executors, status);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import {
|
||||
composeRemoveRowRule,
|
||||
ApprovalMatrix,
|
||||
canEditExecutors,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
notifyActiveStep,
|
||||
@@ -136,7 +137,8 @@ const SkudExplanationForm: React.FC<IRequestFormProps> = ({
|
||||
requestId: editId,
|
||||
employeeEmail: data.Employee,
|
||||
requestTypeId,
|
||||
editable: !readOnly
|
||||
editable: !readOnly,
|
||||
executorsEditable: canEditExecutors(context, readOnly)
|
||||
});
|
||||
|
||||
const validate = (): string | undefined => {
|
||||
@@ -276,12 +278,14 @@ const SkudExplanationForm: React.FC<IRequestFormProps> = ({
|
||||
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||
/>
|
||||
|
||||
{!executors.loading && executors.rows.length > 0 && (
|
||||
{!executors.loading && (executors.editable || executors.rows.length > 0) && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title="Исполнение (после согласования)"
|
||||
roleLabel="Исполнитель"
|
||||
canRemoveRow={executors.editable ? () => true : undefined}
|
||||
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
|
||||
emptyText="Исполнители не заданы по маршруту."
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -87,8 +87,8 @@ describe('cellKind / toMissedKind', () => {
|
||||
});
|
||||
|
||||
it('месяц начинается с понедельника: 1 июля 2026 — среда, два пустых места', () => {
|
||||
expect(cells[0]).toBeNull();
|
||||
expect(cells[1]).toBeNull();
|
||||
expect(cells[0]).toBeUndefined();
|
||||
expect(cells[1]).toBeUndefined();
|
||||
expect(cells[2]?.day).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,12 +130,12 @@ export function monthWorkedHours(byDay: Map<string, IDayInfo>): number {
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Ячейки месяца: ведущие null — пустые места до первого числа (неделя с понедельника). */
|
||||
export function buildMonthCells(year: number, month: number, byDay: Map<string, IDayInfo>): (IDayInfo | null)[] {
|
||||
/** Ячейки месяца: ведущие undefined — пустые места до первого числа (неделя с понедельника). */
|
||||
export function buildMonthCells(year: number, month: number, byDay: Map<string, IDayInfo>): (IDayInfo | undefined)[] {
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const firstColumn = (new Date(year, month, 1).getDay() + 6) % DAYS_IN_WEEK;
|
||||
const cells: (IDayInfo | null)[] = [];
|
||||
for (let i = 0; i < firstColumn; i += 1) cells.push(null);
|
||||
const cells: (IDayInfo | undefined)[] = [];
|
||||
for (let i = 0; i < firstColumn; i += 1) cells.push(undefined);
|
||||
for (let d = 1; d <= daysInMonth; d += 1) {
|
||||
const key = toDateKey(year, month, d);
|
||||
cells.push(byDay.get(key) ?? { day: d, dateKey: key, events: [], first: '', last: '' });
|
||||
|
||||
@@ -14,7 +14,7 @@ type MatrixEditor = ReturnType<typeof useMatrixEditor>;
|
||||
export interface ISkudRoute {
|
||||
/** Согласующие — редактируемы заявителем в новой заявке / черновике / доработке. */
|
||||
approvers: MatrixEditor;
|
||||
/** Исполнители по маршруту (фаза «Исполнение») — только чтение. */
|
||||
/** Исполнители по маршруту (фаза «Исполнение») — только чтение, кроме редактора маршрута. */
|
||||
executors: MatrixEditor;
|
||||
}
|
||||
|
||||
@@ -27,11 +27,13 @@ interface IRouteSource {
|
||||
requestTypeId: number | undefined;
|
||||
/** Можно ли править согласующих. */
|
||||
editable: boolean;
|
||||
/** Можно ли править исполнителей (только редактору маршрута, см. canEditExecutors). */
|
||||
executorsEditable?: boolean;
|
||||
/** Меняется после решения согласующего — перечитать строки. */
|
||||
reloadSeq?: number;
|
||||
}
|
||||
|
||||
const byPhase = (execution: boolean) => (rows: IMatrixRow[]): IMatrixRow[] =>
|
||||
const byPhase = (execution: boolean): ((rows: IMatrixRow[]) => IMatrixRow[]) => (rows) =>
|
||||
rows.filter((r) => (r.phase === APPROVAL_PHASE.execution) === execution);
|
||||
|
||||
/**
|
||||
@@ -40,7 +42,7 @@ const byPhase = (execution: boolean) => (rows: IMatrixRow[]): IMatrixRow[] =>
|
||||
* маршрута «пропадал» бы. Паттерн тот же, что в Referral.
|
||||
*/
|
||||
export function useSkudRoute(source: IRouteSource): ISkudRoute {
|
||||
const { context, approval, requestId, employeeEmail, requestTypeId, editable, reloadSeq = 0 } = source;
|
||||
const { context, approval, requestId, employeeEmail, requestTypeId, editable, executorsEditable = false, reloadSeq = 0 } = source;
|
||||
const canLoad = !!requestId || (!!employeeEmail && !!requestTypeId);
|
||||
const reloadKey = `${employeeEmail}-${requestId ?? 'new'}-${reloadSeq}`;
|
||||
|
||||
@@ -62,6 +64,6 @@ export function useSkudRoute(source: IRouteSource): ISkudRoute {
|
||||
const jobTitle = (login: string): Promise<string> => approval.getJobTitle(login);
|
||||
|
||||
const approvers = useMatrixEditor(loadApprovers, reloadKey, editable, canLoad, jobTitle, resolveApproverOrgInfo(context));
|
||||
const executors = useMatrixEditor(loadExecutors, reloadKey, false, canLoad, jobTitle, resolveApproverOrgInfo(context));
|
||||
const executors = useMatrixEditor(loadExecutors, reloadKey, executorsEditable, canLoad, jobTitle, resolveApproverOrgInfo(context));
|
||||
return { approvers, executors };
|
||||
}
|
||||
|
||||
@@ -29,7 +29,12 @@ import {
|
||||
REQUEST_STATUS,
|
||||
isEditableStatus,
|
||||
IApproverToWrite,
|
||||
unassignedStepMessage
|
||||
unassignedStepMessage,
|
||||
approvalPhaseRows,
|
||||
executorsToWrite,
|
||||
useFormExecutors,
|
||||
ExecutorsMatrix,
|
||||
initialSubmitStatus
|
||||
} from '../../shared/approval';
|
||||
import {
|
||||
uploadAttachments,
|
||||
@@ -172,7 +177,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
const loadMatrix = useCallback(
|
||||
() =>
|
||||
editId
|
||||
? approval.getMatrixForRequest(editId)
|
||||
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
|
||||
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, [
|
||||
{ fieldName: 'WorkType', value: data.WorkType },
|
||||
{ fieldName: 'Department', value: data.Department }
|
||||
@@ -186,6 +191,21 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
!!editId || (!!data.WorkType && !!requestTypeId),
|
||||
(login) => approval.getJobTitle(login)
|
||||
);
|
||||
// Исполнители (фаза «Исполнение») резолвятся с теми же fieldValues, что и
|
||||
// согласующие: условные шаги маршрута зависят от вида работ и отдела.
|
||||
const executors = useFormExecutors({
|
||||
readOnly,
|
||||
context,
|
||||
approval,
|
||||
requestTypeId,
|
||||
userEmail,
|
||||
editId,
|
||||
departmentId: null,
|
||||
fieldValues: [
|
||||
{ fieldName: 'WorkType', value: data.WorkType },
|
||||
{ fieldName: 'Department', value: data.Department }
|
||||
]
|
||||
});
|
||||
// Должность нового «Руководителя» — approval-route её не знает (резолвил шаг
|
||||
// на ДРУГОГО человека и отдал его должность), поэтому подтягиваем сами по
|
||||
// e-mail из поля (SPS-JobTitle, тот же источник, что и у обычных согласующих,
|
||||
@@ -278,7 +298,13 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
// работ (раньше в этом случае заявка сразу становилась «Согласовано» в
|
||||
// обход согласования; теперь это просто «На согласовании» с пустой
|
||||
// матрицей — сигнал администратору маршрутов донастроить условие).
|
||||
const finalStatus = intendedStatus === REQUEST_STATUS.draft ? REQUEST_STATUS.draft : REQUEST_STATUS.onApproval;
|
||||
// Есть исполнители — статус отправки по initialSubmitStatus (без согласующих
|
||||
// сразу «Выполнение»); иначе, как раньше, «На согласовании» (в т.ч. при пустом маршруте).
|
||||
const submitStatus =
|
||||
executors.rows.length > 0
|
||||
? initialSubmitStatus(matrixRows.length, executors.rows.length)
|
||||
: REQUEST_STATUS.onApproval;
|
||||
const finalStatus = intendedStatus === REQUEST_STATUS.draft ? REQUEST_STATUS.draft : submitStatus;
|
||||
|
||||
// requestId уже задан → редактируем существующую заявку, пишем в ту же
|
||||
// партицию (editListId). Иначе — создаём новую, и партицию для записи
|
||||
@@ -293,6 +319,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
requestId,
|
||||
data,
|
||||
toApprovers(matrixRows),
|
||||
executorsToWrite(executors.rows),
|
||||
finalStatus,
|
||||
writeListId
|
||||
);
|
||||
@@ -315,7 +342,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
// Передача в Planner теперь происходит только после реального решения
|
||||
// согласующего (см. Ticket1CView.handoffToPlanner) — здесь заявка всегда
|
||||
// отправляется на согласование, минуя Planner напрямую.
|
||||
if (finalStatus === REQUEST_STATUS.onApproval) {
|
||||
if (finalStatus === REQUEST_STATUS.onApproval || finalStatus === REQUEST_STATUS.execution) {
|
||||
// Имена списков — той же партиции, куда фактически записали (writeListId),
|
||||
// не обязательно текущей на момент рендера формы: поток ищет строку
|
||||
// маршрута именно в НЕЙ.
|
||||
@@ -345,6 +372,11 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.`
|
||||
);
|
||||
if (unassignedError) return unassignedError;
|
||||
const unassignedExecutorError = unassignedStepMessage(
|
||||
executors.rows,
|
||||
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||
);
|
||||
if (unassignedExecutorError) return unassignedExecutorError;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -424,6 +456,8 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С."
|
||||
/>
|
||||
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
isEditableStatus,
|
||||
REQUEST_STATUS
|
||||
REQUEST_STATUS,
|
||||
approvalPhaseRows,
|
||||
useViewExecutors,
|
||||
ExecutorsMatrix
|
||||
} from '../../shared/approval';
|
||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
@@ -112,10 +115,11 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
||||
// decisionSeq входит в reloadKey: после решения строки маршрута меняются на
|
||||
// сервере, поэтому счётчик инкрементируется и useMatrixEditor перечитывает матрицу.
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
|
||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||
approval.getJobTitle(l)
|
||||
);
|
||||
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -246,6 +250,8 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
emptyText="Согласование не требовалось для этого вида работ."
|
||||
/>
|
||||
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
</div>
|
||||
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
|
||||
@@ -32,10 +32,10 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
||||
export type ISaveResult = ISaveItemResult;
|
||||
|
||||
/**
|
||||
* Сохранить заявку в отдел 1С: содержимое + маршрут согласования.
|
||||
* Сохранить заявку в отдел 1С: содержимое + маршрут (согласующие и исполнители).
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* Фазы исполнения нет: после согласования заявка уходит в Planner, а не исполнителю
|
||||
* из матрицы. Возвращает Id заявки (+ код при создании).
|
||||
* executors — исполнители из маршрута админки (фаза «Исполнение»), могут быть пустыми:
|
||||
* тогда после согласования заявка уходит в Planner. Возвращает Id заявки (+ код при создании).
|
||||
*
|
||||
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
||||
* ОБНОВЛЯЕМОЙ заявки (см. loadTicketRequest), при создании — партиция,
|
||||
@@ -49,13 +49,14 @@ export async function saveTicketRequest(
|
||||
existingId: number | undefined,
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
executors: IApproverToWrite[],
|
||||
status: string,
|
||||
listId?: string
|
||||
): Promise<ISaveResult> {
|
||||
const item = buildItem(data, status);
|
||||
|
||||
const result = await saveRequestItem(FORM_ID, existingId, listId, TICKET1C_CODE_PREFIX, item);
|
||||
await writeRoute(ticket1cApprovalService(listId), result.id, approvers, [], status);
|
||||
await writeRoute(ticket1cApprovalService(listId), result.id, approvers, executors, status);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
canEditExecutors,
|
||||
getPlannedMatrix,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -314,7 +315,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
||||
const executors = useMatrixEditor(
|
||||
loadExecutors,
|
||||
`${userEmail}-${editId ?? 'new'}-${projectId}-${targetProjectId}`,
|
||||
false,
|
||||
canEditExecutors(context, readOnly),
|
||||
executorsReady,
|
||||
(login) => approval.getJobTitle(login)
|
||||
);
|
||||
@@ -507,12 +508,14 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
||||
|
||||
{/* Блок исполнителя показываем, только когда по маршруту реально есть
|
||||
исполнители (грузится по проектам, см. executorsReady). */}
|
||||
{executorsReady && !executors.loading && executors.rows.length > 0 && (
|
||||
{executorsReady && !executors.loading && (executors.editable || executors.rows.length > 0) && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title="Исполнение (после согласования)"
|
||||
roleLabel="Исполнитель"
|
||||
canRemoveRow={executors.editable ? () => true : undefined}
|
||||
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
|
||||
emptyText="Исполнители не заданы по маршруту."
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -15,9 +15,14 @@ import {
|
||||
import {
|
||||
composeRemoveRowRule,
|
||||
ApprovalMatrix,
|
||||
ExecutorsMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
useFormExecutors,
|
||||
approvalPhaseRows,
|
||||
executorsToWrite,
|
||||
initialSubmitStatus,
|
||||
getPlannedMatrix,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -156,7 +161,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
||||
const loadMatrix = useCallback(
|
||||
() =>
|
||||
editId
|
||||
? approval.getMatrixForRequest(editId)
|
||||
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
|
||||
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'),
|
||||
[context, approval, editId, data.Employee, requestTypeId]
|
||||
);
|
||||
@@ -168,6 +173,16 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
||||
(login) => approval.getJobTitle(login)
|
||||
);
|
||||
|
||||
// Исполнители фазы «Исполнение» — по маршруту из админки, только чтение.
|
||||
const executors = useFormExecutors({
|
||||
readOnly,
|
||||
context,
|
||||
approval,
|
||||
requestTypeId,
|
||||
userEmail: data.Employee,
|
||||
editId
|
||||
});
|
||||
|
||||
// Даты корректны, если конец не раньше начала (обе обязательны).
|
||||
const datesValid = !!(data.StartDate && data.EndDate && data.EndDate >= data.StartDate);
|
||||
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
|
||||
@@ -225,6 +240,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
||||
rtId,
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
executorsToWrite(executors.rows),
|
||||
status,
|
||||
writeListId
|
||||
);
|
||||
@@ -239,7 +255,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
|
||||
// не текущей: поток ищет строку маршрута именно в НЕЙ.
|
||||
if (status === REQUEST_STATUS.onApproval) {
|
||||
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||
await notifyActiveStep(context, approval, id, {
|
||||
requestTypeId: rtId,
|
||||
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||
@@ -261,11 +277,22 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||
);
|
||||
if (unassignedError) return unassignedError;
|
||||
const unassignedExecutorError = unassignedStepMessage(
|
||||
executors.rows,
|
||||
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||
);
|
||||
if (unassignedExecutorError) return unassignedExecutorError;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
|
||||
// Есть исполнители — initialSubmitStatus учитывает маршрут без согласующих.
|
||||
const handleSubmit = (): Promise<void> =>
|
||||
submit.run(
|
||||
'submit',
|
||||
save(executors.rows.length > 0 ? initialSubmitStatus(matrix.rows.length, executors.rows.length) : REQUEST_STATUS.onApproval),
|
||||
validateSubmit
|
||||
);
|
||||
|
||||
const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange };
|
||||
|
||||
@@ -322,6 +349,8 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
||||
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||
/>
|
||||
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
|
||||
@@ -8,8 +8,11 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ExecutorsMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
useViewExecutors,
|
||||
approvalPhaseRows,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId
|
||||
} from '../../shared/approval';
|
||||
@@ -70,7 +73,8 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
||||
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
||||
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
|
||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||
approval.getJobTitle(l)
|
||||
);
|
||||
@@ -150,6 +154,8 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
||||
</FormCard>
|
||||
|
||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
|
||||
@@ -62,7 +62,7 @@ export type ISaveResult = ISaveItemResult;
|
||||
/**
|
||||
* Сохранить заявку без содержания: содержимое + маршрут согласования.
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей.
|
||||
* executors — исполнители фазы «Исполнение» из маршрута (пусто, если в маршруте их нет).
|
||||
* Возвращает Id заявки (+ код при создании).
|
||||
*
|
||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadUnpaidLeaveRequest). Имеет смысл
|
||||
@@ -75,6 +75,7 @@ export async function saveUnpaidLeaveRequest(
|
||||
requestTypeId: number,
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
executors: IApproverToWrite[],
|
||||
status: string,
|
||||
listId?: string
|
||||
): Promise<ISaveResult> {
|
||||
@@ -84,6 +85,6 @@ export async function saveUnpaidLeaveRequest(
|
||||
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId);
|
||||
|
||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||
await writeRoute(unpaidLeaveApprovalService(listId), result.id, approvers, [], status);
|
||||
await writeRoute(unpaidLeaveApprovalService(listId), result.id, approvers, executors, status);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
## Маршрут
|
||||
- Тип заявки в approval-route API (`/api/request-types`): id **11**, `pageUrl` «hr-vacation»,
|
||||
`listId` «/sites/requests/Lists/List5», section «Кадры».
|
||||
- Фазы: только согласование. Фазы исполнения нет — после согласования заявка
|
||||
просто получает статус «Согласовано» (никакого Planner, в отличие от Ticket1C).
|
||||
- Фазы: согласование, а при наличии исполнителей в маршруте (бывает: тип заявки может их
|
||||
содержать) — ещё и исполнение. Без исполнителей после согласования заявка получает статус
|
||||
«Согласовано», с исполнителем — сразу «Выполнение». Planner не используется (в отличие от Ticket1C).
|
||||
- **Матрица редактируема заявителем** при новой заявке / черновике / возврате
|
||||
(как RecruitingHire / WorkplaceSetup): `useMatrixEditor(editable = !readOnly)`.
|
||||
Заявитель может добавлять/удалять/переставлять согласующих поверх планируемого
|
||||
@@ -29,17 +30,30 @@
|
||||
- **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`.
|
||||
|
||||
## Подписание в ЭДО
|
||||
После того как все согласующие согласовали (статус заявки становится «Согласовано»), из браузера
|
||||
**последнего согласующего** (`VacationView` → `onDecided`) заявление уходит на подпись в TANSU ЭДО:
|
||||
После того как все согласующие согласовали, из браузера **последнего согласующего**
|
||||
(`VacationView` → `onDecided`) заявление уходит на подпись в TANSU ЭДО. Срабатывает на решение, которое
|
||||
завершило фазу согласования (`isApprovalPhaseCompleted`: «На согласовании» → «Согласовано» **или**
|
||||
«Выполнение»), а не только на «Согласовано»: если в маршруте есть исполнитель, заявка сразу становится
|
||||
«Выполнение». Решение исполнителя повторно отправку не запускает. В зелёном сообщении «Решение сохранено»
|
||||
появляется строка «Документы отправлены на подписание в ЭДО…»:
|
||||
- **Кто подписывает** — сотрудник, указанный в заявке (поле «Сотрудник»). Его почта передаётся и как
|
||||
получатель, и как `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) нужной компании.
|
||||
Запрос один на все файлы: ЭДО принимает его целиком или отклоняет целиком (например, если у какой-то
|
||||
компании не распознан), поэтому повтор безопасен. Сертификат Sigex компании нужен ЭДО только для mTLS
|
||||
(карточка документа): без него документ принимается и подписывается как обычно. Ответ 200 значит
|
||||
«принято»: в зелёном сообщении просто «Документы отправлены на подписание в ЭДО».
|
||||
- **ИИН** берётся из `GET /Employee/ByEmail` (поле `Iin`), **БИН компаний** — из `GET /Dictionary/Companies`
|
||||
по `CompanyId` каждой записи сотрудника (`shared/edo/signingTarget.ts`). Компания без БИН в справочнике
|
||||
пропускается и показывается в сообщении; остальные уходят.
|
||||
- **С каким ЭДО работать** — свойство веб-части «С каким ЭДО работать» (`edoApiBaseUrl`): стейдж или
|
||||
локальный бэкенд. По умолчанию стейдж. Токен берётся для того же AAD-приложения, что у approval-route.
|
||||
- **Отпуск без содержания в ЭДО не подписывается** (`UnpaidLeave`): подписание подключено только к этой форме.
|
||||
- **Неудача отправки** показывается сообщением над панелью решения и согласование не отменяет; рядом
|
||||
кнопка «Повторить отправку на подпись». Признака «уже отправлено» в заявке нет, поэтому после
|
||||
перезагрузки страницы повтор недоступен.
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
useFormExecutors,
|
||||
ExecutorsMatrix,
|
||||
approvalPhaseRows,
|
||||
executorsToWrite,
|
||||
initialSubmitStatus,
|
||||
getPlannedMatrix,
|
||||
resolveWriteListId,
|
||||
notifyActiveStep,
|
||||
@@ -29,7 +34,7 @@ import {
|
||||
} from '../../shared/approval';
|
||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||
import { IRequestFormProps } from '../../models/types';
|
||||
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||
import { getEmployeeOrganizations, formatEmployeeOrganizationsWithDepartment, IEmployeeOrganization } from '../../shared/dictionary';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
|
||||
import {
|
||||
@@ -104,6 +109,15 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
};
|
||||
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||
|
||||
// Должность и подразделение больше не вводятся руками: берутся из ПЕРВОЙ (основной)
|
||||
// записи Employee/ByEmail и пишутся в скалярные колонки Position/Department (их
|
||||
// читают документ и ЭДО); по каждой организации то же видно в таблице (Organization).
|
||||
const primaryOrgFields = (records: IEmployeeOrganization[]): Pick<IFormData, 'Position' | 'Department' | 'DepartmentId'> => ({
|
||||
Position: records[0]?.position || '',
|
||||
Department: records[0]?.section || '',
|
||||
DepartmentId: records[0]?.departmentId || ''
|
||||
});
|
||||
|
||||
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
|
||||
// его должность и организации (Employee/ByEmail — см. shared/dictionary/
|
||||
// employeeOrganizations.service.ts; сотрудник может состоять сразу в
|
||||
@@ -120,8 +134,8 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
if (!alive) return;
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
Position: prev.Position || records[0]?.position || prev.Position,
|
||||
Organization: formatEmployeeOrganizations(records)
|
||||
...primaryOrgFields(records),
|
||||
Organization: formatEmployeeOrganizationsWithDepartment(records)
|
||||
}));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
@@ -137,15 +151,15 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
const handleEmployeeChange = (login: string): void => {
|
||||
setData((prev) => ({ ...prev, Employee: login }));
|
||||
if (!login) {
|
||||
setData((prev) => ({ ...prev, Organization: '' }));
|
||||
setData((prev) => ({ ...prev, ...primaryOrgFields([]), Organization: '' }));
|
||||
return;
|
||||
}
|
||||
getEmployeeOrganizations(context, login)
|
||||
.then((records) =>
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
Position: records[0]?.position || prev.Position,
|
||||
Organization: formatEmployeeOrganizations(records)
|
||||
...primaryOrgFields(records),
|
||||
Organization: formatEmployeeOrganizationsWithDepartment(records)
|
||||
}))
|
||||
)
|
||||
.catch(() => undefined);
|
||||
@@ -157,7 +171,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
const loadMatrix = useCallback(
|
||||
() =>
|
||||
editId
|
||||
? approval.getMatrixForRequest(editId)
|
||||
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
|
||||
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'),
|
||||
[context, approval, editId, data.Employee, requestTypeId]
|
||||
);
|
||||
@@ -169,6 +183,17 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
(login) => approval.getJobTitle(login)
|
||||
);
|
||||
|
||||
// Исполнители из маршрута админки (фаза «Исполнение»): только чтение, таблица
|
||||
// и запись в заявку — когда они в маршруте есть.
|
||||
const executors = useFormExecutors({
|
||||
readOnly,
|
||||
context,
|
||||
approval,
|
||||
requestTypeId,
|
||||
userEmail: data.Employee,
|
||||
editId
|
||||
});
|
||||
|
||||
const daysNum = Number(data.DaysCount);
|
||||
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
|
||||
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт
|
||||
@@ -177,7 +202,6 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate;
|
||||
const isValid = !!(
|
||||
data.Employee &&
|
||||
data.Position.trim() &&
|
||||
data.StartDate &&
|
||||
isStartDateValid &&
|
||||
Number.isFinite(daysNum) &&
|
||||
@@ -187,7 +211,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
if (data.StartDate && !isStartDateValid) {
|
||||
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
|
||||
}
|
||||
if (!isValid) return 'Заполните обязательные поля: сотрудник, должность, дата начала, количество дней (> 0).';
|
||||
if (!isValid) return 'Заполните обязательные поля: сотрудник, дата начала, количество дней (> 0).';
|
||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||
return undefined;
|
||||
@@ -223,6 +247,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
rtId,
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
executorsToWrite(executors.rows),
|
||||
status,
|
||||
writeListId
|
||||
);
|
||||
@@ -238,7 +263,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
// Имена списков — той же партиции, куда фактически записали (writeListId),
|
||||
// не обязательно текущей на момент рендера формы: поток ищет строку
|
||||
// маршрута именно в НЕЙ.
|
||||
if (status === REQUEST_STATUS.onApproval) {
|
||||
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||
await notifyActiveStep(context, approval, id, {
|
||||
requestTypeId: rtId,
|
||||
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||
@@ -260,11 +285,27 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||
);
|
||||
if (unassignedError) return unassignedError;
|
||||
const unassignedExecutorError = unassignedStepMessage(
|
||||
executors.rows,
|
||||
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||
);
|
||||
if (unassignedExecutorError) return unassignedExecutorError;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
|
||||
// Есть исполнители — initialSubmitStatus учтёт и маршрут без согласующих (сразу «Выполнение»);
|
||||
// иначе прежнее поведение: «На согласовании».
|
||||
const handleSubmit = (): Promise<void> =>
|
||||
submit.run(
|
||||
'submit',
|
||||
save(
|
||||
executors.rows.length > 0
|
||||
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
|
||||
: REQUEST_STATUS.onApproval
|
||||
),
|
||||
validateSubmit
|
||||
);
|
||||
|
||||
const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange };
|
||||
|
||||
@@ -321,6 +362,8 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||
/>
|
||||
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
|
||||
@@ -10,10 +10,14 @@ import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
useViewExecutors,
|
||||
ExecutorsMatrix,
|
||||
approvalPhaseRows,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId
|
||||
} from '../../shared/approval';
|
||||
import { REQUEST_STATUS } from '../../shared/approval/models/statuses';
|
||||
import { isApprovalPhaseCompleted } from '../../shared/approval/models/statuses';
|
||||
import { executionPhaseRows } from '../../shared/approval/useRouteExecutors';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import {
|
||||
approvalListTitle,
|
||||
@@ -24,7 +28,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';
|
||||
|
||||
@@ -61,8 +65,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);
|
||||
|
||||
@@ -78,10 +83,12 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
||||
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
||||
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
|
||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||
approval.getJobTitle(l)
|
||||
);
|
||||
// Исполнители (фаза «Исполнение») — отдельная таблица, если они есть в маршруте.
|
||||
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -134,25 +141,38 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
// Все согласовали → отправляем заявление на подпись сотруднику, на которого оформлен отпуск.
|
||||
// Решение к этому моменту уже применено, поэтому неудача отправки показывается отдельным
|
||||
// сообщением и согласование не отменяет. Повторить можно кнопкой, пока страница не закрыта:
|
||||
// признака «уже отправлено» в заявке нет, и после перезагрузки повтор был бы дублем.
|
||||
const sendToSigning = (): Promise<void> => {
|
||||
// Все согласовали → отправляем заявление на подпись сотруднику, на которого оформлен отпуск, —
|
||||
// по документу на каждую его компанию, одним запросом. Решение к этому моменту уже применено,
|
||||
// поэтому неудача показывается отдельным сообщением и согласование не отменяет. Повторить можно
|
||||
// кнопкой: запрос принимается целиком или отклоняется целиком, а повтор уже принятого ЭДО
|
||||
// отклонит как дубль — признака «уже отправлено» в заявке нет.
|
||||
// Возвращает фразу для зелёного сообщения панели решения, если документы ушли; при неудаче —
|
||||
// ничего (ошибку и кнопку повтора показывает блок ниже).
|
||||
const sendToSigning = (): Promise<string | undefined> => {
|
||||
setSigningError('');
|
||||
setSigningInfo('Отправляем заявление на подпись в ЭДО…');
|
||||
setSigningResult(undefined);
|
||||
setSigningBusy(true);
|
||||
return sendVacationForSigning(context, { data, employeeName, itemId, code })
|
||||
.then(() => {
|
||||
setSigningInfo(`Заявление отправлено на подпись: письмо со ссылкой ушло на ${data.Employee}.`);
|
||||
// Исполнитель — первый в фазе исполнения маршрута; берём свежие строки, а не те, что на экране:
|
||||
// решение только что применено, и экран мог ещё не перечитать маршрут.
|
||||
return approval
|
||||
.getMatrixForRequest(itemId)
|
||||
.then((rows) => executionPhaseRows(rows).filter((r) => r.email)[0]?.email)
|
||||
.catch((): string | undefined => undefined)
|
||||
.then((initiatorEmail) => sendVacationForSigning(context, { data, employeeName, itemId, code, initiatorEmail }))
|
||||
.then((result): string | undefined => {
|
||||
setSigningResult(result);
|
||||
return 'Документы отправлены на подписание в ЭДО.';
|
||||
})
|
||||
.catch((e) => {
|
||||
setSigningInfo('');
|
||||
.catch((e): string | undefined => {
|
||||
setSigningError(
|
||||
`Заявка согласована, но отправить заявление на подпись не удалось: ${friendlyErrorMessage(e)}`
|
||||
);
|
||||
return undefined;
|
||||
})
|
||||
.then(() => setSigningBusy(false));
|
||||
.then((note) => {
|
||||
setSigningBusy(false);
|
||||
return note;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -180,6 +200,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
||||
</FormCard>
|
||||
|
||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
@@ -190,11 +211,16 @@ 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 && 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>
|
||||
</>
|
||||
@@ -207,10 +233,14 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
// «Согласовано» = маршрут пройден целиком (фазы исполнения у отпуска нет).
|
||||
// Это решение завершило согласование: подписывать пора, а не когда заявка «Согласовано».
|
||||
// Если в маршруте есть исполнитель, последний согласующий переводит заявку сразу в
|
||||
// «Выполнение», и проверка только на «Согласовано» пропускала такие заявки. Предыдущий
|
||||
// статус (`status` — до этого решения) отсекает решения исполнителя.
|
||||
// Промис возвращаем: пока он не разрешится, панель решения не пишет «Решение
|
||||
// сохранено» — иначе успех появлялся бы раньше, чем заявление ушло на подпись.
|
||||
return newStatus === REQUEST_STATUS.approved ? sendToSigning() : undefined;
|
||||
// сохранено» — иначе успех появлялся бы раньше, чем заявление ушло на подпись; а его
|
||||
// результат панель допишет к зелёному сообщению.
|
||||
return isApprovalPhaseCompleted(status, newStatus) ? sendToSigning() : undefined;
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as React from 'react';
|
||||
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
|
||||
import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
|
||||
import { FormGrid, FormField, TextInput, PeopleField, OrganizationTable } from '../../../shared/ui';
|
||||
import { ProjectPicker } from '../../../shared/dictionary';
|
||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
|
||||
import { isoDatePlusDays } from '../../../shared/dateUtils';
|
||||
|
||||
/** Единственная секция формы «Заявка на отпуск»: сотрудник, должность, даты. */
|
||||
/** Единственная секция формы «Заявка на отпуск»: сотрудник (должность и подразделение подтягиваются из справочника), даты. */
|
||||
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => (
|
||||
<FormGrid>
|
||||
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||
@@ -15,21 +15,7 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
|
||||
placeholder={EMPLOYEE_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Должность" htmlFor="Position" required>
|
||||
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
||||
</FormField>
|
||||
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
|
||||
<FormField label="Подразделение" htmlFor="Department">
|
||||
<DepartmentPicker
|
||||
id="Department"
|
||||
context={context}
|
||||
value={data.Department}
|
||||
onChange={(name, id) => {
|
||||
set('Department', name);
|
||||
set('DepartmentId', id);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<OrganizationTable value={data.Organization} printIndex={printOrgIndex} />
|
||||
<FormField label="Проект" htmlFor="Project">
|
||||
<ProjectPicker
|
||||
id="Project"
|
||||
|
||||
@@ -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';
|
||||
@@ -15,6 +15,19 @@ export interface IVacationToSign {
|
||||
/** Уникальный код заявки (`RequestUniqueCode`); пусто — берётся id. */
|
||||
code: string;
|
||||
itemId: number;
|
||||
/**
|
||||
* Почта исполнителя заявки (тот, кто идёт в маршруте после согласующих): он «инициатор» в ЭДО, и
|
||||
* на его почту придёт сообщение о подписи. Нет исполнителя в маршруте — инициатора нет, и никто не уведомляется о подписях.
|
||||
*/
|
||||
initiatorEmail?: string;
|
||||
}
|
||||
|
||||
/** Итог отправки: по каким компаниям документ ушёл и по каким нельзя. */
|
||||
export interface IVacationSigningResult {
|
||||
/** Названия компаний, по которым заявление отправлено на подпись. */
|
||||
sent: string[];
|
||||
/** Компании сотрудника, по которым документ не собрать (нет БИН в справочнике), и почему. */
|
||||
skipped: ISkippedCompany[];
|
||||
}
|
||||
|
||||
/** Сегодняшняя дата `дд.мм.гггг` по местному времени — дата составления заявления. */
|
||||
@@ -37,46 +50,69 @@ 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
|
||||
})
|
||||
}))
|
||||
);
|
||||
|
||||
await submitSigningRequest(context, {
|
||||
recipient: { iin: targets.iin, email: data.Employee, name: targets.name },
|
||||
initiatorEmail: vacation.initiatorEmail,
|
||||
files
|
||||
});
|
||||
|
||||
return submitSigningRequest(context, {
|
||||
recipient: { iin: target.iin, email: data.Employee, name: target.name },
|
||||
companyBin: target.companyBin,
|
||||
initiatorEmail: data.Employee,
|
||||
file: { name: vacationFileName(employeeName, documentDate), content: file }
|
||||
});
|
||||
return {
|
||||
sent: targets.companies.map((c) => c.name),
|
||||
skipped: targets.skipped
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export type ISaveResult = ISaveItemResult;
|
||||
/**
|
||||
* Сохранить заявку на отпуск: содержимое + маршрут согласования.
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* Фазы исполнения у отпуска нет — writeRoute вызывается с пустым списком исполнителей.
|
||||
* executors — исполнители фазы «Исполнение» из маршрута админки (пусто, если в маршруте их нет).
|
||||
* Возвращает Id заявки (+ код при создании).
|
||||
*
|
||||
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
||||
@@ -70,6 +70,7 @@ export async function saveVacationRequest(
|
||||
requestTypeId: number,
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
executors: IApproverToWrite[],
|
||||
status: string,
|
||||
listId?: string
|
||||
): Promise<ISaveResult> {
|
||||
@@ -79,6 +80,6 @@ export async function saveVacationRequest(
|
||||
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId);
|
||||
|
||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||
await writeRoute(vacationApprovalService(listId), result.id, approvers, [], status);
|
||||
await writeRoute(vacationApprovalService(listId), result.id, approvers, executors, status);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
canEditExecutors,
|
||||
getPlannedMatrix,
|
||||
resolveWriteListId,
|
||||
notifyActiveStep,
|
||||
@@ -213,7 +214,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
||||
const executors = useMatrixEditor(
|
||||
loadExecutors,
|
||||
`${userEmail}-${editId ?? 'new'}-${data.DepartmentId}`,
|
||||
false,
|
||||
canEditExecutors(context, readOnly),
|
||||
!!editId || !!requestTypeId,
|
||||
(login) => approval.getJobTitle(login)
|
||||
);
|
||||
@@ -398,12 +399,14 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
||||
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||
/>
|
||||
|
||||
{!executors.loading && executors.rows.length > 0 && (
|
||||
{!executors.loading && (executors.editable || executors.rows.length > 0) && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title="Исполнение (после согласования)"
|
||||
roleLabel="Исполнитель"
|
||||
canRemoveRow={executors.editable ? () => true : undefined}
|
||||
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
|
||||
emptyText="Исполнители не заданы по маршруту."
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface IApproverOrgInfo {
|
||||
* email не найден в кадровом справочнике (404) или пуст — getEmployeeOrganizations
|
||||
* отдаёт [], здесь это просто пустые отдел/организация, не ошибка матрицы.
|
||||
*/
|
||||
export function resolveApproverOrgInfo(context: WebPartContext) {
|
||||
export function resolveApproverOrgInfo(context: WebPartContext): (email: string) => Promise<IApproverOrgInfo> {
|
||||
return async (email: string): Promise<IApproverOrgInfo> => {
|
||||
const records = await getEmployeeOrganizations(context, email);
|
||||
const first = records[0];
|
||||
|
||||
@@ -63,11 +63,19 @@ export { notifyActiveStep } from './notify.service';
|
||||
export type { INotifyTarget } from './notify.service';
|
||||
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||
export {
|
||||
approvalPhaseRows,
|
||||
executionPhaseRows,
|
||||
executorsToWrite,
|
||||
useFormExecutors,
|
||||
useViewExecutors
|
||||
} from './useRouteExecutors';
|
||||
export { resolveApproverOrgInfo } from './approverOrgInfo';
|
||||
export type { IApproverOrgInfo } from './approverOrgInfo';
|
||||
export { canEditRoute, composeRemoveRowRule } from './routeEditRights';
|
||||
export { canEditRoute, canEditExecutors, composeRemoveRowRule } from './routeEditRights';
|
||||
export { canEditAllFields } from './fullEditRights';
|
||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||
export { ExecutorsMatrix } from './ui/ExecutorsMatrix';
|
||||
export { ApprovalHistory } from './history/ApprovalHistory';
|
||||
export { ApprovalHistoryFeed } from './history/ApprovalHistoryFeed';
|
||||
export { HISTORY_TEXT } from './history/historyTexts';
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
REQUEST_STATUS,
|
||||
REQUEST_STATUS_CHOICES,
|
||||
initialSubmitStatus,
|
||||
isApprovalPhaseCompleted,
|
||||
isEditableStatus,
|
||||
isSubmittedStatus,
|
||||
} from './statuses';
|
||||
@@ -54,6 +55,41 @@ describe('initialSubmitStatus', () => {
|
||||
// возвращает initialSubmitStatus, ОБЯЗАН считаться отправкой: иначе строки
|
||||
// маршрута получают недопустимый статус, а активации не происходит вовсе —
|
||||
// именно так заявка без согласующих перестала попадать в очередь исполнения.
|
||||
describe('isApprovalPhaseCompleted', () => {
|
||||
it('последний согласующий без исполнителей: «На согласовании» → «Согласовано»', () => {
|
||||
expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.approved)).toBe(true);
|
||||
});
|
||||
|
||||
// Тот самый случай: в маршруте есть исполнитель, и заявка сразу становится «Выполнение».
|
||||
it('последний согласующий при наличии исполнителя: «На согласовании» → «Выполнение»', () => {
|
||||
expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.execution)).toBe(true);
|
||||
});
|
||||
|
||||
it('согласование завершилось, исполнители уже отработали: → «Закрыта»', () => {
|
||||
expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.closed)).toBe(true);
|
||||
});
|
||||
|
||||
it('согласуют не последние: заявка остаётся «На согласовании»', () => {
|
||||
expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.onApproval)).toBe(false);
|
||||
});
|
||||
|
||||
it('возврат и отклонение не завершают согласование', () => {
|
||||
expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.returned)).toBe(false);
|
||||
expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.rejected)).toBe(false);
|
||||
});
|
||||
|
||||
// Решение исполнителя не должно второй раз запускать то, что делается по итогам согласования.
|
||||
it('решение исполнителя — не завершение согласования', () => {
|
||||
expect(isApprovalPhaseCompleted(REQUEST_STATUS.execution, REQUEST_STATUS.execution)).toBe(false);
|
||||
expect(isApprovalPhaseCompleted(REQUEST_STATUS.execution, REQUEST_STATUS.closed)).toBe(false);
|
||||
});
|
||||
|
||||
it('предыдущий статус неизвестен — не гадаем', () => {
|
||||
expect(isApprovalPhaseCompleted(undefined, REQUEST_STATUS.approved)).toBe(false);
|
||||
expect(isApprovalPhaseCompleted('', REQUEST_STATUS.execution)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSubmittedStatus', () => {
|
||||
it('любой результат initialSubmitStatus считается отправкой', () => {
|
||||
[initialSubmitStatus(1, 0), initialSubmitStatus(0, 1), initialSubmitStatus(0, 0)].forEach((s) =>
|
||||
|
||||
@@ -61,6 +61,21 @@ export function initialSubmitStatus(approversCount: number, executorsCount: numb
|
||||
return REQUEST_STATUS.approved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Это решение завершило фазу СОГЛАСОВАНИЯ: заявка была «На согласовании», а стала «Согласовано»,
|
||||
* «Выполнение» или «Закрыта».
|
||||
*
|
||||
* Статус «Согласовано» бывает только у маршрута без исполнителей. Если исполнитель в маршруте
|
||||
* есть, последний согласующий переводит заявку сразу в «Выполнение», и проверять только
|
||||
* «Согласовано» — значит пропускать такие заявки. Сравнение с ПРЕДЫДУЩИМ статусом нужно затем,
|
||||
* чтобы решение исполнителя («Выполнение» → «Выполнение»/«Закрыта») не принималось за
|
||||
* завершение согласования и побочное действие не срабатывало второй раз.
|
||||
*/
|
||||
export function isApprovalPhaseCompleted(previous: string | undefined, next: string): boolean {
|
||||
if (previous !== REQUEST_STATUS.onApproval) return false;
|
||||
return next === REQUEST_STATUS.approved || next === REQUEST_STATUS.execution || next === REQUEST_STATUS.closed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Статус заявки означает «отправлена» (в отличие от черновика)?
|
||||
*
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { effectiveUserEmail } from '../impersonation';
|
||||
|
||||
/**
|
||||
* Кто может удалять согласующих из маршрута (и при составлении заявки, и на
|
||||
* своём шаге согласования) — сверх обычных прав инициатора/согласующего.
|
||||
@@ -30,3 +33,15 @@ export function canEditRoute(email: string | undefined): boolean {
|
||||
export function composeRemoveRowRule(email: string | undefined): (() => boolean) | undefined {
|
||||
return canEditRoute(email) ? () => true : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Может ли текущий пользователь править состав ИСПОЛНИТЕЛЕЙ (добавлять и удалять)
|
||||
* при составлении заявки: те же редакторы маршрута, что и для согласующих.
|
||||
* Исполнителей по умолчанию назначает маршрут из админки, заявителю они только
|
||||
* показываются; редактору маршрута матрица «Исполнение» становится редактируемой.
|
||||
* Учитывает подмену личности (effectiveUserEmail). readOnly — заявка уже не
|
||||
* редактируется (отправлена), тогда править нельзя никому.
|
||||
*/
|
||||
export function canEditExecutors(context: WebPartContext, readOnly: boolean): boolean {
|
||||
return !readOnly && canEditRoute(effectiveUserEmail(context));
|
||||
}
|
||||
|
||||
@@ -23,8 +23,12 @@ interface IDecisionPanelProps {
|
||||
* Отказ (reject) панель НЕ считает провалом решения — решение к этому моменту
|
||||
* уже применено на сервере; сообщить о проблеме побочного действия должен сам
|
||||
* обработчик (у него для этого есть своя область экрана).
|
||||
*
|
||||
* Может вернуть строку — её панель допишет к зелёному сообщению «Решение сохранено…»
|
||||
* (например, «Документы отправлены на подписание в ЭДО»). Нет побочного действия или оно
|
||||
* не удалось — не возвращайте ничего, сообщение останется прежним.
|
||||
*/
|
||||
onDecided?: (status: string) => void | Promise<void>;
|
||||
onDecided?: (status: string) => void | string | undefined | Promise<void | string | undefined>;
|
||||
/** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные
|
||||
* согласующим). Если бросит — решение не применяется, показывается ошибка.
|
||||
* decision — какое решение применяется (проверки, нужные только при
|
||||
@@ -66,6 +70,8 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
||||
const [comment, setComment] = useState<string>('');
|
||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||
const [resultStatus, setResultStatus] = useState<string | undefined>(undefined);
|
||||
// Что обработчик onDecided велел дописать к сообщению об успехе.
|
||||
const [resultNote, setResultNote] = useState<string | undefined>(undefined);
|
||||
const [delegated, setDelegated] = useState<number>(0);
|
||||
const [showDelegate, setShowDelegate] = useState<boolean>(false);
|
||||
// Выбранные делеганты копятся здесь и пишутся одной пачкой по кнопке — иначе
|
||||
@@ -119,13 +125,16 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
||||
// отработал, кнопки остаются заблокированными (submitting), и пользователь не
|
||||
// видит успеха раньше, чем всё сделано. Провал onDecided на статус решения не
|
||||
// влияет — оно уже применено, поэтому сообщение показываем всё равно.
|
||||
let note: string | undefined;
|
||||
if (onDecided) {
|
||||
try {
|
||||
await onDecided(status);
|
||||
const returned = await onDecided(status);
|
||||
note = typeof returned === 'string' && returned ? returned : undefined;
|
||||
} catch (e) {
|
||||
console.error('Побочное действие после решения не выполнилось', e);
|
||||
}
|
||||
}
|
||||
setResultNote(note);
|
||||
setResultStatus(status);
|
||||
setSubmitting(false);
|
||||
} catch (e) {
|
||||
@@ -205,7 +214,7 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
||||
<div className={styles.panel}>
|
||||
<Alert variant="ok">
|
||||
{isExecution ? 'Отмечено как выполнено' : 'Решение сохранено'}. Статус заявки: «
|
||||
{resultStatus}».
|
||||
{resultStatus}».{resultNote ? ` ${resultNote}` : ''}
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
import { ISiteUser } from '../models/ISiteUser';
|
||||
import { IUseMatrixEditor } from '../useMatrixEditor';
|
||||
import { ApprovalMatrix } from './ApprovalMatrix';
|
||||
|
||||
interface IExecutorsMatrixProps {
|
||||
editor: IUseMatrixEditor;
|
||||
search: (query: string) => Promise<ISiteUser[]>;
|
||||
/** Заголовок; по умолчанию «Исполнение». */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Таблица исполнителей — рисуется только когда в маршруте из админки они есть:
|
||||
* у форм без фазы исполнения блока нет вовсе (кроме редактора маршрута, см. canEditExecutors).
|
||||
*/
|
||||
export const ExecutorsMatrix: React.FC<IExecutorsMatrixProps> = ({ editor, search, title = 'Исполнение' }) => {
|
||||
// Редактор маршрута видит блок и пустым — иначе исполнителя некуда добавить.
|
||||
if (editor.loading || (editor.rows.length === 0 && !editor.editable)) return null;
|
||||
return (
|
||||
<ApprovalMatrix
|
||||
editor={editor}
|
||||
search={search}
|
||||
title={title}
|
||||
roleLabel="Исполнитель"
|
||||
canRemoveRow={editor.editable ? () => true : undefined}
|
||||
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
|
||||
emptyText="Исполнители не назначены."
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useCallback } from 'react';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
|
||||
import { getPlannedMatrix } from './approvalSubmission.service';
|
||||
import { resolveApproverOrgInfo } from './approverOrgInfo';
|
||||
import { IResolveFieldValue } from './models/IApprovalRouteApi';
|
||||
import { IMatrixRow } from './models/IMatrixRow';
|
||||
import { APPROVAL_PHASE } from './models/statuses';
|
||||
import { canEditExecutors } from './routeEditRights';
|
||||
import { IUseMatrixEditor, useMatrixEditor } from './useMatrixEditor';
|
||||
|
||||
/** Строки фазы согласования (всё, что не «Исполнение») — для таблицы согласующих. */
|
||||
export const approvalPhaseRows = (rows: IMatrixRow[]): IMatrixRow[] =>
|
||||
rows.filter((r) => r.phase !== APPROVAL_PHASE.execution);
|
||||
|
||||
/** Строки фазы исполнения — для таблицы исполнителей. */
|
||||
export const executionPhaseRows = (rows: IMatrixRow[]): IMatrixRow[] =>
|
||||
rows.filter((r) => r.phase === APPROVAL_PHASE.execution);
|
||||
|
||||
/**
|
||||
* Строки матрицы исполнителей → то, что пишет writeRoute. Номер шага
|
||||
* нормализуется к 1..N внутри фазы: у уже записанных строк он сквозной (со
|
||||
* смещением на согласующих), а writeRoute смещает фазу сам.
|
||||
*/
|
||||
export const executorsToWrite = (rows: IMatrixRow[]): IApproverToWrite[] => {
|
||||
const base = rows.reduce((min, r) => Math.min(min, r.order), Number.MAX_SAFE_INTEGER);
|
||||
return rows.map((r) => ({
|
||||
email: r.email,
|
||||
position: r.position,
|
||||
allowReassign: r.allowReassign,
|
||||
order: r.order - base + 1,
|
||||
slaHours: r.slaHours,
|
||||
askOverdueReason: r.askOverdueReason,
|
||||
autoApprove: r.autoApprove
|
||||
}));
|
||||
};
|
||||
|
||||
interface IFormExecutorsSource {
|
||||
context: WebPartContext;
|
||||
approval: ApprovalListService;
|
||||
requestTypeId: number | undefined;
|
||||
/** E-mail, по которому резолвится маршрут (инициатор/выбранный сотрудник). */
|
||||
userEmail: string;
|
||||
/** Редактируемая заявка — исполнители читаются из её строк, а не планируются заново. */
|
||||
editId: number | undefined;
|
||||
/** Те же параметры, что форма отдаёт getPlannedMatrix для согласующих. */
|
||||
departmentId?: string | null; // eslint-disable-line @rushstack/no-new-null -- транзитом в resolveRoute
|
||||
fieldValues?: IResolveFieldValue[];
|
||||
projectId?: string | null; // eslint-disable-line @rushstack/no-new-null -- транзитом в resolveRoute
|
||||
/** Заявка не редактируется — правка состава исполнителей закрыта для всех. */
|
||||
readOnly?: boolean;
|
||||
/** Дополнительный ключ перезагрузки (значение поля, влияющее на маршрут). */
|
||||
reloadSuffix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Исполнители формы заполнения (фаза «Исполнение»): планируемые по маршруту из
|
||||
* админки для новой заявки, из записанных строк — для черновика/доработки.
|
||||
* Заявителю — только чтение: исполнителя назначает маршрут. Редактор маршрута
|
||||
* (см. canEditExecutors) может добавлять и удалять исполнителей.
|
||||
*/
|
||||
export function useFormExecutors(source: IFormExecutorsSource): IUseMatrixEditor {
|
||||
const { context, approval, requestTypeId, userEmail, editId, departmentId, fieldValues, projectId, reloadSuffix = '', readOnly = false } = source;
|
||||
const fieldValuesKey = JSON.stringify(fieldValues ?? []);
|
||||
const load = useCallback(
|
||||
() =>
|
||||
editId
|
||||
? approval.getMatrixForRequest(editId).then(executionPhaseRows)
|
||||
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution', departmentId, fieldValues, projectId),
|
||||
// fieldValues сравниваем по содержимому: форма пересоздаёт массив на каждый рендер.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[context, approval, editId, requestTypeId, userEmail, departmentId, fieldValuesKey, projectId]
|
||||
);
|
||||
return useMatrixEditor(
|
||||
load,
|
||||
`x-${userEmail}-${editId ?? 'new'}-${departmentId ?? ''}-${projectId ?? ''}-${fieldValuesKey}-${reloadSuffix}`,
|
||||
canEditExecutors(context, readOnly),
|
||||
!!editId || (!!userEmail && !!requestTypeId),
|
||||
(login) => approval.getJobTitle(login),
|
||||
resolveApproverOrgInfo(context)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Исполнители вьюхи просмотра — из фактических строк заявки. decisionSeq в
|
||||
* ключе: после решения/замены строки на сервере меняются — перечитываем.
|
||||
*/
|
||||
export function useViewExecutors(
|
||||
context: WebPartContext,
|
||||
approval: ApprovalListService,
|
||||
itemId: number,
|
||||
decisionSeq: number
|
||||
): IUseMatrixEditor {
|
||||
const load = useCallback(
|
||||
() => approval.getMatrixForRequest(itemId).then(executionPhaseRows),
|
||||
[approval, itemId]
|
||||
);
|
||||
return useMatrixEditor(
|
||||
load,
|
||||
`e-${itemId}-${decisionSeq}`,
|
||||
false,
|
||||
true,
|
||||
(l) => approval.getJobTitle(l),
|
||||
resolveApproverOrgInfo(context)
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
|
||||
import { IStorageLike, readCachedCompanies, writeCachedCompanies } from './companiesCache';
|
||||
import { ICompany } from './models/ICompany';
|
||||
|
||||
// GET /Dictionary/Companies — справочник компаний, ответ — плоский массив
|
||||
// { Id, Name } (без обёртки, как /Dictionary/Departments).
|
||||
// { Id, Name, Bin } (без обёртки, как /Dictionary/Departments).
|
||||
async function fetchCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||
const response: HttpClientResponse = await client.get(
|
||||
@@ -17,12 +18,33 @@ async function fetchCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Справочник в рамках сессии не меняется — грузим один раз и отдаём тот же
|
||||
// промис повторным вызовам (как в departments.service).
|
||||
/** localStorage, если он есть: в некоторых режимах браузера обращение к нему бросает исключение. */
|
||||
function browserStorage(): IStorageLike | undefined {
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||
const storage = browserStorage();
|
||||
const cached = storage ? readCachedCompanies(storage, Date.now()) : undefined;
|
||||
if (cached) return cached;
|
||||
|
||||
const fresh = await fetchCompanies(context);
|
||||
if (storage) writeCachedCompanies(storage, fresh, Date.now());
|
||||
return fresh;
|
||||
}
|
||||
|
||||
// Справочник живёт в localStorage 4 часа (см. companiesCache.ts) — между перезагрузками страницы
|
||||
// к API не ходим. Поверх этого один промис на сессию: параллельные вызовы делят один запрос и не
|
||||
// читают хранилище заново (как в departments.service). Ошибку не кешируем — следующий вызов
|
||||
// попробует ещё раз.
|
||||
let cache: Promise<ICompany[]> | undefined;
|
||||
export function getCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||
if (!cache) {
|
||||
cache = fetchCompanies(context).catch((e) => {
|
||||
cache = loadCompanies(context).catch((e) => {
|
||||
cache = undefined;
|
||||
throw e;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
COMPANIES_CACHE_KEY,
|
||||
COMPANIES_CACHE_TTL_MS,
|
||||
IStorageLike,
|
||||
readCachedCompanies,
|
||||
writeCachedCompanies
|
||||
} from './companiesCache';
|
||||
import { ICompany } from './models/ICompany';
|
||||
|
||||
class MemoryStorage implements IStorageLike {
|
||||
public readonly data = new Map<string, string>();
|
||||
// eslint-disable-next-line @rushstack/no-new-null
|
||||
public getItem(key: string): string | null {
|
||||
return this.data.has(key) ? (this.data.get(key) as string) : null;
|
||||
}
|
||||
public setItem(key: string, value: string): void {
|
||||
this.data.set(key, value);
|
||||
}
|
||||
public removeItem(key: string): void {
|
||||
this.data.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const COMPANIES: ICompany[] = [
|
||||
{ Id: '9de064a4-44c8-88ef-11ec-8284930fad96', Name: 'ТОО TANSU Construction', Bin: '021040005165' },
|
||||
{ Id: '0a1b2c3d-0000-4000-8000-000000000002', Name: 'ТОО TANSU Строй' }
|
||||
];
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
|
||||
describe('кэш справочника компаний', () => {
|
||||
it('в пустом хранилище кэша нет', () => {
|
||||
expect(readCachedCompanies(new MemoryStorage(), NOW)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('записанное читается обратно, пока не прошло 4 часа', () => {
|
||||
const storage = new MemoryStorage();
|
||||
writeCachedCompanies(storage, COMPANIES, NOW);
|
||||
|
||||
expect(readCachedCompanies(storage, NOW + 1000)).toEqual(COMPANIES);
|
||||
expect(readCachedCompanies(storage, NOW + COMPANIES_CACHE_TTL_MS - 1)).toEqual(COMPANIES);
|
||||
});
|
||||
|
||||
it('через 4 часа кэш устаревает и удаляется', () => {
|
||||
const storage = new MemoryStorage();
|
||||
writeCachedCompanies(storage, COMPANIES, NOW);
|
||||
|
||||
expect(readCachedCompanies(storage, NOW + COMPANIES_CACHE_TTL_MS)).toBeUndefined();
|
||||
expect(storage.data.has(COMPANIES_CACHE_KEY)).toBe(false);
|
||||
});
|
||||
|
||||
it('срок — ровно 4 часа', () => {
|
||||
expect(COMPANIES_CACHE_TTL_MS).toBe(14_400_000);
|
||||
});
|
||||
|
||||
it('запись из будущего (часы переведены назад) не доверяется', () => {
|
||||
const storage = new MemoryStorage();
|
||||
writeCachedCompanies(storage, COMPANIES, NOW + 60_000);
|
||||
|
||||
expect(readCachedCompanies(storage, NOW)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('битый JSON и неожиданная форма — как отсутствующий кэш', () => {
|
||||
const broken = new MemoryStorage();
|
||||
broken.setItem(COMPANIES_CACHE_KEY, '{не json');
|
||||
expect(readCachedCompanies(broken, NOW)).toBeUndefined();
|
||||
expect(broken.data.has(COMPANIES_CACHE_KEY)).toBe(false);
|
||||
|
||||
const wrongShape = new MemoryStorage();
|
||||
wrongShape.setItem(COMPANIES_CACHE_KEY, JSON.stringify({ savedAt: NOW, items: [{ Id: 1 }] }));
|
||||
expect(readCachedCompanies(wrongShape, NOW)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('недоступное хранилище не ломает ни чтение, ни запись', () => {
|
||||
const throwing: IStorageLike = {
|
||||
getItem: () => {
|
||||
throw new Error('запрещено');
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('переполнено');
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error('запрещено');
|
||||
}
|
||||
};
|
||||
|
||||
expect(readCachedCompanies(throwing, NOW)).toBeUndefined();
|
||||
expect(() => writeCachedCompanies(throwing, COMPANIES, NOW)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ICompany } from './models/ICompany';
|
||||
|
||||
/**
|
||||
* Ключ в localStorage. Версия в имени: поменяется форма записи — старые кэши просто перестанут
|
||||
* читаться, а не сломают разбор.
|
||||
*/
|
||||
export const COMPANIES_CACHE_KEY = 'rp.dictionary.companies.v1';
|
||||
|
||||
/** Справочник компаний меняется редко, поэтому живёт в кэше 4 часа. */
|
||||
export const COMPANIES_CACHE_TTL_MS = 4 * 60 * 60 * 1000;
|
||||
|
||||
/** Тот минимум localStorage, который нужен кэшу — для подмены в тестах. */
|
||||
export interface IStorageLike {
|
||||
// Сигнатура повторяет DOM-интерфейс Storage, где «нет значения» — это null.
|
||||
// eslint-disable-next-line @rushstack/no-new-null
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
interface ICachedCompanies {
|
||||
savedAt: number;
|
||||
items: ICompany[];
|
||||
}
|
||||
|
||||
function isCompany(value: unknown): value is ICompany {
|
||||
const company = value as Partial<ICompany> | undefined;
|
||||
return !!company && typeof company.Id === 'string' && typeof company.Name === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Свежий кэш или undefined. Просроченный, битый или в неожиданной форме кэш считается отсутствующим
|
||||
* и удаляется: лучше лишний раз сходить в справочник, чем показать мусор.
|
||||
*/
|
||||
export function readCachedCompanies(storage: IStorageLike, now: number): ICompany[] | undefined {
|
||||
// eslint-disable-next-line @rushstack/no-new-null
|
||||
let raw: string | null;
|
||||
try {
|
||||
raw = storage.getItem(COMPANIES_CACHE_KEY);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!raw) return undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<ICachedCompanies>;
|
||||
const fresh =
|
||||
typeof parsed.savedAt === 'number' &&
|
||||
now - parsed.savedAt >= 0 &&
|
||||
now - parsed.savedAt < COMPANIES_CACHE_TTL_MS &&
|
||||
Array.isArray(parsed.items) &&
|
||||
parsed.items.every(isCompany);
|
||||
if (fresh) return parsed.items as ICompany[];
|
||||
} catch {
|
||||
// битый JSON — как отсутствующий кэш
|
||||
}
|
||||
|
||||
try {
|
||||
storage.removeItem(COMPANIES_CACHE_KEY);
|
||||
} catch {
|
||||
// хранилище недоступно — удалять нечего
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Кладёт справочник в кэш. Переполнение или запрет хранилища (режим инкогнито) кэш не ломают — просто его не будет. */
|
||||
export function writeCachedCompanies(storage: IStorageLike, items: ICompany[], now: number): void {
|
||||
try {
|
||||
storage.setItem(COMPANIES_CACHE_KEY, JSON.stringify({ savedAt: now, items } as ICachedCompanies));
|
||||
} catch {
|
||||
// кэш — оптимизация, а не источник данных
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IEmployeeOrganization } from './models/IEmployeeOrganization';
|
||||
import { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines';
|
||||
|
||||
const rec = (p: Partial<IEmployeeOrganization>): IEmployeeOrganization => ({
|
||||
id: '', fio: '', email: '', iin: '', company: 'ТОО А', companyId: '', section: '', departmentId: '', position: '', mobile: '', ...p
|
||||
});
|
||||
|
||||
describe('организации с подразделением', () => {
|
||||
it('на каждую организацию своё подразделение', () => {
|
||||
const text = formatEmployeeOrganizationsWithDepartment([
|
||||
rec({ company: 'ТОО А', position: 'Директор', section: 'Дирекция' }),
|
||||
rec({ company: 'ТОО Б', position: 'Казначей', section: 'Финансы' })
|
||||
]);
|
||||
expect(text.split('\n').map(parseOrganizationLine)).toEqual([
|
||||
{ company: 'ТОО А', position: 'Директор', department: 'Дирекция' },
|
||||
{ company: 'ТОО Б', position: 'Казначей', department: 'Финансы' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('без подразделения формат прежний «Компания — должность»', () => {
|
||||
expect(formatEmployeeOrganizationsWithDepartment([rec({ position: 'Директор' })])).toBe('ТОО А — Директор');
|
||||
});
|
||||
|
||||
it('старые строки разбираются с пустым подразделением', () => {
|
||||
expect(parseOrganizationLine('ТОО А — Директор')).toEqual({ company: 'ТОО А', position: 'Директор', department: '' });
|
||||
expect(parseOrganizationLine('ТОО А')).toEqual({ company: 'ТОО А', position: '', department: '' });
|
||||
});
|
||||
});
|
||||
@@ -28,3 +28,5 @@ export {
|
||||
getCompanyDirectorEmail,
|
||||
getManagerEmail
|
||||
} from './employeeLookup.service';
|
||||
export { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines';
|
||||
export type { IOrganizationLine } from './organizationLines';
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IEmployeeOrganization } from './models/IEmployeeOrganization';
|
||||
|
||||
/**
|
||||
* Организации сотрудника с подразделением: «Компания — должность — подразделение»
|
||||
* на строку. Нужен формам, что показывают организации таблицей (см.
|
||||
* OrganizationTable). Первый сегмент по-прежнему компания — остальные разборщики
|
||||
* (OrgLetterhead и др.) берут только его и не ломаются. Нет подразделения —
|
||||
* формат прежний, как у formatEmployeeOrganizations.
|
||||
*/
|
||||
export function formatEmployeeOrganizationsWithDepartment(records: IEmployeeOrganization[]): string {
|
||||
return records
|
||||
.map((r) => {
|
||||
if (r.section) return [r.company, r.position, r.section].join(' — ');
|
||||
return r.position ? `${r.company} — ${r.position}` : r.company;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export interface IOrganizationLine {
|
||||
company: string;
|
||||
position: string;
|
||||
department: string;
|
||||
}
|
||||
|
||||
/** Разбор строки «Компания — должность[ — подразделение]»; старые строки без подразделения дают пустое. */
|
||||
export function parseOrganizationLine(line: string): IOrganizationLine {
|
||||
const [company = '', position = '', department = ''] = line.split(' — ').map((p) => p.trim());
|
||||
return { company, position, department };
|
||||
}
|
||||
@@ -7,22 +7,34 @@ import { IFioParts } from './fio';
|
||||
/** Путь приёма документов на подпись от интеграций (TANSU ЭДО, multipart/form-data). */
|
||||
const SIGNING_REQUESTS_PATH = '/api/v1/integration/signing-requests';
|
||||
|
||||
/** Что отправляем в ЭДО: одного получателя, он же подписант, и один файл. */
|
||||
/** Файл на подпись и компания, от имени которой он идёт (по её сертификату Sigex в ЭДО). */
|
||||
/** Как портал называет себя в ЭДО. */
|
||||
export const EDO_SOURCE_NAME = 'Портал заявок';
|
||||
|
||||
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 };
|
||||
/** Кому сообщать о подписи; пусто — никому. */
|
||||
initiatorEmail?: string;
|
||||
files: IEdoSigningFile[];
|
||||
}
|
||||
|
||||
/** Ответ ЭДО: то, что нужно показать пользователю. */
|
||||
export interface IEdoSigningResult {
|
||||
/** Кабинет получателя без ключа: войти по ЭЦП или через Microsoft. */
|
||||
cabinetUrl: string;
|
||||
documents: Array<{ id: string; fileName: string }>;
|
||||
documents: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Ответ ЭДО в snake_case — только поля, которые читаем. */
|
||||
@@ -32,7 +44,7 @@ interface IEdoResponseBody {
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет документ на подпись в ЭДО.
|
||||
* Отправляет документы на подпись в ЭДО одним запросом: либо принимаются все, либо ни один.
|
||||
*
|
||||
* Токен берётся для приложения ЭДО текущего пользователя (AadHttpClient), поэтому в ЭДО
|
||||
* запрос приходит от того, кто нажал «Согласовать», — он заводится там автоматически при
|
||||
@@ -52,9 +64,14 @@ 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);
|
||||
if (request.initiatorEmail) form.append('initiator_email', request.initiatorEmail);
|
||||
// Так ЭДО подпишет документ в колонке «Кто отправил», а не техническим client_id.
|
||||
form.append('service_initiator_name', EDO_SOURCE_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 {
|
||||
@@ -75,6 +92,9 @@ export async function submitSigningRequest(
|
||||
const body = (await response.json()) as IEdoResponseBody;
|
||||
return {
|
||||
cabinetUrl: body.cabinet_url || '',
|
||||
documents: (body.documents || []).map((d) => ({ id: d.id || '', fileName: d.file_name || '' }))
|
||||
documents: (body.documents || []).map((d) => ({
|
||||
id: d.id || '',
|
||||
fileName: d.file_name || ''
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -19,3 +19,39 @@
|
||||
font-size: 13.5px;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
// Блок таблицы на всю ширину сетки формы (без FormField — его подпись дублировала бы заголовок колонки).
|
||||
.tableWrap {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
// Таблица организаций: те же цвета, что у .line — «подставлено автоматически».
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-sm;
|
||||
background: $gray-50;
|
||||
font-family: $font-sans;
|
||||
font-size: 13.5px;
|
||||
color: $gray-700;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 9px 13px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: $gray-500;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
tr + tr td {
|
||||
border-top: 1px solid $gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from 'react';
|
||||
import styles from './OrganizationField.module.scss';
|
||||
import { parseOrganizationLine } from '../../dictionary/organizationLines';
|
||||
|
||||
interface IOrganizationTableProps {
|
||||
/** `\n`-joined «Компания — должность — подразделение» на организацию (см. formatEmployeeOrganizationsWithDepartment). */
|
||||
value: string;
|
||||
/** Индекс строки, остающейся в печати (см. OrganizationField.printIndex); остальные — `rp-no-print`. */
|
||||
printIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Организации сотрудника таблицей «Организация · Должность · Подразделение» —
|
||||
* read-only, подтягивается из Employee/ByEmail. На каждую организацию своё
|
||||
* подразделение (сотрудник может состоять в нескольких). Без внешней подписи: её роль
|
||||
* играет заголовок колонки «Организация». Нет данных — ничего не рендерим.
|
||||
*/
|
||||
export const OrganizationTable: React.FC<IOrganizationTableProps> = ({ value, printIndex }) => {
|
||||
const rows = value ? value.split('\n').map(parseOrganizationLine) : [];
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Организация</th>
|
||||
<th>Должность</th>
|
||||
<th>Подразделение</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className={printIndex !== undefined && i !== printIndex ? 'rp-no-print' : undefined}>
|
||||
<td>{r.company}</td>
|
||||
<td>{r.position || '—'}</td>
|
||||
<td>{r.department || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { trimPickerQuery } from './pickerContext';
|
||||
|
||||
const body = (q: unknown): string => JSON.stringify({ queryParams: { QueryString: q, PrincipalType: 1 } });
|
||||
const query = (b: unknown): unknown => (JSON.parse(b as string) as { queryParams: { QueryString: unknown } }).queryParams.QueryString;
|
||||
|
||||
describe('trimPickerQuery', () => {
|
||||
it('убирает пробел в конце и в начале', () => {
|
||||
expect(query(trimPickerQuery(body('Иван ')))).toBe('Иван');
|
||||
expect(query(trimPickerQuery(body(' Иван Петров ')))).toBe('Иван Петров');
|
||||
});
|
||||
|
||||
it('схлопывает двойные пробелы внутри', () => {
|
||||
expect(query(trimPickerQuery(body('Иван Петров')))).toBe('Иван Петров');
|
||||
});
|
||||
|
||||
it('не трогает остальные поля запроса', () => {
|
||||
const parsed = JSON.parse(trimPickerQuery(body('a ')) as string) as { queryParams: { PrincipalType: number } };
|
||||
expect(parsed.queryParams.PrincipalType).toBe(1);
|
||||
});
|
||||
|
||||
it('чужие тела возвращает как есть', () => {
|
||||
expect(trimPickerQuery('не json QueryString')).toBe('не json QueryString');
|
||||
expect(trimPickerQuery(undefined)).toBeUndefined();
|
||||
const other = JSON.stringify({ a: 1 });
|
||||
expect(trimPickerQuery(other)).toBe(other);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,43 @@ export function pickedLogin(user: IPickedUser): string {
|
||||
return user.secondaryText || user.loginName || user.id || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Убирает пробелы по краям (и схлопывает внутренние) у QueryString в теле запроса
|
||||
* поиска сотрудника. PeoplePicker из @pnp/spfx-controls-react шлёт введённый
|
||||
* текст как есть, а SharePoint с «Иван » (пробел в конце, обычное дело при вводе
|
||||
* «имя фамилия») ничего не находит. Тело не JSON или без QueryString — возвращается как было.
|
||||
*/
|
||||
export function trimPickerQuery(body: unknown): unknown {
|
||||
if (typeof body !== 'string' || body.indexOf('QueryString') < 0) return body;
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { queryParams?: { QueryString?: unknown } };
|
||||
const query = parsed.queryParams?.QueryString;
|
||||
if (typeof query !== 'string') return body;
|
||||
parsed.queryParams!.QueryString = query.trim().replace(/\s+/g, ' ');
|
||||
return JSON.stringify(parsed);
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* spHttpClient с подчисткой запроса поиска (см. trimPickerQuery): прокси, чтобы
|
||||
* остальные методы клиента работали как прежде, подменяется только post.
|
||||
*/
|
||||
function withTrimmedSearch(client: WebPartContext['spHttpClient']): WebPartContext['spHttpClient'] {
|
||||
return new Proxy(client, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop !== 'post') return Reflect.get(target, prop, receiver);
|
||||
return (url: string, config: unknown, options?: { body?: unknown }) =>
|
||||
(target.post as (u: string, c: unknown, o?: unknown) => unknown)(
|
||||
url,
|
||||
config,
|
||||
options ? { ...options, body: trimPickerQuery(options.body) } : options
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Контекст для PeoplePicker из WebPartContext.
|
||||
* as unknown: @pnp/spfx-controls-react тянет свою копию sp-http-msgraph,
|
||||
@@ -27,6 +64,6 @@ export function buildPickerContext(context: WebPartContext): IPeoplePickerContex
|
||||
return {
|
||||
absoluteUrl: context.pageContext.web.absoluteUrl,
|
||||
msGraphClientFactory: context.msGraphClientFactory,
|
||||
spHttpClient: context.spHttpClient
|
||||
spHttpClient: withTrimmedSearch(context.spHttpClient)
|
||||
} as unknown as IPeoplePickerContext;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
|
||||
onDelete,
|
||||
}) => {
|
||||
const { saving, kind } = submit;
|
||||
const unfrozenForm = formId != null && UNFROZEN_FORMS.has(formId);
|
||||
const unfrozenForm = formId !== undefined && UNFROZEN_FORMS.has(formId);
|
||||
// false, пока не пришёл ответ о членстве в группе, — безопасный дефолт: не
|
||||
// мигать разблокированными кнопками до подтверждения, что это тестировщик.
|
||||
const [isTester, setIsTester] = React.useState<boolean>(false);
|
||||
|
||||
@@ -47,6 +47,7 @@ export { PeopleField } from './PeopleField/PeopleField';
|
||||
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
||||
export { PeopleTextField } from './PeopleField/PeopleTextField';
|
||||
export { OrganizationField } from './OrganizationField/OrganizationField';
|
||||
export { OrganizationTable } from './OrganizationField/OrganizationTable';
|
||||
export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton';
|
||||
export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';
|
||||
export { LinkField } from './LinkField/LinkField';
|
||||
|
||||
Reference in New Issue
Block a user