переработка выбора

This commit is contained in:
gorbenko.pavel
2026-08-19 14:52:34 +05:00
parent 82512eb1db
commit 30fb3fa2b1
7 changed files with 224 additions and 77 deletions
@@ -12,7 +12,7 @@ import {
ManagedRequestMatrix,
ManagedRequestPage,
ManagedRequestSummary,
RequestType
RequestListPartition
} from '../models';
// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
@@ -86,15 +86,19 @@ interface IRawDataRow {
RequestStatus?: string;
Created?: string;
Author?: { Title?: string };
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит SharePoint: колонки может не быть в старых списках/строках
RequestUniqueCode?: string | null;
}
function toSummary(r: IRawDataRow): ManagedRequestSummary {
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 || ''
authorName: r.Author?.Title || '',
uniqueCode: r.RequestUniqueCode || null,
listId
};
}
@@ -124,24 +128,41 @@ export class RequestManageService {
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];
}
/**
* Заявки типа requestType постранично, для комбобокса выбора заявки. Сначала
* Search по названию (свободный терм, пустой query — просто последние без
* фильтра), затем дочитывание полей REST'ом по найденным Id — Search не
* отдаёт произвольные поля списка надёжно (та же двухшаговая схема, что в
* request-portal).
* Заявки по ВСЕМ партициям списка данных этого типа (архив + текущая)
* постранично, для комбобокса выбора заявки. Сначала Search по названию
* (свободный терм, пустой query — просто последние без фильтра), затем
* дочитывание полей REST'ом по найденным Id — Search не отдаёт произвольные
* поля списка надёжно (та же двухшаговая схема, что в request-portal).
*
* Заявки, набранные ростом одного типа, физически «переезжают» на новый
* список-партицию (см. ТЗ по архивации) — искать нужно сразу по всем, иначе
* заявки из архивных партиций в комбобоксе просто не найти.
*/
public async searchRequests(
requestType: RequestType,
partitions: RequestListPartition[],
siteUrl: string,
query: string,
page: number,
pageSize = 20
): Promise<ManagedRequestPage> {
if (!requestType.listId || !requestType.siteUrl) return { items: [], hasMore: false };
if (partitions.length === 0) return { items: [], hasMore: false };
const sp = this.clientFor(requestType.siteUrl);
const absUrl = listAbsUrl(requestType.siteUrl, requestType.listId);
const clauses = [`Path:"${absUrl}*"`];
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);
@@ -154,65 +175,83 @@ export class RequestManageService {
SortList: [{ Property: 'Created', Direction: 1 }]
});
const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string }>;
// Дедуп по Id обязателен: TrimDuplicates: false выше выключает штатную
// дедупликацию Search (она иногда схлопывает нужные нам заявки), и тогда
// индекс может отдать один и тот же элемент несколькими хитами
// (переиндексация, несколько «версий» в crawl). Без этого фильтра хиты
// превращались в пункты комбобокса один-в-один, и заявка показывалась в
// списке дважды — с одинаковым React-ключом. Та же причина и то же лечение,
// что в request-portal (features/myRequests/myRequests.service.ts/hitsToEntries).
const seen = new Set<number>();
const ids = rawRows
.map(r => Number(r.ListItemID))
.filter(id => !isNaN(id) && id > 0)
.filter(id => {
if (seen.has(id)) return false;
seen.add(id);
return true;
});
if (ids.length === 0) return { items: [], hasMore: false };
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 list = sp.web.getList(requestType.listId);
const dataRows = (await list.items
.filter(ids.map(id => `Id eq ${id}`).join(' or '))
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title')
.expand('Author')
.top(ids.length)()) as IRawDataRow[];
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));
})
);
const byId = new Map(dataRows.map(r => [r.Id, r]));
// Порядок задаёт Search (SortList по дате), не порядок ответа REST.
const items = ids
.map(id => byId.get(id))
.filter((r): r is IRawDataRow => !!r)
.map(toSummary);
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) — для экрана редактирования. */
public async getMatrix(requestType: RequestType, requestId: number): Promise<ManagedRequestMatrix> {
if (!requestType.listId || !requestType.siteUrl) {
throw new Error('У типа заявки не указан список SharePoint.');
}
const sp = this.clientFor(requestType.siteUrl);
const dataList = sp.web.getList(requestType.listId);
/**
* Заявка + полный маршрут (обе фазы, по 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')
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title', 'RequestUniqueCode')
.expand('Author')() as Promise<IRawDataRow>,
this.listTitles(sp, requestType.listId)
this.listTitles(sp, listId)
]);
const dataListUrl = listAbsUrl(requestType.siteUrl, requestType.listId);
const dataListUrl = listAbsUrl(siteUrl, listId);
if (!approvalListTitle) {
return {
request: toSummary(requestRow),
siteUrl: requestType.siteUrl,
request: toSummary(requestRow, listId),
siteUrl,
dataListTitle,
dataListUrl,
approvalListTitle: '',
@@ -264,7 +303,7 @@ export class RequestManageService {
};
});
return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows };
return { request: toSummary(requestRow, listId), siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows };
}
/** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */