diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/README.md b/src/webparts/requestPortal/forms/WorkplaceSetup/README.md index 1b1a2db..1c2223f 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/README.md +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/README.md @@ -33,6 +33,13 @@ `WorkplaceFurnitureOther`. Необязательная. Уходит в маршрут как `WorkplaceFurnitureJson` (пункты через запятую, `routeFields.ts`) — на условие шага исполнения «исполнитель по мебели». +- **Добавление исполнителей по правке согласующего** (`routeExecutorsSync.ts`): + маршрут планируется при подаче, поэтому если руководитель/Ербол поменял мебель + или ПО, при сохранении (и перед решением) строятся два плана исполнения — со + старыми и новыми значениями — и разница добавляется в конец исполнения + («Ждёт очереди», `ApproverService.addMissingExecutors`). Никого не удаляем; + уже стоящих в исполнении (в любом статусе) не дублируем + (`shared/approval/missingExecutors.ts`). - **Маска телефона** (PhoneInput). - Печать — общий механизм (без «Вложений»). diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx index f84cce5..0160a5f 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx @@ -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 = ({ 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 = ({ 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 => { 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 = ({ context, itemI // updateReviewFields пишет только поля руководителя (ReviewFields), остальное из data игнорирует. const saveReviewFields = (): Promise => updateReviewFields(itemId, data, listId); - // Полное право шире: оно включает и «ПО»/«Доступы». - const saveFields = (): Promise => (canEditAll ? saveAllFields() : saveReviewFields()); + // Полное право шире: оно включает и поля руководителя. После записи полей — + // исполнители, которых привела правка мебели/ПО (маршрут планировался при + // подаче и сам их не добавит, см. routeExecutorsSync.ts). + const saveFields = async (): Promise => { + 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 => { 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 = ({ 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 (
@@ -272,10 +293,7 @@ const WorkplaceSetupView: React.FC = ({ context, itemI title="Организация рабочего места" subtitle={`№${code || itemId}`} belowSubtitle={ - + } headerRight={status ? : undefined} > diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/routeExecutorsSync.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/routeExecutorsSync.ts new file mode 100644 index 0000000..feaa5cb --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/routeExecutorsSync.ts @@ -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 { + 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 => + 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)}`); + } +} diff --git a/src/webparts/requestPortal/shared/approval/approvalList.service.ts b/src/webparts/requestPortal/shared/approval/approvalList.service.ts index b2df7b4..5fa9bcb 100644 --- a/src/webparts/requestPortal/shared/approval/approvalList.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalList.service.ts @@ -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 } : {}), }); } diff --git a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts index 2a19b3f..bfba56e 100644 --- a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts +++ b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts @@ -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 { + 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); + } + /** * Шаг фазы «Исполнение», который ещё можно заменить или удалить: ждёт очереди * или уже активен («На исполнении»). Выполненный шаг — решение человека, его diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index d582573..0f1bf69 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -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'; diff --git a/src/webparts/requestPortal/shared/approval/missingExecutors.test.ts b/src/webparts/requestPortal/shared/approval/missingExecutors.test.ts new file mode 100644 index 0000000..7406c69 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/missingExecutors.test.ts @@ -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)]); + }); +}); diff --git a/src/webparts/requestPortal/shared/approval/missingExecutors.ts b/src/webparts/requestPortal/shared/approval/missingExecutors.ts new file mode 100644 index 0000000..b163cbd --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/missingExecutors.ts @@ -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( + 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; +}