arm-core/web/src/utils/consolidations.ts
2026-10-27 18:53:01 +04:00

160 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Чистые правила экранов консолидаций (спека `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<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;
/**
* Переход на другую вкладку отчёта (`/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:<version_id>`, значение — `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<Storage, 'getItem'> | 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<Storage, 'setItem'> | 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,
};
};