feat: исполнители заявки 1С сами заменяют и удаляют друг друга
Tests / frontend-test (push) Successful in 2m0s

Отдел 1С передаёт заявки внутри себя: исполнитель с невыполненным шагом
может в поданной заявке заменить или убрать невыполненного исполнителя,
в т.ч. активного и себя. Выполненные шаги не трогаются, последнего
невыполненного — только заменить. Механизм тот же, что в подборе, но
право по роли в маршруте, а не по списку email.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-07 15:46:36 +05:00
parent d4868933f1
commit 5c10c8f127
4 changed files with 144 additions and 10 deletions
+2 -1
View File
@@ -20,6 +20,7 @@
| Удалять любых согласующих **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все остальные формы с маршрутом согласования (отпуск, увольнение, 1С и т. д.); в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `composeRemoveRowRule` | | Удалять любых согласующих **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все остальные формы с маршрутом согласования (отпуск, увольнение, 1С и т. д.); в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `composeRemoveRowRule` |
| Добавлять и удалять исполнителей **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все формы, где в маршруте из админки есть фаза исполнения; в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `canEditExecutors` | | Добавлять и удалять исполнителей **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все формы, где в маршруте из админки есть фаза исполнения; в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `canEditExecutors` |
| Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` | | Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` |
| То же в поданной заявке: заменять и удалять невыполненных исполнителей, **в том числе активного и себя** | Исполнители заявки, у которых свой шаг исполнения ещё не выполнен | роль в маршруте (фаза «Исполнение»), без списка email | **Только** Заявка в отдел 1С | `forms/Ticket1C/useExecutorEditing.ts` |
| Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` | | Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
| То же: **все поля и вложения**, но **не только на своём шаге**: достаточно быть в маршруте (согласование или исполнение), пока заявка «На согласовании» или «Выполнение» | Ербол Шаймерденов | email в коде (список по каждой форме) + участие в маршруте | **Только** Организация рабочего места | `shared/approval/fullEditRights.ts` → `canEditAllFields` + `forms/WorkplaceSetup/useApproverRights.ts` | | То же: **все поля и вложения**, но **не только на своём шаге**: достаточно быть в маршруте (согласование или исполнение), пока заявка «На согласовании» или «Выполнение» | Ербол Шаймерденов | email в коде (список по каждой форме) + участие в маршруте | **Только** Организация рабочего места | `shared/approval/fullEditRights.ts` → `canEditAllFields` + `forms/WorkplaceSetup/useApproverRights.ts` |
| Заменять человека на шаге согласования (не удалять шаг) **до отправки**: новая заявка, черновик, доработка | Любой инициатор | право по статусу заявки (форма редактируема) | **Только** Организация рабочего места | `forms/WorkplaceSetup/WorkplaceSetupForm.tsx` (`canReplaceRow`) + `useMatrixEditor.replace` | | Заменять человека на шаге согласования (не удалять шаг) **до отправки**: новая заявка, черновик, доработка | Любой инициатор | право по статусу заявки (форма редактируема) | **Только** Организация рабочего места | `forms/WorkplaceSetup/WorkplaceSetupForm.tsx` (`canReplaceRow`) + `useMatrixEditor.replace` |
@@ -57,7 +58,7 @@
## Известные ограничения ## Известные ограничения
- **Замена активного исполнителя (подбор).** Если прежний исполнитель держит - **Замена активного исполнителя (подбор, 1С).** Если прежний исполнитель держит
заявку открытой и, не обновив страницу, нажмёт «Выполнено», решение заявку открытой и, не обновив страницу, нажмёт «Выполнено», решение
запишется в шаг, который уже принадлежит новому исполнителю. При решении запишется в шаг, который уже принадлежит новому исполнителю. При решении
пока не перепроверяется, что шаг всё ещё за этим человеком пока не перепроверяется, что шаг всё ещё за этим человеком
@@ -37,6 +37,12 @@
шаг. Работает только для НОВОЙ заявки (`positionCode` есть только у планируемого шаг. Работает только для НОВОЙ заявки (`positionCode` есть только у планируемого
маршрута, не у уже записанных строк списка согласования). маршрута, не у уже записанных строк списка согласования).
- **Исполнители сами заменяют и удаляют друг друга** в поданной заявке
(`useExecutorEditing.ts`): любой исполнитель с невыполненным шагом может заменить
или убрать невыполненного исполнителя, в т.ч. активного и себя. Выполненные шаги
не трогаются, последнего невыполненного — только заменить. Карточка Planner на
людей не назначается, поэтому замена её не затрагивает.
## Особое поведение ## Особое поведение
- **Контакты заявителя предзаполняются через Microsoft Graph** (`/me`): ФИО, email, - **Контакты заявителя предзаполняются через Microsoft Graph** (`/me`): ФИО, email,
телефон, отдел, должность — единственная форма портала, которая это делает телефон, отдел, должность — единственная форма портала, которая это делает
@@ -42,6 +42,7 @@ import { handoffToPlanner, isPlannerHandoffStatus } from './plannerHandoff';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
import { ContactSection } from './sections/ContactSection'; import { ContactSection } from './sections/ContactSection';
import { useStableCallback } from '../../shared/useStableCallback'; import { useStableCallback } from '../../shared/useStableCallback';
import { useExecutorEditing } from './useExecutorEditing';
interface ITicket1CViewProps { interface ITicket1CViewProps {
context: WebPartContext; context: WebPartContext;
@@ -101,6 +102,32 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
approval.getJobTitle(l) approval.getJobTitle(l)
); );
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
const notify = (rowId?: number): void => {
if (!requestTypeId) return;
notifyActiveStep(
context,
approval,
itemId,
{
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
},
rowId
).catch(() => undefined);
};
const executorEditing = useExecutorEditing({
approval,
approverService,
itemId,
executorRows: executors.rows,
reloadKey: decisionSeq,
onChanged: () => setDecisionSeq((n) => n + 1),
notifyRow: notify
});
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setLoading(true); setLoading(true);
@@ -140,15 +167,6 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
const sectionProps = { data, set, context }; const sectionProps = { data, set, context };
const notify = (): void => {
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
@@ -196,6 +214,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
emptyText="Согласование не требовалось для этого вида работ." emptyText="Согласование не требовалось для этого вида работ."
/> />
{executorEditing.error && <Alert variant="err">{executorEditing.error}</Alert>}
{!executors.loading && executors.rows.length > 0 && ( {!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix <ApprovalMatrix
editor={executors} editor={executors}
@@ -203,6 +222,10 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
title="Исполнение" title="Исполнение"
roleLabel="Исполнитель" roleLabel="Исполнитель"
emptyText="Исполнители не назначены." emptyText="Исполнители не назначены."
canReplaceRow={executorEditing.canReplaceRow}
onReplaceRow={executorEditing.onReplaceRow}
canRemoveRow={executorEditing.canRemoveRow}
onRemoveRow={executorEditing.onRemoveRow}
/> />
)} )}
</div> </div>
@@ -0,0 +1,104 @@
import { useEffect, useState } from 'react';
import {
ApprovalListService,
ApproverService,
APPROVAL_PHASE,
REQUEST_STATUS,
IMatrixRow,
ISiteUser
} from '../../shared/approval';
import { friendlyErrorMessage } from '../../shared/diagnostics';
/** Шаг исполнения ещё не выполнен — его можно заменить или удалить. */
function isPendingExecutor(row: { status: string }): boolean {
return row.status === REQUEST_STATUS.queued || row.status === REQUEST_STATUS.onExecution;
}
export interface IExecutorEditing {
canReplaceRow?: (row: IMatrixRow) => boolean;
canRemoveRow?: (row: IMatrixRow) => boolean;
onReplaceRow: (row: IMatrixRow, user: ISiteUser) => void;
onRemoveRow: (row: IMatrixRow) => void;
error?: string;
}
interface IExecutorEditingParams {
approval: ApprovalListService;
approverService: ApproverService;
itemId: number;
executorRows: IMatrixRow[];
reloadKey: number;
/** Маршрут изменился на сервере — перечитать матрицы. */
onChanged: () => void;
/** Оповестить строку, ставшую активной (заявка теперь у этого исполнителя). */
notifyRow: (rowId: number) => void;
}
/**
* Замена/удаление исполнителей в поданной заявке 1С. Право — у самих
* исполнителей отдела 1С: кто стоит в фазе «Исполнение» с невыполненным шагом,
* может заменить или убрать любого невыполненного исполнителя (в т.ч.
* активного и себя) — так отдел передаёт заявку внутри себя. Выполненные шаги
* не трогаются, последнего невыполненного исполнителя можно только заменить
* (см. ApproverService.deleteExecutionStep).
*/
export function useExecutorEditing(params: IExecutorEditingParams): IExecutorEditing {
const { approval, approverService, itemId, executorRows, reloadKey, onChanged, notifyRow } = params;
const [isExecutor, setIsExecutor] = useState<boolean>(false);
const [error, setError] = useState<string | undefined>(undefined);
// В загруженных строках матрицы нет email, поэтому «я ли исполнитель»
// проверяем по Id пользователя в строках списка согласования.
useEffect(() => {
let alive = true;
Promise.all([approval.getForRequest(itemId), approval.getCurrentUserId()])
.then(([rows, userId]) => {
if (!alive) return;
setIsExecutor(
rows.some((r) => r.phase === APPROVAL_PHASE.execution && r.approverId === userId && isPendingExecutor(r))
);
})
.catch(() => {
if (alive) setIsExecutor(false);
});
return () => {
alive = false;
};
}, [approval, itemId, reloadKey]);
const pendingCount = executorRows.filter(isPendingExecutor).length;
const onReplaceRow = (row: IMatrixRow, user: ISiteUser): void => {
if (!row.id) return;
const rowId = row.id;
setError(undefined);
approverService
.replaceExecutionStep(itemId, rowId, user)
.then((wasActive) => {
onChanged();
// Шаг уже был в работе — заявка сразу у нового исполнителя, сообщаем ему.
if (wasActive) notifyRow(rowId);
})
.catch((e) => setError(friendlyErrorMessage(e)));
};
const onRemoveRow = (row: IMatrixRow): void => {
if (!row.id) return;
setError(undefined);
approverService
.deleteExecutionStep(itemId, row.id)
.then((activatedRowIds) => {
onChanged();
activatedRowIds.forEach(notifyRow);
})
.catch((e) => setError(friendlyErrorMessage(e)));
};
return {
canReplaceRow: isExecutor ? isPendingExecutor : undefined,
canRemoveRow: isExecutor ? (row) => isPendingExecutor(row) && pendingCount > 1 : undefined,
onReplaceRow,
onRemoveRow,
error
};
}