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 { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { FLOW_RESOURCE, FLOW_NOTIFY_URL, getNotificationsEnabled, getNotifyOnlyTo } from '../config';
|
import { FLOW_RESOURCE, FLOW_NOTIFY_URL, getNotificationsEnabled, getNotifyOnlyTo } from '../config';
|
||||||
import { ApprovalListService } from './approvalList.service';
|
import { ApprovalListService } from './approvalList.service';
|
||||||
import { REQUEST_STATUS } from './models/statuses';
|
import { IApprovalRow } from './models/IApprovalRow';
|
||||||
|
import { pickRowsToNotify } from './notifyTargets';
|
||||||
|
|
||||||
// Данные формы для тела запроса к потоку (постоянны для формы).
|
// Данные формы для тела запроса к потоку (постоянны для формы).
|
||||||
export interface INotifyTarget {
|
export interface INotifyTarget {
|
||||||
@@ -54,12 +55,40 @@ async function isNotifyAllowed(approval: ApprovalListService, approverId: number
|
|||||||
return false;
|
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(
|
export async function notifyActiveStep(
|
||||||
@@ -69,27 +98,13 @@ export async function notifyActiveStep(
|
|||||||
target: INotifyTarget,
|
target: INotifyTarget,
|
||||||
rowId?: number
|
rowId?: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
let rows: IApprovalRow[];
|
||||||
try {
|
try {
|
||||||
const rows = await approval.getForRequest(requestId);
|
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,
|
|
||||||
});
|
|
||||||
} catch {
|
} 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