Архивация списков (#9)
Tests / frontend-test (push) Successful in 1m12s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/9
This commit was merged in pull request #9.
This commit is contained in:
2026-08-19 09:07:53 +00:00
parent 3a71d0df1f
commit 6f789f8f1c
67 changed files with 2617 additions and 901 deletions
@@ -16,21 +16,34 @@ import {
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
resolveWriteListId,
notifyActiveStep,
useRequestTypeId,
REQUEST_STATUS,
isEditableStatus,
IApproverToWrite,
findUnassignedStep
unassignedStepMessage
} from '../../shared/approval';
import { IRequestFormProps } from '../../models/types';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { saveVacationRequest, vacationApprovalService } from './submit';
import { loadVacationRequest } from './load';
import { RequestSection } from './sections/RequestSection';
const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => {
const VacationForm: React.FC<IRequestFormProps> = ({
requestType,
context,
onCancel,
onSaved,
editId,
editListId
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
@@ -40,7 +53,10 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
const approval = useMemo(() => vacationApprovalService(), []);
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
// vacationApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => vacationApprovalService(editListId), [editListId]);
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
const requestTypeIdState = useRequestTypeId(context, requestType.id);
@@ -52,7 +68,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
return;
}
let alive = true;
loadVacationRequest(editId)
loadVacationRequest(editId, editListId)
.then((loaded) => {
if (alive) {
setData(loaded.data);
@@ -69,7 +85,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId]);
}, [editId, editListId]);
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя и его должность.
useEffect(() => {
@@ -158,7 +174,18 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
if (!rtId) {
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
}
const { id, code } = await saveVacationRequest(requestId, rtId, data, toApprovers(matrix.rows), status);
// requestId уже задан → редактируем существующую заявку, пишем в ту же
// партицию (editListId); иначе резолвится ТЕКУЩАЯ (см. resolveWriteListId —
// именно этот запрос проверяет порог элементов и запускает архивацию).
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await saveVacationRequest(
requestId,
rtId,
data,
toApprovers(matrix.rows),
status,
writeListId
);
setRequestId(id);
setRequestStatus(status);
if (code) {
@@ -168,11 +195,14 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
onSaved();
}
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, куда фактически записали (writeListId),
// не обязательно текущей на момент рендера формы: поток ищет строку
// маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, {
requestTypeId: rtId,
dataListName: dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID)
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
});
}
};
@@ -185,10 +215,11 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
}
const unassigned = findUnassignedStep(matrix.rows);
if (unassigned) {
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`;
}
const unassignedError = unassignedStepMessage(
matrix.rows,
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
);
if (unassignedError) return unassignedError;
return undefined;
};
@@ -207,7 +238,12 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
<FormCard
title={requestType.title}
subtitle="Категория: Отдел кадров"
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={requestId} />}
belowSubtitle={
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
/>
}
headerRight={
requestId || requestStatus ? (
<>
@@ -12,7 +12,12 @@ import {
notifyActiveStep,
useRequestTypeId
} from '../../shared/approval';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadVacationRequest } from './load';
import { vacationApprovalService } from './submit';
@@ -23,6 +28,8 @@ interface IVacationViewProps {
itemId: number;
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
formKey: string;
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
listId?: string;
}
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
@@ -31,16 +38,21 @@ const NOOP_SET: SetField = () => undefined;
// Заявка на отпуск только для просмотра (режим согласующего). Переиспользует ту
// же секцию, что и форма подачи; редактирование выключено через
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey }) => {
const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey, listId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const approval = useMemo(() => vacationApprovalService(), []);
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// vacationApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => vacationApprovalService(listId), [listId]);
const { id: requestTypeId } = useRequestTypeId(context, formKey);
const approverService = useMemo(() => new ApproverService(approval, dataListTitle(FORM_ID)), [approval]);
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
@@ -53,7 +65,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey }
useEffect(() => {
let alive = true;
setLoading(true);
loadVacationRequest(itemId)
loadVacationRequest(itemId, listId)
.then((loaded) => {
if (alive) {
setData(loaded.data);
@@ -72,7 +84,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey }
return () => {
alive = false;
};
}, [itemId]);
}, [itemId, listId]);
if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>;
@@ -94,8 +106,8 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey }
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
requestTypeId,
dataListName: dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID)
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
@@ -109,7 +121,12 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey }
<FormCard
title="Заявка на отпуск"
subtitle={`№${code || itemId}`}
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={itemId} />}
belowSubtitle={
<InitiatorLine
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
itemId={itemId}
/>
}
headerRight={status ? <StatusBadge status={status} /> : undefined}
>
<FormModeProvider readOnly>
@@ -11,10 +11,17 @@ export interface ILoadedRequest {
code: string;
}
/** Прочитать заявку на отпуск по id: модель формы + статус (правка / просмотр). */
export async function loadVacationRequest(itemId: number): Promise<ILoadedRequest> {
const item = (await getSP()
.web.lists.getByTitle(dataListTitle(FORM_ID))
/**
* Прочитать заявку на отпуск по id: модель формы + статус (правка / просмотр).
*
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан —
* идём напрямую по url списка, а не по имени: так не нужно резолвить, как
* называется архивный список, и запрос не зависит от реестра партиций.
*/
export async function loadVacationRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list
.items.getById(itemId)
.select(
'Id',
@@ -1,18 +1,15 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
import { buildRequestUniqueCode } from '../../shared/requestCode';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { ensureEmployee } from '../../shared/employee';
import { FORM_ID, IFormData } from './model';
/** Экземпляр сервиса списка согласования этой формы. */
export function vacationApprovalService(): ApprovalListService {
return new ApprovalListService(approvalListTitle(FORM_ID));
}
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */
async function ensureEmployee(login: string): Promise<{ Id: number; Title: string }> {
const full = login.indexOf('|') >= 0 ? login : `i:0#.f|membership|${login}`;
return getSP().web.ensureUser(full);
/**
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
* именем формы для читаемости вызывающего кода.
*/
export function vacationApprovalService(listId?: string): ApprovalListService {
return approvalServiceFor(FORM_ID, listId);
}
function pad2(n: number): string {
@@ -46,42 +43,34 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
};
}
export interface ISaveResult {
id: number;
/** Задан только при создании новой заявки (существующий код не меняется). */
code?: string;
}
export type ISaveResult = ISaveItemResult;
/**
* Сохранить заявку на отпуск: содержимое + маршрут согласования.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения у отпуска нет — writeRoute вызывается с пустым списком исполнителей.
* Возвращает Id заявки (+ код при создании).
*
* listId — партиция, в которую фактически пишем: при редактировании — партиция
* ОБНОВЛЯЕМОЙ заявки (см. loadVacationRequest), при создании — партиция,
* полученная от approval-route API непосредственно перед вызовом (см.
* getCurrentListPartition в VacationForm.tsx) — именно этот вызов, а не кеш из
* onInit, проверяет порог элементов и запускает архивацию следующей партиции.
* Не задан (сбой запроса за текущей партицией) → откат на список по конвенции
* имён, как до появления партиционирования.
*/
export async function saveVacationRequest(
existingId: number | undefined,
requestTypeId: number,
data: IFormData,
approvers: IApproverToWrite[],
status: string
status: string,
listId?: string
): Promise<ISaveResult> {
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const employee = await ensureEmployee(data.Employee);
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee);
let id: number;
let code: string | undefined;
if (existingId) {
await list.items.getById(existingId).update(item);
id = existingId;
} else {
const added = await list.items.add(item);
id = (added as { Id: number }).Id;
// Код известен только после создания элемента (нужен его Id).
code = buildRequestUniqueCode(requestTypeId, id, new Date());
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(vacationApprovalService(), id, approvers, [], status);
return { id, code };
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
await writeRoute(vacationApprovalService(listId), result.id, approvers, [], status);
return result;
}