feat: столбец «Исполнитель» в таблицах заявок
Показывает, у кого заявка сейчас: ФИО и почта согласующего или исполнителя текущего шага (несколько — параллельный шаг, при делегировании — делегат), при доработке — инициатор. Есть в «Инициированных», «Согласованных», «Выполненных мной» и «Все заявки»; в очередях и «Доработке» держатель всегда сам пользователь. Дочитывается только для видимой страницы, одним запросом на список согласования. Группировка по партициям вынесена в approvalPartitionsFor и общая с комментарием к решению. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,7 @@ import {
|
||||
} from '../../shared/approval';
|
||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||
import { DATA_SITE_URL } from '../../shared/config';
|
||||
import { IMyRequest } from './models/IMyRequest';
|
||||
import { IAssignee, IMyRequest } from './models/IMyRequest';
|
||||
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
||||
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
|
||||
import { perfScope, traced } from '../../shared/perfTrace';
|
||||
@@ -1581,9 +1581,10 @@ export async function getAllOwnRequests(
|
||||
return Array.from(byKey.values()).sort((a, b) => b.created.localeCompare(a.created));
|
||||
}
|
||||
|
||||
// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев:
|
||||
// на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный.
|
||||
const DECISION_COMMENT_ROW_LIMIT = 2000;
|
||||
// Сколько строк маршрута максимум забираем за раз при дочитывании по странице
|
||||
// (комментарий к решению, текущий исполнитель): на странице ≤20 заявок, у
|
||||
// каждой обычно единицы шагов — запас многократный.
|
||||
const PAGE_ROUTE_ROW_LIMIT = 2000;
|
||||
|
||||
/**
|
||||
* Проставляет decisionComment — комментарий согласующего к его решению по
|
||||
@@ -1613,19 +1614,34 @@ export async function hydrateDecisionComments(
|
||||
);
|
||||
}
|
||||
|
||||
async function hydrateDecisionCommentsInner(
|
||||
/** Заявки страницы из одной партиции + список согласования этой партиции. */
|
||||
interface IApprovalPartitionBatch {
|
||||
approvalList: IList;
|
||||
target: IListTarget;
|
||||
typeName: string;
|
||||
rows: IMyRequest[];
|
||||
/** OData-условие «TicketId — одна из заявок пачки», уже в скобках. */
|
||||
ticketFilter: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Разложить заявки по партициям и найти список согласования каждой — общая
|
||||
* часть всех дочитываний из строк маршрута (комментарий к решению, текущий
|
||||
* исполнитель).
|
||||
*
|
||||
* По ПАРТИЦИИ, а не по типу: строки маршрута заявки лежат в списке
|
||||
* согласования той же партиции, что и сама заявка. Для архивной заявки
|
||||
* «список согласования её типа» — это чужой список текущей партиции, где по
|
||||
* её TicketId нашлись бы либо ничего, либо строки другой заявки.
|
||||
*/
|
||||
async function approvalPartitionsFor(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
rows: IMyRequest[],
|
||||
decisionStatus: string
|
||||
): Promise<void> {
|
||||
rows: IMyRequest[]
|
||||
): Promise<IApprovalPartitionBatch[]> {
|
||||
const typeById = new Map<number, IRequestType>();
|
||||
types.forEach((t) => typeById.set(t.id, t));
|
||||
|
||||
// По ПАРТИЦИИ, а не по типу: строки маршрута заявки лежат в списке
|
||||
// согласования той же партиции, что и сама заявка. Для архивной заявки
|
||||
// «список согласования её типа» — это чужой список текущей партиции, где по
|
||||
// её TicketId нашлись бы либо ничего, либо комментарий к другой заявке.
|
||||
const rowsByPartition = new Map<string, { target: IListTarget; typeName: string; rows: IMyRequest[] }>();
|
||||
rows.forEach((r) => {
|
||||
const key = r.listId.toLowerCase();
|
||||
@@ -1650,23 +1666,39 @@ async function hydrateDecisionCommentsInner(
|
||||
}))
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
Array.from(rowsByPartition.values()).map(async ({ target, typeName, rows: typeRows }) => {
|
||||
try {
|
||||
const lookup = located.get(approvalListKey(target.siteUrl, target.listId));
|
||||
if (!lookup || lookup.kind !== 'ok') return;
|
||||
const approvalList = spForSite(target.siteUrl, context).web.lists.getByTitle(
|
||||
lookup.value.approvalListTitle
|
||||
);
|
||||
const batches: IApprovalPartitionBatch[] = [];
|
||||
rowsByPartition.forEach(({ target, typeName, rows: partitionRows }) => {
|
||||
const lookup = located.get(approvalListKey(target.siteUrl, target.listId));
|
||||
if (!lookup || lookup.kind !== 'ok') return;
|
||||
batches.push({
|
||||
approvalList: spForSite(target.siteUrl, context).web.lists.getByTitle(lookup.value.approvalListTitle),
|
||||
target,
|
||||
typeName,
|
||||
rows: partitionRows,
|
||||
// Скобки обязательны: у OData `and` связывает сильнее `or`, без них
|
||||
// «(…) and ApprovalStatus eq …» у вызывающего разобралось бы как
|
||||
// «TicketId eq 1 or (TicketId eq 2 and ApprovalStatus eq …)».
|
||||
ticketFilter: `(${partitionRows.map((r) => `TicketId eq ${r.id}`).join(' or ')})`,
|
||||
});
|
||||
});
|
||||
return batches;
|
||||
}
|
||||
|
||||
// TicketId (OR-цепочка) в скобках обязателен: у OData `and` связывает
|
||||
// сильнее `or», без скобок фильтр разобрался бы как
|
||||
// «TicketId eq 1 or (TicketId eq 2 and ApprovalStatus eq …)».
|
||||
const ticketFilter = typeRows.map((r) => `TicketId eq ${r.id}`).join(' or ');
|
||||
async function hydrateDecisionCommentsInner(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
rows: IMyRequest[],
|
||||
decisionStatus: string
|
||||
): Promise<void> {
|
||||
const batches = await approvalPartitionsFor(context, types, rows);
|
||||
|
||||
await Promise.all(
|
||||
batches.map(async ({ approvalList, target, typeName, rows: typeRows, ticketFilter }) => {
|
||||
try {
|
||||
const stepRows = (await approvalList.items
|
||||
.filter(`(${ticketFilter}) and ApprovalStatus eq '${decisionStatus}'`)
|
||||
.filter(`${ticketFilter} and ApprovalStatus eq '${decisionStatus}'`)
|
||||
.select('TicketId', 'Comment')
|
||||
.top(DECISION_COMMENT_ROW_LIMIT)()) as Array<Record<string, unknown>>;
|
||||
.top(PAGE_ROUTE_ROW_LIMIT)()) as Array<Record<string, unknown>>;
|
||||
|
||||
const commentByTicket = new Map<number, string>();
|
||||
stepRows.forEach((r) => {
|
||||
@@ -1694,6 +1726,88 @@ async function hydrateDecisionCommentsInner(
|
||||
);
|
||||
}
|
||||
|
||||
// Статусы ЗАЯВКИ, при которых она у кого-то в работе по маршруту: тогда
|
||||
// держатель — человек из строки маршрута с текущей очередью.
|
||||
const IN_ROUTE_REQUEST_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.execution];
|
||||
|
||||
// Статусы СТРОКИ маршрута «очередь дошла до этого человека» (см. createSteps/decide).
|
||||
const CURRENT_TURN_STEP_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution];
|
||||
|
||||
// Внутреннее имя поля «Согласующий» строки маршрута (см. FIELDS.approver в
|
||||
// shared/approval/approvalList.service.ts). При делегировании в нём уже
|
||||
// делегат, поэтому отдельно смотреть DelegatedBy не нужно.
|
||||
const STEP_APPROVER_FIELD = 'TansuSiteApprover';
|
||||
|
||||
/**
|
||||
* Проставляет currentAssignees — кто сейчас держит заявку:
|
||||
* - на согласовании / выполнении — люди из строк маршрута с текущей очередью
|
||||
* (несколько — параллельный шаг);
|
||||
* - возвращена на доработку — инициатор;
|
||||
* - иначе (черновик, итоговые статусы) — никого.
|
||||
*
|
||||
* Только для видимой страницы, как и hydrateDecisionComments: строки маршрута
|
||||
* по сотням заявок сразу не запросить (предел длины URL у $filter). Мутирует
|
||||
* переданные строки.
|
||||
*/
|
||||
export async function hydrateCurrentAssignees(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
rows: IMyRequest[]
|
||||
): Promise<void> {
|
||||
rows.forEach((r) => {
|
||||
const isReturned = r.status === REQUEST_STATUS.returned && (r.authorName || r.authorEmail);
|
||||
r.currentAssignees = isReturned ? [{ name: r.authorName, email: r.authorEmail }] : [];
|
||||
});
|
||||
|
||||
const inRoute = rows.filter((r) => IN_ROUTE_REQUEST_STATUSES.indexOf(r.status) >= 0);
|
||||
if (inRoute.length === 0) return;
|
||||
return traced('текущий исполнитель (REST)', () => hydrateCurrentAssigneesInner(context, types, inRoute));
|
||||
}
|
||||
|
||||
async function hydrateCurrentAssigneesInner(
|
||||
context: WebPartContext,
|
||||
types: IRequestType[],
|
||||
rows: IMyRequest[]
|
||||
): Promise<void> {
|
||||
const batches = await approvalPartitionsFor(context, types, rows);
|
||||
const statusFilter = CURRENT_TURN_STEP_STATUSES.map((s) => `ApprovalStatus eq '${s}'`).join(' or ');
|
||||
|
||||
await Promise.all(
|
||||
batches.map(async ({ approvalList, target, typeName, rows: partitionRows, ticketFilter }) => {
|
||||
try {
|
||||
const stepRows = (await approvalList.items
|
||||
.filter(`${ticketFilter} and (${statusFilter})`)
|
||||
.select('TicketId', `${STEP_APPROVER_FIELD}/Title`, `${STEP_APPROVER_FIELD}/EMail`)
|
||||
.expand(STEP_APPROVER_FIELD)
|
||||
.top(PAGE_ROUTE_ROW_LIMIT)()) as Array<Record<string, unknown>>;
|
||||
|
||||
const assigneesByTicket = new Map<number, IAssignee[]>();
|
||||
stepRows.forEach((r) => {
|
||||
const ticketId = r.TicketId as number | undefined;
|
||||
const user = r[STEP_APPROVER_FIELD] as { Title?: string; EMail?: string } | undefined;
|
||||
const assignee: IAssignee = { name: (user?.Title || '').trim(), email: (user?.EMail || '').trim() };
|
||||
if (!ticketId || (!assignee.name && !assignee.email)) return;
|
||||
const assignees = assigneesByTicket.get(ticketId) ?? [];
|
||||
// Один человек может стоять в двух строках одного шага (напр. сам и
|
||||
// как делегат) — в ячейке он нужен один раз.
|
||||
const isDuplicate = assignees.some((a) => a.name === assignee.name && a.email === assignee.email);
|
||||
if (!isDuplicate) assignees.push(assignee);
|
||||
assigneesByTicket.set(ticketId, assignees);
|
||||
});
|
||||
|
||||
partitionRows.forEach((r) => {
|
||||
r.currentAssignees = assigneesByTicket.get(r.id) ?? [];
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[Текущие заявки] «${typeName}» (${target.listId}): не удалось прочитать текущего исполнителя:`,
|
||||
(e as Error).message
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// «На согласовании»: список заявок уже отобран по строкам согласования
|
||||
// (getPendingApprovalIndex — там же определяется, чья сейчас очередь,
|
||||
// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search
|
||||
|
||||
Reference in New Issue
Block a user