Feature/request manage server load (#22)
Tests / frontend-test (push) Successful in 45s
Tests / backend-test (push) Successful in 1m43s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/22
This commit was merged in pull request #22.
This commit is contained in:
2026-09-25 06:30:59 +00:00
parent 2b8fe77fac
commit 1bf8d4ca11
12 changed files with 612 additions and 479 deletions
@@ -5,7 +5,7 @@ import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { RequestManageService } from '../../services/requestManageService';
import { ApiClientError } from '../../services/httpClient';
import { REQUEST_STATUS, RequestListPartition, UserApprovalRow, UserTemplateStepRow } from '../../models';
import { REQUEST_STATUS, UserApprovalRow, UserTemplateStepRow } from '../../models';
import { routeStepStatusLabels, texts } from '../../texts';
import { shortGuid } from '../../utils/guid';
import { describeStepCondition } from '../../utils/stepExecutor';
@@ -15,7 +15,6 @@ import { mapWithLimit } from '../../utils/parallel';
import { IManageByUserRouteParams, replaceRoute } from '../../routing';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useRequestTypeListsAll } from '../../hooks/useRequestTypeListsAll';
import { useTemplateRouteStepsByExecutor } from '../../hooks/useTemplateRouteStepsByExecutor';
import { useTemplateRoutesLookup } from '../../hooks/useTemplateRoutesLookup';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
@@ -95,7 +94,6 @@ interface IReplaceScope {
export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, dictionary, requestManage, route }) => {
const requestTypes = useRequestTypes(api);
const listSeries = useRequestTypeListsAll(api);
const employees = usePortalEmployees(dictionary);
const dictionaries = usePortalDictionaries(dictionary);
@@ -125,27 +123,21 @@ export const ManageByUserScreen: React.FC<IManageByUserScreenProps> = ({ api, di
});
}, [employeeEmail, targetEmail]);
const partitionsByType = React.useMemo(() => {
const map = new Map<number, RequestListPartition[]>();
listSeries.items.forEach(s => map.set(s.requestTypeId, s.partitions));
return map;
}, [listSeries.items]);
const [liveRows, setLiveRows] = React.useState<UserApprovalRow[]>([]);
const [liveLoading, setLiveLoading] = React.useState(false);
const [liveError, setLiveError] = React.useState<string | undefined>();
const [checkedLiveKeys, setCheckedLiveKeys] = React.useState<Set<string>>(new Set());
const reloadLiveRows = React.useCallback(() => {
if (!employeeEmail || requestTypes.isLoading || listSeries.isLoading) return;
if (!employeeEmail || requestTypes.isLoading) return;
setLiveLoading(true);
setLiveError(undefined);
requestManage
.findActiveApprovalRowsForApprover(requestTypes.items, partitionsByType, employeeEmail, ACTIVE_STATUSES)
api.requestManage
.byApprover(employeeEmail, ACTIVE_STATUSES)
.then(setLiveRows)
.catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: findActiveApprovalRowsForApprover')))
.catch((e: Error) => setLiveError(reportError(e, 'ManageByUserScreen: byApprover')))
.finally(() => setLiveLoading(false));
}, [requestManage, requestTypes.items, requestTypes.isLoading, partitionsByType, listSeries.isLoading, employeeEmail]);
}, [api, requestTypes.isLoading, employeeEmail]);
React.useEffect(() => {
setLiveRows([]);
@@ -214,13 +214,23 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
const isBlockStart = block.start === i;
const movable = blockIndex > lastLockedBlockIndex;
const rowEditable = isEditableRowStatus(r.status);
// Уведомлять можно только активный шаг (флоу шлёт письмо тому,
// чей RowID мы передали, независимо от статуса — сам он не
// Уведомлять можно активный шаг (флоу шлёт письмо тому, чей
// RowID мы передали, независимо от статуса — сам он не
// проверяет очередь). «Ждёт очереди» — шаг ещё не наступил,
// слать по нему нечего: согласующий/исполнитель пока не
// должен действовать (см. request-portal/notify.service.ts,
// где по той же причине уведомляется только активный шаг).
const notifiable = r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution;
//
// «Возвращена на доработку» — тоже уведомляемый статус, хотя
// и не активный: для него тот же поток шлёт письмо не
// согласующему строки, а АВТОРУ заявки (см.
// RequestManageService.notifyRow) — так интерфейс
// редактирования заявок получает кнопку «сообщить автору о
// доработке».
const notifiable =
r.status === REQUEST_STATUS.onApproval ||
r.status === REQUEST_STATUS.onExecution ||
r.status === REQUEST_STATUS.returned;
// Переключение параллельности — тоже перестановка (меняет order
// относительно соседней строки), поэтому смотрим на isMovableRowStatus,
// а не на rowEditable: у «Возвращена на доработку» кнопка
@@ -235,12 +235,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
const searchRequests = React.useCallback(
(query: string, page: number) => {
if (!requestType || !requestType.siteUrl || partitions.items.length === 0) {
if (!requestType || !requestType.siteUrl) {
return Promise.resolve({ items: [], hasMore: false });
}
return requestManage.searchRequests(partitions.items, requestType.siteUrl, query, page);
return api.requestManage.search(requestType.id, query, page);
},
[requestManage, requestType, partitions.items]
[api, requestType]
);
// silent=true — фоновое обновление (после своего же действия, автообновление
@@ -270,8 +270,8 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
}
if (!silent) setMatrixLoading(true);
setMatrixError(undefined);
requestManage
.getMatrix(requestType.siteUrl, listId, selectedRequest.id)
api.requestManage
.matrix(requestType.id, listId, selectedRequest.id)
.then(m => {
setMatrix(m);
// title/status/listId в комбобоксе (а значит, и в localStorage) держим
@@ -296,12 +296,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
return same ? prev : { ...prev, selectedRequest: m.request };
});
})
.catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: getMatrix')))
.catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: matrix')))
.finally(() => {
if (!silent) setMatrixLoading(false);
});
},
[requestManage, requestType, selectedRequest, partitions.items, partitions.isLoading]
[api, requestType, selectedRequest, partitions.items, partitions.isLoading]
);
React.useEffect(() => {
@@ -23,6 +23,9 @@ export interface UserApprovalRow extends ManagedApprovalRow {
requestUniqueCode: string | null;
}
/** Форма, в которой UserApprovalRow отдаёт Tnsu.ApprovalRoute.Api — см. requestManage.ts/ManagedApprovalRowApi. */
export type UserApprovalRowApi = Omit<UserApprovalRow, 'date'>;
/** Шаг шаблона маршрута с ExecutorEmail = искомый человек, обогащённый для отображения. */
export interface UserTemplateStepRow extends TemplateRouteStep {
requestTypeName: string;
@@ -154,3 +154,17 @@ export interface ManagedRequestMatrix {
/** Обе фазы вместе, упорядочены по StepOrder. */
rows: ManagedApprovalRow[];
}
// Формы, в которых те же данные отдаёт Tnsu.ApprovalRoute.Api
// (backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageDtos.cs) —
// сервис читает списки SharePoint от имени ПРИЛОЖЕНИЯ, а не текущего
// пользователя (см. services/api.ts/requestManage), поэтому видимость заявок не
// зависит от прав администратора на списки. Отличаются от ManagedApprovalRow/
// ManagedRequestMatrix только одним полем: `date` (отформатированная
// «дд.мм.гггг») здесь нет — она вычисляется на фронтенде из `dateIso` (см.
// services/api.ts/toApprovalRow), бэкенду незачем знать про формат отображения.
export type ManagedApprovalRowApi = Omit<ManagedApprovalRow, 'date'>;
export interface ManagedRequestMatrixApi extends Omit<ManagedRequestMatrix, 'rows'> {
rows: ManagedApprovalRowApi[];
}
@@ -3,6 +3,11 @@ import {
CreateRequestTypeInput,
CreateTemplateRouteInput,
CreateTemplateRouteStepInput,
ManagedApprovalRow,
ManagedApprovalRowApi,
ManagedRequestMatrix,
ManagedRequestMatrixApi,
ManagedRequestPage,
PatchRequestTypeInput,
PatchTemplateRouteInput,
PatchTemplateRouteStepInput,
@@ -15,8 +20,26 @@ import {
TemplateRouteStep,
UpdateRequestTypeInput,
UpdateTemplateRouteInput,
UpdateTemplateRouteStepInput
UpdateTemplateRouteStepInput,
UserApprovalRow,
UserApprovalRowApi
} from '../models';
import { formatDateIso } from '../utils/date';
// Api-формы (dateIso, без отформатированного date) → формы, которыми пользуются
// экраны (ManageRequestsScreen/ManageByUserScreen) — см. requestManage.ts/
// ManagedApprovalRowApi. Единственная разница — производное поле date.
function toApprovalRow(r: ManagedApprovalRowApi): ManagedApprovalRow {
return { ...r, date: formatDateIso(r.dateIso) };
}
function toMatrix(m: ManagedRequestMatrixApi): ManagedRequestMatrix {
return { ...m, rows: m.rows.map(toApprovalRow) };
}
function toUserApprovalRow(r: UserApprovalRowApi): UserApprovalRow {
return { ...r, date: formatDateIso(r.dateIso) };
}
// Тонкий 1:1 фасад над контроллерами бэкенда:
// backend/Tnsu.ApprovalRoute.Api/Controllers/{RequestTypes,TemplateRoutes,TemplateRouteSteps}Controller.cs
@@ -107,4 +130,29 @@ export class ApprovalRouteApi {
listByExecutorEmail: (executorEmail: string): Promise<TemplateRouteStep[]> =>
this.http.get<TemplateRouteStep[]>('/api/template-route-steps', { executorEmail })
};
// Чтение заявок и маршрутов согласования от имени ПРИЛОЖЕНИЯ (см.
// backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs) —
// в отличие от остальных групп выше, это НЕ CRUD над сущностями самого API:
// данные по-прежнему живут в списках SharePoint, которыми управляет
// request-portal (см. requestManageService.ts), только читаются они теперь
// не PnP-клиентом текущего пользователя, а этим бэкендом, поэтому видимость
// заявок не зависит от прав администратора на списки SharePoint.
public readonly requestManage = {
search: (requestTypeId: number, query: string, page: number, pageSize = 20): Promise<ManagedRequestPage> =>
this.http.get<ManagedRequestPage>('/api/request-manage/search', { requestTypeId, query, page, pageSize }),
matrix: (requestTypeId: number, listId: string, requestId: number): Promise<ManagedRequestMatrix> =>
this.http
.get<ManagedRequestMatrixApi>('/api/request-manage/matrix', { requestTypeId, listId, requestId })
.then(toMatrix),
byApprover: (approverEmail: string, activeStatuses: string[]): Promise<UserApprovalRow[]> =>
this.http
.get<UserApprovalRowApi[]>('/api/request-manage/by-approver', {
approverEmail,
statuses: activeStatuses.join(',')
})
.then(rows => rows.map(toUserApprovalRow))
};
}
@@ -3,29 +3,9 @@ import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/site-users/web';
import '@pnp/sp/search';
// sp.batched() — findActiveApprovalRowsForApprover читает все партиции ОДНОГО
// сайта одним HTTP-запросом (см. метод ниже). Импорт side-effect'ный: без него
// метод есть в типах, но в рантайме не зарегистрирован.
import '@pnp/sp/batching';
import { AadHttpClient } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
APPROVAL_PHASE,
ManagedApprovalRow,
ManagedRequestMatrix,
ManagedRequestPage,
ManagedRequestSummary,
RequestListPartition,
RequestType,
UserApprovalRow
} from '../models';
// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
// (конвенция имён; см. request-portal/src/webparts/requestPortal/forms/*/provisioning.json
// и forms/*/approval/provisioning.json — этот сервис читает и пишет ровно те же
// списки SharePoint, только с другого клиента).
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
import { ManagedRequestMatrix, UserApprovalRow } from '../models';
// Тот же поток оповещения согласующего/исполнителя, что и в request-portal
// (src/webparts/requestPortal/shared/config.ts) — один и тот же список
@@ -42,79 +22,26 @@ const FLOW_NOTIFY_URL =
// буквально — здесь их не выбирают заново, а просто дублируют.
const FIELDS = {
approver: 'TansuSiteApprover',
status: 'ApprovalStatus', // статус СТРОКИ маршрута — не путать со статусом заявки (см. IRawDataRow.RequestStatus ниже)
status: 'ApprovalStatus', // статус СТРОКИ маршрута — не путать со статусом заявки
ticket: 'Ticket',
date: 'Date',
comment: 'Comment',
order: 'StepOrder',
allowReassign: 'AllowReassignToOtherApprovers',
phase: 'Phase',
// Колонки SLA заводятся провижинингом в request-portal вместе с остальными.
slaHours: 'SLAhours',
askOverdueReason: 'AskOverdueReason'
order: 'StepOrder'
};
function siteOrigin(siteUrl: string): string {
const match = /^https?:\/\/[^/]+/.exec(siteUrl);
return match ? match[0] : siteUrl;
}
function listAbsUrl(siteUrl: string, listId: string): string {
return `${siteOrigin(siteUrl)}${listId}`;
}
// KQL не переносит большинство спецсимволов в свободном терме — оставляем буквы
// (лат./кир.), цифры и пробелы. Та же логика, что в request-portal
// (features/myRequests/myRequests.service.ts/sanitizeForKql).
function sanitizeForKql(text: string): string {
return text.replace(/[^a-zA-Zа-яА-ЯёЁ0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
}
function freeTextClause(query: string): string | undefined {
const q = sanitizeForKql(query);
if (!q) return undefined;
return `(${q.split(' ').map(w => `${w}*`).join(' AND ')})`;
}
function formatDateIso(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
return `${dd}.${mm}.${d.getFullYear()}`;
}
interface IRawDataRow {
Id: number;
Title?: string;
/** Статус ЗАЯВКИ (список «— данные») — не путать с FIELDS.status (статус строки маршрута). */
RequestStatus?: string;
Created?: string;
Author?: { Title?: string };
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит SharePoint: колонки может не быть в старых списках/строках
RequestUniqueCode?: string | null;
}
function toSummary(r: IRawDataRow, listId: string): ManagedRequestSummary {
return {
id: r.Id,
title: r.Title || `#${r.Id}`,
status: r.RequestStatus || '',
created: r.Created || '',
authorName: r.Author?.Title || '',
uniqueCode: r.RequestUniqueCode || null,
listId
};
}
/**
* Чтение/правка заявок и их маршрутов согласования НАПРЯМУЮ в SharePoint —
* в отличие от остального этого веб-парта (который говорит с Tnsu.ApprovalRoute.Api),
* данные заявок и строки маршрута живут только в списках SharePoint, которыми
* управляет request-portal. У бэкенда этого API нет и не будет: типы заявок
* знают только list_id/site_url (см. models/requestTypes.ts), содержимое списков
* ему не принадлежит.
* Правка заявок и их маршрутов согласования НАПРЯМУЮ в SharePoint, от имени
* ТЕКУЩЕГО пользователя — в отличие от остального этого веб-парта (который
* говорит с Tnsu.ApprovalRoute.Api), данные заявок и строки маршрута живут
* только в списках SharePoint, которыми управляет request-portal.
*
* Чтение заявок (поиск, матрица, строки по согласующему) сюда больше не входит —
* оно переехало на бэкенд (см. services/api.ts/requestManage,
* backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs):
* PnP-клиент читал списки от имени залогиненного администратора, и видимость
* заявок молча урезалась его собственными правами на списки SharePoint —
* бэкенд же читает от имени приложения. Правка (переназначить/удалить/
* переставить/уведомить) по-прежнему требует прав самого пользователя на
* запись в эти списки, поэтому остаётся здесь.
*/
export class RequestManageService {
constructor(private readonly context: WebPartContext) {}
@@ -123,202 +50,6 @@ export class RequestManageService {
return spfi(siteUrl).using(SPFx(this.context));
}
// Title списка данных → Title списка согласования. undefined, если Title не
// оканчивается на «— данные» (список заведён не по конвенции request-portal,
// управлять маршрутом для него отсюда нельзя).
private async listTitles(sp: SPFI, listId: string): Promise<{ dataListTitle: string; approvalListTitle: string | undefined }> {
const info = (await sp.web.getList(listId).select('Title')()) as { Title: string };
const approvalListTitle = DATA_LIST_SUFFIX.test(info.Title)
? `${info.Title.replace(DATA_LIST_SUFFIX, '')} — согласование`
: undefined;
return { dataListTitle: info.Title, approvalListTitle };
}
// К какой партиции относится хит Search — по префиксу его Path. Партиции
// определяются вызывающей стороной (ListPartitionService.ListAsync), их
// список короткий (единицы), линейный поиск тут не проблема.
private partitionForPath(
siteUrl: string,
partitions: RequestListPartition[],
path: string
): RequestListPartition | undefined {
return partitions.filter(p => path.indexOf(listAbsUrl(siteUrl, p.listId)) === 0)[0];
}
/**
* Заявки по ВСЕМ партициям списка данных этого типа (архив + текущая)
* постранично, для комбобокса выбора заявки. Сначала Search по названию
* (свободный терм, пустой query — просто последние без фильтра), затем
* дочитывание полей REST'ом по найденным Id — Search не отдаёт произвольные
* поля списка надёжно (та же двухшаговая схема, что в request-portal).
*
* Заявки, набранные ростом одного типа, физически «переезжают» на новый
* список-партицию (см. ТЗ по архивации) — искать нужно сразу по всем, иначе
* заявки из архивных партиций в комбобоксе просто не найти.
*/
public async searchRequests(
partitions: RequestListPartition[],
siteUrl: string,
query: string,
page: number,
pageSize = 20
): Promise<ManagedRequestPage> {
if (partitions.length === 0) return { items: [], hasMore: false };
const sp = this.clientFor(siteUrl);
const pathClauses = partitions.map(p => `Path:"${listAbsUrl(siteUrl, p.listId)}*"`);
const pathClause = pathClauses.length > 1 ? `(${pathClauses.join(' OR ')})` : pathClauses[0];
const clauses = [pathClause];
const text = freeTextClause(query);
if (text) clauses.push(text);
const result = await sp.search({
Querytext: clauses.join(' AND '),
SelectProperties: ['ListItemID', 'Path'],
RowLimit: pageSize,
StartRow: (page - 1) * pageSize,
TrimDuplicates: false,
SortList: [{ Property: 'Created', Direction: 1 }]
});
const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string; Path?: string }>;
// Дедуп обязателен по паре (listId, Id), а НЕ по одному Id: у разных
// партиций независимая нумерация элементов (см. ТЗ по архивации), поэтому
// одинаковый номер в разных партициях — это две РАЗНЫЕ заявки. TrimDuplicates:
// false выше выключает штатную дедупликацию Search (она иногда схлопывает
// нужные нам заявки), и тогда индекс может отдать один и тот же элемент
// несколькими хитами (переиндексация, несколько «версий» в crawl) — та же
// причина и то же лечение, что в request-portal
// (features/myRequests/myRequests.service.ts/hitsToEntries).
const seen = new Set<string>();
const hits: Array<{ id: number; partition: RequestListPartition }> = [];
rawRows.forEach(r => {
const id = Number(r.ListItemID);
if (isNaN(id) || id <= 0 || !r.Path) return;
const partition = this.partitionForPath(siteUrl, partitions, r.Path);
if (!partition) return;
const key = `${partition.listId}|${id}`;
if (seen.has(key)) return;
seen.add(key);
hits.push({ id, partition });
});
if (hits.length === 0) return { items: [], hasMore: false };
const idsByListId = new Map<string, number[]>();
hits.forEach(h => idsByListId.set(h.partition.listId, [...(idsByListId.get(h.partition.listId) || []), h.id]));
const rowByKey = new Map<string, IRawDataRow>();
await Promise.all(
Array.from(idsByListId.entries()).map(async ([listId, ids]) => {
const list = sp.web.getList(listId);
const dataRows = (await list.items
.filter(ids.map(id => `Id eq ${id}`).join(' or '))
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title', 'RequestUniqueCode')
.expand('Author')
.top(ids.length)()) as IRawDataRow[];
dataRows.forEach(r => rowByKey.set(`${listId}|${r.Id}`, r));
})
);
// Порядок задаёт Search (SortList по дате), не порядок ответа REST.
const items = hits
.map(h => {
const row = rowByKey.get(`${h.partition.listId}|${h.id}`);
return row ? toSummary(row, h.partition.listId) : undefined;
})
.filter((r): r is ManagedRequestSummary => !!r);
const totalRows = (result as unknown as { TotalRows?: number }).TotalRows ?? items.length;
return { items, hasMore: page * pageSize < totalRows };
}
/**
* Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования.
*
* listId — КОНКРЕТНАЯ партиция, где физически лежит заявка (из
* ManagedRequestSummary.listId, отданного searchRequests/предыдущей
* getMatrix) — не requestType.listId: та партиция может быть уже не той, где
* лежит эта заявка (см. ТЗ по архивации).
*/
public async getMatrix(siteUrl: string, listId: string, requestId: number): Promise<ManagedRequestMatrix> {
const sp = this.clientFor(siteUrl);
const dataList = sp.web.getList(listId);
const [requestRow, { dataListTitle, approvalListTitle }] = await Promise.all([
dataList.items
.getById(requestId)
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title', 'RequestUniqueCode')
.expand('Author')() as Promise<IRawDataRow>,
this.listTitles(sp, listId)
]);
const dataListUrl = listAbsUrl(siteUrl, listId);
if (!approvalListTitle) {
return {
request: toSummary(requestRow, listId),
siteUrl,
dataListTitle,
dataListUrl,
approvalListTitle: '',
rows: []
};
}
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
const rawRows = (await approvalList.items
.filter(`${FIELDS.ticket}Id eq ${requestId}`)
.select(
'Id',
'Title',
FIELDS.status,
FIELDS.date,
FIELDS.comment,
FIELDS.order,
FIELDS.allowReassign,
FIELDS.phase,
FIELDS.slaHours,
FIELDS.askOverdueReason,
`${FIELDS.approver}/Id`,
`${FIELDS.approver}/Title`,
`${FIELDS.approver}/EMail`
)
.expand(FIELDS.approver)
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
const rows: ManagedApprovalRow[] = rawRows.map((r, i) => this.toApprovalRow(r, requestId, i));
return { request: toSummary(requestRow, listId), siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows };
}
/**
* Строка списка согласования (сырые OData-поля) → ManagedApprovalRow. Общий
* маппинг для getMatrix (одна заявка, ticketId известен заранее) и
* findActiveApprovalRowsForApprover (много заявок сразу, ticketId — из самой строки).
*/
private toApprovalRow(r: Record<string, unknown>, ticketId: number, index: number): ManagedApprovalRow {
const approver = r[FIELDS.approver] as { Id?: number; Title?: string; EMail?: string } | undefined;
return {
id: r.Id as number,
ticketId,
approverId: approver?.Id ?? null,
approverName: approver?.Title || '',
approverEmail: approver?.EMail || '',
position: (r.Title as string) || '',
status: (r[FIELDS.status] as string) || '',
date: formatDateIso((r[FIELDS.date] as string) || ''),
dateIso: (r[FIELDS.date] as string) || '',
comment: (r[FIELDS.comment] as string) || '',
order: (r[FIELDS.order] as number) ?? index + 1,
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
// ?? null, а не || null: ноль здесь — значимое значение («напоминать
// сразу»), и превращать его в «не задано» нельзя.
slaHours: (r[FIELDS.slaHours] as number | null) ?? null,
askOverdueReason: (r[FIELDS.askOverdueReason] as boolean | null) ?? null
};
}
/** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */
private async resolveUserId(sp: SPFI, email: string): Promise<number> {
const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`;
@@ -326,166 +57,6 @@ export class RequestManageService {
return user.Id;
}
/**
* Активные строки согласования данного человека СРАЗУ по всем типам заявок и
* всем их партициям (архив + текущая) — для экрана «Заявки по согласующему»
* (ManageByUserScreen): найти всё, что нужно переназначить перед увольнением/
* сменой должности, без похода в каждый тип заявки по отдельности.
*
* REST-only, без Search API — в отличие от request-portal/myRequests.service.ts
* (тот ищет по текущему пользователю на КАЖДОЙ загрузке страницы, и там счёт
* идёт на миллисекунды). Здесь редкий admin-вызов на одного конкретного
* сотрудника, а задержка индексации Search — это ровно тот класс багов
* (устаревшее назначение), ради исправления которого существует сам экран, так
* что жертвовать точностью ради скорости здесь не место.
*
* partitionsByType — серия партиций КАЖДОГО типа (архив+текущая), уже
* полученная вызывающей стороной через api.requestTypes.listsAll(). Partition
* даёт Title списка данных бесплатно (без похода в REST) — approvalListTitle
* выводится из него той же конвенцией имён, что и listTitles() выше.
*/
public async findActiveApprovalRowsForApprover(
requestTypes: RequestType[],
partitionsByType: Map<number, RequestListPartition[]>,
approverEmail: string,
activeStatuses: string[]
): Promise<UserApprovalRow[]> {
interface ITarget {
requestTypeId: number;
requestTypeName: string;
siteUrl: string;
dataListId: string;
approvalListTitle: string;
}
const targets: ITarget[] = [];
requestTypes.forEach(rt => {
(partitionsByType.get(rt.id) || []).forEach(p => {
if (!p.title || !DATA_LIST_SUFFIX.test(p.title)) return;
targets.push({
requestTypeId: rt.id,
requestTypeName: rt.name,
siteUrl: p.siteUrl,
dataListId: p.listId,
approvalListTitle: `${p.title.replace(DATA_LIST_SUFFIX, '')} — согласование`
});
});
});
if (targets.length === 0) return [];
const bySite = new Map<string, ITarget[]>();
targets.forEach(t => bySite.set(t.siteUrl, [...(bySite.get(t.siteUrl) || []), t]));
const statusFilter = activeStatuses.map(s => `${FIELDS.status} eq '${s}'`).join(' or ');
const rows: UserApprovalRow[] = [];
await Promise.all(
Array.from(bySite.values()).map(async siteTargets => {
const siteUrl = siteTargets[0].siteUrl;
try {
const sp = this.clientFor(siteUrl);
// Один раз на сайт — тот же приём, что в fetchRecentApprovalRows
// request-portal: резолвить email в Id и фильтровать по approverId
// надёжнее, чем `TansuSiteApprover/EMail eq '...'` в $filter по
// Person-полю (последнее в SharePoint REST ненадёжно).
const approverId = await this.resolveUserId(sp, approverEmail);
// Фаза A: строки согласования — ОДИН batch-запрос на все партиции сайта.
const approvalHitsByList = new Map<string, Array<Record<string, unknown>>>();
const [batchedApprovalSp, executeApproval] = sp.batched();
const pendingApproval = siteTargets.map(t =>
batchedApprovalSp.web.lists
.getByTitle(t.approvalListTitle)
.items.filter(`${FIELDS.approver}Id eq ${approverId} and (${statusFilter})`)
.select(
'Id',
'Title',
FIELDS.status,
FIELDS.date,
FIELDS.comment,
FIELDS.order,
FIELDS.allowReassign,
FIELDS.phase,
FIELDS.slaHours,
FIELDS.askOverdueReason,
`${FIELDS.ticket}Id`,
`${FIELDS.approver}/Id`,
`${FIELDS.approver}/Title`,
`${FIELDS.approver}/EMail`
)
.expand(FIELDS.approver)
.top(2000)()
.then(items => { approvalHitsByList.set(t.dataListId, items as Array<Record<string, unknown>>); })
.catch((e: Error) => {
// Одна сломанная/переименованная вручную партиция не должна
// лишить результата остальные партиции того же сайта.
console.error(`[Заявки по согласующему] «${t.approvalListTitle}» (${t.dataListId}): ${e.message}`);
})
);
await executeApproval();
await Promise.all(pendingApproval);
// Фаза B: Title/статус/уникальный код заявок — по найденным Id,
// сгруппированным по СПИСКУ ДАННЫХ, тоже одним batch-запросом на сайт.
const ticketIdsByList = new Map<string, Set<number>>();
siteTargets.forEach(t => {
(approvalHitsByList.get(t.dataListId) || []).forEach(h => {
const ticketId = h[`${FIELDS.ticket}Id`] as number | undefined;
if (!ticketId) return;
const set = ticketIdsByList.get(t.dataListId) || new Set<number>();
set.add(ticketId);
ticketIdsByList.set(t.dataListId, set);
});
});
const dataRowsByList = new Map<string, IRawDataRow[]>();
if (ticketIdsByList.size > 0) {
const [batchedDataSp, executeData] = sp.batched();
const pendingData = Array.from(ticketIdsByList.entries()).map(([dataListId, idsSet]) => {
const ids = Array.from(idsSet);
return batchedDataSp.web
.getList(dataListId)
.items.filter(ids.map(id => `Id eq ${id}`).join(' or '))
.select('Id', 'Title', 'RequestStatus', 'Created', 'RequestUniqueCode')
.top(ids.length)()
.then(items => { dataRowsByList.set(dataListId, items as IRawDataRow[]); })
.catch((e: Error) => {
console.error(`[Заявки по согласующему] список данных ${dataListId}: ${e.message}`);
});
});
await executeData();
await Promise.all(pendingData);
}
// Склейка.
siteTargets.forEach(t => {
const hits = approvalHitsByList.get(t.dataListId) || [];
const dataById = new Map((dataRowsByList.get(t.dataListId) || []).map(r => [r.Id, r]));
hits.forEach((h, i) => {
const ticketId = h[`${FIELDS.ticket}Id`] as number;
if (!ticketId) return;
const dataRow = dataById.get(ticketId);
rows.push({
...this.toApprovalRow(h, ticketId, i),
requestTypeId: t.requestTypeId,
requestTypeName: t.requestTypeName,
siteUrl: t.siteUrl,
listId: t.dataListId,
approvalListTitle: t.approvalListTitle,
requestTitle: dataRow?.Title || `#${ticketId}`,
requestUniqueCode: dataRow?.RequestUniqueCode || null
});
});
});
} catch (e) {
console.error(`[Заявки по согласующему] сайт ${siteUrl}: ${(e as Error).message}`);
}
})
);
return rows;
}
/**
* Появится ли в фазе этой заявки ВТОРОЙ согласующий с данным email, если строку
* row переназначить на него — та же проверка, что ManageRequestsScreen делает
@@ -549,8 +120,8 @@ export class RequestManageService {
): Promise<void> {
const sp = this.clientFor(matrix.siteUrl);
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.getById(rowId).update({
[FIELDS.slaHours]: slaHours,
[FIELDS.askOverdueReason]: askOverdueReason
SLAhours: slaHours,
AskOverdueReason: askOverdueReason
});
}
@@ -587,7 +158,7 @@ export class RequestManageService {
[FIELDS.status]: status,
[`${FIELDS.ticket}Id`]: ticketId,
[FIELDS.order]: order,
[FIELDS.allowReassign]: allowReassign,
AllowReassignToOtherApprovers: allowReassign,
[FIELDS.phase]: phase
});
}
@@ -618,12 +189,21 @@ export class RequestManageService {
}
/**
* Отправить уведомление согласующему/исполнителю конкретной строки маршрута —
* тот же поток Power Automate, что и request-portal (см. FLOW_NOTIFY_URL
* выше): поток сам находит по RowID согласующего, статус и заявку и шлёт
* письмо. В отличие от request-portal (там вызывается автоматически после
* решения), здесь — по явному клику на кнопку в таблице маршрута, поэтому
* ошибку НЕ глушим — вызывающая сторона показывает её пользователю.
* Отправить уведомление по конкретной строке маршрута — тот же поток Power
* Automate, что и request-portal (см. FLOW_NOTIFY_URL выше): поток сам
* находит по RowID строку, её статус и заявку.
*
* Обычно (статус «На согласовании»/«На исполнении») он уведомляет
* согласующего/исполнителя ЭТОЙ строки — см. вызывающую сторону,
* ManageRequestsPhaseTable/notifiable. Для строки в статусе «Возвращена на
* доработку» этот же поток (по сложившейся практике портала — он уже читает
* статус строки, см. комментарий у FLOW_NOTIFY_URL) уведомляет автора заявки,
* а не согласующего, вернувшего её — так интерфейс редактирования заявок
* получает кнопку «сообщить автору о доработке» без отдельного потока.
*
* В отличие от request-portal (там вызывается автоматически после решения),
* здесь — по явному клику на кнопку в таблице маршрута, поэтому ошибку НЕ
* глушим — вызывающая сторона показывает её пользователю.
*/
public async notifyRow(
requestTypeId: number,
@@ -0,0 +1,12 @@
// ISO 8601 (или пустая строка) → «дд.мм.гггг» для отображения в таблицах
// маршрута. Раздельно от dateIso: последняя нужна для расчёта просрочки
// (см. utils/sla.ts) и не должна терять точность через локаль при парсинге
// уже отформатированной строки назад.
export function formatDateIso(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
return `${dd}.${mm}.${d.getFullYear()}`;
}