feat: добавление исполнителей при правке мебели/ПО согласующим
Маршрут планируется один раз при подаче: если мебель или ПО отметил руководитель на своём шаге, исполнитель по ним не появлялся. Теперь при сохранении правок (и перед решением) строятся два плана исполнения — со старыми и новыми значениями — и разница встаёт в конец исполнения «Ждёт очереди» с SLA из маршрута. Никого не удаляем, уже стоящих не дублируем; ошибка добавления не теряется и блокирует решение. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user