feat: история согласования — маршрут пересохраняется патчем

При доработке и повторной отправке writeRoute раньше удалял все строки
маршрута и создавал заново — кто вернул или отклонил заявку и с каким
комментарием, терялось. Теперь маршрут пересохраняется патчем
(planRoutePatch + ApprovalListService.patchSteps): строки, что остаются в
маршруте, обновляются на месте, и их прошлые решения лежат в истории
версий; недостающие шаги создаются, выпавшие и строки делегатов уходят в
корзину. Всё одним пакетом. Проверено пилотами на увольнении и рабочем
месте (обе фазы, уход в корзину). Аварийный откат для всех форм —
ROUTE_PATCH_ENABLED = false.

Из версий строк маршрута и заявки собирается лента «История
согласования» (shared/approval/history): отправки, решения с
комментариями и временем на шаге, делегирование, замена исполнителя,
у кого заявка сейчас. Свёрнутый раздел в карточке и в форме доработки
всех форм, грузится только при раскрытии.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-09-29 12:42:42 +05:00
parent 5b1e94bf42
commit 153c966f0b
40 changed files with 1334 additions and 20 deletions
@@ -5,6 +5,15 @@ import { IMatrixRow } from './models/IMatrixRow';
import { ApprovalStatus, IResolveFieldValue } from './models/IApprovalRouteApi';
import { APPROVAL_PHASE, REQUEST_STATUS, isSubmittedStatus } from './models/statuses';
/**
* Выключатель пересохранения маршрута патчем — ДЛЯ ВСЕХ форм сразу (см.
* writeRoute). Проверено пилотами на «Заявлении на увольнение» и «Организации
* рабочего места» 2026-09-29: обновление строк на месте, обе фазы, уход
* выпавших шагов в корзину. false — аварийный откат к прежнему «удалить всё и
* создать заново» (история прошлых отправок снова перестанет сохраняться).
*/
const ROUTE_PATCH_ENABLED = true;
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
// status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution'
// (исполнители). Имена подтягиваются по executorEmail (бэкенд отдаёт email + должность).
@@ -72,6 +81,12 @@ export async function writeApprovers(
// очереди. Первого исполнителя переключит на «На исполнении»
// approval.activateNextQueued, когда фаза согласования будет пройдена целиком
// (см. approverDecision.service.ts/decide).
//
// Пересохранение — ПАТЧЕМ (ApprovalListService.patchSteps, см. ROUTE_PATCH_ENABLED):
// строки, что остаются в маршруте, обновляются на месте, и их прошлые решения
// (кто вернул/отклонил и с каким комментарием) сохраняются в истории версий —
// из неё строится «История согласования». Раньше маршрут удалялся и создавался
// заново, и история прошлых отправок терялась.
export async function writeRoute(
approval: ApprovalListService,
requestId: number,
@@ -79,8 +94,6 @@ export async function writeRoute(
executors: IApproverToWrite[],
status: string
): Promise<void> {
await approval.deleteForRequest(requestId);
// При ОТПРАВКЕ все шаги пишем «Ждёт очереди», затем активируем первый —
// так активен ровно текущий шаг, остальные ждут. В черновике — статус заявки
// (Черновик), без активации.
@@ -102,14 +115,26 @@ export async function writeRoute(
0
);
await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1);
await approval.createSteps(
requestId,
executors,
rowStatus,
APPROVAL_PHASE.execution,
lastApprovalStep + 1
);
if (ROUTE_PATCH_ENABLED) {
await approval.patchSteps(
requestId,
[
{ approvers, phase: APPROVAL_PHASE.approval, orderStart: 1 },
{ approvers: executors, phase: APPROVAL_PHASE.execution, orderStart: lastApprovalStep + 1 },
],
rowStatus
);
} else {
await approval.deleteForRequest(requestId);
await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1);
await approval.createSteps(
requestId,
executors,
rowStatus,
APPROVAL_PHASE.execution,
lastApprovalStep + 1
);
}
if (submitted) {
await approval.activateNextQueued(requestId);