diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx index d7df482..0b22ae0 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx @@ -14,6 +14,7 @@ import { } from '../../shared/ui'; import { ApprovalMatrix, + ApprovalHistory, StatusBadge, useMatrixEditor, getPlannedMatrix, @@ -339,6 +340,19 @@ const ClearanceForm: React.FC = ({ /> )} + {/* Автору на доработке важнее всего видеть, почему вернули или + отклонили. Только у уже существующей заявки: у новой истории нет. */} + {editId && ( +
+ +
+ )} + = ({ context, itemId, formKey {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} + {/* История согласования: маршрут пересохраняется патчем (см. + writeRoute): прошлые решения лежат в версиях строк. */} +
+ +
+
= ({ waitingText="Укажите начальника участка и проект, чтобы построить маршрут согласования." /> + {/* Автору на доработке важнее всего видеть, почему вернули или + отклонили. Только у уже существующей заявки: у новой истории нет. */} + {editId && ( +
+ +
+ )} + = ({ context, ite
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} + {/* История согласования: маршрут пересохраняется патчем (см. + writeRoute): прошлые решения лежат в версиях строк. */} +
+ +
+
= ({ approval.searchUsers(q)} /> + {/* Автору на доработке важнее всего видеть, почему вернули или + отклонили, — поэтому история и в форме, а не только в просмотре. + Только у уже существующей заявки: у новой истории нет. */} + {editId && ( +
+ +
+ )} + = ({ context, itemId, formKey approval.searchUsers(q)} />
+ {/* История согласования: маршрут пересохраняется патчем (см. + writeRoute): прошлые решения лежат в версиях строк. */} +
+ +
+ {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). Итог автосоздания обходного листа — тут же, а не в шапке: он появляется именно как следствие решения «Согласовать», логичнее видеть его рядом. */} diff --git a/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx b/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx index d6216bb..9be0af3 100644 --- a/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx +++ b/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx @@ -14,6 +14,7 @@ import { } from '../../shared/ui'; import { ApprovalMatrix, + ApprovalHistory, StatusBadge, useMatrixEditor, getPlannedMatrix, @@ -303,6 +304,19 @@ const EmployeeTransferForm: React.FC = ({ approval.searchUsers(q)} /> + {/* Автору на доработке важнее всего видеть, почему вернули или + отклонили. Только у уже существующей заявки: у новой истории нет. */} + {editId && ( +
+ +
+ )} + = ({ context, i {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} + {/* История согласования: маршрут пересохраняется патчем (см. + writeRoute): прошлые решения лежат в версиях строк. */} +
+ +
+
= ({ /> )} + {/* Автору на доработке важнее всего видеть, почему вернули или + отклонили. Только у уже существующей заявки: у новой истории нет. */} + {editId && ( +
+ +
+ )} + = ({ context, itemI
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} + {/* История согласования: маршрут пересохраняется патчем (см. + writeRoute): прошлые решения лежат в версиях строк. */} +
+ +
+
= ({ /> )} + {/* Автору на доработке важнее всего видеть, почему вернули или + отклонили. Только у уже существующей заявки: у новой истории нет. */} + {editId && ( +
+ +
+ )} + = ({ cont
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} + {/* История согласования: маршрут пересохраняется патчем (см. + writeRoute): прошлые решения лежат в версиях строк. */} +
+ +
+
= ({ /> )} + {/* Автору на доработке важнее всего видеть, почему вернули или + отклонили. Только у уже существующей заявки: у новой истории нет. */} + {editId && ( +
+ +
+ )} + = ({ context, itemId, formKey,
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} + {/* История согласования: маршрут пересохраняется патчем (см. + writeRoute): прошлые решения лежат в версиях строк. */} +
+ +
+
= ({ waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования." /> + {/* Автору на доработке важнее всего видеть, почему вернули или + отклонили. Только у уже существующей заявки: у новой истории нет. */} + {editId && ( +
+ +
+ )} + = ({ context, itemI approval.searchUsers(q)} />
+ {/* История согласования: маршрут пересохраняется патчем (см. + writeRoute): прошлые решения лежат в версиях строк. */} +
+ +
+
= ({ emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С." /> + {/* Автору на доработке важнее всего видеть, почему вернули или + отклонили. Только у уже существующей заявки: у новой истории нет. */} + {editId && ( +
+ +
+ )} + = ({ context, itemId, formKey, />
+ {/* История согласования: маршрут пересохраняется патчем (см. + writeRoute): прошлые решения лежат в версиях строк. */} +
+ +
+
= ({ /> )} + {/* Автору на доработке важнее всего видеть, почему вернули или + отклонили. Только у уже существующей заявки: у новой истории нет. */} + {editId && ( +
+ +
+ )} + = ({ context, itemId, for )}
+ {/* История согласования: маршрут пересохраняется патчем (см. + writeRoute): прошлые решения лежат в версиях строк. */} +
+ +
+
= ({ approval.searchUsers(q)} /> + {/* Автору на доработке важнее всего видеть, почему вернули или + отклонили. Только у уже существующей заявки: у новой истории нет. */} + {editId && ( +
+ +
+ )} + = ({ context, itemId, for
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} + {/* История согласования: маршрут пересохраняется патчем (см. + writeRoute): прошлые решения лежат в версиях строк. */} +
+ +
+
= ({ approval.searchUsers(q)} /> + {/* Автору на доработке важнее всего видеть, почему вернули или + отклонили. Только у уже существующей заявки: у новой истории нет. */} + {editId && ( +
+ +
+ )} + = ({ context, itemId, formKey,
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} + {/* История согласования: маршрут пересохраняется патчем (см. + writeRoute): прошлые решения лежат в версиях строк. */} +
+ +
+
= ({ /> )} + {/* Автору на доработке важнее всего видеть, почему вернули или + отклонили. Только у уже существующей заявки: у новой истории нет. */} + {editId && ( +
+ +
+ )} + = ({ context, itemI )}
+ {/* История согласования: маршрут пересохраняется патчем (см. + writeRoute): прошлые решения лежат в версиях строк. */} +
+ +
+
{ + return { + Title: a.position || a.email, + [`${FIELDS.approver}Id`]: approverId, + [FIELDS.status]: status, + [FIELDS.order]: order, + [FIELDS.allowReassign]: a.allowReassign, + [FIELDS.phase]: phase, + // ?? null, а не || null: ноль часов — значимое значение. + [FIELDS.slaHours]: a.slaHours ?? null, + [FIELDS.askOverdueReason]: a.askOverdueReason ?? null, + [FIELDS.autoApprove]: a.autoApprove ?? false, + }; + } + + /** + * Пересохранить маршрут заявки ПАТЧЕМ вместо «удалить всё и создать заново» + * (deleteForRequest + createSteps). Строки, которые остаются в маршруте, + * обновляются на месте — их прошлые значения (кто, когда, с каким + * комментарием решил) SharePoint сохраняет в истории версий. Лишние строки + * уходят в корзину, недостающие шаги создаются. Какую строку куда — решает + * planRoutePatch (см. routePatchPlan.ts). + * + * phases — фазы маршрута с их смещением порядка, как в writeRoute. Всё + * пишется ОДНИМ пакетом. Вызывается из writeRoute (выключатель — + * ROUTE_PATCH_ENABLED в approvalSubmission.service.ts). + */ + public async patchSteps( + requestId: number, + phases: Array<{ approvers: IApproverToWrite[]; phase: string; orderStart: number }>, + status: string + ): Promise { + // Шаги всех фаз одним списком, с итоговым порядком — как их записал бы createSteps. + const steps = phases.reduce>( + (all, p) => + all.concat(p.approvers.map((a, i) => ({ a, phase: p.phase, order: p.orderStart - 1 + (a.order ?? i + 1) }))), + [] + ); + // Шаг без человека не записать — см. тот же случай в createSteps. + const toWrite = steps.filter((s) => !!s.a.email); + if (toWrite.length < steps.length) { + console.error(`[Согласование] patchSteps: пропущены шаги без назначенного человека — заявка №${requestId}`); + } + + const [existing, approverIds] = await Promise.all([ + this.getForRequest(requestId), + Promise.all(toWrite.map((s) => this.resolveUserId(s.a.email))), + ]); + const plan = planRoutePatch( + existing, + toWrite.map((s, i) => ({ phase: s.phase, approverId: approverIds[i] })) + ); + + const fieldsOf = (stepIndex: number): Record => { + const s = toWrite[stepIndex]; + return this.stepFields(s.a, approverIds[stepIndex], status, s.phase, s.order); + }; + + // Батч — см. createSteps: список переиспользуем, .items/.getById берём заново. + const [batchedSP, execute] = getSP().batched(); + const batchedList = batchedSP.web.lists.getByTitle(this.listTitle); + /* eslint-disable no-void -- промисы батча разрешатся вместе после execute(), см. createSteps */ + plan.update.forEach(({ rowId, stepIndex }) => { + void batchedList.items.getById(rowId).update({ + ...fieldsOf(stepIndex), + // Состояние прошлого круга сбрасываем — оно остаётся в истории версий строки. + [FIELDS.date]: null, + [FIELDS.comment]: null, + [`${FIELDS.delegatedBy}Id`]: null, + }); + }); + plan.create.forEach((stepIndex) => { + void batchedList.items.add({ ...fieldsOf(stepIndex), [`${FIELDS.ticket}Id`]: requestId }); + }); + // В корзину, а не насовсем: у выпавшего шага могла быть история решений. + plan.remove.forEach((rowId) => { + void batchedList.items.getById(rowId).recycle(); + }); + /* eslint-enable no-void */ + await execute(); + } + // Продвигает очередь: среди ещё не активных строк со статусом «Ждёт очереди» // в текущей фазе активирует те, что делят наименьший StepOrder (обычно одна // строка; несколько — параллельный шаг). Фаза исполнения активируется, только @@ -247,6 +329,25 @@ export class ApprovalListService { }); } + /** + * История версий каждой строки маршрута заявки — сырьё для «Истории + * согласования» (см. history/approvalHistory.service.ts). Версии всех строк — + * одним пакетом. Порядок версий внутри строки не гарантирован, сортирует + * вызывающая сторона. + */ + public async getRowVersions(requestId: number): Promise>>> { + const rows = await this.getForRequest(requestId); + if (rows.length === 0) return []; + + const [batchedSP, execute] = getSP().batched(); + const batchedList = batchedSP.web.lists.getByTitle(this.listTitle); + const pending = rows.map( + (r) => batchedList.items.getById(r.id).versions() as Promise>> + ); + await execute(); + return Promise.all(pending); + } + // Строки согласования по заявке, в порядке согласования (по Order). public async getForRequest(requestId: number): Promise { const rows = (await this.list() diff --git a/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts b/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts index 702ef1b..974b663 100644 --- a/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts @@ -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 { - 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); diff --git a/src/webparts/requestPortal/shared/approval/history/ApprovalHistory.module.scss b/src/webparts/requestPortal/shared/approval/history/ApprovalHistory.module.scss new file mode 100644 index 0000000..2592719 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/history/ApprovalHistory.module.scss @@ -0,0 +1,110 @@ +@import '../../../../../styles/design-tokens'; + +// Ширина колонки со временем — под «29.09.2026 11:02»; уже — текст переносится. +$time-col-width: 124px; +// Ниже этой ширины время встаёт над текстом события, а не слева от него. +$feed-stack-breakpoint: 640px; + +.feed { + display: flex; + flex-direction: column; + gap: $sp-4; +} + +.round { + display: flex; + flex-direction: column; + gap: $sp-2; +} + +.roundTitle { + padding-bottom: $sp-1; + border-bottom: 1px solid $gray-200; + font-size: $fs-overline; + font-weight: 600; + letter-spacing: 1.4px; + text-transform: uppercase; + color: $gray-500; +} + +.events { + display: flex; + flex-direction: column; + gap: $sp-3; + margin: 0; + padding: 0; + list-style: none; +} + +.event { + display: flex; + align-items: flex-start; + gap: $sp-3; + font-size: $fs-sm; + color: $gray-900; +} + +.current { + padding: $sp-2 $sp-3; + border-radius: $r-sm; + background: $gold-50; +} + +.marker { + flex: 0 0 auto; + width: 20px; + text-align: center; + font-weight: 700; + line-height: 1.4; +} + +.markerNeutral { color: $navy-700; } +.markerPositive { color: $green-600; } +.markerWarning { color: $amber-700; } +.markerNegative { color: $red-500; } +.markerCurrent { color: $gold-600; } + +.eventBody { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.eventLine { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: $sp-1 $sp-3; +} + +.time { + flex: 0 0 $time-col-width; + color: $gray-500; + font-variant-numeric: tabular-nums; + + @media (max-width: $feed-stack-breakpoint) { + flex-basis: 100%; + } +} + +.meta { + font-size: $fs-label; + color: $gray-500; +} + +.comment { + color: $gray-700; + font-style: italic; + overflow-wrap: anywhere; +} + +.note { + font-size: $fs-label; + color: $gray-500; +} + +.error { + font-size: $fs-sm; + color: $red-500; +} diff --git a/src/webparts/requestPortal/shared/approval/history/ApprovalHistory.tsx b/src/webparts/requestPortal/shared/approval/history/ApprovalHistory.tsx new file mode 100644 index 0000000..edf9a8d --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/history/ApprovalHistory.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { CollapseSection } from '@tnsu/ui-kit'; +import { ApprovalListService } from '../approvalList.service'; +import { ApprovalHistoryFeed } from './ApprovalHistoryFeed'; +import { HISTORY_TEXT } from './historyTexts'; + +interface Props { + approval: ApprovalListService; + /** Title или серверный url списка данных — как у ApproverService. */ + dataListRef: string; + requestId: number; + /** + * Меняется после решения/делегирования — лента перечитывается (тот же + * decisionSeq, что у матрицы маршрута в просмотре заявки). + */ + reloadKey?: string | number; +} + +/** + * Раздел «История согласования» в карточке заявки — свёрнут по умолчанию: + * лента грузит версии всех строк маршрута, и делать это при каждом открытии + * заявки незачем. Не печатается — оборачивает вызывающая сторона (rp-no-print). + */ +export const ApprovalHistory: React.FC = ({ approval, dataListRef, requestId, reloadKey }) => ( + + + +); diff --git a/src/webparts/requestPortal/shared/approval/history/ApprovalHistoryFeed.tsx b/src/webparts/requestPortal/shared/approval/history/ApprovalHistoryFeed.tsx new file mode 100644 index 0000000..7fc85dd --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/history/ApprovalHistoryFeed.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { ApprovalListService } from '../approvalList.service'; +import { IApprovalHistory } from './historyModel'; +import { loadApprovalHistory } from './approvalHistory.service'; +import { CurrentHolderRow } from './CurrentHolderRow'; +import { HistoryEventRow } from './HistoryEventRow'; +import { HISTORY_TEXT } from './historyTexts'; +import styles from './ApprovalHistory.module.scss'; + +interface Props { + approval: ApprovalListService; + dataListRef: string; + requestId: number; +} + +type LoadState = + | { kind: 'loading' } + | { kind: 'error'; message: string } + | { kind: 'ready'; history: IApprovalHistory }; + +// Лента «Истории согласования». Монтируется только в раскрытом разделе +// (CollapseSection рендерит тело лишь открытым), поэтому версии грузятся не +// при каждом открытии заявки, а когда историю действительно смотрят. +export const ApprovalHistoryFeed: React.FC = ({ approval, dataListRef, requestId }) => { + const [state, setState] = React.useState({ kind: 'loading' }); + + React.useEffect(() => { + let alive = true; + setState({ kind: 'loading' }); + loadApprovalHistory(approval, dataListRef, requestId) + .then((history) => { + if (alive) setState({ kind: 'ready', history }); + }) + .catch((e) => { + if (alive) setState({ kind: 'error', message: (e as Error).message }); + }); + return () => { + alive = false; + }; + }, [approval, dataListRef, requestId]); + + if (state.kind === 'loading') return
{HISTORY_TEXT.loading}
; + if (state.kind === 'error') { + return
{`${HISTORY_TEXT.errorPrefix} ${state.message}`}
; + } + + const { rounds, current } = state.history; + if (rounds.length === 0) return
{HISTORY_TEXT.empty}
; + + return ( +
+ {rounds.map((round, i) => ( +
+
{HISTORY_TEXT.round(round.number)}
+
    + {/* «Сейчас» — только у последней (верхней) отправки. */} + {i === 0 && current.map((holder, j) => )} + {round.events.map((event, j) => ( + + ))} +
+
+ ))} +
{HISTORY_TEXT.partialNote}
+
+ ); +}; diff --git a/src/webparts/requestPortal/shared/approval/history/CurrentHolderRow.tsx b/src/webparts/requestPortal/shared/approval/history/CurrentHolderRow.tsx new file mode 100644 index 0000000..2c46e1c --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/history/CurrentHolderRow.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { formatDuration, ICurrentHolder } from './historyModel'; +import { HISTORY_TEXT } from './historyTexts'; +import styles from './ApprovalHistory.module.scss'; + +interface Props { + holder: ICurrentHolder; +} + +/** Верхняя строка ленты: у кого заявка сейчас и сколько уже ждёт. */ +export const CurrentHolderRow: React.FC = ({ holder }) => { + const waitedMs = holder.since ? Date.now() - new Date(holder.since).getTime() : NaN; + return ( +
  • + +
    +
    + {HISTORY_TEXT.now} + {holder.person || '—'} + {waitedMs > 0 && {HISTORY_TEXT.waiting(formatDuration(waitedMs))}} +
    + {holder.stepTitle &&
    {holder.stepTitle}
    } +
    +
  • + ); +}; diff --git a/src/webparts/requestPortal/shared/approval/history/HistoryEventRow.tsx b/src/webparts/requestPortal/shared/approval/history/HistoryEventRow.tsx new file mode 100644 index 0000000..089157f --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/history/HistoryEventRow.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { formatDuration, HistoryEventKind, IHistoryEvent } from './historyModel'; +import { eventAction, HISTORY_TEXT } from './historyTexts'; +import styles from './ApprovalHistory.module.scss'; + +interface Props { + event: IHistoryEvent; +} + +// Значок и цвет события — по смыслу решения, как у статусов в StatusBadge. +const MARKER: Record = { + submitted: { symbol: '↗', className: styles.markerNeutral }, + resubmitted: { symbol: '↗', className: styles.markerNeutral }, + approved: { symbol: '✓', className: styles.markerPositive }, + executed: { symbol: '✓', className: styles.markerPositive }, + returned: { symbol: '↩', className: styles.markerWarning }, + rejected: { symbol: '✕', className: styles.markerNegative }, + delegated: { symbol: '→', className: styles.markerNeutral }, + replaced: { symbol: '⇄', className: styles.markerNeutral }, +}; + +export function formatDateTime(iso: string): string { + const d = new Date(iso); + if (isNaN(d.getTime())) return ''; + const pad = (n: number): string => (n < 10 ? `0${n}` : `${n}`); + return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** Одно событие ленты: время · кто — что сделал · за сколько + комментарий. */ +export const HistoryEventRow: React.FC = ({ event }) => { + const marker = MARKER[event.kind]; + return ( +
  • + +
    +
    + {formatDateTime(event.time)} + + {event.actor || '—'} — {eventAction(event)} + + {event.durationMs !== undefined && ( + {HISTORY_TEXT.took(formatDuration(event.durationMs))} + )} +
    + {event.stepTitle &&
    {event.stepTitle}
    } + {event.comment &&
    «{event.comment}»
    } +
    +
  • + ); +}; diff --git a/src/webparts/requestPortal/shared/approval/history/approvalHistory.service.ts b/src/webparts/requestPortal/shared/approval/history/approvalHistory.service.ts new file mode 100644 index 0000000..a51c78f --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/history/approvalHistory.service.ts @@ -0,0 +1,86 @@ +import { getSP } from '../../pnp/pnpjs'; +import '@pnp/sp/webs'; +import '@pnp/sp/lists'; +import { IList } from '@pnp/sp/lists'; +import '@pnp/sp/items'; +import { ApprovalListService } from '../approvalList.service'; +import { buildApprovalHistory, IApprovalHistory, IRequestVersion, IRouteRowVersion } from './historyModel'; + +// Внутренние имена колонок строки маршрута — те же, что FIELDS в approvalList.service.ts. +const ROW_FIELDS = { + status: 'ApprovalStatus', + approver: 'TansuSiteApprover', + comment: 'Comment', + date: 'Date', + delegatedBy: 'DelegatedBy', +}; + +/** + * Имя человека из поля версии. В версиях SharePoint Person-поле приходит + * объектом { LookupId, LookupValue, Email }, но на всякий случай принимаем и + * строку, и Title — пустая строка лучше, чем падение всей ленты. + */ +function personName(value: unknown): string { + if (!value) return ''; + if (typeof value === 'string') return value; + const user = value as { LookupValue?: string; Title?: string }; + return (user.LookupValue || user.Title || '').trim(); +} + +function text(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +// Версии — от старой к новой: VersionId растёт с каждой версией (512 на мажорную). +function byVersion(a: Record, b: Record): number { + return Number(a.VersionId) - Number(b.VersionId); +} + +// Момент сохранения версии: Modified этой версии. Created — запасной вариант +// (у элемента он один на все версии, у версии — время её создания). +function versionTime(v: Record): string { + return text(v.Modified) || text(v.Created); +} + +function toRowVersion(v: Record): IRouteRowVersion { + return { + time: versionTime(v), + status: text(v[ROW_FIELDS.status]), + approverName: personName(v[ROW_FIELDS.approver]), + editorName: personName(v.Editor), + stepTitle: text(v.Title), + comment: text(v[ROW_FIELDS.comment]).trim(), + date: text(v[ROW_FIELDS.date]), + delegatedByName: personName(v[ROW_FIELDS.delegatedBy]), + }; +} + +// dataListRef — Title или серверный url списка данных, как у ApproverService. +function dataList(dataListRef: string): IList { + return dataListRef.startsWith('/') + ? getSP().web.getList(dataListRef) + : getSP().web.lists.getByTitle(dataListRef); +} + +async function requestVersions(dataListRef: string, requestId: number): Promise { + const versions = (await dataList(dataListRef).items.getById(requestId).versions()) as Array>; + return [...versions].sort(byVersion).map((v) => ({ + time: versionTime(v), + status: text(v.RequestStatus), + editorName: personName(v.Editor), + })); +} + +/** Загрузить и собрать «Историю согласования» заявки. */ +export async function loadApprovalHistory( + approval: ApprovalListService, + dataListRef: string, + requestId: number +): Promise { + const [rowVersions, reqVersions] = await Promise.all([ + approval.getRowVersions(requestId), + requestVersions(dataListRef, requestId), + ]); + const rows = rowVersions.map((versions) => [...versions].sort(byVersion).map(toRowVersion)); + return buildApprovalHistory(rows, reqVersions); +} diff --git a/src/webparts/requestPortal/shared/approval/history/historyModel.test.ts b/src/webparts/requestPortal/shared/approval/history/historyModel.test.ts new file mode 100644 index 0000000..a00a714 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/history/historyModel.test.ts @@ -0,0 +1,141 @@ +import { + buildApprovalHistory, + formatDuration, + IRequestVersion, + IRouteRowVersion, + rowEvents, + submitEvents, +} from './historyModel'; + +function rv(over: Partial): IRouteRowVersion { + return { + time: '2026-09-24T10:00:00Z', + status: 'Ждёт очереди', + approverName: 'Анна Ким', + editorName: 'Антон Огай', + stepTitle: 'Начальник расчётной группы', + comment: '', + date: '', + delegatedByName: '', + ...over, + }; +} + +function req(time: string, status: string, editorName = 'Антон Огай'): IRequestVersion { + return { time, status, editorName }; +} + +describe('rowEvents', () => { + it('решение даёт событие с комментарием и временем, сколько шаг был у человека', () => { + const events = rowEvents([ + rv({ time: '2026-09-24T10:00:00Z', status: 'Ждёт очереди' }), + rv({ time: '2026-09-24T10:00:05Z', status: 'На согласовании', date: '2026-09-24T10:00:05Z' }), + rv({ time: '2026-09-24T13:00:05Z', status: 'Возвращена на доработку', date: '2026-09-24T13:00:05Z', comment: 'Уточните оклад' }), + ]); + expect(events).toEqual([ + { + kind: 'returned', + time: '2026-09-24T13:00:05Z', + actor: 'Анна Ким', + stepTitle: 'Начальник расчётной группы', + comment: 'Уточните оклад', + durationMs: 3 * 60 * 60 * 1000, + }, + ]); + }); + + it('после пересохранения патчем в той же строке видны оба круга', () => { + const events = rowEvents([ + rv({ status: 'На согласовании', date: '2026-09-24T10:00:00Z' }), + rv({ status: 'Возвращена на доработку', date: '2026-09-25T10:00:00Z', comment: 'Исправьте' }), + rv({ time: '2026-09-26T09:00:00Z', status: 'Ждёт очереди' }), + rv({ status: 'На согласовании', date: '2026-09-26T09:00:00Z' }), + rv({ status: 'Согласовано', date: '2026-09-26T10:00:00Z' }), + ]); + expect(events.map((e) => e.kind)).toEqual(['returned', 'approved']); + expect(events[1].durationMs).toBe(60 * 60 * 1000); + }); + + it('первая версия с «Кем переназначено» — событие делегирования', () => { + const events = rowEvents([ + rv({ time: '2026-09-25T09:00:00Z', approverName: 'Асель Самаева', delegatedByName: 'Анна Ким' }), + ]); + expect(events).toEqual([ + { + kind: 'delegated', + time: '2026-09-25T09:00:00Z', + actor: 'Анна Ким', + target: 'Асель Самаева', + stepTitle: 'Начальник расчётной группы', + }, + ]); + }); + + it('смена человека без смены статуса — замена исполнителя', () => { + const events = rowEvents([ + rv({ status: 'На исполнении', approverName: 'Петров Пётр' }), + rv({ time: '2026-09-26T12:00:00Z', status: 'На исполнении', approverName: 'Сидорова Анна', editorName: 'Алтынай Бултурукова' }), + ]); + expect(events).toEqual([ + { + kind: 'replaced', + time: '2026-09-26T12:00:00Z', + actor: 'Алтынай Бултурукова', + target: 'Сидорова Анна', + previous: 'Петров Пётр', + stepTitle: 'Начальник расчётной группы', + }, + ]); + }); +}); + +describe('submitEvents', () => { + it('отправка из черновика и повторная после возврата; ход по маршруту отправкой не считается', () => { + const events = submitEvents([ + req('2026-09-24T09:00:00Z', 'Черновик'), + req('2026-09-24T10:00:00Z', 'На согласовании'), + req('2026-09-25T10:00:00Z', 'Возвращена на доработку', 'Анна Ким'), + req('2026-09-26T09:00:00Z', 'На согласовании'), + req('2026-09-26T10:00:00Z', 'Выполнение', 'Анна Ким'), + ]); + expect(events.map((e) => [e.kind, e.time])).toEqual([ + ['submitted', '2026-09-24T10:00:00Z'], + ['resubmitted', '2026-09-26T09:00:00Z'], + ]); + }); +}); + +describe('buildApprovalHistory', () => { + it('разбивает ленту на отправки, новые сверху, и показывает, у кого заявка сейчас', () => { + const history = buildApprovalHistory( + [ + [ + rv({ status: 'На согласовании', date: '2026-09-24T10:00:00Z' }), + rv({ status: 'Возвращена на доработку', date: '2026-09-25T10:00:00Z' }), + rv({ time: '2026-09-26T09:00:00Z', status: 'На согласовании', date: '2026-09-26T09:00:00Z' }), + ], + ], + [req('2026-09-24T10:00:00Z', 'На согласовании'), req('2026-09-25T10:00:00Z', 'Возвращена на доработку'), req('2026-09-26T09:00:00Z', 'На согласовании')] + ); + + expect(history.rounds.map((r) => r.number)).toEqual([2, 1]); + expect(history.rounds[0].events.map((e) => e.kind)).toEqual(['resubmitted']); + expect(history.rounds[1].events.map((e) => e.kind)).toEqual(['returned', 'submitted']); + expect(history.current).toEqual([ + { person: 'Анна Ким', stepTitle: 'Начальник расчётной группы', since: '2026-09-26T09:00:00Z' }, + ]); + }); + + it('без версий — пусто', () => { + expect(buildApprovalHistory([], [])).toEqual({ rounds: [], current: [] }); + }); +}); + +describe('formatDuration', () => { + it('минуты, часы, дни', () => { + expect(formatDuration(30 * 1000)).toBe('1 мин'); + expect(formatDuration(15 * 60 * 1000)).toBe('15 мин'); + expect(formatDuration(3 * 60 * 60 * 1000)).toBe('3 ч'); + expect(formatDuration(2 * 24 * 60 * 60 * 1000)).toBe('2 дн'); + }); +}); diff --git a/src/webparts/requestPortal/shared/approval/history/historyModel.ts b/src/webparts/requestPortal/shared/approval/history/historyModel.ts new file mode 100644 index 0000000..281229b --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/history/historyModel.ts @@ -0,0 +1,193 @@ +import { REQUEST_STATUS } from '../models/statuses'; + +/** + * «История согласования» заявки — лента событий, собранная из истории версий + * SharePoint: строк маршрута (решения, делегирование, замены) и самой заявки + * (отправки). Чистые функции, без SharePoint — загрузка в approvalHistory.service.ts. + * + * Прошлые отправки видны, потому что маршрут пересохраняется патчем (см. + * writeRoute): строки остаются, и их прошлые решения лежат в версиях. Решения + * из отправок ДО включения патча (2026-09-29) были удалены вместе со строками — + * у старых заявок видна только их последняя отправка. + */ + +/** Одна версия строки маршрута — поля, нужные для ленты. */ +export interface IRouteRowVersion { + /** Когда создана версия (ISO). */ + time: string; + status: string; + /** Человек в строке («Согласующий»). */ + approverName: string; + /** Кто сохранил эту версию (Editor). */ + editorName: string; + /** Должность шага — Title строки. */ + stepTitle: string; + comment: string; + /** Колонка «Дата»: момент активации или решения (ISO), '' — пусто. */ + date: string; + /** «Кем переназначено» — у строк делегатов. */ + delegatedByName: string; +} + +/** Одна версия заявки — для событий «отправлено». */ +export interface IRequestVersion { + time: string; + status: string; + editorName: string; +} + +export type HistoryEventKind = + | 'submitted' + | 'resubmitted' + | 'approved' + | 'executed' + | 'returned' + | 'rejected' + | 'delegated' + | 'replaced'; + +export interface IHistoryEvent { + kind: HistoryEventKind; + time: string; + /** Кто сделал. */ + actor: string; + /** Над кем: кому передали / кого поставили вместо. */ + target?: string; + /** Кого заменили (для 'replaced'). */ + previous?: string; + stepTitle?: string; + comment?: string; + /** Сколько шаг был у человека до решения, мс. */ + durationMs?: number; +} + +/** Шаг, который сейчас у человека. */ +export interface ICurrentHolder { + person: string; + stepTitle: string; + /** С какого момента у него (ISO), '' — неизвестно. */ + since: string; +} + +export interface IHistoryRound { + /** Номер отправки, с 1. */ + number: number; + /** События отправки, НОВЫЕ СВЕРХУ. */ + events: IHistoryEvent[]; +} + +export interface IApprovalHistory { + /** Отправки, НОВЫЕ СВЕРХУ. */ + rounds: IHistoryRound[]; + current: ICurrentHolder[]; +} + +const ACTIVE_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution]; + +const DECISION_KINDS: Record = { + [REQUEST_STATUS.approved]: 'approved', + [REQUEST_STATUS.executed]: 'executed', + [REQUEST_STATUS.returned]: 'returned', + [REQUEST_STATUS.rejected]: 'rejected', +}; + +// Статусы заявки, ИЗ которых переход в работу — это отправка автором, а не +// движение по маршруту (На согласовании → Выполнение отправкой не считается). +const BEFORE_SUBMIT_STATUSES: string[] = ['', REQUEST_STATUS.draft, REQUEST_STATUS.returned, REQUEST_STATUS.rejected]; +const SUBMITTED_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.execution]; + +function msBetween(fromIso: string, toIso: string): number | undefined { + const diff = new Date(toIso).getTime() - new Date(fromIso).getTime(); + return isNaN(diff) || diff < 0 ? undefined : diff; +} + +/** События одной строки маршрута по её версиям (от старой к новой). */ +export function rowEvents(versions: IRouteRowVersion[]): IHistoryEvent[] { + const events: IHistoryEvent[] = []; + let arrivedAt = ''; + + versions.forEach((v, i) => { + const prev = i > 0 ? versions[i - 1] : undefined; + const time = v.date || v.time; + + if (!prev && v.delegatedByName) { + events.push({ kind: 'delegated', time: v.time, actor: v.delegatedByName, target: v.approverName, stepTitle: v.stepTitle }); + } + // Тот же шаг, другой человек — замена (не пересохранение: статус не менялся). + if (prev && prev.approverName !== v.approverName && prev.status === v.status) { + events.push({ kind: 'replaced', time: v.time, actor: v.editorName, target: v.approverName, previous: prev.approverName, stepTitle: v.stepTitle }); + } + if (ACTIVE_STATUSES.indexOf(v.status) >= 0 && (!prev || ACTIVE_STATUSES.indexOf(prev.status) < 0)) { + arrivedAt = time; + } + const decision = DECISION_KINDS[v.status]; + if (decision && (!prev || prev.status !== v.status)) { + events.push({ + kind: decision, + time, + actor: v.approverName, + stepTitle: v.stepTitle, + comment: v.comment || undefined, + durationMs: arrivedAt ? msBetween(arrivedAt, time) : undefined, + }); + arrivedAt = ''; + } + }); + return events; +} + +/** Отправки заявки автором — по версиям заявки (от старой к новой). */ +export function submitEvents(versions: IRequestVersion[]): IHistoryEvent[] { + const events: IHistoryEvent[] = []; + versions.forEach((v, i) => { + const prevStatus = i > 0 ? versions[i - 1].status : ''; + if (SUBMITTED_STATUSES.indexOf(v.status) < 0 || BEFORE_SUBMIT_STATUSES.indexOf(prevStatus) < 0) return; + const kind: HistoryEventKind = events.length === 0 ? 'submitted' : 'resubmitted'; + events.push({ kind, time: v.time, actor: v.editorName }); + }); + return events; +} + +/** Кто держит заявку сейчас — по последним версиям строк. */ +function currentHolders(rows: IRouteRowVersion[][]): ICurrentHolder[] { + return rows + .filter((versions) => versions.length > 0 && ACTIVE_STATUSES.indexOf(versions[versions.length - 1].status) >= 0) + .map((versions) => { + const last = versions[versions.length - 1]; + return { person: last.approverName, stepTitle: last.stepTitle, since: last.date }; + }); +} + +/** + * Лента целиком: события по времени, разбитые на отправки (каждая отправка + * автором открывает новую), новые сверху. + */ +export function buildApprovalHistory(rows: IRouteRowVersion[][], requestVersions: IRequestVersion[]): IApprovalHistory { + const all = rows + .reduce((acc, versions) => acc.concat(rowEvents(versions)), []) + .concat(submitEvents(requestVersions)) + .sort((a, b) => a.time.localeCompare(b.time)); + + const rounds: IHistoryRound[] = []; + all.forEach((event) => { + const startsRound = event.kind === 'submitted' || event.kind === 'resubmitted'; + if (startsRound || rounds.length === 0) rounds.push({ number: rounds.length + 1, events: [] }); + rounds[rounds.length - 1].events.push(event); + }); + + return { + rounds: rounds.map((r) => ({ ...r, events: [...r.events].reverse() })).reverse(), + current: currentHolders(rows), + }; +} + +const MINUTE_MS = 60 * 1000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; + +/** «15 мин», «3 ч», «2 дн» — грубо, для подписи «сколько держал шаг». */ +export function formatDuration(ms: number): string { + if (ms < HOUR_MS) return `${Math.max(1, Math.round(ms / MINUTE_MS))} мин`; + if (ms < DAY_MS) return `${Math.round(ms / HOUR_MS)} ч`; + return `${Math.round(ms / DAY_MS)} дн`; +} diff --git a/src/webparts/requestPortal/shared/approval/history/historyTexts.ts b/src/webparts/requestPortal/shared/approval/history/historyTexts.ts new file mode 100644 index 0000000..26c58d6 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/history/historyTexts.ts @@ -0,0 +1,36 @@ +import { HistoryEventKind, IHistoryEvent } from './historyModel'; + +// Тексты «Истории согласования» — в одном месте (задел под локализацию). +// Формулировки безличные («согласовано», а не «согласовала»): пол по имени не +// угадываем. +export const HISTORY_TEXT = { + title: 'История согласования', + loading: 'Загрузка истории…', + empty: 'Пока нет событий: заявка ещё не отправлялась.', + errorPrefix: 'Не удалось загрузить историю:', + round: (n: number): string => `Отправка ${n}`, + now: 'сейчас', + waiting: (duration: string): string => `ждёт ${duration}`, + took: (duration: string): string => `за ${duration}`, + partialNote: + 'Показаны события, сохранённые в SharePoint. Решения из отправок до включения истории могли не сохраниться.', +}; + +const ACTION: Record = { + submitted: 'отправлено на согласование', + resubmitted: 'отправлено повторно', + approved: 'согласовано', + executed: 'выполнено', + returned: 'возвращено на доработку', + rejected: 'отклонено', + delegated: 'передано', + replaced: 'заменён исполнитель', +}; + +/** Что произошло — «согласовано», «передано: Асель Самаева», «заменён исполнитель: А → Б». */ +export function eventAction(event: IHistoryEvent): string { + const action = ACTION[event.kind]; + if (event.kind === 'delegated' && event.target) return `${action}: ${event.target}`; + if (event.kind === 'replaced') return `${action}: ${event.previous || '—'} → ${event.target || '—'}`; + return action; +} diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index 4e3bc5c..5831fa6 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -68,6 +68,9 @@ export type { IApproverOrgInfo } from './approverOrgInfo'; export { canEditRoute } from './routeEditRights'; export { canEditAllFields } from './fullEditRights'; export { ApprovalMatrix } from './ui/ApprovalMatrix'; +export { ApprovalHistory } from './history/ApprovalHistory'; +export { ApprovalHistoryFeed } from './history/ApprovalHistoryFeed'; +export { HISTORY_TEXT } from './history/historyTexts'; export { UserPicker } from './ui/UserPicker'; export { DecisionPanel } from './ui/DecisionPanel'; export { StatusBadge } from './ui/StatusBadge'; diff --git a/src/webparts/requestPortal/shared/approval/routePatchPlan.test.ts b/src/webparts/requestPortal/shared/approval/routePatchPlan.test.ts new file mode 100644 index 0000000..9cae051 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/routePatchPlan.test.ts @@ -0,0 +1,94 @@ +import { IExistingRouteRow, planRoutePatch } from './routePatchPlan'; + +const APPROVAL = 'Согласование'; +const EXECUTION = 'Исполнение'; + +function row(id: number, approverId: number, phase = APPROVAL, delegatedById = 0): IExistingRouteRow { + return { id, approverId, phase, delegatedById }; +} + +describe('planRoutePatch', () => { + it('маршрут не изменился — все строки обновляются на месте, ничего не создаётся и не удаляется', () => { + const plan = planRoutePatch( + [row(10, 1), row(11, 2), row(12, 3)], + [ + { phase: APPROVAL, approverId: 1 }, + { phase: APPROVAL, approverId: 2 }, + { phase: APPROVAL, approverId: 3 }, + ] + ); + expect(plan.update).toEqual([ + { rowId: 10, stepIndex: 0 }, + { rowId: 11, stepIndex: 1 }, + { rowId: 12, stepIndex: 2 }, + ]); + expect(plan.create).toEqual([]); + expect(plan.remove).toEqual([]); + }); + + it('согласующий сменился — старая строка убирается, новая создаётся', () => { + const plan = planRoutePatch( + [row(10, 1), row(11, 2)], + [ + { phase: APPROVAL, approverId: 1 }, + { phase: APPROVAL, approverId: 9 }, + ] + ); + expect(plan.update).toEqual([{ rowId: 10, stepIndex: 0 }]); + expect(plan.create).toEqual([1]); + expect(plan.remove).toEqual([11]); + }); + + it('порядок шагов поменялся — строки сопоставляются по человеку, а не по позиции', () => { + const plan = planRoutePatch( + [row(10, 1), row(11, 2)], + [ + { phase: APPROVAL, approverId: 2 }, + { phase: APPROVAL, approverId: 1 }, + ] + ); + expect(plan.update).toEqual([ + { rowId: 11, stepIndex: 0 }, + { rowId: 10, stepIndex: 1 }, + ]); + expect(plan.create).toEqual([]); + expect(plan.remove).toEqual([]); + }); + + it('один человек дважды в фазе — пары по порядку, лишняя строка убирается', () => { + const plan = planRoutePatch([row(10, 1), row(11, 1), row(12, 1)], [ + { phase: APPROVAL, approverId: 1 }, + { phase: APPROVAL, approverId: 1 }, + ]); + expect(plan.update).toEqual([ + { rowId: 10, stepIndex: 0 }, + { rowId: 11, stepIndex: 1 }, + ]); + expect(plan.remove).toEqual([12]); + }); + + it('фаза важна — тот же человек в другой фазе считается другим шагом', () => { + const plan = planRoutePatch([row(10, 1, APPROVAL)], [{ phase: EXECUTION, approverId: 1 }]); + expect(plan.update).toEqual([]); + expect(plan.create).toEqual([0]); + expect(plan.remove).toEqual([10]); + }); + + it('строка делегата не сопоставляется и убирается, даже если человек есть в новом маршруте', () => { + const plan = planRoutePatch([row(10, 1), row(11, 5, APPROVAL, 1)], [ + { phase: APPROVAL, approverId: 1 }, + { phase: APPROVAL, approverId: 5 }, + ]); + expect(plan.update).toEqual([{ rowId: 10, stepIndex: 0 }]); + expect(plan.create).toEqual([1]); + expect(plan.remove).toEqual([11]); + }); + + it('первая отправка (строк ещё нет) — всё создаётся', () => { + const plan = planRoutePatch([], [ + { phase: APPROVAL, approverId: 1 }, + { phase: EXECUTION, approverId: 2 }, + ]); + expect(plan).toEqual({ update: [], create: [0, 1], remove: [] }); + }); +}); diff --git a/src/webparts/requestPortal/shared/approval/routePatchPlan.ts b/src/webparts/requestPortal/shared/approval/routePatchPlan.ts new file mode 100644 index 0000000..9781f64 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/routePatchPlan.ts @@ -0,0 +1,76 @@ +/** + * План пересохранения маршрута «патчем»: какие существующие строки обновить, + * какие шаги создать заново и какие строки убрать. Чистая функция, без + * SharePoint — её выполняет ApprovalListService.patchSteps. + * + * Зачем патч, а не «удалить всё и создать заново» (см. writeRoute): строка, + * которая остаётся в маршруте, обновляется на месте, и SharePoint сохраняет её + * прошлые значения в истории версий — кто, когда и с каким комментарием решал + * в прошлом круге (напр. вернул на доработку). При удалении это терялось. + * + * Строка и шаг считаются «одним и тем же», если совпадают фаза и согласующий. + * Если один человек стоит в фазе несколько раз, пары сопоставляются по порядку. + * Строки делегатов (delegatedById > 0) добавлены переназначением, а не + * маршрутом — новому маршруту они не соответствуют и убираются. + */ + +/** Существующая строка маршрута — то, что нужно для сопоставления. */ +export interface IExistingRouteRow { + id: number; + phase: string; + approverId: number; + /** Кто переназначил — у строк делегатов; 0 у обычных строк маршрута. */ + delegatedById: number; +} + +/** Шаг нового маршрута. index — его позиция во входном массиве. */ +export interface IPlannedRouteStep { + phase: string; + approverId: number; +} + +export interface IRoutePatchPlan { + /** Обновить строку rowId значениями шага stepIndex. */ + update: Array<{ rowId: number; stepIndex: number }>; + /** Индексы шагов, для которых подходящей строки нет, — создать. */ + create: number[]; + /** Строки, которым в новом маршруте ничего не соответствует, — убрать. */ + remove: number[]; +} + +function matchKey(phase: string, approverId: number): string { + return `${phase}|${approverId}`; +} + +/** + * existing — в порядке маршрута (как отдаёт getForRequest, по StepOrder): + * от этого зависит, какая строка достанется какому шагу, если один человек + * стоит в фазе дважды. + */ +export function planRoutePatch(existing: IExistingRouteRow[], planned: IPlannedRouteStep[]): IRoutePatchPlan { + // Свободные строки по ключу, в порядке маршрута — первый шаг забирает первую. + const freeRows = new Map(); + existing + .filter((row) => row.delegatedById === 0) + .forEach((row) => { + const key = matchKey(row.phase, row.approverId); + freeRows.set(key, (freeRows.get(key) ?? []).concat(row.id)); + }); + + const plan: IRoutePatchPlan = { update: [], create: [], remove: [] }; + const usedRowIds = new Set(); + + planned.forEach((step, stepIndex) => { + const candidates = freeRows.get(matchKey(step.phase, step.approverId)) ?? []; + const rowId = candidates.shift(); + if (rowId === undefined) { + plan.create.push(stepIndex); + return; + } + usedRowIds.add(rowId); + plan.update.push({ rowId, stepIndex }); + }); + + plan.remove = existing.filter((row) => !usedRowIds.has(row.id)).map((row) => row.id); + return plan; +}