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
@@ -37,6 +37,12 @@
шаг. Работает только для НОВОЙ заявки (`positionCode` есть только у планируемого
маршрута, не у уже записанных строк списка согласования).
- **Исполнители сами заменяют и удаляют друг друга** в поданной заявке
(`useExecutorEditing.ts`): любой исполнитель с невыполненным шагом может заменить
или убрать невыполненного исполнителя, в т.ч. активного и себя. Выполненные шаги
не трогаются, последнего невыполненного — только заменить. Карточка Planner на
людей не назначается, поэтому замена её не затрагивает.
## Особое поведение
- **Контакты заявителя предзаполняются через Microsoft Graph** (`/me`): ФИО, email,
телефон, отдел, должность — единственная форма портала, которая это делает
@@ -42,6 +42,7 @@ import { handoffToPlanner, isPlannerHandoffStatus } from './plannerHandoff';
import { RequestSection } from './sections/RequestSection';
import { ContactSection } from './sections/ContactSection';
import { useStableCallback } from '../../shared/useStableCallback';
import { useExecutorEditing } from './useExecutorEditing';
interface ITicket1CViewProps {
context: WebPartContext;
@@ -101,6 +102,32 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
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(() => {
let alive = true;
setLoading(true);
@@ -140,15 +167,6 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
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 (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
@@ -196,6 +214,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
emptyText="Согласование не требовалось для этого вида работ."
/>
{executorEditing.error && <Alert variant="err">{executorEditing.error}</Alert>}
{!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
editor={executors}
@@ -203,6 +222,10 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
title="Исполнение"
roleLabel="Исполнитель"
emptyText="Исполнители не назначены."
canReplaceRow={executorEditing.canReplaceRow}
onReplaceRow={executorEditing.onReplaceRow}
canRemoveRow={executorEditing.canRemoveRow}
onRemoveRow={executorEditing.onRemoveRow}
/>
)}
</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
};
}