feat: добавление исполнителей при правке мебели/ПО согласующим
Маршрут планируется один раз при подаче: если мебель или ПО отметил руководитель на своём шаге, исполнитель по ним не появлялся. Теперь при сохранении правок (и перед решением) строятся два плана исполнения — со старыми и новыми значениями — и разница встаёт в конец исполнения «Ждёт очереди» с SLA из маршрута. Никого не удаляем, уже стоящих не дублируем; ошибка добавления не теряется и блокирует решение. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,13 @@
|
||||
`WorkplaceFurnitureOther`. Необязательная. Уходит в маршрут как
|
||||
`WorkplaceFurnitureJson` (пункты через запятую, `routeFields.ts`) — на условие
|
||||
шага исполнения «исполнитель по мебели».
|
||||
- **Добавление исполнителей по правке согласующего** (`routeExecutorsSync.ts`):
|
||||
маршрут планируется при подаче, поэтому если руководитель/Ербол поменял мебель
|
||||
или ПО, при сохранении (и перед решением) строятся два плана исполнения — со
|
||||
старыми и новыми значениями — и разница добавляется в конец исполнения
|
||||
(«Ждёт очереди», `ApproverService.addMissingExecutors`). Никого не удаляем;
|
||||
уже стоящих в исполнении (в любом статусе) не дублируем
|
||||
(`shared/approval/missingExecutors.ts`).
|
||||
- **Маска телефона** (PhoneInput).
|
||||
- Печать — общий механизм (без «Вложений»).
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import { workplaceApprovalService, updateReviewFields, updateAllWorkplaceFields
|
||||
import { WORKPLACE_CHANGE_TRACKING } from './changeTracking';
|
||||
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
|
||||
import { useApproverRights } from './useApproverRights';
|
||||
import { addExecutorsForEditedFields, IExecutorsSyncResult } from './routeExecutorsSync';
|
||||
import { EmployeeSection } from './sections/EmployeeSection';
|
||||
import { EquipmentSection } from './sections/EquipmentSection';
|
||||
import { FurnitureSection } from './sections/FurnitureSection';
|
||||
@@ -103,6 +104,8 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
|
||||
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
|
||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||
// Title списка данных (а не url) — для оповещений, шапки «Инициатор» и чтения инициатора.
|
||||
const dataListName = listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID);
|
||||
const approval = useMemo(() => workplaceApprovalService(listId), [listId]);
|
||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||
const attachments = useAttachmentEditor(dataListRef, itemId);
|
||||
@@ -175,6 +178,18 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
};
|
||||
}, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]);
|
||||
|
||||
const notify = (newStatus?: string): void => {
|
||||
if (!requestTypeId) return;
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName,
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
const saveAllFields = async (): Promise<void> => {
|
||||
const editor = { name: context.pageContext.user.displayName, email: effectiveUserEmail(context) };
|
||||
const nextChanges = mergeFieldChanges(approverChanges, savedData, data, WORKPLACE_CHANGE_TRACKING, editor, new Date());
|
||||
@@ -187,15 +202,33 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
// updateReviewFields пишет только поля руководителя (ReviewFields), остальное из data игнорирует.
|
||||
const saveReviewFields = (): Promise<void> => updateReviewFields(itemId, data, listId);
|
||||
|
||||
// Полное право шире: оно включает и «ПО»/«Доступы».
|
||||
const saveFields = (): Promise<void> => (canEditAll ? saveAllFields() : saveReviewFields());
|
||||
// Полное право шире: оно включает и поля руководителя. После записи полей —
|
||||
// исполнители, которых привела правка мебели/ПО (маршрут планировался при
|
||||
// подаче и сам их не добавит, см. routeExecutorsSync.ts).
|
||||
const saveFields = async (): Promise<IExecutorsSyncResult> => {
|
||||
const before = savedData;
|
||||
if (canEditAll) {
|
||||
await saveAllFields();
|
||||
} else {
|
||||
await saveReviewFields();
|
||||
setSavedData(data);
|
||||
}
|
||||
return addExecutorsForEditedFields(
|
||||
{ context, approval, approverService, requestTypeId, itemId, dataListTitle: dataListName },
|
||||
before,
|
||||
data
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
setSaveState('saving');
|
||||
setSaveErrorMessage('');
|
||||
try {
|
||||
await saveFields();
|
||||
const executorsSync = await saveFields();
|
||||
setSaveState('saved');
|
||||
// Новые исполнители должны появиться в таблице «Исполнение» — перечитываем маршрут.
|
||||
if (executorsSync.addedCount > 0) setDecisionSeq((n) => n + 1);
|
||||
if (executorsSync.activatedRowIds.length > 0) notify();
|
||||
} catch (e) {
|
||||
setSaveErrorMessage(friendlyErrorMessage(e));
|
||||
setSaveState('error');
|
||||
@@ -226,18 +259,6 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
const sectionProps = { data, set, context, printOrgIndex };
|
||||
const canEditAnything = canEditAll || canEditReviewFields;
|
||||
|
||||
const notify = (newStatus?: string): void => {
|
||||
if (!requestTypeId) return;
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
@@ -272,10 +293,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
title="Организация рабочего места"
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
|
||||
itemId={itemId}
|
||||
/>
|
||||
<InitiatorLine listTitle={dataListName} itemId={itemId} />
|
||||
}
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import {
|
||||
ApprovalListService,
|
||||
ApproverService,
|
||||
IApproverToWrite,
|
||||
IMatrixRow,
|
||||
IResolveFieldValue,
|
||||
getPlannedMatrix,
|
||||
planMissingExecutors
|
||||
} from '../../shared/approval';
|
||||
import { getCreatedBy } from '../../shared/createdBy';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import { IFormData } from './model';
|
||||
import { routeFieldValues } from './routeFields';
|
||||
|
||||
/** Окружение просмотра заявки, нужное для добавления исполнителей. */
|
||||
export interface IExecutorsSyncContext {
|
||||
context: WebPartContext;
|
||||
approval: ApprovalListService;
|
||||
approverService: ApproverService;
|
||||
/** Id типа заявки в approval-route; undefined — ещё не загружен. */
|
||||
requestTypeId: number | undefined;
|
||||
itemId: number;
|
||||
/** Title списка данных заявки — по нему читаем инициатора (getCreatedBy). */
|
||||
dataListTitle: string;
|
||||
}
|
||||
|
||||
export interface IExecutorsSyncResult {
|
||||
addedCount: number;
|
||||
/** Строки, ставшие активными, — им нужно оповещение. */
|
||||
activatedRowIds: number[];
|
||||
}
|
||||
|
||||
const NOTHING_ADDED: IExecutorsSyncResult = { addedCount: 0, activatedRowIds: [] };
|
||||
|
||||
function toWrite(row: IMatrixRow): IApproverToWrite {
|
||||
return {
|
||||
email: row.email,
|
||||
position: row.position,
|
||||
allowReassign: row.allowReassign,
|
||||
slaHours: row.slaHours,
|
||||
askOverdueReason: row.askOverdueReason,
|
||||
autoApprove: row.autoApprove
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Согласующий поправил «Мебель» или «Необходимое ПО» в поданной заявке — добавить
|
||||
* исполнителей, которых эти поля приводят по маршруту из админки (иначе, напр.,
|
||||
* мебель, отмеченная руководителем, осталась бы без исполнителя: маршрут
|
||||
* планировался при подаче).
|
||||
*
|
||||
* Строим два плана исполнения — со старыми и с новыми мебелью/ПО — и добавляем
|
||||
* разницу (planMissingExecutors). Остальные поля маршрута (организации, отдел)
|
||||
* в обоих планах берём новые: правка организаций полным правом исполнителей
|
||||
* не добавляет — такого не просили, а маршрут по ним уже был при подаче.
|
||||
*
|
||||
* Маршрут резолвится по инициатору (Автор элемента), как при подаче.
|
||||
* Ошибка — с пояснением, что поля уже сохранены: молча терять исполнителя нельзя.
|
||||
*/
|
||||
export async function addExecutorsForEditedFields(
|
||||
sync: IExecutorsSyncContext,
|
||||
before: IFormData,
|
||||
after: IFormData
|
||||
): Promise<IExecutorsSyncResult> {
|
||||
const fieldsBefore = routeFieldValues({ Companies: after.Companies, Furniture: before.Furniture }, before.Software);
|
||||
const fieldsAfter = routeFieldValues(after, after.Software);
|
||||
if (JSON.stringify(fieldsBefore) === JSON.stringify(fieldsAfter)) return NOTHING_ADDED;
|
||||
|
||||
try {
|
||||
if (!sync.requestTypeId) throw new Error('не загружен тип заявки');
|
||||
const initiator = await getCreatedBy(sync.dataListTitle, sync.itemId);
|
||||
if (!initiator?.email) throw new Error('не удалось определить инициатора заявки');
|
||||
|
||||
const planExecution = (fieldValues: IResolveFieldValue[]): Promise<IMatrixRow[]> =>
|
||||
getPlannedMatrix(
|
||||
sync.context,
|
||||
sync.requestTypeId as number,
|
||||
initiator.email,
|
||||
sync.approval,
|
||||
'Execution',
|
||||
after.DepartmentId,
|
||||
fieldValues
|
||||
);
|
||||
const [plannedBefore, plannedAfter, currentRows] = await Promise.all([
|
||||
planExecution(fieldsBefore),
|
||||
planExecution(fieldsAfter),
|
||||
sync.approval.getMatrixForRequest(sync.itemId)
|
||||
]);
|
||||
|
||||
const toAdd = planMissingExecutors(plannedBefore, plannedAfter, currentRows);
|
||||
if (toAdd.length === 0) return NOTHING_ADDED;
|
||||
const activatedRowIds = await sync.approverService.addMissingExecutors(sync.itemId, toAdd.map(toWrite));
|
||||
return { addedCount: toAdd.length, activatedRowIds };
|
||||
} catch (e) {
|
||||
throw new Error(`Изменения сохранены, но не удалось добавить исполнителей по маршруту: ${friendlyErrorMessage(e)}`);
|
||||
}
|
||||
}
|
||||
@@ -321,7 +321,8 @@ export class ApprovalListService {
|
||||
return this.resolveUserId(email);
|
||||
}
|
||||
|
||||
// Добавляет одну строку согласования с заданным Order (для делегата).
|
||||
// Добавляет одну строку согласования с заданным Order (для делегата, ручного
|
||||
// добавления участника и исполнителя, которого привела правка полей).
|
||||
// delegatedById — Id пользователя, который выполнил переназначение (не строки
|
||||
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту
|
||||
// строку ещё раз, если делегат недоступен (см. ApproverService.delegate).
|
||||
@@ -342,6 +343,12 @@ export class ApprovalListService {
|
||||
[FIELDS.order]: order,
|
||||
[FIELDS.allowReassign]: approver.allowReassign,
|
||||
[FIELDS.phase]: phase,
|
||||
// SLA шага из маршрута админки — у исполнителя, добавленного по правке
|
||||
// полей (ApproverService.addMissingExecutors). У делегата их нет: пусто/Нет,
|
||||
// как и раньше. ?? null, а не || null: ноль часов — значимое значение.
|
||||
[FIELDS.slaHours]: approver.slaHours ?? null,
|
||||
[FIELDS.askOverdueReason]: approver.askOverdueReason ?? null,
|
||||
[FIELDS.autoApprove]: approver.autoApprove ?? false,
|
||||
...(delegatedById ? { [`${FIELDS.delegatedBy}Id`]: delegatedById } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
import { IList } from '@pnp/sp/lists';
|
||||
import '@pnp/sp/items';
|
||||
import { ApprovalListService } from './approvalList.service';
|
||||
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
|
||||
import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
|
||||
import { IApprovalRow } from './models/IApprovalRow';
|
||||
import { ISiteUser } from './models/ISiteUser';
|
||||
import { emitRequestsChanged } from '../requestEvents';
|
||||
import { planQueuedStepMove, StepMoveDirection } from './queuedStepMove';
|
||||
import { planStepAdd } from './routeStepAdd';
|
||||
import { IStepAddRow, planStepAdd } from './routeStepAdd';
|
||||
|
||||
function isActiveStatus(status: string): boolean {
|
||||
return status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.onExecution;
|
||||
@@ -209,6 +209,40 @@ export class ApproverService {
|
||||
return rowsAfter.filter((r) => isActiveStatus(r.status) && !activeBefore.has(r.id)).map((r) => r.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавить в поданную заявку исполнителей, которых привела правка полей
|
||||
* маршрута согласующим (КОГО — решает planMissingExecutors). Каждый встаёт в
|
||||
* КОНЕЦ исполнения «Ждёт очереди», по тем же правилам, что addStep
|
||||
* (planStepAdd), но без права редактора маршрута: людей назначает маршрут из
|
||||
* админки, а не пользователь. SLA шага переносится из маршрута.
|
||||
*
|
||||
* Возвращает Id строк, ставших активными (обычно пусто); им нужно оповещение.
|
||||
*/
|
||||
public async addMissingExecutors(requestId: number, executors: IApproverToWrite[]): Promise<number[]> {
|
||||
if (executors.length === 0) return [];
|
||||
const rows = await this.approval.getForRequest(requestId);
|
||||
const activeBefore = new Set(rows.filter((r) => isActiveStatus(r.status)).map((r) => r.id));
|
||||
|
||||
// По одному и с локальным дописыванием rows: иначе второй исполнитель
|
||||
// получил бы тот же order, что первый, и стал бы параллельным шагом.
|
||||
const planned: IStepAddRow[] = rows.slice();
|
||||
for (const executor of executors) {
|
||||
const approverId = await this.approval.getUserIdByEmail(executor.email);
|
||||
const plan = planStepAdd(planned, APPROVAL_PHASE.execution, approverId);
|
||||
if (!plan.ok) throw new Error(plan.error);
|
||||
await this.approval.addApproverRow(requestId, executor, plan.order, REQUEST_STATUS.queued, APPROVAL_PHASE.execution);
|
||||
planned.push({ approverId, order: plan.order, status: REQUEST_STATUS.queued, phase: APPROVAL_PHASE.execution });
|
||||
}
|
||||
|
||||
await this.approval.activateNextQueued(requestId);
|
||||
const rowsAfter = await this.approval.getForRequest(requestId);
|
||||
|
||||
// Новым исполнителям — права на элемент данных (см. addStep).
|
||||
await this.touchRequest(requestId);
|
||||
emitRequestsChanged();
|
||||
return rowsAfter.filter((r) => isActiveStatus(r.status) && !activeBefore.has(r.id)).map((r) => r.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Шаг фазы «Исполнение», который ещё можно заменить или удалить: ждёт очереди
|
||||
* или уже активен («На исполнении»). Выполненный шаг — решение человека, его
|
||||
|
||||
@@ -81,6 +81,8 @@ export { canEditAllFields } from './fullEditRights';
|
||||
export { planQueuedStepMove, queuedStepMoveRule } from './queuedStepMove';
|
||||
export type { StepMoveDirection, IStepMoveRow, IStepOrderChange } from './queuedStepMove';
|
||||
export { useQueuedStepMove } from './useQueuedStepMove';
|
||||
export { planMissingExecutors } from './missingExecutors';
|
||||
export type { IPlannedPerson, ICurrentRoutePerson } from './missingExecutors';
|
||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||
export { ExecutorsMatrix } from './ui/ExecutorsMatrix';
|
||||
export { ApprovalHistory } from './history/ApprovalHistory';
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ICurrentRoutePerson, IPlannedPerson, planMissingExecutors } from './missingExecutors';
|
||||
|
||||
const APPROVAL = 'Согласование';
|
||||
const EXECUTION = 'Исполнение';
|
||||
|
||||
const IT = 'it@tnsu.kz';
|
||||
const FURNITURE = 'furniture@tnsu.kz';
|
||||
const MANAGER = 'manager@tnsu.kz';
|
||||
|
||||
function planned(email: string): IPlannedPerson {
|
||||
return { email };
|
||||
}
|
||||
|
||||
function current(email: string, phase = EXECUTION): ICurrentRoutePerson {
|
||||
return { email, phase };
|
||||
}
|
||||
|
||||
describe('planMissingExecutors', () => {
|
||||
it('добавляет исполнителя, которого привела правка', () => {
|
||||
const result = planMissingExecutors([planned(IT)], [planned(IT), planned(FURNITURE)], [current(IT)]);
|
||||
expect(result).toEqual([planned(FURNITURE)]);
|
||||
});
|
||||
|
||||
it('план не изменился — никого не добавляет', () => {
|
||||
expect(planMissingExecutors([planned(IT)], [planned(IT)], [current(IT)])).toEqual([]);
|
||||
});
|
||||
|
||||
it('не возвращает исполнителя, которого убрали вручную (он был в плане и до правки)', () => {
|
||||
expect(planMissingExecutors([planned(IT)], [planned(IT), planned(FURNITURE)], [current(FURNITURE)])).toEqual([]);
|
||||
});
|
||||
|
||||
it('не дублирует того, кто уже есть среди исполнителей (в любом статусе)', () => {
|
||||
expect(planMissingExecutors([], [planned(FURNITURE)], [current(FURNITURE)])).toEqual([]);
|
||||
});
|
||||
|
||||
it('согласующий с тем же email — не помеха: в исполнение добавляем', () => {
|
||||
expect(planMissingExecutors([], [planned(MANAGER)], [current(MANAGER, APPROVAL)])).toEqual([planned(MANAGER)]);
|
||||
});
|
||||
|
||||
it('сравнивает email без учёта регистра и пробелов', () => {
|
||||
expect(planMissingExecutors([], [planned(' Furniture@TNSU.kz ')], [current(FURNITURE)])).toEqual([]);
|
||||
expect(planMissingExecutors([planned('FURNITURE@tnsu.kz')], [planned(FURNITURE)], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('пропускает шаг без назначенного человека', () => {
|
||||
expect(planMissingExecutors([], [planned('')], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('один человек на двух шагах нового плана — добавляет один раз', () => {
|
||||
expect(planMissingExecutors([], [planned(FURNITURE), planned(FURNITURE)], [])).toEqual([planned(FURNITURE)]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { APPROVAL_PHASE } from './models/statuses';
|
||||
|
||||
/**
|
||||
* Каких исполнителей добавить в УЖЕ ПОДАННУЮ заявку, когда согласующий поправил
|
||||
* поля, от которых зависит маршрут (напр. «Мебель» в «Организации рабочего
|
||||
* места»: отметил её руководитель — нужен исполнитель по мебели).
|
||||
* Чистая функция без SharePoint: решает, КОГО добавить. Выполняет
|
||||
* ApproverService.addMissingExecutors.
|
||||
*
|
||||
* Правила (согласованы с владельцем 2026-10-29):
|
||||
* - добавляем только тех, кого привела ПРАВКА: есть в плане после неё и нет в
|
||||
* плане до неё. Так не вернётся исполнитель, которого вручную заменили или
|
||||
* удалили, и не подтянутся правки маршрута в админке, сделанные после подачи;
|
||||
* - не добавляем того, кто уже есть среди исполнителей заявки в ЛЮБОМ статусе
|
||||
* (ждёт, исполняет, выполнил, делегировал) — иначе дубль;
|
||||
* - никого не удаляем: сняли мебель — исполнитель по мебели остаётся;
|
||||
* - шаг без назначенного человека (пустой email) пропускаем: добавить некого.
|
||||
*/
|
||||
|
||||
/** То, что нужно о человеке в плане маршрута; подходит IMatrixRow. */
|
||||
export interface IPlannedPerson {
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** То, что нужно о строке маршрута заявки; подходит IMatrixRow. */
|
||||
export interface ICurrentRoutePerson {
|
||||
email: string;
|
||||
phase?: string;
|
||||
}
|
||||
|
||||
// SharePoint и approval-route могут вернуть один и тот же email в разном регистре.
|
||||
function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function planMissingExecutors<T extends IPlannedPerson>(
|
||||
plannedBefore: IPlannedPerson[],
|
||||
plannedAfter: T[],
|
||||
currentRows: ICurrentRoutePerson[]
|
||||
): T[] {
|
||||
const wasPlanned = new Set(plannedBefore.map((p) => normalizeEmail(p.email)));
|
||||
const alreadyInRoute = new Set(
|
||||
currentRows.filter((r) => r.phase === APPROVAL_PHASE.execution).map((r) => normalizeEmail(r.email))
|
||||
);
|
||||
|
||||
const toAdd: T[] = [];
|
||||
plannedAfter.forEach((person) => {
|
||||
const email = normalizeEmail(person.email);
|
||||
if (!email || wasPlanned.has(email) || alreadyInRoute.has(email)) return;
|
||||
// Один человек на нескольких шагах нового плана — добавляем один раз.
|
||||
alreadyInRoute.add(email);
|
||||
toAdd.push(person);
|
||||
});
|
||||
return toAdd;
|
||||
}
|
||||
Reference in New Issue
Block a user