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)}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user