|
|
|
@@ -16,7 +16,6 @@ import { effectiveUserEmail } from '../../shared/impersonation';
|
|
|
|
|
import { IMyRequest } from './models/IMyRequest';
|
|
|
|
|
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
|
|
|
|
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
|
|
|
|
|
import { traced } from './perfTrace';
|
|
|
|
|
|
|
|
|
|
// Список данных «... — данные» → список согласования «... — согласование»
|
|
|
|
|
// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json).
|
|
|
|
@@ -203,9 +202,7 @@ async function buildPendingIndex(
|
|
|
|
|
// полноценный Search, который эти вызовы как раз заменяют.
|
|
|
|
|
const resolved = await Promise.all(
|
|
|
|
|
flat.map(({ type, target }) =>
|
|
|
|
|
traced(`resolveApprovalSearchTarget:${type.name}#${target.listId}`, () =>
|
|
|
|
|
resolveApprovalSearchTarget(context, type, target)
|
|
|
|
|
)
|
|
|
|
|
resolveApprovalSearchTarget(context, type, target)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
@@ -228,24 +225,22 @@ async function buildPendingIndex(
|
|
|
|
|
// поэтому идут параллельно, а не один за другим.
|
|
|
|
|
const [hitsByTarget, recentByTarget] = await Promise.all([
|
|
|
|
|
okEntries.length > 0
|
|
|
|
|
? traced('search:approvalsAcrossAll', () => searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses))
|
|
|
|
|
? searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses)
|
|
|
|
|
: Promise.resolve(new Map<string, number[]>()),
|
|
|
|
|
// Довесок нужен только режиму «чья очередь сейчас»: при текстовом поиске
|
|
|
|
|
// важна полнота по архиву, а не мгновенная свежесть (см. scope='involved').
|
|
|
|
|
okEntries.length > 0 && scope === 'myTurn'
|
|
|
|
|
? traced('recentRowsAcrossAll', () => fetchRecentApprovalRows(context, okEntries, tokenEmail, statuses))
|
|
|
|
|
? fetchRecentApprovalRows(context, okEntries, tokenEmail, statuses)
|
|
|
|
|
: Promise.resolve(new Map<string, Array<Record<string, unknown>>>()),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const parts = await Promise.all(
|
|
|
|
|
okEntries.map((entry) =>
|
|
|
|
|
traced(`finalizeApprovalTarget:${entry.type.name}#${entry.target.listId}`, () =>
|
|
|
|
|
finalizeApprovalTarget(
|
|
|
|
|
context,
|
|
|
|
|
entry,
|
|
|
|
|
hitsByTarget.get(targetKey(entry)) ?? [],
|
|
|
|
|
recentByTarget.get(targetKey(entry)) ?? []
|
|
|
|
|
)
|
|
|
|
|
finalizeApprovalTarget(
|
|
|
|
|
context,
|
|
|
|
|
entry,
|
|
|
|
|
hitsByTarget.get(targetKey(entry)) ?? [],
|
|
|
|
|
recentByTarget.get(targetKey(entry)) ?? []
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
@@ -343,15 +338,15 @@ export interface INavCounts {
|
|
|
|
|
// своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму
|
|
|
|
|
// вкладку: иначе счётчик и список могли бы разойтись.
|
|
|
|
|
export async function getNavCounts(context: WebPartContext): Promise<INavCounts> {
|
|
|
|
|
return traced('ИТОГО getNavCounts', () => getNavCountsInner(context));
|
|
|
|
|
return getNavCountsInner(context);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getNavCountsInner(context: WebPartContext): Promise<INavCounts> {
|
|
|
|
|
const types = await getLinkedTypes(context);
|
|
|
|
|
|
|
|
|
|
const [turn, rework] = await Promise.all([
|
|
|
|
|
traced('buildPendingIndex:myTurn', () => buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES)),
|
|
|
|
|
traced('getOwnRequestsByStatus:rework', () => getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1)),
|
|
|
|
|
buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES),
|
|
|
|
|
getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
@@ -419,7 +414,7 @@ async function resolveApprovalSearchTarget(
|
|
|
|
|
|
|
|
|
|
const dataTitle =
|
|
|
|
|
target.title ??
|
|
|
|
|
((await traced(`dataListTitle:${type.name}#${target.listId}`, () => list.select('Title')())).Title as string) ??
|
|
|
|
|
((await list.select('Title')()).Title as string) ??
|
|
|
|
|
'';
|
|
|
|
|
if (!DATA_LIST_SUFFIX.test(dataTitle)) {
|
|
|
|
|
return {
|
|
|
|
@@ -432,9 +427,9 @@ async function resolveApprovalSearchTarget(
|
|
|
|
|
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
|
|
|
|
|
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
|
|
|
|
|
|
|
|
|
|
const rootFolder = (await traced(`approvalListUrl:${type.name}#${target.listId}`, () =>
|
|
|
|
|
approvalList.select('RootFolder/ServerRelativeUrl').expand('RootFolder')()
|
|
|
|
|
)) as { RootFolder?: { ServerRelativeUrl?: string } };
|
|
|
|
|
const rootFolder = (await approvalList.select('RootFolder/ServerRelativeUrl').expand('RootFolder')()) as {
|
|
|
|
|
RootFolder?: { ServerRelativeUrl?: string };
|
|
|
|
|
};
|
|
|
|
|
const relUrl = rootFolder.RootFolder?.ServerRelativeUrl;
|
|
|
|
|
if (!relUrl) {
|
|
|
|
|
return { kind: 'error', type, target, message: `Список согласования «${approvalListTitle}» не найден` };
|
|
|
|
@@ -583,7 +578,7 @@ async function fetchRecentApprovalRows(
|
|
|
|
|
// SharePoint REST — поэтому резолвим email в числовой Id (как и везде в
|
|
|
|
|
// проекте) и фильтруем по TansuSiteApproverId.
|
|
|
|
|
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
|
|
|
|
|
const approver = await traced(`ensureUser:${siteUrl}`, () => sp.web.ensureUser(claimsLogin));
|
|
|
|
|
const approver = await sp.web.ensureUser(claimsLogin);
|
|
|
|
|
const filter = `TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`;
|
|
|
|
|
|
|
|
|
|
const [batchedSP, execute] = sp.batched();
|
|
|
|
@@ -609,7 +604,7 @@ async function fetchRecentApprovalRows(
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await traced(`recentRowsBatch:${siteUrl} (партиций: ${siteEntries.length})`, execute);
|
|
|
|
|
await execute();
|
|
|
|
|
await Promise.all(pending);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(`[Текущие заявки] Довесок по свежести для ${siteUrl} не сработал:`, (e as Error).message);
|
|
|
|
@@ -662,12 +657,10 @@ async function finalizeApprovalTarget(
|
|
|
|
|
// Id режем на пачки — тот же предел длины URL REST $filter, что и в
|
|
|
|
|
// loadByIds (см. REST_ID_FILTER_CHUNK).
|
|
|
|
|
chunk(searchHitIds, REST_ID_FILTER_CHUNK).map((idsChunk) =>
|
|
|
|
|
traced(`approvalRowsRest:${type.name}#${target.listId}`, () =>
|
|
|
|
|
approvalList.items
|
|
|
|
|
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
|
|
|
|
|
.select('Id', 'TicketId', 'ApprovalStatus')
|
|
|
|
|
.top(idsChunk.length)()
|
|
|
|
|
)
|
|
|
|
|
approvalList.items
|
|
|
|
|
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
|
|
|
|
|
.select('Id', 'TicketId', 'ApprovalStatus')
|
|
|
|
|
.top(idsChunk.length)()
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
).reduce((all, r) => all.concat(r as Array<Record<string, unknown>>), [] as Array<Record<string, unknown>>);
|
|
|
|
@@ -702,12 +695,10 @@ async function finalizeApprovalTarget(
|
|
|
|
|
// Заголовок/статус/дата заявки — отдельным запросом к списку ДАННЫХ (см.
|
|
|
|
|
// комментарий у функции: через Lookup-expand это не отдаётся).
|
|
|
|
|
const ticketFilter = ticketIds.map((id) => `Id eq ${id}`).join(' or ');
|
|
|
|
|
const ticketRows = (await traced(`ticketRowsRest:${type.name}#${target.listId}`, () =>
|
|
|
|
|
list.items
|
|
|
|
|
.filter(ticketFilter)
|
|
|
|
|
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created')
|
|
|
|
|
.top(ticketIds.length)()
|
|
|
|
|
)) as Array<Record<string, unknown>>;
|
|
|
|
|
const ticketRows = (await list.items
|
|
|
|
|
.filter(ticketFilter)
|
|
|
|
|
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created')
|
|
|
|
|
.top(ticketIds.length)()) as Array<Record<string, unknown>>;
|
|
|
|
|
|
|
|
|
|
debug.rows = ticketRows.map((r) => ({
|
|
|
|
|
...toMyRequest(r, undefined, type, 'approval', target),
|
|
|
|
@@ -831,16 +822,14 @@ async function fetchAllSearchRows(
|
|
|
|
|
let startRow = 0;
|
|
|
|
|
|
|
|
|
|
for (;;) {
|
|
|
|
|
const results = await traced(`search:${label}#${startRow}`, () =>
|
|
|
|
|
sp.search({
|
|
|
|
|
Querytext: kql,
|
|
|
|
|
RowLimit: SEARCH_PAGE_SIZE,
|
|
|
|
|
StartRow: startRow,
|
|
|
|
|
SelectProperties: selectProperties,
|
|
|
|
|
TrimDuplicates: false,
|
|
|
|
|
...(sortList ? { SortList: sortList } : {}),
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
const results = await sp.search({
|
|
|
|
|
Querytext: kql,
|
|
|
|
|
RowLimit: SEARCH_PAGE_SIZE,
|
|
|
|
|
StartRow: startRow,
|
|
|
|
|
SelectProperties: selectProperties,
|
|
|
|
|
TrimDuplicates: false,
|
|
|
|
|
...(sortList ? { SortList: sortList } : {}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const page = results.PrimarySearchResults as Array<Record<string, unknown>>;
|
|
|
|
|
rows.push(...page);
|
|
|
|
@@ -1080,13 +1069,11 @@ async function loadByIds(
|
|
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
chunk(ids, REST_ID_FILTER_CHUNK).map(async (idsChunk) => {
|
|
|
|
|
const rows = (await traced(`loadByIds:${kind}:${type.name}#${target.listId}`, () =>
|
|
|
|
|
list.items
|
|
|
|
|
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
|
|
|
|
|
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
|
|
|
|
.expand('Author')
|
|
|
|
|
.top(idsChunk.length)()
|
|
|
|
|
)) as Array<Record<string, unknown>>;
|
|
|
|
|
const rows = (await list.items
|
|
|
|
|
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
|
|
|
|
|
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
|
|
|
|
.expand('Author')
|
|
|
|
|
.top(idsChunk.length)()) as Array<Record<string, unknown>>;
|
|
|
|
|
rows.forEach((r) => {
|
|
|
|
|
const author = r.Author as { EMail?: string; Title?: string } | undefined;
|
|
|
|
|
byKey.set(
|
|
|
|
@@ -1205,13 +1192,13 @@ export async function getOwnRequests(
|
|
|
|
|
): Promise<IOwnRequestsPage> {
|
|
|
|
|
if (types.length === 0) return { rows: [], totalRows: 0 };
|
|
|
|
|
|
|
|
|
|
const searchPage = await traced('searchOwnRequests', () => searchOwnRequests(context, types, '', page));
|
|
|
|
|
const searchPage = await searchOwnRequests(context, types, '', page);
|
|
|
|
|
|
|
|
|
|
if (page !== 1) return searchPage;
|
|
|
|
|
|
|
|
|
|
const freshRows = (
|
|
|
|
|
await Promise.all(
|
|
|
|
|
types.map((t) => traced(`getFreshOwnRowsForType:${t.name}`, () => getFreshOwnRowsForType(context, t)))
|
|
|
|
|
types.map((t) => getFreshOwnRowsForType(context, t))
|
|
|
|
|
)
|
|
|
|
|
).reduce((all, r) => all.concat(r), [] as IMyRequest[]);
|
|
|
|
|
|
|
|
|
@@ -1248,23 +1235,21 @@ export async function searchOwnRequests(
|
|
|
|
|
if (text) clauses.push(text);
|
|
|
|
|
const kql = clauses.join(' AND ');
|
|
|
|
|
|
|
|
|
|
const results = await traced('search:searchOwnRequests', () =>
|
|
|
|
|
sp.search({
|
|
|
|
|
Querytext: kql,
|
|
|
|
|
RowLimit: PAGE_SIZE,
|
|
|
|
|
StartRow: (page - 1) * PAGE_SIZE,
|
|
|
|
|
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
|
|
|
|
TrimDuplicates: false,
|
|
|
|
|
// Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате).
|
|
|
|
|
SortList: [{ Property: 'Created', Direction: 1 }],
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
const results = await sp.search({
|
|
|
|
|
Querytext: kql,
|
|
|
|
|
RowLimit: PAGE_SIZE,
|
|
|
|
|
StartRow: (page - 1) * PAGE_SIZE,
|
|
|
|
|
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
|
|
|
|
TrimDuplicates: false,
|
|
|
|
|
// Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате).
|
|
|
|
|
SortList: [{ Property: 'Created', Direction: 1 }],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const totalRows = results.TotalRows || 0;
|
|
|
|
|
const entries = hitsToEntries(results, types);
|
|
|
|
|
const byKey =
|
|
|
|
|
entries.length > 0
|
|
|
|
|
? await traced('loadByIds:own(searchOwnRequests)', () => loadByIds(context, entries, 'own'))
|
|
|
|
|
? await loadByIds(context, entries, 'own')
|
|
|
|
|
: new Map<string, IMyRequest>();
|
|
|
|
|
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
|
|
|
|
|
const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
|
|
|
|
@@ -1299,23 +1284,21 @@ export async function getOwnRequestsByStatus(
|
|
|
|
|
if (text) clauses.push(text);
|
|
|
|
|
const kql = clauses.join(' AND ');
|
|
|
|
|
|
|
|
|
|
const results = await traced('search:getOwnRequestsByStatus', () =>
|
|
|
|
|
sp.search({
|
|
|
|
|
Querytext: kql,
|
|
|
|
|
RowLimit: PAGE_SIZE,
|
|
|
|
|
StartRow: (page - 1) * PAGE_SIZE,
|
|
|
|
|
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
|
|
|
|
TrimDuplicates: false,
|
|
|
|
|
// Свежие сверху — как в searchOwnRequests.
|
|
|
|
|
SortList: [{ Property: 'Created', Direction: 1 }],
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
const results = await sp.search({
|
|
|
|
|
Querytext: kql,
|
|
|
|
|
RowLimit: PAGE_SIZE,
|
|
|
|
|
StartRow: (page - 1) * PAGE_SIZE,
|
|
|
|
|
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
|
|
|
|
TrimDuplicates: false,
|
|
|
|
|
// Свежие сверху — как в searchOwnRequests.
|
|
|
|
|
SortList: [{ Property: 'Created', Direction: 1 }],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const totalRows = results.TotalRows || 0;
|
|
|
|
|
const entries = hitsToEntries(results, types);
|
|
|
|
|
const byKey =
|
|
|
|
|
entries.length > 0
|
|
|
|
|
? await traced('loadByIds:own(getOwnRequestsByStatus)', () => loadByIds(context, entries, 'own'))
|
|
|
|
|
? await loadByIds(context, entries, 'own')
|
|
|
|
|
: new Map<string, IMyRequest>();
|
|
|
|
|
const searchRows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
|
|
|
|
|
|
|
|
|
@@ -1326,9 +1309,7 @@ export async function getOwnRequestsByStatus(
|
|
|
|
|
// вернуть на доработку только что, индекс мог не успеть её подхватить.
|
|
|
|
|
const freshRows = (
|
|
|
|
|
await Promise.all(
|
|
|
|
|
types.map((t) =>
|
|
|
|
|
traced(`getRecentlyChangedOwnRowsForType:${t.name}`, () => getRecentlyChangedOwnRowsForType(context, t))
|
|
|
|
|
)
|
|
|
|
|
types.map((t) => getRecentlyChangedOwnRowsForType(context, t))
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.reduce((acc, r) => acc.concat(r), [] as IMyRequest[])
|
|
|
|
|