Архивация списков (#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
@@ -381,6 +381,7 @@ export class ApprovalListService {
const batchedItems = batchedSP.web.lists.getByTitle(this.listTitle).items;
rows.forEach((row) => {
// void — по той же причине, что и в createSteps выше.
// eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error
void batchedItems.getById(row.id).delete();
});
await execute();
@@ -1,7 +1,15 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, APPROVAL_API_BASE_URL } from '../config';
import { ApprovalStatus, IApiResponse, IRequestType, IResolvedRoute, IResolveFieldValue } from './models/IApprovalRouteApi';
import { APPROVAL_API_CLIENT_ID, getApprovalApiBaseUrl } from '../config';
import {
ApprovalStatus,
ICurrentListPartition,
IApiResponse,
IRequestType,
IRequestTypeListSeries,
IResolvedRoute,
IResolveFieldValue
} from './models/IApprovalRouteApi';
// Все обращения к внешнему approval-route API — только здесь.
// Клиент авторизуется через AadHttpClient (SSO по текущему пользователю).
@@ -14,7 +22,7 @@ async function client(context: WebPartContext): Promise<AadHttpClient> {
export async function getRequestTypes(context: WebPartContext): Promise<IRequestType[]> {
const c = await client(context);
const response: HttpClientResponse = await c.get(
`${APPROVAL_API_BASE_URL}/api/request-types`,
`${getApprovalApiBaseUrl()}/api/request-types`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
@@ -24,6 +32,53 @@ export async function getRequestTypes(context: WebPartContext): Promise<IRequest
return body.data ?? [];
}
/**
* GET /api/request-types/lists — серии партиций сразу по ВСЕМ типам заявок.
*
* Именно bulk-эндпоинт, а не по одному на тип: партиции нужны и для записи (куда
* писать), и для поиска (по каким спискам искать), причём поиск идёт сразу по
* всем типам — запрашивать серию отдельно на каждый тип означало бы N запросов
* на каждое открытие «Моих заявок».
*
* Типы без настроенного списка SharePoint в ответ не попадают.
*/
export async function getListPartitions(context: WebPartContext): Promise<IRequestTypeListSeries[]> {
const c = await client(context);
const response: HttpClientResponse = await c.get(
`${getApprovalApiBaseUrl()}/api/request-types/lists`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`request-types/lists: ${response.status} ${response.statusText}`);
}
const body: IApiResponse<IRequestTypeListSeries[]> = await response.json();
return body.data ?? [];
}
/**
* GET /api/request-types/{id}/lists/current — партиция, в которую нужно писать
* НОВУЮ заявку этого типа. Вызывать перед КАЖДЫМ созданием, не кешировать: этот
* запрос заодно проверяет порог элементов на бэкенде и ставит в очередь
* создание следующей партиции при достижении — без него архивация никогда не
* запустится, сколько бы заявок ни создали (bulk-эндпоинт lists, который
* портал и так грузит при onInit, только читает реестр и порог не проверяет).
*/
export async function getCurrentListPartition(
context: WebPartContext,
requestTypeId: number
): Promise<ICurrentListPartition> {
const c = await client(context);
const response: HttpClientResponse = await c.get(
`${getApprovalApiBaseUrl()}/api/request-types/${requestTypeId}/lists/current`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`request-types/${requestTypeId}/lists/current: ${response.status} ${response.statusText}`);
}
const body: IApiResponse<ICurrentListPartition> = await response.json();
return body.data;
}
// Кеш на уровне МОДУЛЯ (не useRef/useState) — список типов заявок админ меняет
// редко, а без кеша каждая форма и каждый переход в режим согласующего дёргали
// бы /api/request-types заново. Живёт до перезагрузки страницы — тот же принцип,
@@ -77,12 +132,14 @@ export async function resolveRoute(
requestTypeId: number,
userEmail: string,
status: ApprovalStatus = 'Approval',
// eslint-disable-next-line @rushstack/no-new-null -- null отправляется в теле запроса как «не задано», undefined в JSON.stringify пропал бы вовсе
departmentId: string | null = null,
fieldValues: IResolveFieldValue[] = [],
// eslint-disable-next-line @rushstack/no-new-null -- см. departmentId
projectId: string | null = null
): Promise<IResolvedRoute> {
const c = await client(context);
const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`;
const url = `${getApprovalApiBaseUrl()}/api/template-routes/resolve`;
// Эндпоинт сменился с GET (query) на POST (JSON-тело).
// projectId (uuid проекта) — для форм, где согласующий зависит от проекта
@@ -0,0 +1,24 @@
import { RequestTypeId } from '../../models/types';
import { approvalListTitle, approvalListTitleForListId } from '../listTitles';
import { ApprovalListService } from './approvalList.service';
/**
* Экземпляр сервиса списка согласования формы.
*
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
* архивной партиции имя приходится резолвить через реестр — если реестр не
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
* а не тихо откатится на текущую партицию.
*
* Раньше каждая форма объявляла свою «xApprovalService» с телом-копией этой
* функции (8 одинаковых реализаций) — оставлены только тонкие обёртки под
* именем формы (см. submit.ts каждой формы), чтобы вызывающий код не терял
* читаемость («vacationApprovalService» вместо безликого параметра formId).
*/
export function approvalServiceFor(formId: RequestTypeId, listId?: string): ApprovalListService {
return new ApprovalListService(
listId ? approvalListTitleForListId(formId, listId) : approvalListTitle(formId)
);
}
@@ -15,8 +15,10 @@ export async function getPlannedMatrix(
userEmail: string,
approval: ApprovalListService,
status: ApprovalStatus = 'Approval',
// eslint-disable-next-line @rushstack/no-new-null -- транзитом в resolveRoute(), см. её departmentId
departmentId?: string | null,
fieldValues?: IResolveFieldValue[],
// eslint-disable-next-line @rushstack/no-new-null -- см. departmentId
projectId?: string | null
): Promise<IMatrixRow[]> {
if (!userEmail) return [];
@@ -36,6 +38,7 @@ export async function getPlannedMatrix(
phase,
slaHours: s.slaHours,
askOverdueReason: s.askOverdueReason,
warning: s.warning,
}))
);
}
@@ -1,6 +1,7 @@
import { getSP } from '../pnp/pnpjs';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import { IList } from '@pnp/sp/lists';
import '@pnp/sp/items';
import { ApprovalListService } from './approvalList.service';
import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
@@ -33,9 +34,17 @@ const ROW_STATUS: Record<ApproverDecision, string> = {
// заявке (requestId). Пока в портале нет маршрутизации на форму заявки, этот сервис
// готов, но UI-режим согласующего ещё не подключён (см. план).
export class ApproverService {
/**
* dataListRef — либо Title списка данных (текущая партиция, как раньше), либо
* его серверный относительный url (конкретная, в т.ч. архивная партиция —
* см. shared/listTitles.ts). Различаем по ведущему '/': Title списка в
* SharePoint с него не начинается, url — всегда начинается. Так конструктору
* не нужен третий параметр «что именно передали» — вызывающая сторона просто
* передаёт то, что у неё есть (см. submit.ts каждой формы).
*/
constructor(
private readonly approval: ApprovalListService,
private readonly dataListTitle: string
private readonly dataListRef: string
) {}
// Ожидающая строка текущего пользователя, если сейчас его очередь; иначе undefined.
@@ -162,8 +171,14 @@ export class ApproverService {
return REQUEST_STATUS.execution;
}
private dataList(): IList {
return this.dataListRef.startsWith('/')
? getSP().web.getList(this.dataListRef)
: getSP().web.lists.getByTitle(this.dataListRef);
}
private async updateRequestStatus(id: number, status: string): Promise<void> {
await getSP().web.lists.getByTitle(this.dataListTitle).items.getById(id).update({
await this.dataList().items.getById(id).update({
RequestStatus: status,
});
}
@@ -13,23 +13,44 @@ export type { IMatrixRow } from './models/IMatrixRow';
export type { IApprovalRow } from './models/IApprovalRow';
export type {
ApprovalStatus,
IListPartition,
ICurrentListPartition,
IRequestType,
IRequestTypeListSeries,
IResolvedRoute,
IResolvedRouteStep,
IResolveFieldValue,
} from './models/IApprovalRouteApi';
export { getRequestTypes, getRequestTypeIdByPageUrl, resolveRoute } from './approvalRoute.service';
export {
getRequestTypes,
getRequestTypeIdByPageUrl,
getListPartitions,
getCurrentListPartition,
resolveRoute
} from './approvalRoute.service';
export {
initListPartitions,
listPartitionsLoaded,
partitionsForTypeId,
currentPartitionForTypeId,
currentPartitionForPageUrl,
partitionForPageUrlAndListId,
isConfirmedStaleListId,
registerCurrentPartition,
} from './listPartitions';
export { resolveWriteListId } from './writeListId';
export { useRequestTypeId } from './useRequestTypeId';
export type { IRequestTypeIdState } from './useRequestTypeId';
export { ApprovalListService } from './approvalList.service';
export type { IApproverToWrite } from './approvalList.service';
export { approvalServiceFor } from './approvalServiceFactory';
export { ApproverService } from './approverDecision.service';
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
export { notifyActiveStep } from './notify.service';
export type { INotifyTarget } from './notify.service';
export { useMatrixEditor, findUnassignedStep } from './useMatrixEditor';
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
export type { IUseMatrixEditor } from './useMatrixEditor';
export { ApprovalMatrix } from './ui/ApprovalMatrix';
export { UserPicker } from './ui/UserPicker';
@@ -0,0 +1,169 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { getListPartitions, getRequestTypes } from './approvalRoute.service';
import { ICurrentListPartition, IListPartition } from './models/IApprovalRouteApi';
/**
* Реестр партиций списков — единственное место, которое знает, куда ПИСАТЬ и где
* ИСКАТЬ заявки каждого типа.
*
* Заполняется один раз при старте web part (initListPartitions в onInit), дальше
* читается синхронно. Синхронные аксессоры принципиальны: имена списков нужны в
* пропсах JSX и в useMemo форм (см. shared/listTitles.ts), а формы — это лениво
* подгружаемые чанки, которые монтируются заведомо позже onInit. Тот же приём,
* что у initSP/getSP и setImpersonatedEmail.
*
* Если approval-route недоступен, реестр остаётся пустым, и вызывающая сторона
* молча откатывается к прежнему поведению «одна партиция по RequestType.listId» —
* портал обязан принимать заявки даже когда админский API лежит.
*/
/** Партиции по ЧИСЛОВОМУ id типа заявки. */
const partitionsByTypeId = new Map<number, IListPartition[]>();
/** pageUrl (слаг формы, напр. 'hr-vacation') → числовой id типа заявки. */
const typeIdByPageUrl = new Map<string, number>();
let loaded = false;
/**
* Загрузить серии партиций и карту слагов. Вызывать один раз в onInit.
*
* Ошибку НЕ пробрасываем: портал должен подняться и работать по прежней
* конвенции имён, даже если approval-route не ответил. Иначе недоступность
* админского API означала бы полностью нерабочий портал.
*/
export async function initListPartitions(context: WebPartContext): Promise<void> {
try {
// Серии — по числовому id, а имена списков в формах — по слагу (pageUrl),
// поэтому нужен и справочник типов, чтобы связать одно с другим.
const [series, types] = await Promise.all([getListPartitions(context), getRequestTypes(context)]);
partitionsByTypeId.clear();
typeIdByPageUrl.clear();
types.forEach((t) => {
if (t.pageUrl) typeIdByPageUrl.set(t.pageUrl, t.id);
});
series.forEach((s) => {
// По номеру, а не в порядке ответа: номер задаёт хронологию серии, и на него
// же опирается выбор «последней» партиции ниже.
const sorted = [...s.partitions].sort((a, b) => a.number - b.number);
partitionsByTypeId.set(s.requestTypeId, sorted);
});
loaded = true;
} catch (e) {
console.warn(
'[Партиции списков] не удалось загрузить серии из approval-route — работаем по конвенции имён с партицией №1',
e
);
}
}
/** Загрузился ли реестр. false — все аксессоры ниже отдают пустоту, и вызывающий использует свой фолбэк. */
export function listPartitionsLoaded(): boolean {
return loaded;
}
/** Все партиции типа заявки (архив + текущая), по возрастанию номера. Пусто — реестр не загружен или тип без списка. */
export function partitionsForTypeId(requestTypeId: number): IListPartition[] {
return partitionsByTypeId.get(requestTypeId) ?? [];
}
/**
* Партиция, в которую идёт запись новых заявок.
*
* Если сервер по какой-то причине не отметил ни одну как текущую, берём с
* наибольшим номером — это та же партиция по смыслу, и лучше записать в неё, чем
* отказать пользователю в создании заявки.
*/
export function currentPartitionForTypeId(requestTypeId: number): IListPartition | undefined {
const partitions = partitionsForTypeId(requestTypeId);
if (partitions.length === 0) return undefined;
return partitions.filter((p) => p.isCurrent)[0] ?? partitions[partitions.length - 1];
}
/** То же, но по слагу формы (pageUrl) — в формах известен именно он, а не числовой id. */
export function currentPartitionForPageUrl(pageUrl: string): IListPartition | undefined {
const id = typeIdByPageUrl.get(pageUrl);
return id === undefined ? undefined : currentPartitionForTypeId(id);
}
/**
* Вписывает в кеш партицию, которую approval-route ТОЛЬКО ЧТО вернул как текущую
* (см. getCurrentListPartition) — вызывать сразу после успешного ответа, ДО того
* как эта партиция понадобится дальше в том же сохранении (approvalListTitleForListId
* и т.п.), а не только при следующей перезагрузке страницы.
*
* Без этого партиция, созданная провижинингом ПОСЛЕ onInit (порог элементов
* достигнут прямо во время этого запроса), была бы неизвестна isConfirmedStaleListId,
* и попытка сохранить в неё же заявку — записав данные в новый список — падала бы
* следующим шагом с «список не найден в реестре партиций», хотя ссылка совершенно
* свежая и рабочая (см. форму пользователя: данные создались, ошибка была только
* в определении имени списка согласования).
*
* Тип, о котором реестр вообще ничего не знает (initListPartitions не ответил при
* старте — typeIdByPageUrl пуст для этого pageUrl), не трогаем: мержить партицию в
* несуществующую серию нечем, а откат на конвенцию имён и так работает без реестра.
*/
export function registerCurrentPartition(pageUrl: string, partition: ICurrentListPartition): void {
const id = typeIdByPageUrl.get(pageUrl);
if (id === undefined) return;
const lower = partition.listId.toLowerCase();
const merged: IListPartition[] = partitionsForTypeId(id)
.filter((p) => p.listId.toLowerCase() !== lower)
.map((p) => ({ ...p, isCurrent: false }));
merged.push({
number: partition.number,
siteUrl: partition.siteUrl,
listId: partition.listId,
title: partition.title,
isCurrent: true,
createdAt: new Date().toISOString()
});
merged.sort((a, b) => a.number - b.number);
partitionsByTypeId.set(id, merged);
}
/**
* Конкретная партиция по её listId — нужна, чтобы открыть/сохранить АРХИВНУЮ
* заявку: тогда известен не номер, а сам listId (пришёл из строки поиска, см.
* IMyRequest.listId в features/myRequests), и его надо сопоставить с записью в
* реестре, чтобы узнать имя списка (для парного списка согласования — см.
* shared/listTitles.ts, approvalListTitleForListId).
*
* undefined — либо реестр про тип вообще ничего не знает (серия пуста), либо
* partitions есть, но именно такого listId среди них нет. Эти два случая —
* РАЗНЫЕ по смыслу: см. isConfirmedStaleListId ниже, только он умеет их различать.
*/
export function partitionForPageUrlAndListId(pageUrl: string, listId: string): IListPartition | undefined {
const id = typeIdByPageUrl.get(pageUrl);
if (id === undefined) return undefined;
const lower = listId.toLowerCase();
return partitionsForTypeId(id).filter((p) => p.listId.toLowerCase() === lower)[0];
}
/**
* true — ТОЛЬКО когда реестр про серию этого типа реально что-то знает (она не
* пуста) и listId в неё не входит. Тогда ссылка гарантированно битая/устаревшая
* (например, партицию удалили из реестра руками) — вот здесь уместна ошибка.
*
* Если же серия пуста — approval-route не ответил при старте, тип только что
* появился, реестр ещё не успел зарегистрировать эту серию — мы просто НЕ ЗНАЕМ,
* валиден ли listId, и вины на нём нет. В этом случае false: тот же приём
* «доверять RequestType.listId без реестра», что и у listTargetsForType в
* myRequests.service.ts — иначе временная недоступность approval-route выглядела
* бы как «эта заявка вообще не открывается», хотя ссылка совершенно рабочая.
*/
export function isConfirmedStaleListId(pageUrl: string, listId: string): boolean {
const id = typeIdByPageUrl.get(pageUrl);
if (id === undefined) return false;
const series = partitionsForTypeId(id);
if (series.length === 0) return false;
const lower = listId.toLowerCase();
return !series.some((p) => p.listId.toLowerCase() === lower);
}
@@ -20,6 +20,53 @@ export interface IApiResponse<T> {
data: T;
}
/**
* Одна партиция серии списков типа заявок.
*
* Списки заявок нумерованные: когда в текущем наборе элементов достигается
* порог, approval-route создаёт следующую пару списков («<Форма> <N> — данные» +
* «<Форма> <N> — согласование»), а прежняя пара остаётся архивом только для
* чтения. Писать нужно в партицию с isCurrent, а ИСКАТЬ — по всем сразу,
* иначе заявки из архива просто исчезнут из выдачи.
*/
export interface IListPartition {
/** Номер партиции в серии, с 1. */
number: number;
/** Абсолютный URL сайта, где живёт список этой партиции. */
siteUrl: string;
/** Серверный относительный URL списка данных — та же семантика, что у IRequestType.listId. */
listId: string;
/** Title списка данных на момент создания. null, если approval-route не смог его прочитать. */
title?: string;
/** true — в эту партицию идёт запись новых заявок. Ровно одна в серии. */
isCurrent: boolean;
createdAt: string;
}
// GET /api/request-types/lists → { data: IRequestTypeListSeries[] }
// Серии сразу по всем типам заявок: одним запросом, а не по одному на тип.
export interface IRequestTypeListSeries {
requestTypeId: number;
partitions: IListPartition[];
}
/**
* GET /api/request-types/{id}/lists/current → { data: ICurrentListPartition }
*
* Партиция, в которую нужно писать НОВУЮ заявку. Не чистый GET на бэкенде —
* этот же запрос проверяет порог элементов и при необходимости ставит в
* очередь создание следующей партиции, поэтому его нужно звать перед КАЖДЫМ
* созданием заявки, а не читать из кеша (см. shared/approval/listPartitions.ts,
* который для этого не подходит — грузится один раз при onInit).
*/
export interface ICurrentListPartition {
requestTypeId: number;
number: number;
siteUrl: string;
listId: string;
title?: string;
}
// Условие видимости шага маршрута (настраивается в админке маршрутов —
// «Показывать шаг только при выполнении условия», сравнение «Поле списка»/
// «Оператор»/«Значение»). fieldName — внутреннее имя колонки списка (напр.
@@ -41,11 +88,14 @@ export interface IResolvedRoute {
export interface IResolvedRouteStep {
id: number;
orderNo: number;
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
executorEmail: string | null;
// eslint-disable-next-line @rushstack/no-new-null -- см. executorEmail
position: string | null;
allowReassignToOtherApprovers: boolean;
// Предупреждение маршрута по этому шагу (например, роль не разрешилась в
// конкретного человека) — приходит от API, показываем автору при желании.
// eslint-disable-next-line @rushstack/no-new-null -- см. executorEmail
warning: string | null;
// SLA шага в часах и признак опроса причины просрочки. Наследование
// «шаг перекрывает тип заявки» бэкенд уже применил — здесь итоговое значение,
@@ -31,4 +31,16 @@ export interface IMatrixRow {
slaHours?: number | null;
// eslint-disable-next-line @rushstack/no-new-null -- см. slaHours
askOverdueReason?: boolean | null;
/**
* Предупреждение approval-route по ЭТОМУ шагу (см. IResolvedRouteStep.warning) —
* например, роль не разрешилась в конкретного человека, и почему именно.
* Только у ПЛАНИРУЕМОГО маршрута (см. getPlannedMatrix): у уже записанных строк
* (loadXxxRequest) неоткуда взяться — резолв запускался один раз при подаче.
*
* findUnassignedStep показывает его вместо своего общего текста, когда он есть —
* бэкенд знает причину точнее («роль не назначена в справочнике отдела» и т.п.),
* а не просто «нет назначенного человека».
*/
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда (IResolvedRouteStep.warning)
warning?: string | null;
}
@@ -144,6 +144,17 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
)}
<td data-label={roleLabel}>
{r.who}
{/* Причина, по которой approval-route не назначил на этот шаг
конкретного человека (роль не разрешилась и т.п., см.
IResolvedRouteStep.warning/IMatrixRow.warning) — та же строка,
что уходит в ошибку валидации при отправке (см.
unassignedStepMessage), но видна СРАЗУ в таблице, а не только
на попытке отправить: пустая строка «Согласующий» сама по себе
не объясняет, что делать — вручную выбрать человека здесь
нельзя, шаг заведомо требует правки маршрута в админке. */}
{!r.who && r.warning && (
<span className={styles.warningInline}>{r.warning}</span>
)}
{/* Доп. согласующий, добавленный другим согласующим (с правом
переназначения) — помечаем, кто его добавил. Пусто у обычных
строк маршрута. Как и комментарий — inline под именем, чтобы
@@ -169,6 +169,19 @@
font-weight: 500;
}
// Причина, по которой approval-route не назначил человека на шаг (см.
// ApprovalMatrix.tsx/r.warning) — красным, как настоящая проблема маршрута
// (в отличие от нейтрального addedByInline/commentInline): пустая строка
// «Согласующий» без этого текста выглядит как баг, а не как «маршрут просит
// вмешательства администратора».
.warningInline {
display: block;
margin-top: 3px;
font-size: $fs-caption;
color: $red-500;
line-height: 1.45;
}
// ВАЖНО: не display:flex — это сняло бы с ячейки display:table-cell, и она
// перестала бы вставать в строку рядом с соседней (кнопки уезжали на вторую
// строку). Ячейка остаётся табличной, кнопки держатся в строке через nowrap.
@@ -118,6 +118,25 @@ export function findUnassignedStep(rows: IMatrixRow[]): IMatrixRow | undefined {
return rows.filter((r) => !r.email).sort((a, b) => a.order - b.order)[0];
}
/**
* Текст ошибки под первый незанятый шаг — то, что раньше было скопировано в
* validateSubmit каждой формы (findUnassignedStep + `row.warning || <шаблон>`).
* fallback принимает саму строку и решает, что писать, если approval-route не
* прислал свою причину (warning) — у большинства форм это общий шаблон «без
* назначенного человека — выберите …», но у некоторых (см. Ticket1CForm) текст
* другой, поэтому шаблон не зашит сюда, а передаётся вызывающей стороной.
*
* undefined — назначенных не хватает не для этих rows (или все шаги заняты).
*/
export function unassignedStepMessage(
rows: IMatrixRow[],
fallback: (row: IMatrixRow) => string
): string | undefined {
const row = findUnassignedStep(rows);
if (!row) return undefined;
return row.warning || fallback(row);
}
/**
* Загружает матрицу согласования и (если editable) позволяет её редактировать.
* @param load загрузчик строк (планируемый маршрут из resolve или строки заявки).
@@ -0,0 +1,51 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { RequestTypeId } from '../../models/types';
import { getCurrentListPartition } from './approvalRoute.service';
import { registerCurrentPartition } from './listPartitions';
/**
* Партиция, в которую фактически писать при сохранении заявки.
*
* existingId задан → редактируем существующую заявку, пишем в ту же партицию
* (editListId), запись новых партиций тут ни при чём.
*
* existingId не задан → создаём новую заявку, и партицию для записи спрашиваем
* у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit) — именно этот вызов
* проверяет порог элементов текущего списка и ставит в очередь создание
* следующей партиции при достижении; без него архивация никогда не сработает,
* сколько бы заявок ни создали.
*
* Свежерезолвленная партиция сразу вписывается в локальный кеш
* (registerCurrentPartition): если её создало провижинирование ПРЯМО ЭТИМ
* запросом (порог как раз достигнут), кеш из onInit о ней ещё не знает, и без
* этого следующий же шаг сохранения (обычно резолв имени списка согласования
* через approvalListTitleForListId) упал бы с «список не найден в реестре
* партиций», хотя данные уже успешно записались и ссылка совершенно рабочая —
* ровно так это и проявилось на форме «Обходной лист».
*
* Сбой запроса к approval-route не должен блокировать подачу заявки — откат на
* editListId (для новой заявки это undefined → список по конвенции имён,
* прежнее поведение до появления партиционирования).
*
* Раньше эта логика (try/catch + getCurrentListPartition + registerCurrentPartition)
* была скопирована в save() каждой из 8 форм — при первом же баге в ней пришлось
* чинить один и тот же код в 8 файлах. Вынесена сюда, чтобы чинить один раз.
*/
export async function resolveWriteListId(
context: WebPartContext,
formId: RequestTypeId,
requestTypeId: number,
existingId: number | undefined,
editListId: string | undefined
): Promise<string | undefined> {
if (existingId) return editListId;
try {
const partition = await getCurrentListPartition(context, requestTypeId);
registerCurrentPartition(formId, partition);
return partition.listId;
} catch (e) {
console.error('Не удалось получить текущую партицию списка — используется список по конвенции имён', e);
return editListId;
}
}