// Чистые правила экранов консолидаций (спека `consolidations-ui`): подписи и пояснения статусов версии, ручные // переходы, блокировка, «Версия», фильтр «Только в работе». Без React, store и API. import type { CounterPairOut } from '../types/api'; import type { ConsRussianStatus, ConsStatus, LockOut } from '../types/consolidations/common_types'; import { pluralRu } from './labels'; /** Подписи статусов версии — одни для списка, фильтра «Статус», шапки карточки и модалки «Изменить статус». */ 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; /** * Переход на другую вкладку отчёта (`/versions/:version_id/:tab`): сегмент вкладки заменяется, запись истории — тоже, * чтобы «Назад» вёл со страницы отчёта, а не по переключениям вкладок (AURORA-1851, как переключатель документа в AURORA-1692). */ export const reportTabNavigation = (pathname: string, activeKey: string, newKey: string) => ({ to: pathname.replace(new RegExp(`/${activeKey}/?$`), `/${newKey}`), options: { replace: true }, }); /** Статусы, в которых версия закрыта для правок: модалку о парах без встречных записей не показываем. */ export const FINISHED_STATUSES: readonly ConsStatus[] = ['VERIFIED', 'CANCELLED', 'ARCHIVED', 'REJECTED']; /** Префикс ключа `sessionStorage`: `armka:counter-pairs:`, значение — `counterPairsKey`. */ export const COUNTER_PAIRS_STORAGE_PREFIX = 'armka:counter-pairs:'; /** Ключ списка пар — не зависит от порядка: `holder>missing`, отсортированные, через запятую. */ export const counterPairsKey = (pairs: readonly CounterPairOut[]): string => pairs .map((p) => `${p.holder_id}>${p.missing_id}`) .sort() .join(','); /** * Показывать ли модалку о парах без встречных записей (`05-domain-rules.md` §3 п. 9): есть пары, версия не закрыта и * этот же список пар в сессии ещё не показывали. Хранилище недоступно или бросает — показываем. */ export const shouldWarnCounterPairs = ( versionId: string, status: ConsStatus | undefined, pairs: readonly CounterPairOut[], storage: Pick | null, ): boolean => { if (pairs.length === 0) return false; if (status && FINISHED_STATUSES.includes(status)) return false; try { return storage?.getItem(`${COUNTER_PAIRS_STORAGE_PREFIX}${versionId}`) !== counterPairsKey(pairs); } catch { return true; } }; /** Запоминает показанный список пар; ошибки хранилища (приватный режим) глотаем. */ export const rememberCounterPairsShown = ( versionId: string, pairs: readonly CounterPairOut[], storage: Pick | null, ): void => { try { storage?.setItem(`${COUNTER_PAIRS_STORAGE_PREFIX}${versionId}`, counterPairsKey(pairs)); } catch { // хранилище недоступно — модалка просто покажется снова } }; /** Тексты модалки «Проверьте встречные записи участников». */ export const counterPairsWarning = (pairs: readonly CounterPairOut[], unmappedTotal: number): { headline: string; details: string } => { const missing = new Set(pairs.map((p) => p.missing_id)).size; const base = 'Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте.'; return { headline: `Нет встречных записей: ${pairs.length} ${pluralRu(pairs.length, ['пара', 'пары', 'пар'])} у ${missing} ${pluralRu(missing, ['участника', 'участников', 'участников'])}.`, details: unmappedTotal > 0 ? `${base} Ещё ${unmappedTotal} строк без ИНН учтены как внешние.` : base, }; };