6 Commits

Author SHA1 Message Date
Тимур Городецкий 79d2a59229 feat: время создания в столбце «Дата» таблиц заявок
Tests / frontend-test (push) Successful in 2m8s
По одной дате не различить несколько заявок, поданных в один день, —
теперь в столбце дата и время создания.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 16:59:02 +05:00
Тимур Городецкий 9cc5d52bab feat: сортировка «Всех заявок» на рабочее место по дате выхода
По дате выхода удобнее планировать подготовку рабочих мест. Даты для
сортировки дочитываются для всего списка пачками (предел длины URL),
заявки без даты — всегда в конце.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 16:42:20 +05:00
Тимур Городецкий ea07e8d5ab feat: столбец «Исполнено» в таблицах заявок — кто и когда выполнил
Столбец «Исполнитель» показывает только текущего держателя, и было не видно,
кто из исполнителей уже отметил «Выполнено». Теперь видно ФИО и время
каждой отметки; столбец появляется, только если на странице есть такие.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 16:42:07 +05:00
Тимур Городецкий 290598c357 fix: делегат на параллельном шаге получает заявку сразу
Очередь не двигалась, пока на шаге был кто-то активный, поэтому делегат
участника параллельного шага ждал решения второго участника. Текущий шаг
теперь — наименьший order среди нерешённых строк.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 16:07:59 +05:00
Тимур Городецкий 474a663da6 fix: оповещения параллельного шага — всем участникам и без дублей
Письмо уходило только первому из параллельных согласующих, а после решения
одного из них остальные получали повторное. Теперь шаг оповещается целиком
при старте и не оповещается снова, если кто-то из его участников уже решил.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 16:07:59 +05:00
Тимур Городецкий 8fc9776895 fix: тесты фильтров таблиц падали в Jest
filtersStorage импортировал статусы из барреля shared/approval, а тот тянет
@microsoft/sp-http, который в Jest не грузится. Импорт — напрямую из
models/statuses, тип IRequestType — через import type.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 15:27:49 +05:00
17 changed files with 450 additions and 49 deletions
@@ -14,6 +14,7 @@ import {
getOwnRequestsByStatus, getOwnRequestsByStatus,
hydrateCurrentAssignees, hydrateCurrentAssignees,
hydrateDecisionComments, hydrateDecisionComments,
hydrateExecutedSteps,
hydratePendingAuthors, hydratePendingAuthors,
searchOwnRequests, searchOwnRequests,
} from './myRequests.service'; } from './myRequests.service';
@@ -37,6 +38,7 @@ import { RequestHistoryDialog } from './RequestHistoryDialog';
import { SortableHeader } from './SortableHeader'; import { SortableHeader } from './SortableHeader';
import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters'; import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters';
import { useTableRefinement } from './useTableRefinement'; import { useTableRefinement } from './useTableRefinement';
import { formatDateTime } from '../../shared/ui/FieldChanges/formatDateTime';
import styles from './RequestsTableView.module.scss'; import styles from './RequestsTableView.module.scss';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@@ -139,6 +141,8 @@ interface ITableColumns {
status: boolean; status: boolean;
/** Колонка «Исполнитель» — у кого заявка сейчас (см. showsAssignee). */ /** Колонка «Исполнитель» — у кого заявка сейчас (см. showsAssignee). */
assignee: boolean; assignee: boolean;
/** Колонка «Исполнено» — кто из исполнителей уже выполнил (см. showsExecuted). */
executed: boolean;
/** Колонка «Дата выхода» — только «Все заявки» по организации рабочего места. */ /** Колонка «Дата выхода» — только «Все заявки» по организации рабочего места. */
startDate?: boolean; startDate?: boolean;
/** Заголовок колонки с комментарием; undefined — колонки нет. */ /** Заголовок колонки с комментарием; undefined — колонки нет. */
@@ -163,6 +167,17 @@ function showsAssignee(view: ListView, statusFilter: string): boolean {
return true; return true;
} }
// «Исполнено» — в тех же разделах, что и «Исполнитель»: там заявку смотрят со
// стороны, и важно, как далеко продвинулось исполнение. На «Инициированных» с
// фильтром по статусу — только при статусах, где выполнившие уже бывают.
const STATUSES_WITH_EXECUTED: string[] = [REQUEST_STATUS.execution, REQUEST_STATUS.closed];
function showsExecuted(view: ListView, statusFilter: string): boolean {
if (ASSIGNEE_VIEWS.indexOf(view) < 0) return false;
if (view === 'own:created' && statusFilter) return STATUSES_WITH_EXECUTED.indexOf(statusFilter) >= 0;
return true;
}
/** Дата выхода сотрудника есть только у заявки на организацию рабочего места, и показываем её во «Все заявки». */ /** Дата выхода сотрудника есть только у заявки на организацию рабочего места, и показываем её во «Все заявки». */
function showsStartDate(view: ListView, type: IRequestType | undefined): boolean { function showsStartDate(view: ListView, type: IRequestType | undefined): boolean {
return view === 'all' && type?.pageUrl === WORKPLACE_SETUP_FORM_ID; return view === 'all' && type?.pageUrl === WORKPLACE_SETUP_FORM_ID;
@@ -170,6 +185,7 @@ function showsStartDate(view: ListView, type: IRequestType | undefined): boolean
function columnsFor(view: ListView, statusFilter: string, startDate = false): ITableColumns { function columnsFor(view: ListView, statusFilter: string, startDate = false): ITableColumns {
const assignee = showsAssignee(view, statusFilter); const assignee = showsAssignee(view, statusFilter);
const executed = showsExecuted(view, statusFilter);
const decisionStatus = decisionStatusFor(view, statusFilter); const decisionStatus = decisionStatusFor(view, statusFilter);
const commentLabel = const commentLabel =
decisionStatus === REQUEST_STATUS.rejected decisionStatus === REQUEST_STATUS.rejected
@@ -178,15 +194,15 @@ function columnsFor(view: ListView, statusFilter: string, startDate = false): IT
? 'Причина возврата' ? 'Причина возврата'
: undefined; : undefined;
if (view === 'approvals' || view === 'execution') return { author: true, status: false, assignee }; if (view === 'approvals' || view === 'execution') return { author: true, status: false, assignee, executed };
if (view === 'rework') return { author: false, status: false, assignee, commentLabel }; if (view === 'rework') return { author: false, status: false, assignee, executed, commentLabel };
if (view === 'own:created') return { author: false, status: !statusFilter, assignee, commentLabel }; if (view === 'own:created') return { author: false, status: !statusFilter, assignee, executed, commentLabel };
// 'all' — заявки всех авторов вперемешку, поэтому нужны и автор, и статус. // 'all' — заявки всех авторов вперемешку, поэтому нужны и автор, и статус.
if (view === 'all') return { author: true, status: true, assignee, startDate }; if (view === 'all') return { author: true, status: true, assignee, executed, startDate };
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он // История решений: заявка чужая, поэтому нужен автор. Статус — только если он
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено», // не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту). // а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
return { author: true, status: !decisionStatus, assignee, commentLabel }; return { author: true, status: !decisionStatus, assignee, executed, commentLabel };
} }
export interface IRequestsTableViewProps { export interface IRequestsTableViewProps {
@@ -272,12 +288,19 @@ const Table: React.FC<{
{ key: 'code', title: sortable('Номер', 'code'), width: '130px', render: (r) => r.code || `#${r.id}` }, { key: 'code', title: sortable('Номер', 'code'), width: '130px', render: (r) => r.code || `#${r.id}` },
{ key: 'type', title: sortable('Тип', 'type'), width: '200px', render: (r) => r.requestTypeName }, { key: 'type', title: sortable('Тип', 'type'), width: '200px', render: (r) => r.requestTypeName },
{ key: 'title', title: 'Название', render: (r) => r.title || r.code || `#${r.id}` }, { key: 'title', title: 'Название', render: (r) => r.title || r.code || `#${r.id}` },
{ key: 'date', title: sortable('Дата', 'date'), width: '110px', render: (r) => formatDate(r.created) } // Дата создания со временем: по одной дате не различить несколько заявок,
// поданных в один день. Ширина — под «дд.мм.гггг чч:мм» в одну строку.
{
key: 'date',
title: sortable('Дата', 'date'),
width: '150px',
render: (r) => (r.created ? formatDateTime(r.created) : '')
}
]; ];
if (cols.startDate) { if (cols.startDate) {
columns.push({ columns.push({
key: 'startDate', key: 'startDate',
title: 'Дата выхода', title: sortable('Дата выхода', 'startDate'),
width: '120px', width: '120px',
render: (r) => formatDate(r.startDate ?? '') || '—' render: (r) => formatDate(r.startDate ?? '') || '—'
}); });
@@ -314,6 +337,25 @@ const Table: React.FC<{
: '—' : '—'
}); });
} }
// Только если на странице есть хоть одна выполненная часть: у типов заявок
// без фазы исполнения (и на страницах, где до неё ещё не дошло) колонка
// вышла бы из одних прочерков.
if (cols.executed && rows.some((r) => (r.executedSteps ?? []).length > 0)) {
columns.push({
key: 'executed',
title: 'Исполнено',
width: '280px',
// Каждый выполнивший — отдельной строкой: «ФИО · дата время».
render: (r) =>
r.executedSteps && r.executedSteps.length > 0
? r.executedSteps.map((s) => (
<div key={`${s.email}|${s.name}|${s.executedAt}`}>
{[s.name || s.email, s.executedAt ? formatDateTime(s.executedAt) : ''].filter(Boolean).join(' · ')}
</div>
))
: '—'
});
}
if (cols.commentLabel) { if (cols.commentLabel) {
columns.push({ columns.push({
key: 'comment', key: 'comment',
@@ -557,6 +599,13 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
const full = const full =
reuseFull && cachedFull && cachedFull.key === fullKey ? cachedFull.rows : await fetchAndRememberFull(); reuseFull && cachedFull && cachedFull.key === fullKey ? cachedFull.rows : await fetchAndRememberFull();
// Дату выхода обычно дочитываем только для видимой страницы, но сортировать
// по ней надо весь список — дочитываем для всех строк, у кого её ещё нет
// (полный список переиспользуется, повторно по сети не идём).
if (sort.key === 'startDate') {
await hydrateStartDates(context, full.filter((r) => r.startDate === undefined));
}
const refined = sortRows(filterRows(full, localFilters), sort); const refined = sortRows(filterRows(full, localFilters), sort);
const pageRows = refined.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); const pageRows = refined.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
await hydratePendingAuthors(context, types, pageRows); await hydratePendingAuthors(context, types, pageRows);
@@ -614,6 +663,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
await Promise.all([ await Promise.all([
decisionStatus ? hydrateDecisionComments(context, types, result.rows, decisionStatus) : undefined, decisionStatus ? hydrateDecisionComments(context, types, result.rows, decisionStatus) : undefined,
showsAssignee(view, statusFilter) ? hydrateCurrentAssignees(context, types, result.rows) : undefined, showsAssignee(view, statusFilter) ? hydrateCurrentAssignees(context, types, result.rows) : undefined,
showsExecuted(view, statusFilter) ? hydrateExecutedSteps(context, types, result.rows) : undefined,
showsStartDate(view, allRequestsType) ? hydrateStartDates(context, result.rows) : undefined, showsStartDate(view, allRequestsType) ? hydrateStartDates(context, result.rows) : undefined,
]); ]);
return result; return result;
@@ -1,6 +1,6 @@
import { activeFilterChips, formatDay } from './activeFilterChips'; import { activeFilterChips, formatDay } from './activeFilterChips';
import { EMPTY_FILTERS } from './requestsTableFilters'; import { EMPTY_FILTERS } from './requestsTableFilters';
import { IRequestType } from '../../shared/approval'; import type { IRequestType } from '../../shared/approval/models/IApprovalRouteApi';
const TYPES = [{ id: 1, name: 'Заявка на отпуск' }] as IRequestType[]; const TYPES = [{ id: 1, name: 'Заявка на отпуск' }] as IRequestType[];
@@ -1,4 +1,5 @@
import { IRequestType } from '../../shared/approval'; // Только тип и напрямую из models — см. filtersStorage.ts (баррель не грузится в Jest).
import type { IRequestType } from '../../shared/approval/models/IApprovalRouteApi';
import { EMPTY_FILTERS, ITableFilters } from './requestsTableFilters'; import { EMPTY_FILTERS, ITableFilters } from './requestsTableFilters';
/** Чип включённого фильтра: подпись + каким станет набор фильтров, если чип убрать. */ /** Чип включённого фильтра: подпись + каким станет набор фильтров, если чип убрать. */
@@ -1,4 +1,6 @@
import { REQUEST_STATUS_CHOICES } from '../../shared/approval'; // Напрямую из models, а не из барреля shared/approval: баррель тянет
// @microsoft/sp-http, который не грузится в Jest (тесты этого модуля падали).
import { REQUEST_STATUS_CHOICES } from '../../shared/approval/models/statuses';
import { DEFAULT_SORT, EMPTY_FILTERS, hasActiveFilters, isDefaultSort, ISortState, ITableFilters, SortKey } from './requestsTableFilters'; import { DEFAULT_SORT, EMPTY_FILTERS, hasActiveFilters, isDefaultSort, ISortState, ITableFilters, SortKey } from './requestsTableFilters';
/** /**
@@ -19,7 +21,7 @@ export interface IStoredRefinement {
/** Версия формата: поменяется структура — старые записи просто не прочитаются. */ /** Версия формата: поменяется структура — старые записи просто не прочитаются. */
const STORAGE_PREFIX = 'rp-table-filters:v1'; const STORAGE_PREFIX = 'rp-table-filters:v1';
const SORT_KEYS: SortKey[] = ['code', 'type', 'date', 'status']; const SORT_KEYS: SortKey[] = ['code', 'type', 'date', 'status', 'startDate'];
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/; const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
function storageKey(scope: string, tableKey: string): string { function storageKey(scope: string, tableKey: string): string {
@@ -4,6 +4,14 @@ export interface IAssignee {
email: string; email: string;
} }
/** Исполнитель, отметивший свой шаг «Выполнено» (колонка «Исполнено»). */
export interface IExecutedStep {
name: string;
email: string;
/** Когда отметил (ISO-строка, колонка Date строки маршрута). */
executedAt: string;
}
// Строка заявки пользователя для боковой панели «Мои заявки». // Строка заявки пользователя для боковой панели «Мои заявки».
// Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts). // Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts).
export interface IMyRequest { export interface IMyRequest {
@@ -76,6 +84,12 @@ export interface IMyRequest {
* hydrateCurrentAssignees). * hydrateCurrentAssignees).
*/ */
currentAssignees?: IAssignee[]; currentAssignees?: IAssignee[];
/**
* Исполнители, уже отметившие «Выполнено», по времени отметки (колонка
* «Исполнено»). Пустой массив — никто ещё не выполнил или у заявки нет фазы
* исполнения; undefined — не дочитывалось (см. hydrateExecutedSteps).
*/
executedSteps?: IExecutedStep[];
/** /**
* Дата выхода сотрудника на работу (колонка StartDate) — только у заявок на * Дата выхода сотрудника на работу (колонка StartDate) — только у заявок на
* организацию рабочего места, колонка «Дата выхода» во «Все заявки». Как и * организацию рабочего места, колонка «Дата выхода» во «Все заявки». Как и
@@ -24,7 +24,7 @@ import {
} from '../../shared/approval'; } from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation'; import { effectiveUserEmail } from '../../shared/impersonation';
import { DATA_SITE_URL } from '../../shared/config'; import { DATA_SITE_URL } from '../../shared/config';
import { IAssignee, IMyRequest } from './models/IMyRequest'; import { IAssignee, IExecutedStep, IMyRequest } from './models/IMyRequest';
import { parseAuthorOwsUser } from './parseAuthorOwsUser'; import { parseAuthorOwsUser } from './parseAuthorOwsUser';
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug'; import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace'; import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
@@ -1795,6 +1795,76 @@ async function hydrateCurrentAssigneesInner(
); );
} }
// Статусы ЗАЯВКИ, при которых кто-то из исполнителей уже мог отметить «Выполнено».
const WITH_EXECUTION_REQUEST_STATUSES: string[] = [REQUEST_STATUS.execution, REQUEST_STATUS.closed];
/**
* Проставляет executedSteps — кто из исполнителей уже отметил «Выполнено» и
* когда, по времени отметки. Статус строки «Выполнено» бывает только в фазе
* исполнения, поэтому фильтра по статусу достаточно.
*
* Только для видимой страницы, как и hydrateCurrentAssignees. Мутирует
* переданные строки.
*/
export async function hydrateExecutedSteps(
context: WebPartContext,
types: IRequestType[],
rows: IMyRequest[]
): Promise<void> {
rows.forEach((r) => {
r.executedSteps = [];
});
const withExecution = rows.filter((r) => WITH_EXECUTION_REQUEST_STATUSES.indexOf(r.status) >= 0);
if (withExecution.length === 0) return;
return traced('выполнившие исполнители (REST)', () => hydrateExecutedStepsInner(context, types, withExecution));
}
async function hydrateExecutedStepsInner(
context: WebPartContext,
types: IRequestType[],
rows: IMyRequest[]
): Promise<void> {
const batches = await approvalPartitionsFor(context, types, rows);
await Promise.all(
batches.map(async ({ approvalList, target, typeName, rows: partitionRows, ticketFilter }) => {
try {
const stepRows = (await approvalList.items
.filter(`${ticketFilter} and ApprovalStatus eq '${REQUEST_STATUS.executed}'`)
.select('TicketId', 'Date', `${STEP_APPROVER_FIELD}/Title`, `${STEP_APPROVER_FIELD}/EMail`)
.expand(STEP_APPROVER_FIELD)
.orderBy('Date', true)
.top(PAGE_ROUTE_ROW_LIMIT)()) as Array<Record<string, unknown>>;
const stepsByTicket = new Map<number, IExecutedStep[]>();
stepRows.forEach((r) => {
const ticketId = r.TicketId as number | undefined;
const user = r[STEP_APPROVER_FIELD] as { Title?: string; EMail?: string } | undefined;
const step: IExecutedStep = {
name: (user?.Title || '').trim(),
email: (user?.EMail || '').trim(),
executedAt: (r.Date as string) || '',
};
if (!ticketId || (!step.name && !step.email)) return;
const steps = stepsByTicket.get(ticketId) ?? [];
steps.push(step);
stepsByTicket.set(ticketId, steps);
});
partitionRows.forEach((r) => {
r.executedSteps = stepsByTicket.get(r.id) ?? [];
});
} catch (e) {
console.error(
`[Текущие заявки] «${typeName}» (${target.listId}): не удалось прочитать выполнивших исполнителей:`,
(e as Error).message
);
}
})
);
}
// «На согласовании»: список заявок уже отобран по строкам согласования // «На согласовании»: список заявок уже отобран по строкам согласования
// (getPendingApprovalIndex — там же определяется, чья сейчас очередь, // (getPendingApprovalIndex — там же определяется, чья сейчас очередь,
// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search // обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search
@@ -130,6 +130,15 @@ describe('pageSignature', () => {
expect(pageSignature(before)).not.toBe(pageSignature(after)); expect(pageSignature(before)).not.toBe(pageSignature(after));
}); });
it('новый выполнивший меняет подпись — он показан колонкой «Исполнено»', () => {
// Один из параллельных исполнителей отметил «Выполнено»: статус заявки тот же («Выполнение»).
const before = page({ rows: [request({ executedSteps: [] })] });
const after = page({
rows: [request({ executedSteps: [{ name: 'Петров Пётр', email: 'petrov@tnsu.kz', executedAt: '2026-10-08T05:00:00Z' }] })],
});
expect(pageSignature(before)).not.toBe(pageSignature(after));
});
it('пустая страница имеет стабильную подпись', () => { it('пустая страница имеет стабильную подпись', () => {
expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 })); expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 }));
}); });
@@ -55,6 +55,7 @@ export function pageSignature(page: IPage): string {
r.decisionComment ?? '', r.decisionComment ?? '',
r.startDate ?? '', r.startDate ?? '',
(r.currentAssignees ?? []).map((a) => `${a.name}/${a.email}`).join(','), (r.currentAssignees ?? []).map((a) => `${a.name}/${a.email}`).join(','),
(r.executedSteps ?? []).map((s) => `${s.name}/${s.email}/${s.executedAt}`).join(','),
].join('~') ].join('~')
) )
.join(';')}`; .join(';')}`;
@@ -118,6 +118,16 @@ describe('sortRows', () => {
expect(codes(sortRows(rows, { key: 'type', direction: 'asc' }))).toEqual(['b', 'a']); expect(codes(sortRows(rows, { key: 'type', direction: 'asc' }))).toEqual(['b', 'a']);
}); });
it('по дате выхода — в обе стороны, заявки без даты всегда в конце', () => {
const rows = [
request({ code: 'none', startDate: '' }),
request({ code: 'oct', startDate: '2026-10-06T00:00:00Z' }),
request({ code: 'sep', startDate: '2026-09-27T00:00:00Z' }),
];
expect(codes(sortRows(rows, { key: 'startDate', direction: 'asc' }))).toEqual(['sep', 'oct', 'none']);
expect(codes(sortRows(rows, { key: 'startDate', direction: 'desc' }))).toEqual(['oct', 'sep', 'none']);
});
it('не мутирует исходный массив', () => { it('не мутирует исходный массив', () => {
const rows = [request({ code: '4-2609-2' }), request({ code: '4-2609-1' })]; const rows = [request({ code: '4-2609-2' }), request({ code: '4-2609-1' })];
sortRows(rows, { key: 'code', direction: 'asc' }); sortRows(rows, { key: 'code', direction: 'asc' });
@@ -5,7 +5,7 @@ import { IMyRequest } from './models/IMyRequest';
// одной странице (иначе отсортировались бы 20 строк, а не все заявки). // одной странице (иначе отсортировались бы 20 строк, а не все заявки).
/** Колонки, по которым можно сортировать. */ /** Колонки, по которым можно сортировать. */
export type SortKey = 'code' | 'type' | 'date' | 'status'; export type SortKey = 'code' | 'type' | 'date' | 'status' | 'startDate';
export type SortDirection = 'asc' | 'desc'; export type SortDirection = 'asc' | 'desc';
export interface ISortState { export interface ISortState {
@@ -108,13 +108,23 @@ function compareBy(key: SortKey, a: IMyRequest, b: IMyRequest): number {
return a.status.localeCompare(b.status, 'ru'); return a.status.localeCompare(b.status, 'ru');
case 'date': case 'date':
return a.created.localeCompare(b.created); return a.created.localeCompare(b.created);
case 'startDate':
return (a.startDate ?? '').localeCompare(b.startDate ?? '');
} }
} }
/** Новый массив; при равенстве по выбранной колонке — свежие сверху. */ /**
* Новый массив; при равенстве по выбранной колонке — свежие сверху.
* Заявки без даты выхода — всегда в конце, в любом направлении: иначе при
* сортировке «по возрастанию» сверху оказались бы незаполненные.
*/
export function sortRows(rows: IMyRequest[], sort: ISortState): IMyRequest[] { export function sortRows(rows: IMyRequest[], sort: ISortState): IMyRequest[] {
const sign = sort.direction === 'asc' ? 1 : -1; const sign = sort.direction === 'asc' ? 1 : -1;
return [...rows].sort((a, b) => { return [...rows].sort((a, b) => {
if (sort.key === 'startDate') {
const emptyDiff = Number(!a.startDate) - Number(!b.startDate);
if (emptyDiff !== 0) return emptyDiff;
}
const primary = compareBy(sort.key, a, b) * sign; const primary = compareBy(sort.key, a, b) * sign;
return primary !== 0 ? primary : b.created.localeCompare(a.created); return primary !== 0 ? primary : b.created.localeCompare(a.created);
}); });
@@ -6,10 +6,17 @@ import { spForSite } from '../../shared/pnp/pnpjs';
import { traced } from '../../shared/perfTrace'; import { traced } from '../../shared/perfTrace';
import { IMyRequest } from './models/IMyRequest'; import { IMyRequest } from './models/IMyRequest';
// Сколько Id в одном $filter: при сортировке по дате выхода дочитывается весь
// список типа (сотни заявок), а «Id eq … or …» на сотни Id упирается в предел
// длины URL.
const IDS_PER_REQUEST = 50;
/** /**
* Проставляет startDate — дату выхода сотрудника (колонка StartDate списка * Проставляет startDate — дату выхода сотрудника (колонка StartDate списка
* данных). Только для видимой страницы; группировка по партиции строки, как в * данных). Обычно для видимой страницы, при сортировке по дате выхода — для
* hydrateAuthors: в другой партиции тот же Id — чужая заявка. Мутирует строки. * всего списка (см. fetchRefinedPage в RequestsTableView). Группировка по
* партиции строки, как в hydrateAuthors: в другой партиции тот же Id — чужая
* заявка. Мутирует строки.
*/ */
export async function hydrateStartDates(context: WebPartContext, rows: IMyRequest[]): Promise<void> { export async function hydrateStartDates(context: WebPartContext, rows: IMyRequest[]): Promise<void> {
if (rows.length === 0) return; if (rows.length === 0) return;
@@ -29,9 +36,16 @@ async function hydrateStartDatesInner(context: WebPartContext, rows: IMyRequest[
byPartition.set(r.listId.toLowerCase(), bucket); byPartition.set(r.listId.toLowerCase(), bucket);
}); });
const requests: Array<{ siteUrl: string; listId: string; typeName: string; ids: number[] }> = [];
byPartition.forEach((bucket) => {
for (let i = 0; i < bucket.ids.length; i += IDS_PER_REQUEST) {
requests.push({ ...bucket, ids: bucket.ids.slice(i, i + IDS_PER_REQUEST) });
}
});
const byKey = new Map<string, string>(); const byKey = new Map<string, string>();
await Promise.all( await Promise.all(
Array.from(byPartition.values()).map(async ({ siteUrl, listId, typeName, ids }) => { requests.map(async ({ siteUrl, listId, typeName, ids }) => {
try { try {
const sp = spForSite(siteUrl, context); const sp = spForSite(siteUrl, context);
const dataRows = (await sp.web const dataRows = (await sp.web
@@ -14,6 +14,7 @@ import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
import { userSearchVariants } from './userSearchVariants'; import { userSearchVariants } from './userSearchVariants';
import { planRoutePatch } from './routePatchPlan'; import { planRoutePatch } from './routePatchPlan';
import { IStepOrderChange } from './queuedStepMove'; import { IStepOrderChange } from './queuedStepMove';
import { pickRowsToActivate } from './queueActivation';
// Внутренние имена колонок списка согласования (создаются провижинингом). // Внутренние имена колонок списка согласования (создаются провижинингом).
const FIELDS = { const FIELDS = {
@@ -240,9 +241,9 @@ export class ApprovalListService {
await execute(); await execute();
} }
// Продвигает очередь: среди ещё не активных строк со статусом «Ждёт очереди» // Продвигает очередь: в текущей фазе активирует ждущие строки текущего шага —
// в текущей фазе активирует те, что делят наименьший StepOrder (обычно одна // наименьшего StepOrder среди нерешённых (обычно одна строка; несколько —
// строка; несколько — параллельный шаг). Фаза исполнения активируется, только // параллельный шаг; правило см. pickRowsToActivate). Фаза исполнения активируется, только
// когда фаза согласования пройдена целиком (без Отклонено/Возвращена) — иначе // когда фаза согласования пройдена целиком (без Отклонено/Возвращена) — иначе
// строки исполнения остаются «Ждёт очереди». Если фаза согласования уже // строки исполнения остаются «Ждёт очереди». Если фаза согласования уже
// остановлена решением, ничего не активирует. // остановлена решением, ничего не активирует.
@@ -260,13 +261,7 @@ export class ApprovalListService {
approvalRows.length === 0 || approvalRows.every((r) => r.status === REQUEST_STATUS.approved); approvalRows.length === 0 || approvalRows.every((r) => r.status === REQUEST_STATUS.approved);
const activate = async (phaseRows: IApprovalRow[], activeStatus: string): Promise<void> => { const activate = async (phaseRows: IApprovalRow[], activeStatus: string): Promise<void> => {
if (phaseRows.some((r) => r.status === activeStatus)) return; await Promise.all(pickRowsToActivate(phaseRows).map((id) => this.setActive(id, activeStatus)));
const queuedRows = phaseRows.filter((r) => r.status === REQUEST_STATUS.queued);
if (queuedRows.length === 0) return;
const minOrder = Math.min(...queuedRows.map((r) => r.order));
await Promise.all(
queuedRows.filter((r) => r.order === minOrder).map((r) => this.setActive(r.id, activeStatus))
);
}; };
if (!approvalDone) { if (!approvalDone) {
@@ -2,7 +2,8 @@ import { AadHttpClient } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { FLOW_RESOURCE, FLOW_NOTIFY_URL, getNotificationsEnabled, getNotifyOnlyTo } from '../config'; import { FLOW_RESOURCE, FLOW_NOTIFY_URL, getNotificationsEnabled, getNotifyOnlyTo } from '../config';
import { ApprovalListService } from './approvalList.service'; import { ApprovalListService } from './approvalList.service';
import { REQUEST_STATUS } from './models/statuses'; import { IApprovalRow } from './models/IApprovalRow';
import { pickRowsToNotify } from './notifyTargets';
// Данные формы для тела запроса к потоку (постоянны для формы). // Данные формы для тела запроса к потоку (постоянны для формы).
export interface INotifyTarget { export interface INotifyTarget {
@@ -54,12 +55,40 @@ async function isNotifyAllowed(approval: ApprovalListService, approverId: number
return false; return false;
} }
// Одна строка маршрута → один вызов потока. Ошибку глотаем здесь, по строке:
// сбой письма одному участнику параллельного шага не должен лишать письма остальных.
async function notifyRow(
context: WebPartContext,
approval: ApprovalListService,
requestId: number,
target: INotifyTarget,
row: IApprovalRow
): Promise<void> {
try {
if (!(await isNotifyAllowed(approval, row.approverId))) {
return;
}
await postNotify(context, {
RequestTypeId: target.requestTypeId,
RowID: row.id,
RequestID: requestId,
ListName: target.dataListName,
ApproveListName: target.approvalListName,
});
} catch {
// не критично — просто не отправили уведомление
}
}
// Находит АКТИВНЫЙ шаг маршрута (На согласовании / На исполнении) и уведомляет его. // Находит АКТИВНЫЙ шаг маршрута (На согласовании / На исполнении) и уведомляет его.
// Вызывать после отправки заявки и после каждого положительного решения. Поток по // Вызывать после отправки заявки и после каждого положительного решения. Поток по
// переданным полям сам находит согласующего/исполнителя и шлёт уведомление. // переданным полям сам находит согласующего/исполнителя и шлёт уведомление.
// Уведомление некритично — ошибки глотаем, чтобы не ронять основной сценарий. // Уведомление некритично — ошибки глотаем, чтобы не ронять основной сценарий.
// //
// rowId — уведомить именно эту активную строку, а не первую попавшуюся: при // Параллельный шаг (несколько активных строк с одним StepOrder) оповещается
// целиком — каждому участнику свой вызов потока (см. pickRowsToNotify).
//
// rowId — уведомить именно эту активную строку, а не весь шаг: при
// замене/удалении исполнителя на параллельном шаге активных строк несколько, и // замене/удалении исполнителя на параллельном шаге активных строк несколько, и
// письмо должно уйти новому человеку, а не тому, кто уже его получил. // письмо должно уйти новому человеку, а не тому, кто уже его получил.
export async function notifyActiveStep( export async function notifyActiveStep(
@@ -69,27 +98,13 @@ export async function notifyActiveStep(
target: INotifyTarget, target: INotifyTarget,
rowId?: number rowId?: number
): Promise<void> { ): Promise<void> {
let rows: IApprovalRow[];
try { try {
const rows = await approval.getForRequest(requestId); rows = await approval.getForRequest(requestId);
const active = rows.filter(
(r) =>
(r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution) &&
(rowId === undefined || r.id === rowId)
)[0];
if (!active) {
return;
}
if (!(await isNotifyAllowed(approval, active.approverId))) {
return;
}
await postNotify(context, {
RequestTypeId: target.requestTypeId,
RowID: active.id,
RequestID: requestId,
ListName: target.dataListName,
ApproveListName: target.approvalListName,
});
} catch { } catch {
// не критично — просто не отправили уведомление return; // не критично — просто не отправили уведомление
} }
await Promise.all(
pickRowsToNotify(rows, rowId).map((row) => notifyRow(context, approval, requestId, target, row))
);
} }
@@ -0,0 +1,68 @@
import { INotifyCandidate, pickRowsToNotify } from './notifyTargets';
import { REQUEST_STATUS } from './models/statuses';
function row(id: number, order: number, status: string = REQUEST_STATUS.queued): INotifyCandidate {
return { id, order, status };
}
const ids = (rows: INotifyCandidate[]): number[] => rows.map((r) => r.id);
describe('pickRowsToNotify', () => {
it('последовательный маршрут — только одна активная строка, как раньше', () => {
const rows = [row(1, 1, REQUEST_STATUS.approved), row(2, 2, REQUEST_STATUS.onApproval), row(3, 3)];
expect(ids(pickRowsToNotify(rows))).toEqual([2]);
});
it('параллельный шаг — все активные строки с общим StepOrder', () => {
const rows = [row(1, 1, REQUEST_STATUS.onApproval), row(2, 1, REQUEST_STATUS.onApproval), row(3, 2)];
expect(ids(pickRowsToNotify(rows))).toEqual([1, 2]);
});
it('параллельный шаг, один уже согласовал — оставшемуся повторно не шлём', () => {
const rows = [row(1, 1, REQUEST_STATUS.approved), row(2, 1, REQUEST_STATUS.onApproval), row(3, 2)];
expect(pickRowsToNotify(rows)).toEqual([]);
});
it('параллельный шаг, один вернул заявку — оставшемуся не шлём', () => {
const rows = [row(1, 1, REQUEST_STATUS.returned), row(2, 1, REQUEST_STATUS.onApproval)];
expect(pickRowsToNotify(rows)).toEqual([]);
});
it('параллельный шаг пройден — следующий шаг оповещается', () => {
const rows = [
row(1, 1, REQUEST_STATUS.approved),
row(2, 1, REQUEST_STATUS.approved),
row(3, 2, REQUEST_STATUS.onApproval),
];
expect(ids(pickRowsToNotify(rows))).toEqual([3]);
});
it('параллельный исполнитель выполнил — второму повторно не шлём', () => {
const rows = [row(1, 3, REQUEST_STATUS.executed), row(2, 3, REQUEST_STATUS.onExecution)];
expect(pickRowsToNotify(rows)).toEqual([]);
});
it('параллельные исполнители тоже оповещаются все', () => {
const rows = [
row(1, 1, REQUEST_STATUS.approved),
row(2, 2, REQUEST_STATUS.onExecution),
row(3, 2, REQUEST_STATUS.onExecution),
];
expect(ids(pickRowsToNotify(rows))).toEqual([2, 3]);
});
it('rowId задан — только эта строка, даже на параллельном шаге', () => {
const rows = [row(1, 1, REQUEST_STATUS.onExecution), row(2, 1, REQUEST_STATUS.onExecution)];
expect(ids(pickRowsToNotify(rows, 2))).toEqual([2]);
});
it('rowId неактивной строки — никого', () => {
const rows = [row(1, 1, REQUEST_STATUS.onApproval), row(2, 2)];
expect(pickRowsToNotify(rows, 2)).toEqual([]);
});
it('активных строк нет (отклонено/закрыто) — никого', () => {
const rows = [row(1, 1, REQUEST_STATUS.approved), row(2, 2, REQUEST_STATUS.rejected)];
expect(pickRowsToNotify(rows)).toEqual([]);
});
});
@@ -0,0 +1,47 @@
import { IApprovalRow } from './models/IApprovalRow';
import { REQUEST_STATUS } from './models/statuses';
export type INotifyCandidate = Pick<IApprovalRow, 'id' | 'status' | 'order'>;
// Статусы строки, по которой участник уже принял решение.
const DECIDED_STATUSES: string[] = [
REQUEST_STATUS.approved,
REQUEST_STATUS.rejected,
REQUEST_STATUS.returned,
REQUEST_STATUS.executed,
];
/**
* Какие строки маршрута оповестить. rows — в порядке StepOrder (как отдаёт
* ApprovalListService.getForRequest).
*
* - rowId задан — только эта строка, если она активна (замена/удаление
* исполнителя: письмо новому человеку, а не всем активным).
* - rowId не задан — текущий шаг целиком: первая активная строка и все
* активные с тем же StepOrder. При обычном последовательном шаге это одна
* строка; при параллельном — все его участники.
*
* Если кто-то из участников текущего шага уже принял решение, шаг начался
* раньше и письма по нему уже ушли — никого не оповещаем. Иначе после
* решения одного из параллельных согласующих остальные получали бы
* повторное письмо о той же заявке.
*/
export function pickRowsToNotify<T extends INotifyCandidate>(rows: T[], rowId?: number): T[] {
const active = rows.filter(
(r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution
);
if (rowId !== undefined) {
return active.filter((r) => r.id === rowId);
}
if (active.length === 0) {
return [];
}
const currentOrder = active[0].order;
const stepAlreadyStarted = rows.some(
(r) => r.order === currentOrder && DECIDED_STATUSES.indexOf(r.status) >= 0
);
if (stepAlreadyStarted) {
return [];
}
return active.filter((r) => r.order === currentOrder);
}
@@ -0,0 +1,59 @@
import { IQueueRow, pickRowsToActivate } from './queueActivation';
import { REQUEST_STATUS } from './models/statuses';
function row(id: number, order: number, status: string = REQUEST_STATUS.queued): IQueueRow {
return { id, order, status };
}
describe('pickRowsToActivate', () => {
it('отправка: активируется первый шаг', () => {
expect(pickRowsToActivate([row(1, 1), row(2, 2)])).toEqual([1]);
});
it('отправка: параллельный первый шаг активируется целиком', () => {
expect(pickRowsToActivate([row(1, 1), row(2, 1), row(3, 2)])).toEqual([1, 2]);
});
it('последовательный маршрут: пока шаг активен, очередь не двигается', () => {
expect(pickRowsToActivate([row(1, 1, REQUEST_STATUS.onApproval), row(2, 2)])).toEqual([]);
});
it('шаг согласован — активируется следующий', () => {
expect(pickRowsToActivate([row(1, 1, REQUEST_STATUS.approved), row(2, 2)])).toEqual([2]);
});
it('параллельный шаг: один согласовал, второй думает — следующий шаг ждёт', () => {
const rows = [row(1, 1, REQUEST_STATUS.approved), row(2, 1, REQUEST_STATUS.onApproval), row(3, 2)];
expect(pickRowsToActivate(rows)).toEqual([]);
});
it('параллельный шаг: делегат активируется сразу, не дожидаясь второго участника', () => {
// Аня (1) переназначила на Дину (0.5) и ушла в очередь, Борис (2) ещё думает.
const rows = [row(4, 0.5), row(1, 1), row(2, 1, REQUEST_STATUS.onApproval), row(3, 2)];
expect(pickRowsToActivate(rows)).toEqual([4]);
});
it('параллельный шаг: делегат согласовал — Аня возвращается в пару к Борису', () => {
const rows = [
row(4, 0.5, REQUEST_STATUS.approved),
row(1, 1),
row(2, 1, REQUEST_STATUS.onApproval),
row(3, 2),
];
expect(pickRowsToActivate(rows)).toEqual([1]);
});
it('последовательный маршрут с делегатом: сначала делегат, потом делегировавший', () => {
expect(pickRowsToActivate([row(4, 0.5), row(1, 1), row(3, 2)])).toEqual([4]);
expect(pickRowsToActivate([row(4, 0.5, REQUEST_STATUS.approved), row(1, 1), row(3, 2)])).toEqual([1]);
});
it('исполнение: тот же порядок', () => {
const rows = [row(1, 3, REQUEST_STATUS.executed), row(2, 4)];
expect(pickRowsToActivate(rows)).toEqual([2]);
});
it('всё решено — активировать нечего', () => {
expect(pickRowsToActivate([row(1, 1, REQUEST_STATUS.approved)])).toEqual([]);
});
});
@@ -0,0 +1,36 @@
import { IApprovalRow } from './models/IApprovalRow';
import { REQUEST_STATUS } from './models/statuses';
export type IQueueRow = Pick<IApprovalRow, 'id' | 'status' | 'order'>;
/**
* Какие строки ОДНОЙ фазы маршрута сделать активными. Чистая функция без
* SharePoint, выполняет ApprovalListService.activateNextQueued.
*
* Текущий шаг — наименьший StepOrder среди ещё не решённых строк (ждущих и
* активных). Активируются ждущие строки именно этого шага.
*
* Раньше правило было «есть активная строка — очередь не двигаем». На
* параллельном шаге это ломалось: если участник (Аня) переназначал заявку
* делегату (Дина встаёт перед Аней с дробным order), второй участник того же
* шага (Борис) всё ещё «На согласовании», и Дина не активировалась, пока не
* решит Борис. Теперь у Дины меньший order — она текущий шаг и активируется
* сразу, а после её решения Аня возвращается в пару к Борису.
*
* На последовательном маршруте поведение прежнее: пока шаг активен, ждущих
* строк с таким же или меньшим order нет — активировать нечего.
*/
export function pickRowsToActivate(phaseRows: IQueueRow[]): number[] {
const pending = phaseRows.filter(
(r) =>
r.status === REQUEST_STATUS.queued ||
r.status === REQUEST_STATUS.onApproval ||
r.status === REQUEST_STATUS.onExecution
);
if (pending.length === 0) return [];
const currentOrder = Math.min(...pending.map((r) => r.order));
return pending
.filter((r) => r.order === currentOrder && r.status === REQUEST_STATUS.queued)
.map((r) => r.id);
}