diff --git a/src/webparts/requestPortal/shared/approval/approvalList.service.ts b/src/webparts/requestPortal/shared/approval/approvalList.service.ts index 65b2d38..ace1745 100644 --- a/src/webparts/requestPortal/shared/approval/approvalList.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalList.service.ts @@ -14,6 +14,7 @@ import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses'; import { userSearchVariants } from './userSearchVariants'; import { planRoutePatch } from './routePatchPlan'; import { IStepOrderChange } from './queuedStepMove'; +import { pickRowsToActivate } from './queueActivation'; // Внутренние имена колонок списка согласования (создаются провижинингом). const FIELDS = { @@ -240,9 +241,9 @@ export class ApprovalListService { await execute(); } - // Продвигает очередь: среди ещё не активных строк со статусом «Ждёт очереди» - // в текущей фазе активирует те, что делят наименьший StepOrder (обычно одна - // строка; несколько — параллельный шаг). Фаза исполнения активируется, только + // Продвигает очередь: в текущей фазе активирует ждущие строки текущего шага — + // наименьшего StepOrder среди нерешённых (обычно одна строка; несколько — + // параллельный шаг; правило см. pickRowsToActivate). Фаза исполнения активируется, только // когда фаза согласования пройдена целиком (без Отклонено/Возвращена) — иначе // строки исполнения остаются «Ждёт очереди». Если фаза согласования уже // остановлена решением, ничего не активирует. @@ -260,13 +261,7 @@ export class ApprovalListService { approvalRows.length === 0 || approvalRows.every((r) => r.status === REQUEST_STATUS.approved); const activate = async (phaseRows: IApprovalRow[], activeStatus: string): Promise => { - if (phaseRows.some((r) => r.status === activeStatus)) return; - const queuedRows = phaseRows.filter((r) => r.status === REQUEST_STATUS.queued); - if (queuedRows.length === 0) return; - const minOrder = Math.min(...queuedRows.map((r) => r.order)); - await Promise.all( - queuedRows.filter((r) => r.order === minOrder).map((r) => this.setActive(r.id, activeStatus)) - ); + await Promise.all(pickRowsToActivate(phaseRows).map((id) => this.setActive(id, activeStatus))); }; if (!approvalDone) { diff --git a/src/webparts/requestPortal/shared/approval/queueActivation.test.ts b/src/webparts/requestPortal/shared/approval/queueActivation.test.ts new file mode 100644 index 0000000..d8c4184 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/queueActivation.test.ts @@ -0,0 +1,59 @@ +import { IQueueRow, pickRowsToActivate } from './queueActivation'; +import { REQUEST_STATUS } from './models/statuses'; + +function row(id: number, order: number, status: string = REQUEST_STATUS.queued): IQueueRow { + return { id, order, status }; +} + +describe('pickRowsToActivate', () => { + it('отправка: активируется первый шаг', () => { + expect(pickRowsToActivate([row(1, 1), row(2, 2)])).toEqual([1]); + }); + + it('отправка: параллельный первый шаг активируется целиком', () => { + expect(pickRowsToActivate([row(1, 1), row(2, 1), row(3, 2)])).toEqual([1, 2]); + }); + + it('последовательный маршрут: пока шаг активен, очередь не двигается', () => { + expect(pickRowsToActivate([row(1, 1, REQUEST_STATUS.onApproval), row(2, 2)])).toEqual([]); + }); + + it('шаг согласован — активируется следующий', () => { + expect(pickRowsToActivate([row(1, 1, REQUEST_STATUS.approved), row(2, 2)])).toEqual([2]); + }); + + it('параллельный шаг: один согласовал, второй думает — следующий шаг ждёт', () => { + const rows = [row(1, 1, REQUEST_STATUS.approved), row(2, 1, REQUEST_STATUS.onApproval), row(3, 2)]; + expect(pickRowsToActivate(rows)).toEqual([]); + }); + + it('параллельный шаг: делегат активируется сразу, не дожидаясь второго участника', () => { + // Аня (1) переназначила на Дину (0.5) и ушла в очередь, Борис (2) ещё думает. + const rows = [row(4, 0.5), row(1, 1), row(2, 1, REQUEST_STATUS.onApproval), row(3, 2)]; + expect(pickRowsToActivate(rows)).toEqual([4]); + }); + + it('параллельный шаг: делегат согласовал — Аня возвращается в пару к Борису', () => { + const rows = [ + row(4, 0.5, REQUEST_STATUS.approved), + row(1, 1), + row(2, 1, REQUEST_STATUS.onApproval), + row(3, 2), + ]; + expect(pickRowsToActivate(rows)).toEqual([1]); + }); + + it('последовательный маршрут с делегатом: сначала делегат, потом делегировавший', () => { + expect(pickRowsToActivate([row(4, 0.5), row(1, 1), row(3, 2)])).toEqual([4]); + expect(pickRowsToActivate([row(4, 0.5, REQUEST_STATUS.approved), row(1, 1), row(3, 2)])).toEqual([1]); + }); + + it('исполнение: тот же порядок', () => { + const rows = [row(1, 3, REQUEST_STATUS.executed), row(2, 4)]; + expect(pickRowsToActivate(rows)).toEqual([2]); + }); + + it('всё решено — активировать нечего', () => { + expect(pickRowsToActivate([row(1, 1, REQUEST_STATUS.approved)])).toEqual([]); + }); +}); diff --git a/src/webparts/requestPortal/shared/approval/queueActivation.ts b/src/webparts/requestPortal/shared/approval/queueActivation.ts new file mode 100644 index 0000000..6a0bec3 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/queueActivation.ts @@ -0,0 +1,36 @@ +import { IApprovalRow } from './models/IApprovalRow'; +import { REQUEST_STATUS } from './models/statuses'; + +export type IQueueRow = Pick; + +/** + * Какие строки ОДНОЙ фазы маршрута сделать активными. Чистая функция без + * SharePoint, выполняет ApprovalListService.activateNextQueued. + * + * Текущий шаг — наименьший StepOrder среди ещё не решённых строк (ждущих и + * активных). Активируются ждущие строки именно этого шага. + * + * Раньше правило было «есть активная строка — очередь не двигаем». На + * параллельном шаге это ломалось: если участник (Аня) переназначал заявку + * делегату (Дина встаёт перед Аней с дробным order), второй участник того же + * шага (Борис) всё ещё «На согласовании», и Дина не активировалась, пока не + * решит Борис. Теперь у Дины меньший order — она текущий шаг и активируется + * сразу, а после её решения Аня возвращается в пару к Борису. + * + * На последовательном маршруте поведение прежнее: пока шаг активен, ждущих + * строк с таким же или меньшим order нет — активировать нечего. + */ +export function pickRowsToActivate(phaseRows: IQueueRow[]): number[] { + const pending = phaseRows.filter( + (r) => + r.status === REQUEST_STATUS.queued || + r.status === REQUEST_STATUS.onApproval || + r.status === REQUEST_STATUS.onExecution + ); + if (pending.length === 0) return []; + + const currentOrder = Math.min(...pending.map((r) => r.order)); + return pending + .filter((r) => r.order === currentOrder && r.status === REQUEST_STATUS.queued) + .map((r) => r.id); +}