From 474a663da68b67ff86950472cdd784c20d10508a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Thu, 8 Oct 2026 16:07:59 +0500 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BE=D0=BF=D0=BE=D0=B2=D0=B5=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BB=D0=BB?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=88=D0=B0=D0=B3?= =?UTF-8?q?=D0=B0=20=E2=80=94=20=D0=B2=D1=81=D0=B5=D0=BC=20=D1=83=D1=87?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=B0=D0=BC=20=D0=B8=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=B4=D1=83=D0=B1=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Письмо уходило только первому из параллельных согласующих, а после решения одного из них остальные получали повторное. Теперь шаг оповещается целиком при старте и не оповещается снова, если кто-то из его участников уже решил. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../shared/approval/notify.service.ts | 59 ++++++++++------ .../shared/approval/notifyTargets.test.ts | 68 +++++++++++++++++++ .../shared/approval/notifyTargets.ts | 47 +++++++++++++ 3 files changed, 152 insertions(+), 22 deletions(-) create mode 100644 src/webparts/requestPortal/shared/approval/notifyTargets.test.ts create mode 100644 src/webparts/requestPortal/shared/approval/notifyTargets.ts diff --git a/src/webparts/requestPortal/shared/approval/notify.service.ts b/src/webparts/requestPortal/shared/approval/notify.service.ts index 401a99c..bc328f2 100644 --- a/src/webparts/requestPortal/shared/approval/notify.service.ts +++ b/src/webparts/requestPortal/shared/approval/notify.service.ts @@ -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 { + 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 { + 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)) + ); } diff --git a/src/webparts/requestPortal/shared/approval/notifyTargets.test.ts b/src/webparts/requestPortal/shared/approval/notifyTargets.test.ts new file mode 100644 index 0000000..e27d7bb --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/notifyTargets.test.ts @@ -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([]); + }); +}); diff --git a/src/webparts/requestPortal/shared/approval/notifyTargets.ts b/src/webparts/requestPortal/shared/approval/notifyTargets.ts new file mode 100644 index 0000000..0a5c133 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/notifyTargets.ts @@ -0,0 +1,47 @@ +import { IApprovalRow } from './models/IApprovalRow'; +import { REQUEST_STATUS } from './models/statuses'; + +export type INotifyCandidate = Pick; + +// Статусы строки, по которой участник уже принял решение. +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(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); +}