переработка выбора
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
} from '../../models';
|
||||
import { texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useRequestTypeListPartitions } from '../../hooks/useRequestTypeListPartitions';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { findEmployeeByEmail } from '../../utils/employee';
|
||||
@@ -47,6 +48,14 @@ export interface IManageRequestsScreenProps {
|
||||
route?: IManageRequestRouteParams;
|
||||
}
|
||||
|
||||
// Подпись заявки в комбобоксе — уникальный код (RequestUniqueCode, тот же, что
|
||||
// видит заявитель на портале), а не служебный числовой Id списка SharePoint.
|
||||
// Запасной вариант на #id — для заявок без заполненного кода (старые заявки,
|
||||
// либо список данных этой колонки не заводит).
|
||||
function requestLabel(r: Pick<ManagedRequestSummary, 'id' | 'title' | 'uniqueCode'>): string {
|
||||
return `${r.uniqueCode || `#${r.id}`} ${r.title}`;
|
||||
}
|
||||
|
||||
// Цвет плашки статуса — те же группы смысла, что и в request-portal
|
||||
// (StatusBadge.tsx): решено положительно/отрицательно/ожидает/черновик.
|
||||
function statusVariant(status: string): BadgeVariant {
|
||||
@@ -158,7 +167,19 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
if (!route) return;
|
||||
setSelection({
|
||||
requestTypeId: route.requestTypeId,
|
||||
selectedRequest: { id: Number(route.requestId), title: '', status: '', created: '', authorName: '' }
|
||||
// listId из ссылки — какая партиция, если ссылку скопировали уже после
|
||||
// этого фикса; пусто у старых ссылок — тогда reloadMatrix ниже
|
||||
// подставит текущую партицию (тот же эффект, что был раньше, но теперь
|
||||
// явный, а не единственно возможный).
|
||||
selectedRequest: {
|
||||
id: Number(route.requestId),
|
||||
title: '',
|
||||
status: '',
|
||||
created: '',
|
||||
authorName: '',
|
||||
uniqueCode: null,
|
||||
listId: route.listId
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -167,11 +188,15 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
// прислать коллеге. replaceRoute, а не navigate: смена заявки — это правка
|
||||
// состояния текущего экрана, а не переход на новую страницу с отдельным
|
||||
// шагом в истории браузера. title/status заявки в URL не пишем — их выводит
|
||||
// сам requestId, хранить производные данные в ссылке незачем.
|
||||
// сам requestId, хранить производные данные в ссылке незачем. listId —
|
||||
// пишем: без него по ссылке нельзя однозначно понять, в какой партиции
|
||||
// искать requestId (см. IManageRequestRouteParams).
|
||||
React.useEffect(() => {
|
||||
replaceRoute({
|
||||
tab: 'manageRequests',
|
||||
manage: selectedRequest ? { requestTypeId, requestId: String(selectedRequest.id) } : undefined
|
||||
manage: selectedRequest
|
||||
? { requestTypeId, requestId: String(selectedRequest.id), listId: selectedRequest.listId }
|
||||
: undefined
|
||||
});
|
||||
}, [requestTypeId, selectedRequest]);
|
||||
const [matrix, setMatrix] = React.useState<ManagedRequestMatrix | undefined>();
|
||||
@@ -192,6 +217,11 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
// и читать заявки этого типа отсюда просто негде.
|
||||
const manageableRequestTypes = requestTypes.items.filter(rt => rt.listId && rt.siteUrl);
|
||||
|
||||
// Вся серия партиций выбранного типа (архив + текущая) — заявки этого типа
|
||||
// физически лежат в любой из них (см. ТЗ по архивации), искать и открывать
|
||||
// нужно по всем сразу, не только по текущей.
|
||||
const partitions = useRequestTypeListPartitions(api, requestType?.id);
|
||||
|
||||
const selectRequestType = (id: string): void => {
|
||||
// Заявка выбрана для ДРУГОГО типа — сбрасываем, иначе комбобокс продолжит
|
||||
// показывать заявку не того типа, что выбран сейчас.
|
||||
@@ -205,10 +235,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
|
||||
const searchRequests = React.useCallback(
|
||||
(query: string, page: number) => {
|
||||
if (!requestType) return Promise.resolve({ items: [], hasMore: false });
|
||||
return requestManage.searchRequests(requestType, query, page);
|
||||
if (!requestType || !requestType.siteUrl || partitions.items.length === 0) {
|
||||
return Promise.resolve({ items: [], hasMore: false });
|
||||
}
|
||||
return requestManage.searchRequests(partitions.items, requestType.siteUrl, query, page);
|
||||
},
|
||||
[requestManage, requestType]
|
||||
[requestManage, requestType, partitions.items]
|
||||
);
|
||||
|
||||
// silent=true — фоновое обновление (после своего же действия, автообновление
|
||||
@@ -219,18 +251,38 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
const reloadMatrix = React.useCallback(
|
||||
(silent = false) => {
|
||||
if (!requestType || !selectedRequest) return;
|
||||
// Партиции ещё не подтянулись (первый рендер после смены типа/захода по
|
||||
// ссылке) — без них нельзя даже подставить запасной вариант (текущая
|
||||
// партиция) для ссылок без listId, ждём следующего рендера.
|
||||
if (partitions.isLoading) return;
|
||||
if (!requestType.siteUrl) {
|
||||
setMatrixError('У типа заявки не указан список SharePoint.');
|
||||
return;
|
||||
}
|
||||
// listId заявки уже известен (выбрана из комбобокса, либо ссылка после
|
||||
// этого фикса) — используем его. Иначе (старая ссылка без listId) —
|
||||
// единственный разумный запасной вариант: текущая партиция, тот же
|
||||
// список, что искался бы и раньше.
|
||||
const listId = selectedRequest.listId || partitions.items.filter(p => p.isCurrent)[0]?.listId;
|
||||
if (!listId) {
|
||||
setMatrixError('Для типа заявки не найдено ни одной партиции списка SharePoint.');
|
||||
return;
|
||||
}
|
||||
if (!silent) setMatrixLoading(true);
|
||||
setMatrixError(undefined);
|
||||
requestManage
|
||||
.getMatrix(requestType, selectedRequest.id)
|
||||
.getMatrix(requestType.siteUrl, listId, selectedRequest.id)
|
||||
.then(m => {
|
||||
setMatrix(m);
|
||||
// title/status в комбобоксе (а значит, и в localStorage) держим
|
||||
// title/status/listId в комбобоксе (а значит, и в localStorage) держим
|
||||
// синхронными с сервером — особенно важно после захода по глубокой
|
||||
// ссылке, где изначально они пустые (см. эффект-затравку выше).
|
||||
// ссылке, где изначально они пустые (см. эффект-затравку выше), и
|
||||
// после подстановки запасной (текущей) партиции выше: как только
|
||||
// реальная партиция заявки известна, оседает в выборе — следующий
|
||||
// reloadMatrix пойдёт уже по ней напрямую.
|
||||
//
|
||||
// ВАЖНО: возвращаем ТОТ ЖЕ prev (без изменений), если title/status не
|
||||
// отличаются — m.request это каждый раз НОВЫЙ объект (свежий парсинг
|
||||
// ВАЖНО: возвращаем ТОТ ЖЕ prev (без изменений), если ничего не
|
||||
// отличается — m.request это каждый раз НОВЫЙ объект (свежий парсинг
|
||||
// ответа), и если писать его в состояние безусловно, selectedRequest
|
||||
// получает новую ссылку на КАЖДОЙ загрузке, даже когда содержимое не
|
||||
// поменялось. Это меняет зависимости reloadMatrix → пересоздаёт саму
|
||||
@@ -240,7 +292,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
setSelection(prev => {
|
||||
const sel = prev.selectedRequest;
|
||||
if (!sel || sel.id !== m.request.id) return prev;
|
||||
const same = sel.title === m.request.title && sel.status === m.request.status;
|
||||
const same = sel.title === m.request.title && sel.status === m.request.status && sel.listId === m.request.listId;
|
||||
return same ? prev : { ...prev, selectedRequest: m.request };
|
||||
});
|
||||
})
|
||||
@@ -249,7 +301,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
if (!silent) setMatrixLoading(false);
|
||||
});
|
||||
},
|
||||
[requestManage, requestType, selectedRequest]
|
||||
[requestManage, requestType, selectedRequest, partitions.items, partitions.isLoading]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -533,14 +585,14 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
<AsyncSearchableSelect<ManagedRequestSummary>
|
||||
key={requestTypeId || 'none'}
|
||||
value={selectedRequest ? String(selectedRequest.id) : ''}
|
||||
selectedLabel={selectedRequest ? `#${selectedRequest.id} ${selectedRequest.title}` : undefined}
|
||||
selectedLabel={selectedRequest ? requestLabel(selectedRequest) : undefined}
|
||||
selectedSublabel={selectedRequest?.status}
|
||||
disabled={!requestType}
|
||||
placeholder={texts.manageRequests.requestPlaceholder}
|
||||
searchPlaceholder={texts.manageRequests.requestSearchPlaceholder}
|
||||
search={searchRequests}
|
||||
getValue={r => String(r.id)}
|
||||
getLabel={r => `#${r.id} ${r.title}`}
|
||||
getLabel={requestLabel}
|
||||
getSublabel={r => r.status}
|
||||
onChange={(_, item) => selectRequest(item)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import { RequestListPartition } from '../models';
|
||||
import { ApprovalRouteApi } from '../services/api';
|
||||
import { AsyncList, useAsyncList } from './useAsyncList';
|
||||
|
||||
/**
|
||||
* Серия партиций (архив + текущая) ОДНОГО типа заявки — нужна экрану
|
||||
* редактирования заявок: заявка может лежать в любой из них, не только в
|
||||
* текущей (см. requestManageService.ts).
|
||||
*/
|
||||
export function useRequestTypeListPartitions(
|
||||
api: ApprovalRouteApi,
|
||||
requestTypeId: number | undefined
|
||||
): AsyncList<RequestListPartition> {
|
||||
const load = React.useCallback(
|
||||
() => (requestTypeId ? api.requestTypes.lists(requestTypeId) : Promise.resolve([])),
|
||||
[api, requestTypeId]
|
||||
);
|
||||
return useAsyncList(load);
|
||||
}
|
||||
@@ -78,6 +78,21 @@ export interface ManagedRequestSummary {
|
||||
status: string;
|
||||
created: string; // ISO
|
||||
authorName: string;
|
||||
/**
|
||||
* Уникальный код заявки (колонка RequestUniqueCode списка данных) — то, что
|
||||
* реально видит заявитель, в отличие от служебного числового Id списка
|
||||
* SharePoint. null — у заявки его нет (старая заявка, колонка не заполнена)
|
||||
* или список данных этой колонки не заводит; тогда в UI показывается id.
|
||||
*/
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит SharePoint: колонки может не быть
|
||||
uniqueCode: string | null;
|
||||
/**
|
||||
* Список-партиция, в котором физически лежит эта заявка. У разных партиций
|
||||
* независимая нумерация Id (см. ТЗ по архивации), поэтому один и тот же
|
||||
* requestId в разных партициях — это РАЗНЫЕ заявки: без listId открыть
|
||||
* заявку однозначно нельзя.
|
||||
*/
|
||||
listId: string;
|
||||
}
|
||||
|
||||
export interface ManagedRequestPage {
|
||||
|
||||
@@ -19,7 +19,12 @@ describe('parseRoute', () => {
|
||||
it('manageRequests с aamtype+aamreq — даёт заполненный manage', () => {
|
||||
const route = parseRoute('?aatab=manageRequests&aamtype=5&aamreq=42');
|
||||
expect(route.tab).toBe('manageRequests');
|
||||
expect(route.manage).toEqual({ requestTypeId: '5', requestId: '42' });
|
||||
expect(route.manage).toEqual({ requestTypeId: '5', requestId: '42', listId: '' });
|
||||
});
|
||||
|
||||
it('manageRequests с aamlist — партиция заявки читается в manage.listId', () => {
|
||||
const route = parseRoute('?aatab=manageRequests&aamtype=5&aamreq=42&aamlist=%2Fsites%2Fx%2FLists%2FForm2');
|
||||
expect(route.manage).toEqual({ requestTypeId: '5', requestId: '42', listId: '/sites/x/Lists/Form2' });
|
||||
});
|
||||
|
||||
it('manageRequests без aamreq — manage не собирается (id заявки обязателен)', () => {
|
||||
@@ -78,14 +83,14 @@ describe('parseRoute', () => {
|
||||
|
||||
describe('navigate', () => {
|
||||
it('после navigate() параметры читаются обратно тем же parseRoute (round-trip)', () => {
|
||||
navigate({ tab: 'manageRequests', manage: { requestTypeId: '3', requestId: '10' } });
|
||||
navigate({ tab: 'manageRequests', manage: { requestTypeId: '3', requestId: '10', listId: '/sites/x/Lists/Form' } });
|
||||
const roundTripped = parseRoute(window.location.search);
|
||||
expect(roundTripped.tab).toBe('manageRequests');
|
||||
expect(roundTripped.manage).toEqual({ requestTypeId: '3', requestId: '10' });
|
||||
expect(roundTripped.manage).toEqual({ requestTypeId: '3', requestId: '10', listId: '/sites/x/Lists/Form' });
|
||||
});
|
||||
|
||||
it('navigate на dashboard полностью убирает параметры вкладки из адреса', () => {
|
||||
navigate({ tab: 'manageRequests', manage: { requestTypeId: '3', requestId: '10' } });
|
||||
navigate({ tab: 'manageRequests', manage: { requestTypeId: '3', requestId: '10', listId: '/sites/x/Lists/Form' } });
|
||||
navigate({ tab: 'dashboard' });
|
||||
expect(window.location.search).toBe('');
|
||||
});
|
||||
|
||||
@@ -36,6 +36,14 @@ export interface IResolveRouteParams {
|
||||
export interface IManageRequestRouteParams {
|
||||
requestTypeId: string;
|
||||
requestId: string;
|
||||
/**
|
||||
* Партиция (список-архив данных), где физически лежит заявка — у разных
|
||||
* партиций независимая нумерация Id (см. ТЗ по архивации), поэтому без
|
||||
* listId requestId неоднозначен. Пусто — старая ссылка (до архивации) или
|
||||
* ссылка ещё не резолвнута; тогда экран считает, что заявка в текущей
|
||||
* партиции (см. ManageRequestsScreen).
|
||||
*/
|
||||
listId: string;
|
||||
}
|
||||
|
||||
export interface IAdminRoute {
|
||||
@@ -64,7 +72,7 @@ const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'ma
|
||||
const PARAM_KEYS = [
|
||||
'aatab', 'aaroute', 'aarfilter',
|
||||
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
|
||||
'aamtype', 'aamreq'
|
||||
'aamtype', 'aamreq', 'aamlist'
|
||||
];
|
||||
|
||||
// raw — результат URLSearchParams.get() (DOM API, возвращает string | null).
|
||||
@@ -114,7 +122,7 @@ export function parseRoute(search: string = window.location.search): IAdminRoute
|
||||
const manageReqId = params.get('aamreq');
|
||||
const manage: IManageRequestRouteParams | undefined =
|
||||
tab === 'manageRequests' && manageTypeId && manageReqId
|
||||
? { requestTypeId: manageTypeId, requestId: manageReqId }
|
||||
? { requestTypeId: manageTypeId, requestId: manageReqId, listId: params.get('aamlist') || '' }
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
@@ -152,6 +160,7 @@ function buildQuery(route: IAdminRoute): string {
|
||||
if (route.tab === 'manageRequests' && route.manage && route.manage.requestTypeId && route.manage.requestId) {
|
||||
params.set('aamtype', route.manage.requestTypeId);
|
||||
params.set('aamreq', route.manage.requestId);
|
||||
if (route.manage.listId) params.set('aamlist', route.manage.listId);
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
PatchRequestTypeInput,
|
||||
PatchTemplateRouteInput,
|
||||
PatchTemplateRouteStepInput,
|
||||
RequestListPartition,
|
||||
RequestType,
|
||||
RequestTypeListSeries,
|
||||
ResolveTemplateRouteQuery,
|
||||
@@ -47,7 +48,13 @@ export class ApprovalRouteApi {
|
||||
// портала, которому нужен весь список адресов разом, а не по одному запросу
|
||||
// на тип.
|
||||
listsAll: (): Promise<RequestTypeListSeries[]> =>
|
||||
this.http.get<RequestTypeListSeries[]>('/api/request-types/lists')
|
||||
this.http.get<RequestTypeListSeries[]>('/api/request-types/lists'),
|
||||
|
||||
// Вся серия партиций ОДНОГО типа заявки (архив + текущая) — нужна экрану
|
||||
// редактирования заявок: заявка может лежать в любой из них, не только в
|
||||
// текущей (см. requestManageService.ts).
|
||||
lists: (requestTypeId: number): Promise<RequestListPartition[]> =>
|
||||
this.http.get<RequestListPartition[]>(`/api/request-types/${requestTypeId}/lists`)
|
||||
};
|
||||
|
||||
public readonly templateRoutes = {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user