Решение владельца 08.10.2026: recognition.tabular.request → files.tabular_uploaded, recognition.image.request → files.img_uploaded, recognition.result → files.tabular_parsed (общий subject ack и результатов; files.img_parsed не используется). Тела сообщений, Nats-Msg-Id, queue group'ы и schema_version не меняются. Константы api/src/contract/subjects.py, докстринги, тесты, README, CLAUDE.md; копии схем api/contracts/ из docs/contracts/ побайтно; change nats-files-subjects с дельтой спеки. Сквозной change — RSHB/arm-ka-docs#74. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
164 lines
25 KiB
Markdown
164 lines
25 KiB
Markdown
# CLAUDE.md — arm-core (ядро АРМ КА)
|
||
|
||
Общий контекст системы — `../CLAUDE.md` и `../docs/` (workspace). Этот файл — только про этот репозиторий.
|
||
|
||
## Что это
|
||
Ядро: справочники, файлы/документы, маппинг результатов распознавания, вычисления консолидации, маршрут согласования,
|
||
ЕХ, аудит, права. **Единственный писатель в PostgreSQL.** Монорепо `api/` (FastAPI) + `web/` (React/TS) по шаблону
|
||
команды «Шаблон FastAPI» (нулевой коммит `RSHB/DKB_ARMKA@ba172f8`; импорт — `ba92222`, очистка от домена — `8e1f423`).
|
||
|
||
## Соглашения шаблона
|
||
Полный текст — `../docs/context/03-architecture.md`, раздел «Соглашения шаблона команды». Главное для этого репозитория:
|
||
- **Команды** — `make` из корня репозитория (Makefile в корне, backend-цели внутри делают `cd api`, venv — `api/.venv`):
|
||
`venv` (uv, Python 3.11, без `raisa_*`), `lint` (ruff + mypy), `test` (без БД), `test-integration` (PostgreSQL/NATS
|
||
из compose, включает цикл миграций), `check` (= lint + test, Docker не нужен), `up` (compose из `../docs/dev/`),
|
||
`migrate`, `seed` / `seed-dev` (справочники / + dev-пользователи), `dbml-check` (сверка ORM↔DBML), `migration-cycle`
|
||
(только цикл миграций), `e2e` (сквозной прогон загрузки ОСВ с реальным tabular-parser, без профиля `services` — skip),
|
||
`check-infra`, `docker-build`, `web-check` (biome + tsc + build), `check-full` (check + dbml-check + check-infra +
|
||
test-integration + e2e + docker-build + web-check; **перед PR**, вывод — в описание PR).
|
||
Вручную — из `api/`: `python run_server.py`, `alembic upgrade head`, `pytest`; из `web/`: `npm run start|build`.
|
||
Бутстрап в банке — `setup/setup.sh` (pip/npm через Nexus).
|
||
- **Раскладка `api/src/`**: `src/api/endpoints` → `services` → `repositories` → `models`; DTO — `schemas/`; инфраструктура —
|
||
`core/` (config, clients, nats, logger, exception_handlers, auth); `db/` — engine и сессия. Наши добавления:
|
||
`domain/` (чистые функции по §§ правил, без БД и сессий: `policy.py` — видимость справочников §10.1,
|
||
`directory_import.py` — чтение xlsx/csv и план импорта §13, `aliases.py` — нормализация алиаса §3, `inn.py` — формат
|
||
ИНН, `file_types.py` — тип файла по расширению и сигнатуре, `osv_mapping.py` — результат парсера → документ/строки
|
||
ОСВ (§1.3, §1.5, §9.3); Срез 4 — `consolidation_periods.py` (выбор источников и приведение периодов, §2, Q1-эквивалентность ОСВ,
|
||
R-14), `consolidated_osv.py` (чистая сборка свода: склейка кварталов, пробелы, дорезолв, фильтры, §2–§3), `checklist.py`
|
||
(чек-лист комплекта, ячейки `CellStatus`); Срез 3b — `statement_mapping.py` (формы ББ/ОФР: период, черновик, статус D-6),
|
||
`statement_checks.py` (проверки §7 по словарю `api/rules/statement_checks.yaml`), `checklist_csv.py` (CSV чек-листа);
|
||
`policy.py` дополнен `IN_WORK_STATUSES`/`ensure_version_editable`, §10; для шахматки
|
||
и КФО — Срезы 5–6), `contract/` (модели контракта обмена), `api/scripts/` (`check_dbml_vs_orm.py`,
|
||
`seed.py` + `seed_lib/`, `check_infra.py`), данные seed — `api/seed/`.
|
||
- **DI** — ручные фабрики `Depends` в `src/api/dependencies/`; `storage`/`nats` — из `request.app.state`. **Сессия** —
|
||
только `session: SessionDep` (`src/api/dependencies/db.py` = `Depends(get_async_session, scope="function")`): одна
|
||
транзакция на запрос (`get_async_session` открывает `session.begin()`), commit **до** отправки ответа, rollback при
|
||
любом исключении; объявление через голый `Depends(get_async_session)` даёт вторую сессию и ловится тестом
|
||
`test_api_routes_declare_session_with_function_scope`. Сервисы не коммитят сами.
|
||
- **Настройки** — pydantic-settings, `api/.env`, вложенность `__`, инфраструктура под `OPENBAO__*`. Флаги разработки
|
||
(`AUTH_DEV_ISSUER`, `DEBUG`) и обмена (`NATS_ENABLED`, по умолчанию выключен) — отдельные поля `Settings`. NATS
|
||
управляется только `NATS_ENABLED` (от `DEBUG` не зависит); при `DEBUG=false` шаблонный импорт
|
||
`raisa_fastapi_protected_api` в `app.py` условный (пакета нет вне банка — не ошибка); middleware РАИСА в Срезе 0
|
||
не подключается.
|
||
- **БД** — схема **`dkb`** по умолчанию, имя конфигурируемо настройкой `DB_SCHEMA` (`OPENBAO__APP__DB_SCHEMA` в контуре; MetaData, `search_path`, Alembic читают `models.base.SCHEMA`, в миграциях — `from src.models.base import SCHEMA`, не литерал); PK `uuid` через `UUIDPrimaryMixin`; ревизии в
|
||
`api/alembic/versions/`; автогенерация — черновик, финал сверяется с DBML скриптом.
|
||
- **Ошибки** — `common/exceptions.py` → `core/exception_handlers.py`. Доменные ошибки (`DomainError`: `NotFoundError` 404
|
||
`NOT_FOUND`, `ConflictError` 409, `DomainValidationError` 422; код и доп. поля задаёт вызывающий) отдаются как
|
||
`{"detail": {"code", "message", **extra}}` (message по-русски, `code`/`message` в `extra` запрещены); `HTTPException`
|
||
(401/403/413) и ошибки БД — `{"detail": "<строка>"}`, 422 валидации тела — массив. Объект вне видимости пользователя —
|
||
404 с тем же телом, что у несуществующего (не 403). Каталог кодов — README, «Ошибки API». **Логи** — loguru.
|
||
- **Тесты** — pytest-asyncio (auto, session loop), `APP_ENV=test`, `ENV_FILE=.env.test`; маркеры `integration` (нужны
|
||
PostgreSQL/NATS), `fixtures` (реальные данные из `../fixtures`) и `e2e` (поверх `integration`: нужен tabular-parser из
|
||
профиля `services`, иначе skip; `make test-integration` его исключает, гонять — `make e2e`); БД сессии — свежая `armcore_pytest_<hex>` с миграциями,
|
||
удаляется в конце; `domain/` — без БД.
|
||
- **Линтеры** — backend `ruff` + `mypy` (`api/pyproject.toml`); frontend `biome` (ширина 140, одинарные кавычки) + `tsc`.
|
||
- **Frontend** — React 18 + TS strict, rsbuild, antd 6 (основной UI-кит), react-router 7; `layouts/` — страницы
|
||
(`Main`, `Admin`), `widgets/` — блоки (`Sidebar`, `Admin/*`), `components/` — примитивы; env только `REACT_APP_*` через
|
||
`conf/config.ts`; `REACT_APP_ROOT_PATH` = `ROOT_PATH` бэка. Redux Toolkit из шаблона подключён (`store/store.ts`,
|
||
`Provider` в `App.tsx`), но стор пуст и `store/slices/` без слайсов: состояние экранов `/admin` — локальные хуки
|
||
(`widgets/Admin/hooks.ts`), профиль — контекст. `api/client.ts` — единый клиент (`apiFetch`, `api.get|post|put|patch`,
|
||
префикс `${Config.API_URL}/api/v1`, Bearer-токен, `ApiError {status, code, message}` по трём формам `detail`, событие
|
||
`armka:unauthorized` на 401). `auth/` — `AuthGate` (профиль `/me` → `MeContext`/`useCurrentUser`), `DevLogin` (только не
|
||
в production-сборке: `POST /dev/token`), `token.ts` (sessionStorage с запасом в памяти), `permissions.ts` (коды прав
|
||
для скрытия пунктов меню; доступ решает сервер). `/admin` (`layouts/Admin.tsx`, право `USERS_ADMIN`) — пользователи,
|
||
роли, матрица прав.
|
||
- **Сборка** — платформа (Python 3.11) запускает `api/run_server.py` (`SERVER__HOST/PORT`), ресурсы — `raisa_config.yml`;
|
||
SPA: `web/dist` → `api/web/`. `Dockerfile` (`python:3.11-slim`) в корне — для dev-compose и `make check-full`
|
||
(`docker build`; убран из `.gitignore` шаблона). CI в репозитории нет: перед PR — `make check-full`. Без Nexus:
|
||
`make venv` ставит зависимости без `raisa_*`, npm-пакеты `raisa-*` — `optionalDependencies`.
|
||
- **NATS без JetStream** (`NATS_ENABLED=true`): `core/nats.py` — подключение в фоне (supervisor повторяет попытки),
|
||
подписка queue group `arm-core` на `files.tabular_parsed`. Обмен — `services/recognition_exchange.py`: `publish_pending`
|
||
(outbox), `expire_deadlines` (вахтёр сроков), `handle_message` (inbox: ack — конверт `processing`, результат);
|
||
публикатор и вахтёр — фоновые задачи lifespan. Запрос без ack повторно публикуется (`ACK_TIMEOUT_SECONDS`=30 ×
|
||
`OUTBOX_MAX_REPUBLISH`=3, счётчик — `recognition_job.metrics.republish_count`) → `dlq`; дедуп — `request_msg_id` /
|
||
`result_msg_id`. Статусы задания в Срезе 0 `audit_event` не пишут.
|
||
- **Первичные документы (Срез 2c)** — `services/primary_files.py` (загрузка `upload_one`: дедуп по `(ЮЛ, sha256)` под
|
||
advisory-lock, SAVEPOINT на файл, `delete`), `services/file_status.py` (единственное место смены `stored_file.status`
|
||
по ТЕКУЩЕМУ заданию; `PROCESSED`/`SUPERSEDED` обменом не перезаписываются), `services/result_mapping.py` (цикл маппинга:
|
||
`pick_candidate` → проверка ключа результата (OQ-70) → чтение S3 вне транзакции → `map_file` в одной транзакции →
|
||
автозамена `FILE_REPLACE`), `services/documents.py` (карточки, `confirm`), `services/auto_confirm.py` (автоподтверждение чистых документов в транзакции маппинга: `_map_candidate`: `map_file` → автозамена → `auto_confirm` → `on_documents_mapped`; `confirmed_by` NULL, `DOCUMENT_CONFIRM` без пользователя, переход версии — от `uploaded_by`; OQ-157), `domain/osv_mapping.py` (чистые функции:
|
||
`normalize_account_code`, `build_entries`, `counterparty_check`, `document_status`), `repositories/counterparties.py`
|
||
(`CounterpartyIndex` — резолв контрагента без policy). **Фоновых циклов три** (запускаются в lifespan при
|
||
`NATS_ENABLED`; цикл маппинга — ещё и при подключённом S3 (storage)): публикатор outbox, вахтёр сроков и цикл маппинга
|
||
(`MAPPING_POLL_SECONDS`, `MAPPING_MAX_ATTEMPTS`); inbox результат не читает и документов не создаёт. **Порядок блокировок: задание → файл → документы** (inbox, вахтёр, публикатор, маппер,
|
||
`confirm`, `delete`; с Среза 4 далее — advisory-lock ГК → версии консолидации, см. «Консолидации») — иначе
|
||
взаимоблокировки; автозамена дополнительно берёт advisory-lock по ЮЛ. Аудит: `FILE_UPLOAD`,
|
||
`FILE_REPLACE` (системное, `user_id` пуст), `FILE_DELETE`, `DOCUMENT_CONFIRM` (пользователь или система при автоподтверждении); смена статусов обмена и маппинга не аудируется
|
||
(OQ-55). `osv_entry` пишет только маппер, один раз на файл (очистка перед вставкой); `TOTAL` не хранится.
|
||
- **Формы ББ/ОФР (Срез 3b)** — `map_file` (`services/result_mapping.py`) мапит и формы ББ/ОФР: `domain/statement_mapping.py` (чистые
|
||
функции) + `repositories/statements.py` (`financial_statement_meta/column/line/value`); форма создаётся с тем, что есть в результате (колонки могут отсутствовать);
|
||
второй `BALANCE`/`INCOME` того же файла пропускается с предупреждением (OQ-77), `DISCLOSURE`/`OTHER` — один документ
|
||
без данных на тип (`RECOGNIZED`, D-3); документ `BALANCE`/`INCOME` без `meta` (создан до Среза 3b) подтверждается без условий D-9, `…/statement` по нему — 409
|
||
`DOCUMENT_NOT_STATEMENT` (решение №16). Проверки §7 — `domain/statement_checks.py` по словарю `api/rules/statement_checks.yaml`; **файл —
|
||
копия словаря tabular-parser, при изменении правится в обоих репозиториях** (расхождение молчаливо меняет результат проверок).
|
||
Проверки считаются на лету и не хранятся; хранится только акцепт (`checks_accepted_by/at/comment` в шапке). Правка формы и акцепт —
|
||
`services/statements.py` (`PATCH …/statement`, `POST …/statement/checks/accept`): **только до подтверждения** (`CONFIRMED` →
|
||
`DOCUMENT_READONLY`), аудит `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`, `object_type=document`; один запрос — одно событие), первичка
|
||
рядом (`original_amount`, `*_original_value`, `is_modified`/`*_modified`); любая правка сбрасывает акцепт и пересчитывает статус
|
||
документа. **Порядок блокировок «файл → документ»**, шапка (`meta`) читается ПОД блокировкой с `refresh=True` — иначе в сессии
|
||
остаётся устаревший акцепт. Подтверждение формы (`services/documents._check_statement`) требует период (`PERIOD_REQUIRED`) и
|
||
пройденные/акцептованные жёсткие проверки (`CHECKS_NOT_PASSED`, `detail.failures`); мягкие (в т. ч. межформенная 1370↔2400)
|
||
не блокируют. Комментарии к документу — `services/documents.py` (`comments`/`add_comment`, `comment(object_type='document')`,
|
||
без аудита; `comments_count` в карточках). `GET /files/{id}/content` — `services/primary_files.content` +
|
||
`ObjectStorage.stream` (поток из S3, первая порция до заголовков → `FILE_CONTENT_NOT_FOUND`; сессию тело потока не держит;
|
||
имя — RFC 5987, `X-Content-Type-Options: nosniff`; освобождение потока — `common/streaming.py`: `ClosingStreamingResponse` +
|
||
`ChainedStream` закрывают источник и при обрыве клиента). Скачивание первички, CSV и комментарии аудит не пишут (в отличие от
|
||
экспорта сводной ОСВ в xlsx — тот пишет `EXPORT`; OQ-153). `HeaderPatch.units` — непустая строка после обрезки (пробельная — 422). `GET /users/brief` — `current_user` без права; `documents_count` у ЮЛ — `repositories/directories`; CSV чек-листа —
|
||
`?format=csv` у `GET …/checklist`. Контракт для фронта — `../docs/dev/api-contract-slice-3b.md`; решения —
|
||
`openspec/changes/archive/2026-10-07-slice-3b-statements-api/design.md`.
|
||
- **Консолидации (Срез 4)** — `api/endpoints/consolidations/` (periods, versions, locks, status, members, checklist, osv, export,
|
||
mapping), сервисы: `consolidations` (создание, список, карточка, периметр `PUT …/members`), `consolidation_guard`
|
||
(`lock_visible_version`/`lock_editable_version`: версия `FOR UPDATE` → видимость 404 → статус «в работе» → своя блокировка),
|
||
`consolidation_locks` (взять/перехватить/снять; без истечения, OQ-119), `consolidation_status` (`transition` + ручной
|
||
`POST …/status`), `consolidation_documents` (`fits`, `bind_confirmed`, `unbind_entity`, хуки `on_document_confirmed(changed_by, comment)` /
|
||
`on_documents_superseded` / `on_documents_mapped`; `bound_versions` / `unbind_documents` — снятие привязок автоподтверждённых документов при удалении файла, OQ-157), `consolidated_osv` (единая сборка `_build`: страница, сводка, кандидаты,
|
||
экспорт), `counterparty_mapping` (ручное сопоставление, алиас, автоприменение), `osv_export` (openpyxl, лист «Пробелы»).
|
||
Создание и `PUT …/members` проверяют периметр одной функцией `_validate_perimeter` (#42); ячейки чек-листа самоописывающие
|
||
(`document_type`, `account`); `members[].osv_accounts` — `repositories/consolidations.osv_accounts` («что загружено к периоду
|
||
версии», а не подходящесть §3).
|
||
**Правила:** (1) статус версии меняет ТОЛЬКО `consolidation_status.transition`; версия к вызову уже под `FOR UPDATE` у
|
||
вызывающего (иначе параллельный переход или перехват читает устаревший статус); (2) **порядок блокировок: файл → документ →
|
||
advisory-lock ГК → версии (по возрастанию `id`)**; правка версии начинается с `consolidation_version FOR UPDATE`;
|
||
`lock_versions_for_entity` блокирует надмножество версий «в работе» (ЮЛ — член ИЛИ та же ГК), создание версии берёт
|
||
per-ГК `pg_advisory_xact_lock` (R-2); (3) `lock_document`/`lock_file_documents` — `FOR NO KEY UPDATE` (R-3: вставка
|
||
`consolidation_document` берёт `FOR KEY SHARE` на документ и взаимоблокировалась бы с `FOR UPDATE`); (4) сводная ОСВ,
|
||
чек-лист и признак контрагента — чистые функции, в БД не пишутся; единственное хранимое — ручное сопоставление
|
||
(`counterparty_id` + `counterparty_match_kind` + алиас); (5) ключ замены привязки — «тип, счёт, квартал, нарастающий»
|
||
(R-1); ОСВ за Q1 равнозначна нарастающей и квартальной (R-14, Q1-эквивалентность — только для ОСВ, ББ/ОФР за Q1 версиям Q2–Q4
|
||
не подходят, R-14a); (6) «строка версии» для ручного сопоставления/автоприменения — только строки свода (R-11); (7) 404 —
|
||
код `NOT_FOUND`, различие по тексту; правки — `ensure_version_editable` (`VERSION_NOT_EDITABLE`/`LOCK_REQUIRED`/
|
||
`LOCKED_BY_OTHER` без `locked_by_name`). Деньги в ответах — строки с двумя знаками. Аудит: `CONSOLIDATION_CREATE`,
|
||
`CONSOLIDATION_STATUS_CHANGE`, `CONSOLIDATION_LOCK`, `CONSOLIDATION_LOCK_TAKEOVER`, `MEMBERSHIP_CHANGE`,
|
||
`OSV_COUNTERPARTY_MAP`, `LEGAL_ENTITY_CHANGE`, `EXPORT`; `FILE_REPLACE.unbound_version_ids`. Контракт для фронта —
|
||
`../docs/dev/api-contract-slice-4.md`; решения — `openspec/changes/slice-4-consolidation/design.md`.
|
||
- **Аудит** — `services/audit.py`, единственная точка записи `audit_event` (append-only: только `record`, в текущей транзакции;
|
||
тест `test_audit_service.py` сканирует `src/` на UPDATE/DELETE по `AuditEvent`). В маршрутах — `RequestAudit`
|
||
(`get_request_audit`: пользователь, IP, user-agent).
|
||
- **Справочники и права (Срез 1)** — видимость только через `domain/policy.py` (`VisibilityScope`: ГО — всё, филиал — свои
|
||
ГК/ЮЛ/ГСК/ГСЗ и монозаёмщики), код роли в логике не читается; `domain/inn.py` — ИНН строкой (ЮЛ 10, ИП 12 цифр,
|
||
без контрольных разрядов, OQ-88); `domain/kpp.py` — КПП (9 символов, необязателен, у ИП запрещён, `INVALID_KPP`, v5.17); `domain/aliases.py` — `normalized_alias` по §3; `domain/directory_import.py` — импорт
|
||
ЕЦП (§13, зависимость `openpyxl` для xlsx; допущения `ASSUMPTION(OQ-87…91)`). Администрирование — `services/users_admin.py`
|
||
(`LAST_ADMIN` с блокировкой строки права). Порядок: маршрут (`require(code)`) → policy → сервис → аудит.
|
||
- **Аутентификация** — `core/auth.py` (проверка JWT), `src/api/dependencies/auth.py` (`current_user`, `require(code)`);
|
||
dev-эмитент (`POST /api/v1/dev/token`) существует только при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true`.
|
||
Аудит входа — `services/session_audit.py` (`app.state.session_audit`): `LOGIN` (dev-токен; первый запрос после старта),
|
||
`LOGIN_FAILED` (`AuthenticationFailedError` 401/403 → обработчик пишет в отдельной транзакции `session_transaction`);
|
||
нет заголовка `Authorization` или схема не `Bearer`, 403 «недостаточно прав» — не события входа.
|
||
- **Seed** — `python -m scripts.seed [--dev]` (`make seed|seed-dev`), идемпотентен (матрица `role_permission` — целиком только в
|
||
пустую таблицу, в заполненную — лишь пары впервые созданных ролей/прав); `--dev` — только для разработки (CLI отказывает с кодом 2 при `DEBUG=false`).
|
||
**Сверка ORM↔DBML** — `scripts/check_dbml_vs_orm.py` (`make dbml-check`).
|
||
|
||
## Зависимости от соседей
|
||
- Контракты обмена: `../docs/contracts/recognition-request.schema.json`, `recognition-result-message.schema.json`,
|
||
`recognition-result.schema.json`; в репозитории — байт-идентичные копии в `api/contracts/` (сверяются тестами,
|
||
`tests/test_contract.py`). `ex-payload-v1.schema.json` (payload ЕХ) появится в Срезе 7 (`../docs/context/09-delivery-plan.md`).
|
||
- Фикстуры: `../fixtures/` (env `ARM_KA_FIXTURES`); при отсутствии тесты с маркером `fixtures` пропускаются с явной причиной (skip reason).
|
||
- NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`.
|
||
|
||
## Правила репозитория
|
||
- ORM 1:1 с `../docs/model/data-model-v5.17.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка.
|
||
- Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring.
|
||
- Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц.
|
||
- Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR.
|
||
- OpenSpec: `openspec/` здесь; сквозные изменения — change в `../docs`.
|