feat: согласованное заявление на увольнение само создаёт обходной лист
Как только маршрут заявления пройден целиком (статус «Согласовано»), портал заводит обходной лист по нему: форма не открывается — маршрут резолвится через approval-route (обе фазы, по Employee и Position заявления), элемент пишется в текущую партицию списка «Обходной лист … — данные» и сразу уходит на согласование с оповещением активного шага. Поля выводятся из заявления (сотрудник, должность, подразделение, организация, последний рабочий день → дата расторжения договора); отметки парковки/СКУД/обучения остаются профильным согласующим. Пара связывается перекрёстными гиперссылками ClearanceUrl / DismissalUrl — для них добавлен requestLinkUrl: абсолютная ссылка на заявку без посторонних параметров страницы, в отличие от routeUrl. Повторное создание отсекается непустой ClearanceUrl: заявление можно согласовать второй раз после возврата на доработку, и без этой проверки на каждый цикл появлялся бы новый обходной лист. Создание идёт в браузере последнего согласующего — если оно упало, согласование не отменяется, а портал показывает предупреждение «заведите вручную». Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
||||
|
||||
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
|
||||
|
||||
@@ -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 (сотрудник · должность · подразделение · дата расторжения · сумма обучения · парковочная карта · карта СКУД)
|
||||
|
||||
@@ -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<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Итог автосоздания обходного листа после согласования (см. onDecided ниже).
|
||||
const [clearance, setClearance] = useState<string>('');
|
||||
const [clearanceError, setClearanceError] = useState<string>('');
|
||||
|
||||
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
||||
// dismissalApprovalService можно звать не опасаясь исключения.
|
||||
@@ -112,12 +117,38 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ 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 (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
<PrintButton />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
{clearance && <Alert variant="ok">{clearance}</Alert>}
|
||||
{clearanceError && <Alert variant="err">{clearanceError}</Alert>}
|
||||
</div>
|
||||
|
||||
<div className="rp-print-root">
|
||||
<FormCard
|
||||
title="Заявление на увольнение"
|
||||
@@ -148,6 +179,12 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
// «Согласовано» = маршрут заявления пройден целиком (фазы исполнения у
|
||||
// него нет, см. README формы) — значит увольнение подтверждено и пора
|
||||
// заводить обходной лист.
|
||||
if (newStatus === REQUEST_STATUS.approved) {
|
||||
createClearance();
|
||||
}
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -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 **после** решения об увольнении (обход подразделений,
|
||||
|
||||
@@ -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<IClearanceForDismissal> {
|
||||
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<string, unknown>;
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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<IRoute>(() => parseRoute());
|
||||
|
||||
Reference in New Issue
Block a user