fix: оповещения параллельного шага — всем участникам и без дублей
Письмо уходило только первому из параллельных согласующих, а после решения одного из них остальные получали повторное. Теперь шаг оповещается целиком при старте и не оповещается снова, если кто-то из его участников уже решил. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user