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:
parent
2ef4c6ae86
commit
46d553b074
18
web/src/hooks/useBeforeUnload.ts
Normal file
18
web/src/hooks/useBeforeUnload.ts
Normal 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]);
|
||||
}
|
||||
@ -21,7 +21,7 @@ export type ConsRussianStatus =
|
||||
| 'Корректировка данных'
|
||||
| 'Консолидация ГО'
|
||||
| 'Консолидация РФ'
|
||||
| 'Отменён'
|
||||
| 'Отменена'
|
||||
| 'Корректировка'
|
||||
| 'В архиве'
|
||||
| 'Отклонён';
|
||||
|
||||
@ -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;
|
||||
|
||||
135
web/src/utils/consolidations.test.ts
Normal file
135
web/src/utils/consolidations.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
93
web/src/utils/consolidations.ts
Normal file
93
web/src/utils/consolidations.ts
Normal 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;
|
||||
@ -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: 'Статус',
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user