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`. Необязательная. Уходит в маршрут как
`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)}`);
}
}