Feature/request manage server load (#22)
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:
@@ -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([]);
|
||||
|
||||
+13
-3
@@ -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()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user