куча доработок

This commit is contained in:
gorbenko.pavel
2026-08-10 16:07:58 +05:00
parent 4cfb9a545d
commit 71335b325e
10 changed files with 277 additions and 190 deletions
@@ -41,20 +41,18 @@ const MP_APPROVER = 'TansuSiteApproverOWSUSER';
// Статус СТРОКИ МАРШРУТА (колонка ApprovalStatus списка «— согласование») —
// Choice-поле, отсюда суффикс OWSCHCS (подтверждено отладочным перебором
// 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER). Колонку не путать с
// RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком, у него своя
// managed property (см. MP_TICKET_STATUS ниже).
// RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком.
//
// Для статуса ЗАЯВКИ (список ДАННЫХ) отдельной managed property здесь
// НАМЕРЕННО нет: имя для произвольной Choice-колонки в схеме поиска либо надо
// знать точно, либо угадывать по конвенции — угадали неверно (или колонка
// после переименования Status → RequestStatus ещё не переиндексирована), и
// фильтр по статусу молча возвращал 0 заявок. Поэтому статус заявки
// фильтруется НЕ в KQL, а в памяти после дочитывания реальных полей через REST
// (см. getOwnRequestsByStatus) — Search там ищет только по надёжному
// AuthorOWSUSER, статус ни при чём.
const MP_STATUS = 'ApprovalStatusOWSCHCS';
// То же самое для Choice-колонки «Статус» списка ДАННЫХ (внутреннее имя
// RequestStatus, см. forms/*/provisioning.json) — по ней отбираются
// «Доработка» и фильтр по статусу в «Моих заявках». Кириллица в значении
// проблемы не создаёт: KQL — не $filter (там она действительно подводит, см.
// indexApprovalsForType), и по MP_STATUS выше точно так же ищутся русские
// статусы строк маршрута.
// Если отбор по статусу вдруг начнёт стабильно возвращать 0 — проверять надо в
// первую очередь ЭТО имя в схеме поиска (см. предупреждение в searchOwnRequests).
const MP_TICKET_STATUS = 'RequestStatusOWSCHCS';
export function currentUserEmail(context: WebPartContext): string {
return (context.pageContext.user.email || '').toLowerCase();
}
@@ -210,7 +208,7 @@ export async function getNavCounts(context: WebPartContext): Promise<INavCounts>
const [turn, rework] = await Promise.all([
buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES),
getOwnRequests(context, types, 1, REQUEST_STATUS.returned),
getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1),
]);
return {
@@ -593,8 +591,7 @@ async function loadByIds(
// Search — сырьё для довеска по свежести (см. getFreshOwnRowsForType).
// Фильтруем только по AuthorId: статус в $filter не используем, потому что
// кириллица в сравнении там уже подводила (см. indexApprovalsForType) — отбор
// по статусу делает вызывающая сторона. В самом Search кириллица проблемой не
// является, там статус уходит прямо в KQL (см. MP_TICKET_STATUS).
// по статусу делает вызывающая сторона.
async function getOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
try {
const sp = spForSite(type.siteUrl as string, context);
@@ -636,6 +633,39 @@ async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestTyp
);
}
// Заявки автора, изменённые за последние RECENT_MS, — довесок к Search для
// getOwnRequestsByStatus. В отличие от getFreshOwnRowsForType (которая ловит
// НОВЫЕ заявки по дате СОЗДАНИЯ), здесь важна дата ИЗМЕНЕНИЯ: заявку могли
// вернуть на доработку неделю спустя после создания — по Created такую не
// найти, а Modified у неё обновится ровно в момент решения согласующего
// (см. approverDecision.service.ts/decide). Filter по Modified — обычная дата,
// не Choice-колонка, кириллицы в значении нет, поэтому $filter здесь надёжен
// (в отличие от статуса, см. getOwnRequestsByStatus).
async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
try {
const sp = spForSite(type.siteUrl as string, context);
const me = await sp.web.currentUser.select('Id')();
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
const rows = (await sp.web
.getList(type.listId as string)
.items.filter(`AuthorId eq ${me.Id} and Modified ge '${cutoffIso}'`)
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
return rows.map((r) =>
toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own')
);
} catch (e) {
console.error(
`[Текущие заявки] «${type.name}»: не удалось прочитать недавно изменённые заявки:`,
(e as Error).message
);
return [];
}
}
// «Мои заявки» БЕЗ поиска — гибрид для масштаба: Search с серверной пагинацией
// (RowLimit/StartRow) находит страницу без сканирования всего архива, REST по
// «последним N минутам» добирает то, что Search мог не успеть проиндексировать
@@ -644,20 +674,18 @@ async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestTyp
export async function getOwnRequests(
context: WebPartContext,
types: IRequestType[],
page: number,
status: string = ''
page: number
): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 };
const searchPage = await searchOwnRequests(context, types, '', page, status);
const searchPage = await searchOwnRequests(context, types, '', page);
if (page !== 1) return searchPage;
// Довесок по свежести отбираем по статусу здесь: он приходит REST-ом, где
// сравнивать статус в $filter нельзя (кириллица), — зато набор маленький.
const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t))))
.reduce((all, r) => all.concat(r), [] as IMyRequest[])
.filter((r) => !status || r.status === status);
const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t)))).reduce(
(all, r) => all.concat(r),
[] as IMyRequest[]
);
const searchKeys = new Set(searchPage.rows.map((r) => `${r.requestTypeId}-${r.id}`));
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`));
@@ -677,8 +705,7 @@ export async function searchOwnRequests(
context: WebPartContext,
types: IRequestType[],
query: string,
page: number,
status: string = ''
page: number
): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 };
@@ -689,9 +716,6 @@ export async function searchOwnRequests(
`(${types.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`,
`AuthorOWSUSER:"${claimsLogin}"`,
];
// Отбор по статусу заявки — на СЕРВЕРЕ, вместе с постраничностью: иначе
// пришлось бы вычитывать весь архив автора и резать его в памяти.
if (status) clauses.push(`${MP_TICKET_STATUS}:"${status}"`);
const text = freeTextClause(query);
if (text) clauses.push(text);
const kql = clauses.join(' AND ');
@@ -708,18 +732,6 @@ export async function searchOwnRequests(
const totalRows = results.TotalRows || 0;
// Отбор по статусу опирается на managed property, имя которой мы выводим по
// конвенции, а не читаем из схемы (см. MP_TICKET_STATUS). Если она названа
// иначе или не заведена, запрос отработает без ошибки и просто вернёт 0 —
// такое молчание слишком дорого отлаживать вслепую, поэтому подсказываем.
if (status && totalRows === 0) {
console.warn(
`[Мои заявки] По статусу «${status}» ничего не найдено. Если так на ЛЮБОМ статусе — ` +
`проверьте managed property «${MP_TICKET_STATUS}» в схеме поиска SharePoint.`
);
}
const entries = hitsToEntries(results, types);
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
@@ -728,6 +740,51 @@ export async function searchOwnRequests(
return { rows, totalRows };
}
// Свои заявки с конкретным статусом ЗАЯВКИ (не строки маршрута) — «Доработка»
// и фильтр по статусу на вкладке «Инициированные».
//
// Статус НЕ уходит в KQL: единственная надёжная managed property здесь —
// AuthorOWSUSER (стандартное поле автора), а для произвольной Choice-колонки
// имя managed property надо либо знать из схемы поиска, либо угадывать по
// конвенции — угадали неверно (или она ещё не переиндексирована после
// переименования колонки Status → RequestStatus), из-за чего фильтр по
// статусу молча возвращал 0. Поэтому здесь Search ищет ТОЛЬКО по автору
// (+ опционально текст) — это находит ВСЕ заявки автора вплоть до
// KQL_ID_LIMIT, — а отбор по статусу и постраничность делаются в памяти по
// уже дочитанным через REST реальным полям (см. searchOwnAll/loadByIds).
// Тот же принцип, что и в filterPendingByQuery: Search сужает набор по
// надёжному полю, остальное — на клиенте.
export async function getOwnRequestsByStatus(
context: WebPartContext,
types: IRequestType[],
status: string,
page: number,
query: string = ''
): Promise<IOwnRequestsPage> {
const all = await searchOwnAll(context, types, query);
const searchFiltered = all.filter((r) => r.status === status);
// Довесок по недавним изменениям — только на первой странице и только без
// текстового поиска (см. getOwnRequests: тот же принцип — довесок относится
// к «показать всё, свежее по дате», а не к результату полнотекстового поиска).
let merged = searchFiltered;
if (page === 1 && !query.trim()) {
const freshRows = (await Promise.all(types.map((t) => getRecentlyChangedOwnRowsForType(context, t))))
.reduce((acc, r) => acc.concat(r), [] as IMyRequest[])
.filter((r) => r.status === status);
const seen = new Set(searchFiltered.map((r) => `${r.requestTypeId}-${r.id}`));
const onlyFresh = freshRows.filter((r) => !seen.has(`${r.requestTypeId}-${r.id}`));
merged = onlyFresh.concat(searchFiltered);
}
const sorted = merged.sort((a, b) => b.created.localeCompare(a.created));
return {
rows: sorted.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
totalRows: sorted.length,
};
}
// «На согласовании»: список заявок уже отобран по строкам согласования
// (getPendingApprovalIndex — там же определяется, чья сейчас очередь,
// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search
@@ -791,7 +848,7 @@ export interface ISearchPage {
// Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез (до
// KQL_ID_LIMIT). Только Search, без REST-подстраховки на свежесть.
async function searchOwnAll(
export async function searchOwnAll(
context: WebPartContext,
types: IRequestType[],
query: string