Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79d2a59229 | |||
| 9cc5d52bab | |||
| ea07e8d5ab | |||
| 290598c357 | |||
| 474a663da6 | |||
| 8fc9776895 |
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user