Правки #16

Merged
gorbenko.pavel merged 27 commits from fired-request into main 2026-08-26 11:10:27 +00:00
6 changed files with 274 additions and 14 deletions
Showing only changes of commit c174eb8688 - Show all commits
+1 -1
View File
@@ -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());