Sla (#11)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/11
This commit was merged in pull request #11.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
activatedAt,
|
||||
boolToTriState,
|
||||
effectiveSlaHours,
|
||||
formatSlaHours,
|
||||
isRowOverdue,
|
||||
isValidSlaHours,
|
||||
parseSlaHours,
|
||||
slaHoursToInput,
|
||||
triStateToBool
|
||||
} from './sla';
|
||||
import { APPROVAL_PHASE } from '../models';
|
||||
|
||||
// Смысл всех этих проверок один: «не задано» и «ноль/нет» — РАЗНЫЕ состояния.
|
||||
// Если они где-то схлопнутся, шаг с настройкой «напоминать сразу» станет
|
||||
// неотличим от шага без настройки, и напоминания по нему просто не придут.
|
||||
describe('parseSlaHours', () => {
|
||||
it('пустая строка — «не задано», а не ноль', () => {
|
||||
expect(parseSlaHours('')).toBeNull();
|
||||
expect(parseSlaHours(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('ноль остаётся нулём', () => {
|
||||
expect(parseSlaHours('0')).toBe(0);
|
||||
});
|
||||
|
||||
it('число разбирается', () => {
|
||||
expect(parseSlaHours('24')).toBe(24);
|
||||
expect(parseSlaHours(' 48 ')).toBe(48);
|
||||
});
|
||||
|
||||
it('мусор не превращается в ноль', () => {
|
||||
expect(parseSlaHours('abc')).toBeNull();
|
||||
expect(parseSlaHours('-5')).toBeNull();
|
||||
expect(parseSlaHours('1.5')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidSlaHours', () => {
|
||||
it('пусто — допустимо, это «не задано»', () => {
|
||||
expect(isValidSlaHours('')).toBe(true);
|
||||
});
|
||||
|
||||
it('ноль и целые положительные — допустимы', () => {
|
||||
expect(isValidSlaHours('0')).toBe(true);
|
||||
expect(isValidSlaHours('24')).toBe(true);
|
||||
});
|
||||
|
||||
it('отрицательное, дробное и нечисловое — недопустимы', () => {
|
||||
expect(isValidSlaHours('-1')).toBe(false);
|
||||
expect(isValidSlaHours('1.5')).toBe(false);
|
||||
expect(isValidSlaHours('abc')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slaHoursToInput', () => {
|
||||
it('ноль показывается нулём, а не пустотой', () => {
|
||||
expect(slaHoursToInput(0)).toBe('0');
|
||||
});
|
||||
|
||||
it('«не задано» показывается пустым полем', () => {
|
||||
expect(slaHoursToInput(null)).toBe('');
|
||||
expect(slaHoursToInput(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSlaHours', () => {
|
||||
it('ноль часов остаётся нулём, а не прочерком', () => {
|
||||
expect(formatSlaHours(0)).toBe('0 ч');
|
||||
});
|
||||
|
||||
it('не задано — прочерк', () => {
|
||||
expect(formatSlaHours(null)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('triState', () => {
|
||||
it('false и «не задано» — разные значения', () => {
|
||||
expect(triStateToBool('no')).toBe(false);
|
||||
expect(triStateToBool('')).toBeNull();
|
||||
});
|
||||
|
||||
it('преобразование в обе стороны сохраняет все три состояния', () => {
|
||||
expect(boolToTriState(true)).toBe('yes');
|
||||
expect(boolToTriState(false)).toBe('no');
|
||||
expect(boolToTriState(null)).toBe('');
|
||||
expect(triStateToBool(boolToTriState(false))).toBe(false);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { texts } from '../texts';
|
||||
import { APPROVAL_PHASE, ManagedApprovalRow } from '../models';
|
||||
|
||||
/**
|
||||
* Настройки SLA существуют в ТРЁХ состояниях, и это главное, что здесь важно:
|
||||
* «не задано» (наследовать уровнем выше), «задано значение» и — отдельно —
|
||||
* осознанные 0 часов / «Нет». Если бы пустое поле схлопывалось в 0 или false,
|
||||
* шаг с настройкой «напоминать сразу» стал бы неотличим от шага без настройки.
|
||||
*
|
||||
* Формы хранят эти поля строками (так работают TextInput/Select), поэтому
|
||||
* преобразование строка ↔ nullable-значение бэкенда собрано в одном месте, а не
|
||||
* повторено в каждой форме.
|
||||
*/
|
||||
|
||||
/** Значение выпадающего списка для nullable-флага. */
|
||||
export type TriState = '' | 'yes' | 'no';
|
||||
|
||||
export const TRI_STATE_OPTIONS: Array<{ value: TriState; label: string }> = [
|
||||
{ value: '', label: texts.common.inherited },
|
||||
{ value: 'yes', label: texts.common.yes },
|
||||
{ value: 'no', label: texts.common.no }
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
|
||||
export function triStateToBool(value: TriState): boolean | null {
|
||||
if (value === 'yes') return true;
|
||||
if (value === 'no') return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
|
||||
export function boolToTriState(value: boolean | null | undefined): TriState {
|
||||
if (value === true) return 'yes';
|
||||
if (value === false) return 'no';
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Строка поля ввода → часы. Пустая строка — «не задано» (null), а не 0.
|
||||
* Некорректный ввод тоже даёт null: значение проверяется отдельно
|
||||
* (см. isValidSlaHours), молча превращать «abc» в 0 нельзя.
|
||||
*/
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
|
||||
export function parseSlaHours(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
/** Пусто — допустимо («не задано»); заполнено — должно быть целым неотрицательным числом. */
|
||||
export function isValidSlaHours(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return true;
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isInteger(parsed) && parsed >= 0;
|
||||
}
|
||||
|
||||
/** Часы из бэкенда → строка поля ввода. Ноль обязан остаться нулём, а не стать пустотой. */
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
|
||||
export function slaHoursToInput(value: number | null | undefined): string {
|
||||
return value === null || value === undefined ? '' : String(value);
|
||||
}
|
||||
|
||||
/** Часы для показа в таблице: «—» вместо пустого значения, «0 ч» остаётся нулём. */
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
|
||||
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;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ function step(overrides: Partial<TemplateRouteStep>): TemplateRouteStep {
|
||||
fieldName: null,
|
||||
operation: null,
|
||||
value: null,
|
||||
slaHours: null,
|
||||
askOverdueReason: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user