From 46d553b074f77b8a6ad0ab75b236f5c4f0f2caf3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 01:15:55 +0400 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D0=B0=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D0=B8=D0=B4=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B9=20=E2=80=94=20=D0=B2=20utils/consolidations.ts=20?= =?UTF-8?q?=D1=81=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=D0=BC=D0=B8=20(AURORA-1?= =?UTF-8?q?589,=20PR=20#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Подписи и пояснения статусов версии, ручные переходы 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 --- web/src/hooks/useBeforeUnload.ts | 18 +++ web/src/types/consolidations/common_types.ts | 2 +- .../types/consolidations/consolidations.ts | 3 + web/src/utils/consolidations.test.ts | 135 ++++++++++++++++++ web/src/utils/consolidations.ts | 93 ++++++++++++ web/src/widgets/ConsoleTable/config.tsx | 3 +- web/src/widgets/ConsoleTable/consts.ts | 29 +--- web/src/widgets/ConsoleTable/flatten.ts | 5 +- 8 files changed, 258 insertions(+), 30 deletions(-) create mode 100644 web/src/hooks/useBeforeUnload.ts create mode 100644 web/src/utils/consolidations.test.ts create mode 100644 web/src/utils/consolidations.ts diff --git a/web/src/hooks/useBeforeUnload.ts b/web/src/hooks/useBeforeUnload.ts new file mode 100644 index 0000000..7415832 --- /dev/null +++ b/web/src/hooks/useBeforeUnload.ts @@ -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]); +} diff --git a/web/src/types/consolidations/common_types.ts b/web/src/types/consolidations/common_types.ts index 7d7b443..0e81edc 100644 --- a/web/src/types/consolidations/common_types.ts +++ b/web/src/types/consolidations/common_types.ts @@ -21,7 +21,7 @@ export type ConsRussianStatus = | 'Корректировка данных' | 'Консолидация ГО' | 'Консолидация РФ' - | 'Отменён' + | 'Отменена' | 'Корректировка' | 'В архиве' | 'Отклонён'; diff --git a/web/src/types/consolidations/consolidations.ts b/web/src/types/consolidations/consolidations.ts index f546b70..9742e71 100644 --- a/web/src/types/consolidations/consolidations.ts +++ b/web/src/types/consolidations/consolidations.ts @@ -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; diff --git a/web/src/utils/consolidations.test.ts b/web/src/utils/consolidations.test.ts new file mode 100644 index 0000000..2b38b92 --- /dev/null +++ b/web/src/utils/consolidations.test.ts @@ -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); + }); +}); diff --git a/web/src/utils/consolidations.ts b/web/src/utils/consolidations.ts new file mode 100644 index 0000000..caad8c5 --- /dev/null +++ b/web/src/utils/consolidations.ts @@ -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 = { + 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> = { + 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> = { + 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; diff --git a/web/src/widgets/ConsoleTable/config.tsx b/web/src/widgets/ConsoleTable/config.tsx index 0e580e0..e14c50b 100644 --- a/web/src/widgets/ConsoleTable/config.tsx +++ b/web/src/widgets/ConsoleTable/config.tsx @@ -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 = { { 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: 'Статус', diff --git a/web/src/widgets/ConsoleTable/consts.ts b/web/src/widgets/ConsoleTable/consts.ts index dba1fc2..aff5926 100644 --- a/web/src/widgets/ConsoleTable/consts.ts +++ b/web/src/widgets/ConsoleTable/consts.ts @@ -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 = { - 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 = { 'В архиве': TAG_PRESETS.default, 'Верификация ГО': TAG_PRESETS.geekblue, @@ -28,14 +14,5 @@ export const statusColorMapping: Record = { 'Корректировка данных': TAG_PRESETS.gold, 'Обработка документов': TAG_PRESETS.gold, Отклонён: TAG_PRESETS.red, - Отменён: TAG_PRESETS.red, -}; - -export const consStatusDescriptionMapping: Partial> = { - CONSOLIDATION_RF: 'Формирование и проверка КФО, внесение корректировок на уровне РФ', - CONSOLIDATION_GO: 'Формирование и проверка КФО, внесение корректировок на уровне ГО', - VERIFICATION_GO: 'Проверка и подтверждение КФО на уровне ГО', - VERIFICATION_RF: 'Проверка и подтверждение КФО на уровне РФ', - CANCELLED: 'Формирование КФО отменено', - VERIFIED: 'КФО верифицирована, внесены номер и дата решения УОБ', + Отменена: TAG_PRESETS.red, }; diff --git a/web/src/widgets/ConsoleTable/flatten.ts b/web/src/widgets/ConsoleTable/flatten.ts index 8ab2e76..4ca8762 100644 --- a/web/src/widgets/ConsoleTable/flatten.ts +++ b/web/src/widgets/ConsoleTable/flatten.ts @@ -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,