fix: делегат на параллельном шаге получает заявку сразу

Очередь не двигалась, пока на шаге был кто-то активный, поэтому делегат
участника параллельного шага ждал решения второго участника. Текущий шаг
теперь — наименьший order среди нерешённых строк.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-08 16:07:59 +05:00
parent 474a663da6
commit 290598c357
3 changed files with 100 additions and 10 deletions
@@ -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<void> => {
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) {
@@ -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([]);
});
});
@@ -0,0 +1,36 @@
import { IApprovalRow } from './models/IApprovalRow';
import { REQUEST_STATUS } from './models/statuses';
export type IQueueRow = Pick<IApprovalRow, 'id' | 'status' | 'order'>;
/**
* Какие строки ОДНОЙ фазы маршрута сделать активными. Чистая функция без
* 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);
}