Sla #11

Merged
gorbenko.pavel merged 8 commits from SLAendpoint into main 2026-08-13 04:44:08 +00:00
8 changed files with 258 additions and 6 deletions
Showing only changes of commit 0b2a05cd94 - Show all commits
@@ -26,7 +26,7 @@
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
"CertificateSettingName": "AzurePfx",
"CertificatePassword": ""
"CertificatePassword": "Rk.xDjccnfyjdktybz1"
},
"TnsuPortal": {
"BaseUrl": "https://api.tnsu.kz",
@@ -6,12 +6,23 @@ import { texts } from '../../texts';
import { toEmployeeOption } from '../../utils/employee';
import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks';
import { ActionsCell, Badge, BadgeVariant, CellNote, IconButton, PrimaryCell, SearchableSelect } from '../ui';
import { formatSlaHours } from '../../utils/sla';
import { effectiveSlaHours, formatSlaHours, isRowOverdue } from '../../utils/sla';
export interface IManageRequestsPhaseTableProps {
title: string;
/** Строки ОДНОЙ фазы, уже отсортированные по order (см. ManageRequestsScreen). */
rows: ManagedApprovalRow[];
/**
* ВСЕ строки заявки (обе фазы) — нужны для расчёта момента активации шага
* (см. utils/sla.ts/activatedAt): первый шаг исполнения отсчитывается от
* последнего решения фазы согласования, а той фазы в `rows` этой таблицы нет.
*/
allRows: ManagedApprovalRow[];
/** Дата создания заявки (ISO) — точка отсчёта для самого первого шага согласования. */
requestCreatedIso: string;
/** SLA типа заявки в часах — то, что действует, если на строке своё значение не задано. */
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
requestTypeSlaHours: number | null;
/** Заявка целиком только для просмотра (закрыта) — контролов нет вообще. */
readOnly: boolean;
/**
@@ -77,6 +88,9 @@ function diffOrders(original: ManagedApprovalRow[], renumbered: Row[]): Array<{
export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps> = ({
title,
rows,
allRows,
requestCreatedIso,
requestTypeSlaHours,
readOnly,
requestRejected,
statusVariant,
@@ -248,10 +262,30 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
<td>{r.position || texts.common.empty}</td>
<td><Badge variant={statusVariant(r.status)}>{r.status || texts.common.empty}</Badge></td>
<td>{r.date || texts.common.empty}</td>
<td>
{formatSlaHours(r.slaHours)}
{r.slaHours === null && <CellNote>{texts.manageRequests.slaInherited}</CellNote>}
</td>
{(() => {
const hours = effectiveSlaHours(r, requestTypeSlaHours);
// Просроченным может быть только шаг, у которого прямо сейчас
// идёт очередь — решённый уже не тикает, ждущий ещё не начался.
const overdue =
(r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution) &&
isRowOverdue(r, allRows, requestCreatedIso, requestTypeSlaHours, new Date());
const editable = !readOnly && rowEditable && !isGhost;
return (
<td
className={[
overdue ? styles.slaOverdue : '',
editable ? styles.slaEditable : ''
].filter(Boolean).join(' ')}
title={editable ? texts.manageRequests.editSla : undefined}
onClick={editable ? () => onEditSla(r) : undefined}
>
{formatSlaHours(hours)}
{r.slaHours === null && hours !== null && (
<CellNote>{texts.manageRequests.slaInherited}</CellNote>
)}
</td>
);
})()}
{/* Стрелки перестановки — СЛЕВА от действий над строкой (объединить/
переназначить/удалить), одна пара на весь блок (rowSpan): двигают
шаг целиком, а не отдельного участника параллельной группы. */}
@@ -97,3 +97,23 @@
.addRow {
margin-top: 10px;
}
// Ячейка SLA просроченного АКТИВНОГО шага (см. isRowOverdue в utils/sla.ts) —
// только текст, без фона: фон был бы третьим одновременным акцентом в строке
// (жёлтая подсветка hover уже есть) и мешал бы, а не помогал заметить.
.slaOverdue {
color: $red-500;
font-weight: 600;
}
// Ячейка SLA, по которой можно кликнуть и открыть редактирование (см. onEditSla
// в ManageRequestsPhaseTable) — курсор и подчёркивание по пунктиру как явный
// намёк на кликабельность, без которого колонка неотличима от обычного текста.
.slaEditable {
cursor: pointer;
border-bottom: 1px dashed $gray-400;
&:hover {
background: $gray-50;
}
}
@@ -448,6 +448,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
position,
status: initialStatus,
date: '',
dateIso: '',
comment: '',
order,
allowReassign: false,
@@ -575,6 +576,9 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
<ManageRequestsPhaseTable
title={texts.manageRequests.approvalPhaseTitle}
rows={approvalRows}
allRows={matrix ? matrix.rows : []}
requestCreatedIso={matrix ? matrix.request.created : ''}
requestTypeSlaHours={requestType?.slaHours ?? null}
readOnly={readOnly}
requestRejected={requestRejected}
statusVariant={statusVariant}
@@ -592,6 +596,9 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
<ManageRequestsPhaseTable
title={texts.manageRequests.executionPhaseTitle}
rows={executionRows}
allRows={matrix ? matrix.rows : []}
requestCreatedIso={matrix ? matrix.request.created : ''}
requestTypeSlaHours={requestType?.slaHours ?? null}
readOnly={readOnly}
requestRejected={requestRejected}
statusVariant={statusVariant}
@@ -97,6 +97,13 @@ export interface ManagedApprovalRow {
position: string;
status: string;
date: string;
/**
* Та же дата, что в `date`, но необработанная ISO-строка (или '', если решения
* ещё не было) — нужна для расчёта просрочки (см. utils/sla.ts/isRowOverdue):
* `date` отформатирована в «дд.мм.гггг» и назад в Date уже не превращается
* надёжно (зависит от локали при парсинге).
*/
dateIso: string;
comment: string;
order: number;
allowReassign: boolean;
@@ -243,6 +243,7 @@ export class RequestManageService {
position: (r.Title as string) || '',
status: (r[FIELDS.status] as string) || '',
date: formatDateIso((r[FIELDS.date] as string) || ''),
dateIso: (r[FIELDS.date] as string) || '',
comment: (r[FIELDS.comment] as string) || '',
order: (r[FIELDS.order] as number) ?? i + 1,
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
@@ -1,11 +1,15 @@
import {
activatedAt,
boolToTriState,
effectiveSlaHours,
formatSlaHours,
isRowOverdue,
isValidSlaHours,
parseSlaHours,
slaHoursToInput,
triStateToBool
} from './sla';
import { APPROVAL_PHASE } from '../models';
// Смысл всех этих проверок один: «не задано» и «ноль/нет» — РАЗНЫЕ состояния.
// Если они где-то схлопнутся, шаг с настройкой «напоминать сразу» станет
@@ -84,3 +88,98 @@ describe('triState', () => {
expect(triStateToBool(boolToTriState(null))).toBeNull();
});
});
describe('effectiveSlaHours', () => {
it('своё значение строки перекрывает тип', () => {
expect(effectiveSlaHours({ slaHours: 8 }, 48)).toBe(8);
});
it('0 на строке — это не "не задано", тип не подставляется', () => {
expect(effectiveSlaHours({ slaHours: 0 }, 48)).toBe(0);
});
it('строка не задана — берётся SLA типа', () => {
expect(effectiveSlaHours({ slaHours: null }, 48)).toBe(48);
});
it('не задано нигде — null', () => {
expect(effectiveSlaHours({ slaHours: null }, null)).toBeNull();
});
});
// Повторяет ReminderService.ActivatedAt на бэкенде — та же формула, поэтому и
// проверяется на тех же случаях (параллельные шаги, переход между фазами).
describe('activatedAt', () => {
const row = (order: number, phase: string, dateIso: string) => ({ order, phase, dateIso });
it('первый шаг согласования — от создания заявки', () => {
const rows = [row(1, APPROVAL_PHASE.approval, '')];
const result = activatedAt(rows[0], rows, '2026-08-01T00:00:00Z');
expect(result?.toISOString()).toBe('2026-08-01T00:00:00.000Z');
});
it('второй шаг — от решения предыдущего, а не от создания заявки', () => {
const rows = [
row(1, APPROVAL_PHASE.approval, '2026-08-10T12:00:00Z'),
row(2, APPROVAL_PHASE.approval, '')
];
const result = activatedAt(rows[1], rows, '2026-01-01T00:00:00Z');
expect(result?.toISOString()).toBe('2026-08-10T12:00:00.000Z');
});
it('параллельный предыдущий шаг — от ПОСЛЕДНЕГО решения, а не от первого', () => {
const rows = [
row(1, APPROVAL_PHASE.approval, '2026-08-10T08:00:00Z'),
row(1, APPROVAL_PHASE.approval, '2026-08-10T20:00:00Z'),
row(2, APPROVAL_PHASE.approval, '')
];
const result = activatedAt(rows[2], rows, '2026-01-01T00:00:00Z');
expect(result?.toISOString()).toBe('2026-08-10T20:00:00.000Z');
});
it('первый шаг исполнения — от последнего решения фазы согласования', () => {
const rows = [
row(1, APPROVAL_PHASE.approval, '2026-08-10T08:00:00Z'),
row(2, APPROVAL_PHASE.approval, '2026-08-11T09:00:00Z'),
row(1, APPROVAL_PHASE.execution, '')
];
const result = activatedAt(rows[2], rows, '2026-01-01T00:00:00Z');
expect(result?.toISOString()).toBe('2026-08-11T09:00:00.000Z');
});
it('предыдущий шаг ещё не решён — активации нет (undefined)', () => {
const rows = [
row(1, APPROVAL_PHASE.approval, ''),
row(2, APPROVAL_PHASE.approval, '')
];
expect(activatedAt(rows[1], rows, '2026-01-01T00:00:00Z')).toBeUndefined();
});
});
describe('isRowOverdue', () => {
const now = new Date('2026-08-12T12:00:00Z');
it('срок вышел — просрочено', () => {
const rows = [{ order: 1, phase: APPROVAL_PHASE.approval, dateIso: '', slaHours: 24 as number | null }];
expect(isRowOverdue(rows[0], rows, '2026-08-10T00:00:00Z', null, now)).toBe(true);
});
it('срок не вышел — не просрочено', () => {
const rows = [{ order: 1, phase: APPROVAL_PHASE.approval, dateIso: '', slaHours: 24 as number | null }];
expect(isRowOverdue(rows[0], rows, '2026-08-12T00:00:00Z', null, now)).toBe(false);
});
it('SLA не настроен нигде — не просрочено, хотя заявка старая', () => {
const rows = [{ order: 1, phase: APPROVAL_PHASE.approval, dateIso: '', slaHours: null as number | null }];
expect(isRowOverdue(rows[0], rows, '2020-01-01T00:00:00Z', null, now)).toBe(false);
});
it('SLA типа используется, если на строке не задан', () => {
const rows = [{ order: 1, phase: APPROVAL_PHASE.approval, dateIso: '', slaHours: null as number | null }];
expect(isRowOverdue(rows[0], rows, '2026-08-10T00:00:00Z', 24, now)).toBe(true);
});
});
@@ -1,4 +1,5 @@
import { texts } from '../texts';
import { APPROVAL_PHASE, ManagedApprovalRow } from '../models';
/**
* Настройки SLA существуют в ТРЁХ состояниях, и это главное, что здесь важно:
@@ -68,3 +69,86 @@ export function slaHoursToInput(value: number | null | undefined): string {
export function formatSlaHours(value: number | null | undefined): string {
return value === null || value === undefined ? texts.common.empty : `${value} ч`;
}
/**
* Действующий SLA строки: своё значение, а если не задано — SLA типа заявки.
* То же правило наследования, что и на бэкенде при resolve (шаг перекрывает тип) —
* здесь его нужно повторить, чтобы посчитать просрочку и показать реальное число
* часов, а не только «от типа заявки» без цифры.
*/
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
export function effectiveSlaHours(
row: Pick<ManagedApprovalRow, 'slaHours'>,
requestTypeSlaHours: number | null | undefined
): number | null {
if (row.slaHours !== null) return row.slaHours;
return requestTypeSlaHours ?? null;
}
/**
* Момент, с которого шаг считается активным, — начало отсчёта SLA.
*
* Отдельной колонки «когда шаг стал активным» в списке нет (активация — это
* точечное обновление статуса, дату оно не пишет), поэтому момент выводится из
* соседних строк маршрута — ТОЧНО ТА ЖЕ формула, что в backend/ReminderService.cs
* (ActivatedAt), продублирована здесь для показа в админке:
* • первый шаг согласования — дата создания заявки;
* • первый шаг исполнения — последнее решение по фазе согласования (фаза
* исполнения активируется только когда согласование пройдено целиком);
* • остальные шаги — последнее решение по предыдущему (по order) шагу СВОЕЙ фазы.
*
* MAX даты, а не «дата предыдущей строки»: шаг могут делить несколько
* параллельных согласующих с одинаковым order, и следующий шаг активируется
* только после решения последнего из них. «Предыдущий шаг» — ближайший меньший
* СУЩЕСТВУЮЩИЙ order, а не order-1: в нумерации бывают пропуски.
*/
export function activatedAt(
row: Pick<ManagedApprovalRow, 'order' | 'phase'>,
allRows: readonly Pick<ManagedApprovalRow, 'order' | 'phase' | 'dateIso'>[],
requestCreatedIso: string
): Date | undefined {
const samePhase = allRows.filter((r) => r.phase === row.phase);
const isFirstInPhase = samePhase.every((r) => r.order >= row.order);
const lastDecisionOf = (rows: readonly Pick<ManagedApprovalRow, 'dateIso'>[]): Date | undefined => {
const dates = rows
.map((r) => (r.dateIso ? new Date(r.dateIso) : undefined))
.filter((d): d is Date => !!d && !isNaN(d.getTime()));
if (dates.length === 0) return undefined;
return new Date(Math.max(...dates.map((d) => d.getTime())));
};
if (isFirstInPhase) {
if (row.phase === APPROVAL_PHASE.execution) {
return lastDecisionOf(allRows.filter((r) => r.phase === APPROVAL_PHASE.approval));
}
const created = requestCreatedIso ? new Date(requestCreatedIso) : undefined;
return created && !isNaN(created.getTime()) ? created : undefined;
}
const previousOrder = Math.max(...samePhase.filter((r) => r.order < row.order).map((r) => r.order));
return lastDecisionOf(samePhase.filter((r) => r.order === previousOrder));
}
/**
* Строка сейчас просрочена: она активна (на согласовании/исполнении — проверяет
* вызывающая сторона по статусу, здесь неважно) и текущее время уже прошло
* дедлайн (момент активации + действующий SLA). SLA не настроен нигде (ни на
* строке, ни на типе) — просрочки не бывает, что проверять.
*/
export function isRowOverdue(
row: Pick<ManagedApprovalRow, 'order' | 'phase' | 'dateIso' | 'slaHours'>,
allRows: readonly Pick<ManagedApprovalRow, 'order' | 'phase' | 'dateIso'>[],
requestCreatedIso: string,
requestTypeSlaHours: number | null | undefined,
now: Date
): boolean {
const hours = effectiveSlaHours(row, requestTypeSlaHours);
if (hours === null) return false;
const activated = activatedAt(row, allRows, requestCreatedIso);
if (!activated) return false;
const deadline = new Date(activated.getTime() + hours * 60 * 60 * 1000);
return deadline < now;
}