Архивация списков (#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
@@ -20,12 +20,13 @@ import {
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
resolveWriteListId,
notifyActiveStep,
useRequestTypeId,
REQUEST_STATUS,
isEditableStatus,
IApproverToWrite,
findUnassignedStep
unassignedStepMessage
} from '../../shared/approval';
import {
uploadAttachments,
@@ -35,7 +36,12 @@ import {
} from '../../shared/attachments/attachments.service';
import { effectiveUserEmail } from '../../shared/impersonation';
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 { saveTicketRequest, setPlannerTaskId, ticket1cApprovalService } from './submit';
import { loadTicketRequest } from './load';
@@ -63,7 +69,14 @@ function buildPlannerNotes(data: IFormData, code: string): string {
return lines.join('\n');
}
const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => {
const Ticket1CForm: React.FC<IRequestFormProps> = ({
requestType,
context,
onCancel,
onSaved,
editId,
editListId
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [files, setFiles] = useState<File[]>([]);
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
@@ -81,7 +94,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
return;
}
let alive = true;
Promise.all([loadTicketRequest(editId), getAttachments(dataListTitle(FORM_ID), editId)])
Promise.all([loadTicketRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)])
.then(([loaded, atts]) => {
if (alive) {
setData(loaded.data);
@@ -99,7 +112,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId]);
}, [editId, editListId]);
// Предзаполнение контактов заявителя текущим пользователем — только для новой заявки.
// Телефон/отдел/должность в pageContext нет — берём из Graph (/me), с фолбэком.
@@ -127,7 +140,10 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId]);
const approval = useMemo(() => ticket1cApprovalService(), []);
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
// ticket1cApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => ticket1cApprovalService(editListId), [editListId]);
const userEmail = effectiveUserEmail(context);
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
@@ -188,7 +204,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
if (requestId === undefined) {
return;
}
deleteAttachment(dataListTitle(FORM_ID), requestId, fileName)
deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, fileName)
.then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName)))
.catch(() => undefined);
};
@@ -210,7 +226,22 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
? REQUEST_STATUS.onApproval
: REQUEST_STATUS.approved;
const { id, code } = await saveTicketRequest(requestId, data, toApprovers(matrix.rows), finalStatus);
// requestId уже задан → редактируем существующую заявку, пишем в ту же
// партицию (editListId). Иначе — создаём новую, и партицию для записи
// спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit):
// это единственный запрос, который проверяет порог элементов текущего
// списка и ставит в очередь создание следующей партиции при достижении —
// без него архивация никогда не сработает. Сбой запроса не должен
// блокировать подачу заявки — откатываемся на список по конвенции имён.
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await saveTicketRequest(
requestId,
data,
toApprovers(matrix.rows),
finalStatus,
writeListId
);
setRequestId(id);
setRequestStatus(finalStatus);
if (code) {
@@ -219,9 +250,9 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
let attachments = existingAttachments;
if (files.length > 0) {
await uploadAttachments(dataListTitle(FORM_ID), id, files);
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files);
setFiles([]);
attachments = await getAttachments(dataListTitle(FORM_ID), id);
attachments = await getAttachments(writeListId || dataListTitle(FORM_ID), id);
setExistingAttachments(attachments);
}
@@ -230,10 +261,13 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
}
if (finalStatus === REQUEST_STATUS.onApproval) {
// Имена списков — той же партиции, куда фактически записали (writeListId),
// не обязательно текущей на момент рендера формы: поток ищет строку
// маршрута именно в НЕЙ.
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)
});
} else if (finalStatus === REQUEST_STATUS.approved) {
// Согласование не требуется для этого вида работ — сразу передаём в отдел 1С.
@@ -245,7 +279,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
attachments,
webAbsoluteUrl: context.pageContext.web.absoluteUrl
});
await setPlannerTaskId(id, taskId);
await setPlannerTaskId(id, taskId, writeListId);
}
};
@@ -262,10 +296,11 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
// Матрица нередактируема (маршрут задаётся видом работ, а не заявителем) —
// незаполненный шаг заявитель исправить сам не может, только администратор
// маршрутов (роль в шаге не разрешилась в конкретного человека).
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;
};
@@ -284,7 +319,12 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
<FormCard
title={requestType.title}
subtitle="Категория: IT"
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={requestId} />}
belowSubtitle={
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
/>
}
headerRight={
requestId || requestStatus ? (
<>
@@ -27,7 +27,12 @@ import {
REQUEST_STATUS
} from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadTicketRequest } from './load';
import { ticket1cApprovalService, getPlannerTaskId, setPlannerTaskId } from './submit';
@@ -41,6 +46,8 @@ interface ITicket1CViewProps {
itemId: number;
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
formKey: string;
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
listId?: string;
}
/** Текст карточки Planner из данных заявки; rejection — автор и причина отклонения, если есть. */
@@ -72,7 +79,7 @@ function buildPlannerNotes(
}
// Заявка в отдел 1С только для просмотра (режим согласующего).
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }) => {
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
@@ -83,11 +90,13 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
const { id: requestTypeId } = useRequestTypeId(context, formKey);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
const approverService = useMemo(
() => new ApproverService(ticket1cApprovalService(), dataListTitle(FORM_ID)),
[]
);
const approval = useMemo(() => ticket1cApprovalService(), []);
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// ticket1cApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => ticket1cApprovalService(listId), [listId]);
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
// decisionSeq входит в reloadKey: после решения строки маршрута меняются на
// сервере, поэтому счётчик инкрементируется и useMatrixEditor перечитывает матрицу.
@@ -100,7 +109,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
useEffect(() => {
let alive = true;
setLoading(true);
Promise.all([loadTicketRequest(itemId), getAttachments(dataListTitle(FORM_ID), itemId)])
Promise.all([loadTicketRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)])
.then(([loaded, files]) => {
if (alive) {
setData(loaded.data);
@@ -120,14 +129,14 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
return () => {
alive = false;
};
}, [itemId]);
}, [itemId, listId]);
// Передать согласованную/отклонённую заявку в Planner. PlannerTaskId — защита
// от повторного создания карточки, если решение применяется из нескольких
// вкладок/сессий одновременно (см. submit.ts).
const handoffToPlanner = useCallback(
async (finalStatus: string, currentData: IFormData, currentCode: string): Promise<void> => {
const existingTaskId = await getPlannerTaskId(itemId);
const existingTaskId = await getPlannerTaskId(itemId, listId);
if (existingTaskId) {
return;
}
@@ -143,7 +152,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
const bucketName =
finalStatus === REQUEST_STATUS.rejected ? PLANNER_REJECTED_BUCKET_NAME : PLANNER_BUCKET_NAME;
const atts = await getAttachments(dataListTitle(FORM_ID), itemId);
const atts = await getAttachments(listId || dataListTitle(FORM_ID), itemId);
const taskId = await new PlannerTicketService(context).createTask({
planId: PLANNER_PLAN_ID,
bucketName,
@@ -152,9 +161,9 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
attachments: atts,
webAbsoluteUrl: context.pageContext.web.absoluteUrl
});
await setPlannerTaskId(itemId, taskId);
await setPlannerTaskId(itemId, taskId, listId);
},
[approval, context, itemId]
[approval, context, itemId, listId]
);
if (loading) {
@@ -174,8 +183,8 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ 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);
};
@@ -185,7 +194,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
{isEditableStatus(status) && (
<Button
variant="secondary"
onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId })}
onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId, listId })}
>
<Icon name="edit" size="sm" />
Редактировать
@@ -198,7 +207,12 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
<FormCard
title="Заявка в отдел 1С"
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>
@@ -14,10 +14,17 @@ export interface ILoadedRequest {
code: string;
}
/** Прочитать заявку по id: модель формы + статус (для режима чтения). */
export async function loadTicketRequest(itemId: number): Promise<ILoadedRequest> {
const item = (await getSP()
.web.lists.getByTitle(dataListTitle(FORM_ID))
/**
* Прочитать заявку по id: модель формы + статус (для режима чтения).
*
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан —
* идём напрямую по url списка, а не по имени: так не нужно резолвить, как
* называется архивный список, и запрос не зависит от реестра партиций.
*/
export async function loadTicketRequest(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(...SCALAR_FIELDS, 'Title', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
@@ -1,7 +1,5 @@
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, resolveDataList, saveRequestItem } from '../../shared/dataList';
import { FORM_ID, IFormData } from './model';
// Префикс уникального кода заявки — буквенная метка «1С» вместо числового id
@@ -9,9 +7,13 @@ import { FORM_ID, IFormData } from './model';
// больше не хардкодится — см. useRequestTypeId в Form.tsx/View.tsx.
export const TICKET1C_CODE_PREFIX = '1С';
/** Экземпляр сервиса списка согласования этой формы. */
export function ticket1cApprovalService(): ApprovalListService {
return new ApprovalListService(approvalListTitle(FORM_ID));
/**
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
* именем формы для читаемости вызывающего кода.
*/
export function ticket1cApprovalService(listId?: string): ApprovalListService {
return approvalServiceFor(FORM_ID, listId);
}
/** Поля элемента списка данных из модели формы. TaskName пишется в системную колонку Title. */
@@ -27,42 +29,34 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
return item;
}
export interface ISaveResult {
id: number;
/** Задан только при создании новой заявки (существующий код не меняется). */
code?: string;
}
export type ISaveResult = ISaveItemResult;
/**
* Сохранить заявку в отдел 1С: содержимое + маршрут согласования.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения нет: после согласования заявка уходит в Planner, а не исполнителю
* из матрицы. Возвращает Id заявки (+ код при создании).
*
* listId — партиция, в которую фактически пишем: при редактировании — партиция
* ОБНОВЛЯЕМОЙ заявки (см. loadTicketRequest), при создании — партиция,
* полученная от approval-route API непосредственно перед вызовом (см.
* getCurrentListPartition в Ticket1CForm.tsx) — именно этот вызов, а не кеш из
* onInit, проверяет порог элементов и запускает архивацию следующей партиции.
* Не задан (сбой запроса за текущей партицией) → откат на список по конвенции
* имён, как до появления партиционирования.
*/
export async function saveTicketRequest(
existingId: number | undefined,
data: IFormData,
approvers: IApproverToWrite[],
status: string
status: string,
listId?: string
): Promise<ISaveResult> {
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = buildItem(data, status);
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(TICKET1C_CODE_PREFIX, id, new Date());
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(ticket1cApprovalService(), id, approvers, [], status);
return { id, code };
const result = await saveRequestItem(FORM_ID, existingId, listId, TICKET1C_CODE_PREFIX, item);
await writeRoute(ticket1cApprovalService(listId), result.id, approvers, [], status);
return result;
}
/**
@@ -70,14 +64,13 @@ export async function saveTicketRequest(
* после createTask — служит защитой от повторного создания карточки, если
* решение согласующего применяется из нескольких вкладок/сессий одновременно.
*/
export async function getPlannerTaskId(id: number): Promise<string | undefined> {
const item = (await getSP()
.web.lists.getByTitle(dataListTitle(FORM_ID))
.items.getById(id)
.select('PlannerTaskId')()) as { PlannerTaskId?: string };
export async function getPlannerTaskId(id: number, listId?: string): Promise<string | undefined> {
const list = resolveDataList(FORM_ID, listId);
const item = (await list.items.getById(id).select('PlannerTaskId')()) as { PlannerTaskId?: string };
return item.PlannerTaskId || undefined;
}
export async function setPlannerTaskId(id: number, taskId: string): Promise<void> {
await getSP().web.lists.getByTitle(dataListTitle(FORM_ID)).items.getById(id).update({ PlannerTaskId: taskId });
export async function setPlannerTaskId(id: number, taskId: string, listId?: string): Promise<void> {
const list = resolveDataList(FORM_ID, listId);
await list.items.getById(id).update({ PlannerTaskId: taskId });
}