fix(web): правила экранов консолидаций — в utils/consolidations.ts с тестами (AURORA-1589, PR #166)

Подписи и пояснения статусов версии, ручные переходы MANUAL_TRANSITIONS (зеркало
api/src/services/consolidation_status.py, синхронная константа вместо мока), своя
блокировка, активность «Изменить статус», причина ≤ 1000 после обрезки, «Версия»
(1 — пусто, 2+ — номер), разбор ?in_work_only (по умолчанию выключен), «Взять в работу»
по коду статуса CANCELLED. Подпись CANCELLED — «Отменена»; у строки списка — код статуса
(status_code). Хук useBeforeUnload. Тесты vitest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-23 01:15:55 +04:00
parent 2ef4c6ae86
commit 46d553b074
8 changed files with 258 additions and 30 deletions

View File

@ -0,0 +1,18 @@
import { useEffect } from 'react';
/**
* Пока `dirty`, браузер спрашивает подтверждение при закрытии или перезагрузке вкладки (стандартный диалог браузера,
* текст задаёт браузер). Переход по меню приложения при открытой модалке закрыт её маской.
*/
export function useBeforeUnload(dirty: boolean) {
useEffect(() => {
if (!dirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
// старые Chrome/Яндекс Браузер показывают диалог только при непустом returnValue
e.returnValue = '';
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [dirty]);
}

View File

@ -21,7 +21,7 @@ export type ConsRussianStatus =
| 'Корректировка данных'
| 'Консолидация ГО'
| 'Консолидация РФ'
| 'Отменён'
| 'Отменена'
| 'Корректировка'
| 'В архиве'
| 'Отклонён';

View File

@ -33,7 +33,10 @@ export interface ConsRow {
reporting_date: Date | null;
version_no: number;
/** Подпись статуса — для колонки и её фильтра. */
status: ConsRussianStatus;
/** Код статуса — для правил (подпись меняется, код — нет). */
status_code: ConsStatus;
scenario: Scenario;
approach: Approach;
lock: LockOut | null;

View File

@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest';
import type { ConsStatus, LockOut } from '../types/consolidations/common_types';
import {
CONS_STATUS_LABEL,
MANUAL_TRANSITIONS,
canChangeStatus,
canSubmitStatusChange,
canTakeInWork,
consStatusDescription,
endSentence,
isLockedBy,
isValidReason,
manualTransitions,
needsUnmappedWarning,
parseInWorkOnly,
versionLabel,
} from './consolidations';
const lock = (userId: string): LockOut => ({
locked_by: userId,
locked_by_name: 'Соколов А. В.',
locked_at: '2026-10-23T10:00:00Z',
taken_over_from: null,
takeover_reason: null,
});
const ALL: ConsStatus[] = Object.keys(CONS_STATUS_LABEL) as ConsStatus[];
describe('versionLabel', () => {
it('первая версия — пусто, последующие — номер', () => {
expect(versionLabel(1)).toBe('');
expect(versionLabel(2)).toBe('2');
expect(versionLabel(10)).toBe('10');
});
});
describe('parseInWorkOnly', () => {
it('по умолчанию выключен, включает только явное true', () => {
expect(parseInWorkOnly(null)).toBe(false);
expect(parseInWorkOnly('')).toBe(false);
expect(parseInWorkOnly('false')).toBe(false);
expect(parseInWorkOnly('true')).toBe(true);
});
});
describe('подписи и пояснения статусов', () => {
it('подписи из спеки', () => {
expect(CONS_STATUS_LABEL.DATA_CORRECTION).toBe('Корректировка данных');
expect(CONS_STATUS_LABEL.CANCELLED).toBe('Отменена');
});
it('пояснение не повторяет подпись', () => {
for (const s of ALL) {
expect(consStatusDescription(s)).not.toBe(CONS_STATUS_LABEL[s]);
}
});
it('пояснения статусов первой очереди и макета заданы', () => {
for (const s of ['INITIATED', 'DOCUMENTS_PROCESSING', 'DATA_CORRECTION', 'CANCELLED', 'CONSOLIDATION_RF', 'VERIFIED'] as const) {
expect(consStatusDescription(s)).not.toBe('');
}
expect(consStatusDescription('CONSOLIDATION_RF')).toBe('Автоматическая консолидация, ручные проверки и сравнение КФО клиента и банка');
expect(consStatusDescription('VERIFIED')).toBe('Отчет верифицирован ГО, КА ГО внес в систему номер и дату решения УОБ');
});
});
describe('ручные переходы (зеркало MANUAL_TRANSITIONS бэка)', () => {
it('совпадают с api/src/services/consolidation_status.py', () => {
expect(MANUAL_TRANSITIONS).toEqual({
INITIATED: ['CANCELLED'],
DOCUMENTS_PROCESSING: ['CANCELLED', 'INITIATED'],
DATA_CORRECTION: ['CANCELLED', 'INITIATED'],
});
});
it('из остальных статусов переходов нет', () => {
for (const s of ['VERIFIED', 'CANCELLED', 'CONSOLIDATION_RF', 'VERIFICATION_RF', 'CONSOLIDATION_GO', 'VERIFICATION_GO'] as const) {
expect(manualTransitions(s)).toEqual([]);
}
});
});
describe('блокировка и «Изменить статус»', () => {
it('своя блокировка', () => {
expect(isLockedBy(lock('me'), 'me')).toBe(true);
expect(isLockedBy(lock('other'), 'me')).toBe(false);
expect(isLockedBy(null, 'me')).toBe(false);
expect(isLockedBy(undefined, 'me')).toBe(false);
});
it('кнопка: своя / чужая / нет блокировки × есть / нет переходов', () => {
expect(canChangeStatus('DATA_CORRECTION', lock('me'), 'me')).toBe(true);
expect(canChangeStatus('DATA_CORRECTION', lock('other'), 'me')).toBe(false);
expect(canChangeStatus('DATA_CORRECTION', null, 'me')).toBe(false);
expect(canChangeStatus('VERIFIED', lock('me'), 'me')).toBe(false);
expect(canChangeStatus('VERIFIED', lock('other'), 'me')).toBe(false);
expect(canChangeStatus('VERIFIED', null, 'me')).toBe(false);
});
it('«Взять в работу» недоступна только у отменённой версии', () => {
expect(canTakeInWork('CANCELLED')).toBe(false);
expect(canTakeInWork('INITIATED')).toBe(true);
expect(canTakeInWork('VERIFIED')).toBe(true);
});
});
describe('причина и комментарий', () => {
it('пустая и из пробелов — неверна', () => {
expect(isValidReason('')).toBe(false);
expect(isValidReason(' ')).toBe(false);
expect(isValidReason(undefined)).toBe(false);
expect(isValidReason(null)).toBe(false);
});
it('ровно 1000 символов можно, 1001 — нет', () => {
expect(isValidReason('a'.repeat(1000))).toBe(true);
expect(isValidReason('a'.repeat(1001))).toBe(false);
expect(isValidReason(` ${'a'.repeat(1000)} `)).toBe(true);
});
it('модалка: нужен выбранный этап и непустой комментарий', () => {
expect(canSubmitStatusChange(undefined, 'Дубль')).toBe(false);
expect(canSubmitStatusChange('CANCELLED', undefined)).toBe(false);
expect(canSubmitStatusChange('CANCELLED', ' ')).toBe(false);
expect(canSubmitStatusChange('CANCELLED', 'Дубль')).toBe(true);
});
});
describe('endSentence', () => {
it('не ставит вторую точку после инициалов', () => {
expect(endSentence('Соколов А. В.')).toBe('Соколов А. В.');
expect(endSentence('Dev: Аналитик ГО')).toBe('Dev: Аналитик ГО.');
});
});
describe('модалка [31-00]', () => {
it('только при несопоставленных строках', () => {
expect(needsUnmappedWarning(2)).toBe(true);
expect(needsUnmappedWarning(0)).toBe(false);
expect(needsUnmappedWarning(undefined)).toBe(false);
});
});

View File

@ -0,0 +1,93 @@
// Чистые правила экранов консолидаций (спека `consolidations-ui`): подписи и пояснения статусов версии, ручные
// переходы, блокировка, «Версия», фильтр «Только в работе». Без React, store и API.
import type { ConsRussianStatus, ConsStatus, LockOut } from '../types/consolidations/common_types';
/** Подписи статусов версии — одни для списка, фильтра «Статус», шапки карточки и модалки «Изменить статус». */
export const CONS_STATUS_LABEL: Record<ConsStatus, ConsRussianStatus> = {
INITIATED: 'Инициация',
DOCUMENTS_PROCESSING: 'Обработка документов',
DATA_CORRECTION: 'Корректировка данных',
CONSOLIDATION_RF: 'Консолидация РФ',
VERIFICATION_RF: 'Верификация РФ',
CONSOLIDATION_GO: 'Консолидация ГО',
VERIFICATION_GO: 'Верификация ГО',
VERIFIED: 'Верифицирован',
CANCELLED: 'Отменена',
ADJUSTMENT: 'Корректировка',
ARCHIVED: 'В архиве',
REJECTED: 'Отклонён',
};
/**
* Пояснение статуса под заголовком карточки версии. `CONSOLIDATION_RF`, `VERIFIED` — тексты макета [30], [33-01];
* `INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`, `CANCELLED` — временные из ТЗ «Статусная модель», пока нет
* текстов аналитика (design.md change consolidations-ui, решение (б)).
*/
export const CONS_STATUS_DESCRIPTION: Partial<Record<ConsStatus, string>> = {
INITIATED: 'Система зафиксировала состав ГСК на отчетную дату и инициатора (КА РФ или КА ГО)',
DOCUMENTS_PROCESSING:
'КА загружает документы; система отправляет pdf файлы в ИС Сканирование, производит парсинг ОСВ, бухгалтерского баланса и ОФР',
DATA_CORRECTION:
'Выявлены некорректные данные в ИС Сканирование, проводится ручная корректировка. Пользователь валидирует распарсенные данные и вносит правки',
CONSOLIDATION_RF: 'Автоматическая консолидация, ручные проверки и сравнение КФО клиента и банка',
CONSOLIDATION_GO: 'Формирование и проверка КФО, внесение корректировок на уровне ГО',
VERIFICATION_RF: 'Проверка и подтверждение КФО на уровне РФ',
VERIFICATION_GO: 'Проверка и подтверждение КФО на уровне ГО',
VERIFIED: 'Отчет верифицирован ГО, КА ГО внес в систему номер и дату решения УОБ',
CANCELLED: 'Заявка на формирование/подтверждение КФО аннулирована по инициативе пользователя',
};
/** Пояснение статуса; нет текста — пусто (название статуса под заголовком не повторяем). */
export const consStatusDescription = (status: ConsStatus): string => CONS_STATUS_DESCRIPTION[status] ?? '';
/**
* Ручные переходы статуса версии — зеркало `MANUAL_TRANSITIONS` из `api/src/services/consolidation_status.py`
* (API допустимые переходы не отдаёт). Расхождение с бэком сервер ловит 409 `TRANSITION_NOT_ALLOWED`. С маршрутом
* согласования (Срез 7) заменяется ответом API.
*/
export const MANUAL_TRANSITIONS: Partial<Record<ConsStatus, readonly ConsStatus[]>> = {
INITIATED: ['CANCELLED'],
DOCUMENTS_PROCESSING: ['CANCELLED', 'INITIATED'],
DATA_CORRECTION: ['CANCELLED', 'INITIATED'],
};
/** Допустимые ручные переходы из статуса; нет — пустой список. */
export const manualTransitions = (status: ConsStatus): readonly ConsStatus[] => MANUAL_TRANSITIONS[status] ?? [];
/** Версию держит текущий пользователь. */
export const isLockedBy = (lock: LockOut | null | undefined, userId: string): boolean => !!lock && lock.locked_by === userId;
/** Кнопка «Изменить статус» в шапке карточки: своя блокировка и есть ручные переходы. */
export const canChangeStatus = (status: ConsStatus, lock: LockOut | null | undefined, userId: string): boolean =>
isLockedBy(lock, userId) && manualTransitions(status).length > 0;
/** Лимит причины перехвата и комментария к смене статуса (`MAX_TAKEOVER_REASON`, `MAX_STATUS_COMMENT` бэка). */
export const MAX_REASON_LENGTH = 1000;
/** Причина перехвата / комментарий: непусто после обрезки пробелов и не длиннее лимита. */
export const isValidReason = (text: string | null | undefined): boolean => {
const trimmed = (text ?? '').trim();
return trimmed.length > 0 && trimmed.length <= MAX_REASON_LENGTH;
};
/** «Изменить статус» в модалке: выбран новый этап и комментарий непуст. */
export const canSubmitStatusChange = (toStatus: ConsStatus | null | undefined, comment: string | null | undefined): boolean =>
!!toStatus && isValidReason(comment);
/** Колонка «Версия»: у первой версии пусто, у последующих — номер. */
export const versionLabel = (versionNo: number): string => (versionNo === 1 ? '' : String(versionNo));
/** Флажок «Только в работе» из `?in_work_only=`: включён только явным `true` (по умолчанию выключен, как в API). */
export const parseInWorkOnly = (param: string | null): boolean => param === 'true';
/** «Взять в работу» недоступна у отменённой версии — по коду статуса, а не по подписи. */
export const canTakeInWork = (status: ConsStatus): boolean => status !== 'CANCELLED';
/** Точка в конце фразы после ФИО: «Соколов А. В.» уже заканчивается точкой — вторую не ставим. */
export const endSentence = (text: string): string => (text.endsWith('.') ? text : `${text}.`);
/** «+ Создать» в модалке «Новая консолидация»: в консолидации не меньше двух ЮЛ (иначе 422 `PERIMETER_TOO_SMALL`). */
export const MIN_PERIMETER = 2;
/** Модалка [31-00] нужна при уходе со вкладки «ОСВ», пока есть несопоставленные строки. */
export const needsUnmappedWarning = (unmappedTotal: number | null | undefined): boolean => (unmappedTotal ?? 0) > 0;

View File

@ -2,6 +2,7 @@ import type { TableConfig } from '../../components/GenericTable';
import { TableTag } from '../../components/TableTag/TableTag';
import type { ConsRussianStatus, QuarterEnum } from '../../types/consolidations/common_types';
import type { ConsRow } from '../../types/consolidations/consolidations';
import { versionLabel } from '../../utils/consolidations';
import { formatMoscowDate } from '../../utils/dates';
import { quarterShortLabel } from '../../utils/labels';
import { statusColorMapping } from './consts';
@ -15,7 +16,7 @@ export const consConfigBase: TableConfig<ConsRow> = {
{ key: 'year', title: 'Год', features: { sort: true, multiselect: true } },
{ key: 'quarter', title: 'Квартал', features: { multiselect: true }, render: (v) => quarterShortLabel(v as QuarterEnum) },
{ key: 'version_no', title: 'Версия', features: { sort: true }, render: (v) => (String(v).trim() !== '1' ? '' : v) },
{ key: 'version_no', title: 'Версия', features: { sort: true }, render: (v) => versionLabel(Number(v)) },
{
key: 'status',
title: 'Статус',

View File

@ -1,21 +1,7 @@
import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets';
import type { ConsRussianStatus, ConsStatus } from '../../types/consolidations/common_types';
export const statusMapping: Record<ConsStatus, ConsRussianStatus> = {
VERIFIED: 'Верифицирован',
VERIFICATION_GO: 'Верификация ГО',
VERIFICATION_RF: 'Верификация РФ',
INITIATED: 'Инициация',
DOCUMENTS_PROCESSING: 'Обработка документов',
DATA_CORRECTION: 'Корректировка данных',
CONSOLIDATION_GO: 'Консолидация ГО',
CONSOLIDATION_RF: 'Консолидация РФ',
CANCELLED: 'Отменён',
ADJUSTMENT: 'Корректировка',
ARCHIVED: 'В архиве',
REJECTED: 'Отклонён',
};
import type { ConsRussianStatus } from '../../types/consolidations/common_types';
// Подписи и пояснения статусов — `utils/consolidations.ts` (CONS_STATUS_LABEL, CONS_STATUS_DESCRIPTION); здесь — цвета бейджей.
export const statusColorMapping: Record<ConsRussianStatus, Preset> = {
'В архиве': TAG_PRESETS.default,
'Верификация ГО': TAG_PRESETS.geekblue,
@ -28,14 +14,5 @@ export const statusColorMapping: Record<ConsRussianStatus, Preset> = {
'Корректировка данных': TAG_PRESETS.gold,
'Обработка документов': TAG_PRESETS.gold,
Отклонён: TAG_PRESETS.red,
Отменён: TAG_PRESETS.red,
};
export const consStatusDescriptionMapping: Partial<Record<ConsStatus, string>> = {
CONSOLIDATION_RF: 'Формирование и проверка КФО, внесение корректировок на уровне РФ',
CONSOLIDATION_GO: 'Формирование и проверка КФО, внесение корректировок на уровне ГО',
VERIFICATION_GO: 'Проверка и подтверждение КФО на уровне ГО',
VERIFICATION_RF: 'Проверка и подтверждение КФО на уровне РФ',
CANCELLED: 'Формирование КФО отменено',
VERIFIED: 'КФО верифицирована, внесены номер и дата решения УОБ',
Отменена: TAG_PRESETS.red,
};

View File

@ -1,5 +1,5 @@
import type { ConsRow, IConsolidation } from '../../types/consolidations/consolidations';
import { statusMapping } from './consts';
import { CONS_STATUS_LABEL } from '../../utils/consolidations';
export function flattenCons(cons: IConsolidation[]): ConsRow[] {
const out: ConsRow[] = [];
@ -18,7 +18,8 @@ export function flattenCons(cons: IConsolidation[]): ConsRow[] {
quarter: con.quarter,
reporting_date: rep_date,
version_no: v.version_no,
status: statusMapping[v.status],
status: CONS_STATUS_LABEL[v.status],
status_code: v.status,
scenario: v.scenario,
approach: v.approach,
lock: v.lock,