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

Письмо уходило только первому из параллельных согласующих, а после решения
одного из них остальные получали повторное. Теперь шаг оповещается целиком
при старте и не оповещается снова, если кто-то из его участников уже решил.

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 8fc9776895
commit 474a663da6
3 changed files with 152 additions and 22 deletions
@@ -2,7 +2,8 @@ import { AadHttpClient } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { FLOW_RESOURCE, FLOW_NOTIFY_URL, getNotificationsEnabled, getNotifyOnlyTo } from '../config';
import { ApprovalListService } from './approvalList.service';
import { REQUEST_STATUS } from './models/statuses';
import { IApprovalRow } from './models/IApprovalRow';
import { pickRowsToNotify } from './notifyTargets';
// Данные формы для тела запроса к потоку (постоянны для формы).
export interface INotifyTarget {
@@ -54,12 +55,40 @@ async function isNotifyAllowed(approval: ApprovalListService, approverId: number
return false;
}
// Одна строка маршрута → один вызов потока. Ошибку глотаем здесь, по строке:
// сбой письма одному участнику параллельного шага не должен лишать письма остальных.
async function notifyRow(
context: WebPartContext,
approval: ApprovalListService,
requestId: number,
target: INotifyTarget,
row: IApprovalRow
): Promise<void> {
try {
if (!(await isNotifyAllowed(approval, row.approverId))) {
return;
}
await postNotify(context, {
RequestTypeId: target.requestTypeId,
RowID: row.id,
RequestID: requestId,
ListName: target.dataListName,
ApproveListName: target.approvalListName,
});
} catch {
// не критично — просто не отправили уведомление
}
}
// Находит АКТИВНЫЙ шаг маршрута (На согласовании / На исполнении) и уведомляет его.
// Вызывать после отправки заявки и после каждого положительного решения. Поток по
// переданным полям сам находит согласующего/исполнителя и шлёт уведомление.
// Уведомление некритично — ошибки глотаем, чтобы не ронять основной сценарий.
//
// rowId — уведомить именно эту активную строку, а не первую попавшуюся: при
// Параллельный шаг (несколько активных строк с одним StepOrder) оповещается
// целиком — каждому участнику свой вызов потока (см. pickRowsToNotify).
//
// rowId — уведомить именно эту активную строку, а не весь шаг: при
// замене/удалении исполнителя на параллельном шаге активных строк несколько, и
// письмо должно уйти новому человеку, а не тому, кто уже его получил.
export async function notifyActiveStep(
@@ -69,27 +98,13 @@ export async function notifyActiveStep(
target: INotifyTarget,
rowId?: number
): Promise<void> {
let rows: IApprovalRow[];
try {
const rows = await approval.getForRequest(requestId);
const active = rows.filter(
(r) =>
(r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution) &&
(rowId === undefined || r.id === rowId)
)[0];
if (!active) {
return;
}
if (!(await isNotifyAllowed(approval, active.approverId))) {
return;
}
await postNotify(context, {
RequestTypeId: target.requestTypeId,
RowID: active.id,
RequestID: requestId,
ListName: target.dataListName,
ApproveListName: target.approvalListName,
});
rows = await approval.getForRequest(requestId);
} catch {
// не критично — просто не отправили уведомление
return; // не критично — просто не отправили уведомление
}
await Promise.all(
pickRowsToNotify(rows, rowId).map((row) => notifyRow(context, approval, requestId, target, row))
);
}
@@ -0,0 +1,68 @@
import { INotifyCandidate, pickRowsToNotify } from './notifyTargets';
import { REQUEST_STATUS } from './models/statuses';
function row(id: number, order: number, status: string = REQUEST_STATUS.queued): INotifyCandidate {
return { id, order, status };
}
const ids = (rows: INotifyCandidate[]): number[] => rows.map((r) => r.id);
describe('pickRowsToNotify', () => {
it('последовательный маршрут — только одна активная строка, как раньше', () => {
const rows = [row(1, 1, REQUEST_STATUS.approved), row(2, 2, REQUEST_STATUS.onApproval), row(3, 3)];
expect(ids(pickRowsToNotify(rows))).toEqual([2]);
});
it('параллельный шаг — все активные строки с общим StepOrder', () => {
const rows = [row(1, 1, REQUEST_STATUS.onApproval), row(2, 1, REQUEST_STATUS.onApproval), row(3, 2)];
expect(ids(pickRowsToNotify(rows))).toEqual([1, 2]);
});
it('параллельный шаг, один уже согласовал — оставшемуся повторно не шлём', () => {
const rows = [row(1, 1, REQUEST_STATUS.approved), row(2, 1, REQUEST_STATUS.onApproval), row(3, 2)];
expect(pickRowsToNotify(rows)).toEqual([]);
});
it('параллельный шаг, один вернул заявку — оставшемуся не шлём', () => {
const rows = [row(1, 1, REQUEST_STATUS.returned), row(2, 1, REQUEST_STATUS.onApproval)];
expect(pickRowsToNotify(rows)).toEqual([]);
});
it('параллельный шаг пройден — следующий шаг оповещается', () => {
const rows = [
row(1, 1, REQUEST_STATUS.approved),
row(2, 1, REQUEST_STATUS.approved),
row(3, 2, REQUEST_STATUS.onApproval),
];
expect(ids(pickRowsToNotify(rows))).toEqual([3]);
});
it('параллельный исполнитель выполнил — второму повторно не шлём', () => {
const rows = [row(1, 3, REQUEST_STATUS.executed), row(2, 3, REQUEST_STATUS.onExecution)];
expect(pickRowsToNotify(rows)).toEqual([]);
});
it('параллельные исполнители тоже оповещаются все', () => {
const rows = [
row(1, 1, REQUEST_STATUS.approved),
row(2, 2, REQUEST_STATUS.onExecution),
row(3, 2, REQUEST_STATUS.onExecution),
];
expect(ids(pickRowsToNotify(rows))).toEqual([2, 3]);
});
it('rowId задан — только эта строка, даже на параллельном шаге', () => {
const rows = [row(1, 1, REQUEST_STATUS.onExecution), row(2, 1, REQUEST_STATUS.onExecution)];
expect(ids(pickRowsToNotify(rows, 2))).toEqual([2]);
});
it('rowId неактивной строки — никого', () => {
const rows = [row(1, 1, REQUEST_STATUS.onApproval), row(2, 2)];
expect(pickRowsToNotify(rows, 2)).toEqual([]);
});
it('активных строк нет (отклонено/закрыто) — никого', () => {
const rows = [row(1, 1, REQUEST_STATUS.approved), row(2, 2, REQUEST_STATUS.rejected)];
expect(pickRowsToNotify(rows)).toEqual([]);
});
});
@@ -0,0 +1,47 @@
import { IApprovalRow } from './models/IApprovalRow';
import { REQUEST_STATUS } from './models/statuses';
export type INotifyCandidate = Pick<IApprovalRow, 'id' | 'status' | 'order'>;
// Статусы строки, по которой участник уже принял решение.
const DECIDED_STATUSES: string[] = [
REQUEST_STATUS.approved,
REQUEST_STATUS.rejected,
REQUEST_STATUS.returned,
REQUEST_STATUS.executed,
];
/**
* Какие строки маршрута оповестить. rows — в порядке StepOrder (как отдаёт
* ApprovalListService.getForRequest).
*
* - rowId задан — только эта строка, если она активна (замена/удаление
* исполнителя: письмо новому человеку, а не всем активным).
* - rowId не задан — текущий шаг целиком: первая активная строка и все
* активные с тем же StepOrder. При обычном последовательном шаге это одна
* строка; при параллельном — все его участники.
*
* Если кто-то из участников текущего шага уже принял решение, шаг начался
* раньше и письма по нему уже ушли — никого не оповещаем. Иначе после
* решения одного из параллельных согласующих остальные получали бы
* повторное письмо о той же заявке.
*/
export function pickRowsToNotify<T extends INotifyCandidate>(rows: T[], rowId?: number): T[] {
const active = rows.filter(
(r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution
);
if (rowId !== undefined) {
return active.filter((r) => r.id === rowId);
}
if (active.length === 0) {
return [];
}
const currentOrder = active[0].order;
const stepAlreadyStarted = rows.some(
(r) => r.order === currentOrder && DECIDED_STATUSES.indexOf(r.status) >= 0
);
if (stepAlreadyStarted) {
return [];
}
return active.filter((r) => r.order === currentOrder);
}