From c174eb8688d7a71e84755df0035f395a4f60c1ac Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 25 Aug 2026 12:56:50 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=81=D0=BE=D0=B3=D0=BB=D0=B0=D1=81?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE=D0=B5=20=D0=B7=D0=B0=D1=8F?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=20=D1=83?= =?UTF-8?q?=D0=B2=D0=BE=D0=BB=D1=8C=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81?= =?UTF-8?q?=D0=B0=D0=BC=D0=BE=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D1=91=D1=82?= =?UTF-8?q?=20=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=D0=BD=D0=BE=D0=B9=20=D0=BB?= =?UTF-8?q?=D0=B8=D1=81=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Как только маршрут заявления пройден целиком (статус «Согласовано»), портал заводит обходной лист по нему: форма не открывается — маршрут резолвится через approval-route (обе фазы, по Employee и Position заявления), элемент пишется в текущую партицию списка «Обходной лист … — данные» и сразу уходит на согласование с оповещением активного шага. Поля выводятся из заявления (сотрудник, должность, подразделение, организация, последний рабочий день → дата расторжения договора); отметки парковки/СКУД/обучения остаются профильным согласующим. Пара связывается перекрёстными гиперссылками ClearanceUrl / DismissalUrl — для них добавлен requestLinkUrl: абсолютная ссылка на заявку без посторонних параметров страницы, в отличие от routeUrl. Повторное создание отсекается непустой ClearanceUrl: заявление можно согласовать второй раз после возврата на доработку, и без этой проверки на каждый цикл появлялся бы новый обходной лист. Создание идёт в браузере последнего согласующего — если оно упало, согласование не отменяется, а портал показывает предупреждение «заведите вручную». Co-Authored-By: Claude Opus 5 --- docs/forms.md | 2 +- .../requestPortal/forms/Clearance/README.md | 15 +- .../forms/Dismissal/DismissalView.tsx | 39 +++- .../requestPortal/forms/Dismissal/README.md | 41 ++++- .../forms/Dismissal/createClearance.ts | 167 ++++++++++++++++++ src/webparts/requestPortal/shared/routing.ts | 24 +++ 6 files changed, 274 insertions(+), 14 deletions(-) create mode 100644 src/webparts/requestPortal/forms/Dismissal/createClearance.ts diff --git a/docs/forms.md b/docs/forms.md index 87b8cd4..c953d84 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -46,7 +46,7 @@ | [`it-workplace-setup`](../src/webparts/requestPortal/forms/WorkplaceSetup/README.md) | Организация рабочего места | IT | да | да | да | префилл из «прием», поля ПО/доступы правит руководитель | ✅ | | [`it-1c-ticket`](../src/webparts/requestPortal/forms/Ticket1C/README.md) | Заявка в отдел 1С | IT | да | нет | да | контакты предзаполняются из Graph (/me); вместо фазы исполнения — карточка в Microsoft Planner (согласовано/сразу без согласования — рабочий бакет, отклонено — отдельный бакет) | ✅ | | [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker); условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД), сумма обучения | ✅ | -| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | по умолчанию на себя, но можно за другого; подразделение из профиля (read-only), должность/организация из справочника; последний рабочий день не в прошлом | ⏳ тип заявки не заведён в админке approval-route | +| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | по умолчанию на себя, но можно за другого; подразделение из профиля (read-only), должность/организация из справочника; последний рабочий день не в прошлом; **после согласования сам создаёт обходной лист** и связывает пару ссылками | ⏳ тип заявки не заведён в админке approval-route | | `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) | Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения; diff --git a/src/webparts/requestPortal/forms/Clearance/README.md b/src/webparts/requestPortal/forms/Clearance/README.md index d8b0cf3..818e830 100644 --- a/src/webparts/requestPortal/forms/Clearance/README.md +++ b/src/webparts/requestPortal/forms/Clearance/README.md @@ -40,10 +40,17 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова - **Заголовок элемента** (`Title`): `Обходной лист — {ФИО} — дд.мм.гггг`. ## Связь с заявлением на увольнение -В списке данных есть колонка-гиперссылка `DismissalUrl` — ссылка на заявление на -увольнение (обратная ей `ClearanceUrl` — в списке заявления). Колонки существуют -только в списках: форма их не пишет и не показывает, ссылка ставится вручную в -SharePoint. См. `forms/Dismissal/README.md`. +Обходной лист может появиться **сам, без этой формы**: когда согласовано «Заявление +на увольнение» (`hr-dismissal`), портал создаёт лист по нему и сразу отправляет на +согласование — см. `forms/Dismissal/createClearance.ts` и README той формы. Такой +лист приходит уже с заполненными сотрудником, должностью, подразделением и датой +расторжения; отметки (парковка, СКУД, обучение) по-прежнему проставляют профильные +согласующие. Подача этой формы руками остаётся — для увольнений, оформленных без +заявления в портале. + +Пара связана колонками-гиперссылками: `DismissalUrl` здесь → на заявление, +`ClearanceUrl` в заявлении → на лист. Форма их не показывает и не пишет — их +заполняет автосоздание (или вручную в SharePoint). ## Секции формы RequestSection (сотрудник · должность · подразделение · дата расторжения · сумма обучения · парковочная карта · карта СКУД) diff --git a/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx b/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx index 02f0302..ec8d621 100644 --- a/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx @@ -10,7 +10,8 @@ import { ApprovalMatrix, useMatrixEditor, notifyActiveStep, - useRequestTypeId + useRequestTypeId, + REQUEST_STATUS } from '../../shared/approval'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { @@ -22,6 +23,7 @@ import { import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { loadDismissalRequest } from './load'; import { dismissalApprovalService } from './submit'; +import { ensureClearanceForDismissal } from './createClearance'; import { RequestSection } from './sections/RequestSection'; interface IDismissalViewProps { @@ -45,6 +47,9 @@ const DismissalView: React.FC = ({ context, itemId, formKey const [code, setCode] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Итог автосоздания обходного листа после согласования (см. onDecided ниже). + const [clearance, setClearance] = useState(''); + const [clearanceError, setClearanceError] = useState(''); // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) — // dismissalApprovalService можно звать не опасаясь исключения. @@ -112,12 +117,38 @@ const DismissalView: React.FC = ({ context, itemId, formKey }).catch(() => undefined); }; + // Заявление согласовано целиком → сразу заводим обходной лист по нему (данные + // выводятся из заявления, заполнять его руками нечего — см. createClearance.ts). + // Свой статус решения при этом уже применён: неудача автосоздания не отменяет + // согласование, поэтому она показывается предупреждением, а не ошибкой заявки. + const createClearance = (): void => { + setClearanceError(''); + ensureClearanceForDismissal(context, itemId, listId) + .then((result) => + setClearance( + result.existing + ? 'Обходной лист по этому заявлению уже был создан.' + : `Обходной лист создан автоматически${result.code ? ` — №${result.code}` : ''} и отправлен на согласование.` + ) + ) + .catch((e) => + setClearanceError( + `Заявление согласовано, но обходной лист создать не удалось: ${friendlyErrorMessage(e)} — заведите его вручную.` + ) + ); + }; + return (
+
+ {clearance && {clearance}} + {clearanceError && {clearanceError}} +
+
= ({ context, itemId, formKey setStatus(newStatus); setDecisionSeq((n) => n + 1); notify(); + // «Согласовано» = маршрут заявления пройден целиком (фазы исполнения у + // него нет, см. README формы) — значит увольнение подтверждено и пора + // заводить обходной лист. + if (newStatus === REQUEST_STATUS.approved) { + createClearance(); + } }} onDelegated={() => { setDecisionSeq((n) => n + 1); diff --git a/src/webparts/requestPortal/forms/Dismissal/README.md b/src/webparts/requestPortal/forms/Dismissal/README.md index 973e2c9..858fe0f 100644 --- a/src/webparts/requestPortal/forms/Dismissal/README.md +++ b/src/webparts/requestPortal/forms/Dismissal/README.md @@ -40,14 +40,39 @@ RequestTypeId» в `docs/forms.md`). Тип нужно **завести в ад отдельного поля нет: в шаблоне формулировка фиксированная («по собственному желанию»). - **Заголовок элемента** (`Title`): `Заявление на увольнение — {ФИО} — дд.мм.гггг`. -## Связь с обходным листом -В списках данных обеих форм есть **перекрёстные колонки-гиперссылки** (тип `Url`): -`ClearanceUrl` здесь — на обходной лист, `DismissalUrl` в «Обходном листе» — на это -заявление. Одно увольнение = заявление + обходной лист, и по ссылке видно парную -заявку. Не Lookup намеренно: ссылка ведёт на **страницу портала** (`…?rpmode=approve& -rptype=…&rpid=…`, см. `docs/routing.md`), а не на элемент списка, и не ломается, -когда парная заявка уезжает в другую партицию. Пока это колонки **только в списках** — -формы их не пишут и не показывают, ссылка ставится вручную в SharePoint. +## Согласовано → обходной лист создаётся сам +Как только заявление получает статус **«Согласовано»** (маршрут пройден целиком), +портал заводит по нему **обходной лист** — `ensureClearanceForDismissal` +(`createClearance.ts`), вызывается из `DismissalView` в `onDecided` последнего +согласующего. + +Форма обходного листа при этом **не открывается**: маршрут резолвится через +approval-route (`getPlannedMatrix` для фаз «Согласование» и «Исполнение» — по +`Employee` и `Position` заявления), элемент пишется в текущую партицию списка +«Обходной лист … — данные» и сразу уходит на согласование +(`initialSubmitStatus` + `notifyActiveStep`, как при обычной подаче). Поля целиком +выводятся из заявления: сотрудник, должность, подразделение, организация, а +последний рабочий день становится **датой расторжения договора**; отметки +(парковка, СКУД, обучение) остаются пустыми — их заполняют профильные согласующие. + +Пара связывается **перекрёстными колонками-гиперссылками** (тип `Url`): +`ClearanceUrl` в заявлении → на обходной лист, `DismissalUrl` в обходном листе → на +заявление. Не Lookup намеренно: ссылка ведёт на страницу портала +(`…?rpmode=approve&rptype=…&rpid=…&rplist=…`, см. `docs/routing.md`), а не на элемент +списка, и не ломается, когда парная заявка уезжает в другую партицию. + +Что важно знать про это поведение: +- **Идемпотентность — по непустой `ClearanceUrl`.** Заявление можно согласовать + повторно (возврат на доработку → отправка → согласование), и без этой проверки + на каждый цикл появлялся бы новый обходной лист. +- **Работает в браузере последнего согласующего.** Если создание упало (или он + закрыл вкладку в этот момент), заявление всё равно согласовано, а обходного листа + нет — портал покажет предупреждение «заведите вручную». Серверного повтора нет: + фонового обработчика у портала пока не существует, всё делает клиент. +- **Автором обходного листа** в SharePoint станет этот согласующий (в «Мои заявки» + лист попадёт к нему). На маршрут это не влияет — он резолвится по `Employee`. +- Тип заявки `clearance-form` должен быть заведён в админке approval-route — иначе + создание падает на резолве `RequestTypeId`. ## Отличия от «Обходной лист» (clearance-form) Обходной лист оформляет HR **после** решения об увольнении (обход подразделений, diff --git a/src/webparts/requestPortal/forms/Dismissal/createClearance.ts b/src/webparts/requestPortal/forms/Dismissal/createClearance.ts new file mode 100644 index 0000000..6783635 --- /dev/null +++ b/src/webparts/requestPortal/forms/Dismissal/createClearance.ts @@ -0,0 +1,167 @@ +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { + IApproverToWrite, + IMatrixRow, + getPlannedMatrix, + getRequestTypeIdByPageUrl, + initialSubmitStatus, + notifyActiveStep, + resolveWriteListId +} from '../../shared/approval'; +import { resolveDataList } from '../../shared/dataList'; +import { + approvalListTitle, + approvalListTitleForListId, + dataListTitle, + dataListTitleForListId +} from '../../shared/listTitles'; +import { requestLinkUrl } from '../../shared/routing'; +import { FORM_ID as CLEARANCE_FORM_ID, EMPTY_DATA as CLEARANCE_EMPTY, IFormData as IClearanceData } from '../Clearance/model'; +import { clearanceApprovalService, saveClearanceRequest } from '../Clearance/submit'; +import { FORM_ID } from './model'; + +/** Результат автосоздания обходного листа по согласованному заявлению. */ +export interface IClearanceForDismissal { + /** Id обходного листа. undefined — лист уже был создан раньше (ссылка есть, Id из неё не выводим). */ + id?: number; + /** Уникальный код заявки, если обходной лист создан именно сейчас. */ + code?: string; + /** Ссылка на обходной лист (та же, что записана в колонку ClearanceUrl). */ + url: string; + /** true — ничего не создавали, обходной лист по этому заявлению уже был. */ + existing: boolean; +} + +/** Гиперссылка в SharePoint — объект {Url, Description}, а не строка. */ +interface ISpUrlValue { + Url?: string; + Description?: string; +} + +/** Матрица планируемого маршрута → строки для записи в список согласования. */ +function toApprovers(rows: IMatrixRow[]): IApproverToWrite[] { + return rows.map((r) => ({ + email: r.email, + position: r.position, + allowReassign: r.allowReassign, + order: r.order, + slaHours: r.slaHours, + askOverdueReason: r.askOverdueReason + })); +} + +/** + * Создать обходной лист по СОГЛАСОВАННОМУ заявлению на увольнение и связать их + * перекрёстными ссылками (ClearanceUrl / DismissalUrl). + * + * Зачем: увольнение — это всегда пара «заявление + обходной лист», и раньше HR + * заводил второй руками, переписывая те же сотрудника, должность, подразделение и + * дату. Данные для обходного листа целиком выводятся из заявления, поэтому после + * его согласования заводить лист вручную незачем. + * + * **Идемпотентность** — по колонке ClearanceUrl самого заявления: она заполняется + * последним шагом, и непустая означает «лист уже есть». Это важнее, чем кажется: + * заявление может быть согласовано повторно (вернули на доработку → отправили + * снова → снова одобрили), и без этой проверки на каждый цикл появлялся бы новый + * обходной лист. Проверка не абсолютна (два одновременных решения по одной заявке + * прошли бы оба), но параллельных решений по одному последнему шагу не бывает: + * шаг назначен на одного человека. + * + * **Кто автор.** Вызов идёт из браузера последнего согласующего, поэтому в + * SharePoint автором обходного листа окажется он, а не увольняемый или HR. На + * маршрут это не влияет (он резолвится по полю Employee, см. ниже), но в «Мои + * заявки» этого согласующего лист попадёт. + * + * Ошибки НЕ глушим: вызывающая сторона (DismissalView) показывает их отдельным + * предупреждением — согласование при этом уже применено, и молчаливая потеря + * обходного листа хуже, чем сообщение «создайте вручную». + */ +export async function ensureClearanceForDismissal( + context: WebPartContext, + dismissalId: number, + dismissalListId?: string +): Promise { + const dismissalList = resolveDataList(FORM_ID, dismissalListId); + const item = (await dismissalList.items + .getById(dismissalId) + .select( + 'Id', + 'Position', + 'Department', + 'LastWorkingDay', + 'Organization', + 'ClearanceUrl', + 'Employee/EMail' + ) + .expand('Employee')()) as Record; + + const existingLink = (item.ClearanceUrl as ISpUrlValue | undefined)?.Url; + if (existingLink) { + return { url: existingLink, existing: true }; + } + + const employeeEmail = (item.Employee as { EMail?: string } | undefined)?.EMail || ''; + if (!employeeEmail) { + throw new Error('В заявлении не заполнен сотрудник — обходной лист не создан.'); + } + const position = (item.Position as string) || ''; + + // Тип заявки и партиция для записи — как у обычной подачи обходного листа + // (см. ClearanceForm.save): партицию спрашиваем прямо сейчас, именно этот вызов + // проверяет порог элементов и запускает архивацию. + const requestTypeId = await getRequestTypeIdByPageUrl(context, CLEARANCE_FORM_ID); + const writeListId = await resolveWriteListId(context, CLEARANCE_FORM_ID, requestTypeId, undefined, undefined); + const approval = clearanceApprovalService(writeListId); + + // Маршрут обходного листа резолвится по УВОЛЬНЯЕМОМУ (Employee заявления) и его + // должности — условие «IsManager» на бэкенде считается по ней (см. README формы + // «Обходной лист»). Обе фазы: согласование и исполнение. + const fieldValues = [{ fieldName: 'Position', value: position }]; + const approvers = await getPlannedMatrix(context, requestTypeId, employeeEmail, approval, 'Approval', null, fieldValues); + const executors = await getPlannedMatrix(context, requestTypeId, employeeEmail, approval, 'Execution', null, fieldValues); + + const data: IClearanceData = { + ...CLEARANCE_EMPTY, + Employee: employeeEmail, + Position: position, + Department: (item.Department as string) || '', + // Последний рабочий день заявления = дата расторжения договора в обходном листе. + TerminationDate: ((item.LastWorkingDay as string) || '').slice(0, 10), + Organization: (item.Organization as string) || '' + }; + + // Статус — как при обычной отправке: с согласующими «На согласовании», без них + // маршрут сразу уходит в исполнение/согласовано (см. initialSubmitStatus). + const status = initialSubmitStatus(approvers.length, executors.length); + const { id, code } = await saveClearanceRequest( + undefined, + requestTypeId, + data, + toApprovers(approvers), + toApprovers(executors), + status, + writeListId + ); + + // Перекрёстные ссылки: в заявлении — на лист, в листе — на заявление. + const clearanceUrl = requestLinkUrl(CLEARANCE_FORM_ID, id, writeListId); + const dismissalUrl = requestLinkUrl(FORM_ID, dismissalId, dismissalListId); + await dismissalList.items.getById(dismissalId).update({ + ClearanceUrl: { Url: clearanceUrl, Description: `Обходной лист${code ? ` №${code}` : ''}` } + }); + await resolveDataList(CLEARANCE_FORM_ID, writeListId) + .items.getById(id) + .update({ DismissalUrl: { Url: dismissalUrl, Description: 'Заявление на увольнение' } }); + + // Оповещение активного шага — как после обычной отправки. Некритично: сам + // notifyActiveStep ошибки глотает, заявка уже создана и связана. + await notifyActiveStep(context, approval, id, { + requestTypeId, + dataListName: writeListId ? dataListTitleForListId(CLEARANCE_FORM_ID, writeListId) : dataListTitle(CLEARANCE_FORM_ID), + approvalListName: writeListId + ? approvalListTitleForListId(CLEARANCE_FORM_ID, writeListId) + : approvalListTitle(CLEARANCE_FORM_ID) + }); + + return { id, code, url: clearanceUrl, existing: false }; +} diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index 53992b9..03d79fb 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -412,6 +412,30 @@ export function routeUrl(route: IRoute): string { return buildQuery(route); } +/** + * АБСОЛЮТНАЯ ссылка на просмотр заявки — для того, что переживёт текущую сессию: + * колонка-гиперссылка в списке SharePoint, письмо, сообщение в чат. + * + * Отличается от routeUrl() двумя вещами, и обе важны именно для «хранимой» ссылки: + * она с origin (в списке относительный путь бесполезен — его открывают из + * представления SharePoint, а не со страницы портала) и без посторонних + * query-параметров текущей страницы (routeUrl их намеренно сохраняет, потому что + * это навигация ВНУТРИ открытой страницы). + * + * Страница портала берётся из адреса текущей — портал живёт на одной странице, + * другого способа узнать её путь у клиентского кода нет. + */ +export function requestLinkUrl(formKey: string, itemId: number, listId?: string): string { + const params = new URLSearchParams(); + params.set('rpmode', 'approve'); + params.set('rptype', formKey); + params.set('rpid', String(itemId)); + if (listId) { + params.set('rplist', listId); + } + return `${window.location.origin}${window.location.pathname}?${params.toString()}`; +} + /** Текущий маршрут, реактивно обновляется по navigate() и кнопке «назад». */ export function useRoute(): IRoute { const [route, setRoute] = React.useState(() => parseRoute());