feat: добавление исполнителей при правке мебели/ПО согласующим

Маршрут планируется один раз при подаче: если мебель или ПО отметил
руководитель на своём шаге, исполнитель по ним не появлялся. Теперь при
сохранении правок (и перед решением) строятся два плана исполнения — со
старыми и новыми значениями — и разница встаёт в конец исполнения «Ждёт
очереди» с SLA из маршрута. Никого не удаляем, уже стоящих не дублируем;
ошибка добавления не теряется и блокирует решение.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-29 17:30:03 +05:00
parent 45fc06a6ac
commit 36432c75e6
8 changed files with 295 additions and 22 deletions
@@ -33,6 +33,13 @@
`WorkplaceFurnitureOther`. Необязательная. Уходит в маршрут как `WorkplaceFurnitureOther`. Необязательная. Уходит в маршрут как
`WorkplaceFurnitureJson` (пункты через запятую, `routeFields.ts`) — на условие `WorkplaceFurnitureJson` (пункты через запятую, `routeFields.ts`) — на условие
шага исполнения «исполнитель по мебели». шага исполнения «исполнитель по мебели».
- **Добавление исполнителей по правке согласующего** (`routeExecutorsSync.ts`):
маршрут планируется при подаче, поэтому если руководитель/Ербол поменял мебель
или ПО, при сохранении (и перед решением) строятся два плана исполнения — со
старыми и новыми значениями — и разница добавляется в конец исполнения
(«Ждёт очереди», `ApproverService.addMissingExecutors`). Никого не удаляем;
уже стоящих в исполнении (в любом статусе) не дублируем
(`shared/approval/missingExecutors.ts`).
- **Маска телефона** (PhoneInput). - **Маска телефона** (PhoneInput).
- Печать — общий механизм (без «Вложений»). - Печать — общий механизм (без «Вложений»).
@@ -47,6 +47,7 @@ import { workplaceApprovalService, updateReviewFields, updateAllWorkplaceFields
import { WORKPLACE_CHANGE_TRACKING } from './changeTracking'; import { WORKPLACE_CHANGE_TRACKING } from './changeTracking';
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation'; import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
import { useApproverRights } from './useApproverRights'; import { useApproverRights } from './useApproverRights';
import { addExecutorsForEditedFields, IExecutorsSyncResult } from './routeExecutorsSync';
import { EmployeeSection } from './sections/EmployeeSection'; import { EmployeeSection } from './sections/EmployeeSection';
import { EquipmentSection } from './sections/EquipmentSection'; import { EquipmentSection } from './sections/EquipmentSection';
import { FurnitureSection } from './sections/FurnitureSection'; import { FurnitureSection } from './sections/FurnitureSection';
@@ -103,6 +104,8 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для // Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef). // архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
const dataListRef = listId || dataListTitle(FORM_ID); 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 approval = useMemo(() => workplaceApprovalService(listId), [listId]);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]); const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
const attachments = useAttachmentEditor(dataListRef, itemId); const attachments = useAttachmentEditor(dataListRef, itemId);
@@ -175,6 +178,18 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
}; };
}, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]); }, [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 saveAllFields = async (): Promise<void> => {
const editor = { name: context.pageContext.user.displayName, email: effectiveUserEmail(context) }; const editor = { name: context.pageContext.user.displayName, email: effectiveUserEmail(context) };
const nextChanges = mergeFieldChanges(approverChanges, savedData, data, WORKPLACE_CHANGE_TRACKING, editor, new Date()); 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 игнорирует. // updateReviewFields пишет только поля руководителя (ReviewFields), остальное из data игнорирует.
const saveReviewFields = (): Promise<void> => updateReviewFields(itemId, data, listId); 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> => { const handleSave = async (): Promise<void> => {
setSaveState('saving'); setSaveState('saving');
setSaveErrorMessage(''); setSaveErrorMessage('');
try { try {
await saveFields(); const executorsSync = await saveFields();
setSaveState('saved'); setSaveState('saved');
// Новые исполнители должны появиться в таблице «Исполнение» — перечитываем маршрут.
if (executorsSync.addedCount > 0) setDecisionSeq((n) => n + 1);
if (executorsSync.activatedRowIds.length > 0) notify();
} catch (e) { } catch (e) {
setSaveErrorMessage(friendlyErrorMessage(e)); setSaveErrorMessage(friendlyErrorMessage(e));
setSaveState('error'); setSaveState('error');
@@ -226,18 +259,6 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
const sectionProps = { data, set, context, printOrgIndex }; const sectionProps = { data, set, context, printOrgIndex };
const canEditAnything = canEditAll || canEditReviewFields; 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 ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
@@ -272,10 +293,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
title="Организация рабочего места" title="Организация рабочего места"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine listTitle={dataListName} itemId={itemId} />
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
itemId={itemId}
/>
} }
headerRight={status ? <StatusBadge status={status} /> : undefined} 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); return this.resolveUserId(email);
} }
// Добавляет одну строку согласования с заданным Order (для делегата). // Добавляет одну строку согласования с заданным Order (для делегата, ручного
// добавления участника и исполнителя, которого привела правка полей).
// delegatedById — Id пользователя, который выполнил переназначение (не строки // delegatedById — Id пользователя, который выполнил переназначение (не строки
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту // из маршрута — у них делегата нет). Он сохраняет право переназначить эту
// строку ещё раз, если делегат недоступен (см. ApproverService.delegate). // строку ещё раз, если делегат недоступен (см. ApproverService.delegate).
@@ -342,6 +343,12 @@ export class ApprovalListService {
[FIELDS.order]: order, [FIELDS.order]: order,
[FIELDS.allowReassign]: approver.allowReassign, [FIELDS.allowReassign]: approver.allowReassign,
[FIELDS.phase]: phase, [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 } : {}), ...(delegatedById ? { [`${FIELDS.delegatedBy}Id`]: delegatedById } : {}),
}); });
} }
@@ -3,13 +3,13 @@ import '@pnp/sp/webs';
import '@pnp/sp/lists'; import '@pnp/sp/lists';
import { IList } from '@pnp/sp/lists'; import { IList } from '@pnp/sp/lists';
import '@pnp/sp/items'; import '@pnp/sp/items';
import { ApprovalListService } from './approvalList.service'; import { ApprovalListService, IApproverToWrite } from './approvalList.service';
import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses'; import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
import { IApprovalRow } from './models/IApprovalRow'; import { IApprovalRow } from './models/IApprovalRow';
import { ISiteUser } from './models/ISiteUser'; import { ISiteUser } from './models/ISiteUser';
import { emitRequestsChanged } from '../requestEvents'; import { emitRequestsChanged } from '../requestEvents';
import { planQueuedStepMove, StepMoveDirection } from './queuedStepMove'; import { planQueuedStepMove, StepMoveDirection } from './queuedStepMove';
import { planStepAdd } from './routeStepAdd'; import { IStepAddRow, planStepAdd } from './routeStepAdd';
function isActiveStatus(status: string): boolean { function isActiveStatus(status: string): boolean {
return status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.onExecution; 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); 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 { planQueuedStepMove, queuedStepMoveRule } from './queuedStepMove';
export type { StepMoveDirection, IStepMoveRow, IStepOrderChange } from './queuedStepMove'; export type { StepMoveDirection, IStepMoveRow, IStepOrderChange } from './queuedStepMove';
export { useQueuedStepMove } from './useQueuedStepMove'; export { useQueuedStepMove } from './useQueuedStepMove';
export { planMissingExecutors } from './missingExecutors';
export type { IPlannedPerson, ICurrentRoutePerson } from './missingExecutors';
export { ApprovalMatrix } from './ui/ApprovalMatrix'; export { ApprovalMatrix } from './ui/ApprovalMatrix';
export { ExecutorsMatrix } from './ui/ExecutorsMatrix'; export { ExecutorsMatrix } from './ui/ExecutorsMatrix';
export { ApprovalHistory } from './history/ApprovalHistory'; 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;
}