arm-core/README.md
Andrey Epifancev eaee3ec5f9 feat(model): DBML v5.16 — counterparty_match_kind, миграция 0004, шаг резолва в маппинге
Enum counterparty_match_kind (INN, NAME, ALIAS, MANUAL, PROPAGATED) и nullable-колонка osv_entry.counterparty_match_kind
(NULL <=> counterparty_id NULL). Миграция 0004_dbml_v5_16: enum, колонка, backfill тремя UPDATE по уже смапленным
строкам (ИНН без пробелов -> INN, trim(имя) -> NAME, остальные -> ALIAS), откат удаляет колонку и тип.
CounterpartyIndex.resolve возвращает Resolution(entity_id, kind) (определение в domain/osv_mapping, R-4);
маппинг пишет kind; EntryOut.match_kind. DBML_VERSION 5.16, Makefile/dbml_parity/CLAUDE/README/config.yaml.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 03:23:02 +04:00

81 KiB
Raw Blame History

arm-core — ядро АРМ КА

Ядро автоматизированного рабочего места кредитного аналитика Департамента крупного бизнеса (АРМ КА, АО «Россельхозбанк»): справочники, файлы и документы, приём результатов распознавания, вычисления консолидации (КФО ГСК), маршрут согласования, Единое хранилище (ЕХ), аудит и права. Единственный писатель в PostgreSQL: сервисы распознавания (tabular-parser, scan-gateway) видят только NATS и S3.

Монорепозиторий по шаблону команды «Шаблон FastAPI»: api/ — бэкенд (FastAPI, SQLAlchemy 2 async, Alembic, PostgreSQL 16, core NATS без JetStream), web/ — фронтенд (React + TypeScript, rsbuild).

Этот репозиторий — часть workspace АРМ КА. Общий контекст, модель данных, правила предметной области и контракты — в соседних репозиториях:

  • ../CLAUDE.md — инварианты и процесс работы всего workspace;
  • ../docs/ — контекст (context/), модель данных (model/), контракты (contracts/), окружение (dev/);
  • CLAUDE.md — соглашения этого репозитория.

Срез 0 («скелет ядра»): схема БД по DBML, справочники (seed), аутентификация и права, GET /api/v1/me, /healthcheck. Спецификация — openspec/changes/slice-0-skeleton/.

Срез 1 («справочники»): схема по DBML v5.14, API ГК / ЮЛ / ГСК/ГСЗ с политикой видимости, импорт из ЕЦП CRM, администрирование пользователей и ролей, экран /admin, аудит. Спецификация — openspec/changes/slice-1-directories/; разделы ниже: «API справочников», «Импорт из ЕЦП CRM», «Администрирование и экран /admin», «Ошибки API».

Срез 2c («загрузка ОСВ и маппинг»): схема по DBML v5.15, загрузка файлов POST /legal-entities/{id}/files, статусы файла по заданию распознавания, цикл маппинга результата парсера в document и osv_entry, автозамена версий, API файлов, документов и строк, подтверждение и удаление. Спецификация — openspec/changes/slice-2c-osv-ingest/; разделы ниже: «Первичные документы: загрузка», «Статусы файла и документа», «Цикл маппинга», «API файлов и документов», «Удаление и замена», «Ограничения Среза 2c», «Сквозной прогон (e2e)».

Предварительные требования

  • Python 3.11 (версия платформы) и uv — make venv создаёт окружение и ставит зависимости через uv; без него цель не работает. Системный python3 другой версии не подойдёт: uv сам подберёт интерпретатор 3.11.
  • Docker и compose v2 — для PostgreSQL, NATS и MinIO (compose лежит в ../docs/dev/).
  • Node.js и npm — только для web/. Для запуска бэкенда не нужны.
  • make, curl.

Репозитории workspace должны лежать рядом: arm-core/ и docs/ — соседние каталоги (так же устроено зеркало .wt/<срез>/). Пути ../docs/... ниже и в Makefile рассчитаны на это.

Быстрый старт

Путь «с нуля до зелёного /api/v1/me». Команды make выполняются из корня репозитория.

1. Виртуальное окружение

make venv

Создаёт api/.venv (Python 3.11) и ставит api/requirements.txt и api/requirements-dev.txt. Пакеты raisa_* из Nexus банка вне банка недоступны, поэтому цель пропускает строки raisa_* (подробнее — «Работа без Nexus»). Повторный запуск безопасен.

2. Инфраструктура (PostgreSQL, NATS, MinIO)

Файл .env compose — отдельный от api/.env; он создаётся один раз и в git не попадает:

cp ../docs/dev/.env.example ../docs/dev/.env
make up          # docker compose -f ../docs/dev/docker-compose.yml up -d

make up поднимает postgres (5432), nats (4222, мониторинг 8222), minio (9000, консоль 9001) и одноразовый minio-init, который создаёт бакет arm-ka и завершается с кодом 0. Команда возвращается сразу, а PostgreSQL становится готовым примерно через 6 секунд: дождитесь healthy у трёх долгоживущих сервисов, прежде чем выполнять make migrate:

docker compose -f ../docs/dev/docker-compose.yml up -d --wait postgres nats minio   # возвращается, когда все healthy
docker compose -f ../docs/dev/docker-compose.yml ps                                 # проверка статуса

(голый --wait без списка сервисов возвращает код 1 из-за завершающегося minio-init; поэтому make up для бакета и --wait со списком — вместе). Подробности, смена портов, сброс — ../docs/dev/README.md.

3. Настройки приложения

cp api/.env.example api/.env

Значения из api/.env.example уже совпадают с ../docs/dev/.env.example (стандартные порты и учётные данные compose), менять ничего не нужно. Менять api/.env придётся, только если вы изменили порты или пароли в ../docs/dev/.env (правьте обе части). api/.env в git не попадает.

4. Миграции и справочники

make migrate     # alembic upgrade head: схема dkb, 27 enum-типов, 43 таблицы
make seed-dev    # справочники продукта + dev-пользователи (по одному на каждую роль)

make seed загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует; матрицу прав role_permission он загружает целиком только в пустую таблицу, в заполненную — лишь связи новых ролей и прав (правки администратора не перезаписываются, см. «Администрирование»). make seed-dev (--dev) работает только при DEBUG=true в api/.env; при DEBUG=false загрузчик отказывает с кодом 2 («--dev доступен только при DEBUG=true (dev-пользователи не для прода)»), не трогая БД.

5. Запуск

cd api && .venv/bin/python run_server.py

Сервер слушает 127.0.0.1:8000 (SERVER__HOST, SERVER__PORT из api/.env). Для dev-запуска оставляйте loopback: при DEBUG=true и AUTH_DEV_ISSUER=true эндпоинт dev-токенов выдаёт токен любому, кто знает email пользователя, — не открывайте его в сеть. Дальнейшие команды — из другого терминала.

6. Проверка

curl -s localhost:8000/healthcheck
{"uptime":"2026-10-03T18:37:15.121394+00:00","version":"1.0.0","nats":"ok"}

nats: ok — подключён, down — включён (NATS_ENABLED=true), но связи нет, off — выключен.

Dev-токен по email любого dev-пользователя и профиль текущего пользователя:

curl -s -X POST localhost:8000/api/v1/dev/token \
  -H 'Content-Type: application/json' \
  -d '{"email":"ka_rf@armka.local"}'
# {"access_token":"<JWT>","token_type":"bearer"}

curl -s localhost:8000/api/v1/me -H "Authorization: Bearer <JWT>"
{
  "id": "b8d7595a-787f-4bcb-8e93-7612cc8afba3",
  "email": "ka_rf@armka.local",
  "full_name": "Dev: Аналитик РФ",
  "regional_office": {
    "id": "3b38e823-e09a-47ce-a296-061948a200b0",
    "name": "Ростовский региональный филиал",
    "is_head_office": false
  },
  "business_segment": "DKB",
  "roles": ["KA_RF"],
  "permissions": [
    "CONSOLIDATION_EDIT", "CONSOLIDATION_VIEW", "DIRECTORY_EDIT", "DIRECTORY_VIEW", "DOCUMENT_EDIT",
    "DOCUMENT_VIEW", "EXPORT", "EX_VIEW", "KFO_EXPORT", "KFO_VIEW"
  ]
}

(идентификаторы в вашей БД будут другими). Токен действует 8 часов. Интерактивная документация API — /docs (Swagger UI), схема — /openapi.json; обе — только при DEBUG=true (в проде их нет, 404).

Dev-пользователи (api/seed/dev/users.yaml, создаются make seed-dev):

Email Роль Филиал
ka_rf@armka.local KA_RF Ростовский региональный филиал
rka@armka.local RKA Ростовский региональный филиал
client_manager@armka.local CLIENT_MANAGER Краснодарский региональный филиал
ka_go@armka.local KA_GO Головной офис
curator_go@armka.local CURATOR_GO Головной офис
expert@armka.local EXPERT Головной офис
admin@armka.local ADMIN Головной офис

Права не зашиты в код роли: effective_permissions(user) — объединение прав ролей пользователя (таблицы permission, role_permission).

Что вы увидите в логе при запуске

Старт занимает около секунды; трейсбэков в логе нет. SMB (горячие папки ИС Сканирование из шаблона) необязателен и по умолчанию выключен: в .env.example OPENBAO__SMB__SMB_HOST пуст, сессия SMB не регистрируется (сообщение об этом — только на уровне DEBUG). Если SMB_HOST задан, регистрация выполняется в отдельном потоке (цикл событий не блокируется; старт ждёт её завершения не дольше тайм-аута smbclient, 60 с); ошибка подключения логируется трейсбэком Ошибка при регистрации сессии SMB, приложение продолжает работу.

Строки Бакет проверен, Подключение к NATS установлено и Подписка NATS активна. subject=recognition.result, queue=arm-core означают, что S3 и NATS из compose подключены (MinIO из compose слушает http, поэтому в .env задано OPENBAO__MINIO__MINIO_SECURE=false).

Недоступность S3 или NATS при старте приложение не роняет и не задерживает (недоступный SMB, если он настроен, — не роняет): ошибка логируется, состояние NATS видно в /healthcheck. К NATS ядро подключается в фоне и повторяет попытки само (nats=down, пока связи нет; warning в логе — на первую ошибку и дальше не чаще раза в минуту); после разрыва nats-py переподключается и восстанавливает подписку.

При остановке ресурсы закрываются по шагам: фоновые задачи обмена → NATS → S3 → SMB → пул соединений БД. Ошибка одного шага логируется (Ошибка при остановке приложения. step=...) и не отменяет следующие.

Предупреждение «Включён dev-эмитент токенов … только для разработки» появляется не при старте, а при первом запросе к защищённому эндпоинту (/api/v1/me): проверяющий токены создаётся лениво (штатно, см. «Безопасность dev-режима»).

Остановка и сброс

docker compose -f ../docs/dev/docker-compose.yml down        # остановить, данные (тома) сохраняются
docker compose -f ../docs/dev/docker-compose.yml down -v     # полный сброс: БД и бакет с нуля (затем снова migrate и seed-dev)

Имя проекта compose фиксировано (arm-ka), тома общие для всех worktree.

API справочников

Срез 1: справочники и их политика видимости. Все маршруты — под /api/v1, нужен токен (Authorization: Bearer …), схема — /openapi.json (интерактивно /docs); состав маршрутов проверяет api/tests/test_openapi_routes.py. Права — данные (permission × role_permission), код роли в логике не проверяется. Видимость записей решает src/domain/policy.py: пользователь головного офиса видит всё, пользователь филиала — ГК своего филиала, их ЮЛ и ГСК/ГСЗ и монозаёмщиков своего филиала. Объект вне видимости — 404 с тем же телом, что у несуществующего (не 403).

Метод и путь Право Что делает
GET /directories/regional-offices DIRECTORY_VIEW филиалы (включая Головной офис)
GET /directories/company-groups DIRECTORY_VIEW видимые ГК: q (имя/crm_id/код), is_archived, limit/offset
POST /directories/company-groups DIRECTORY_EDIT создать ГК (пользователь филиала закрепляет только за своим филиалом, иначе OFFICE_NOT_ALLOWED)
GET /directories/company-groups/{group_id} DIRECTORY_VIEW карточка ГК
PATCH /directories/company-groups/{group_id} DIRECTORY_EDIT изменить ГК (в т.ч. перезакрепление за филиалом)
POST /directories/company-groups/{group_id}/archive, /unarchive DIRECTORY_EDIT архивировать / вернуть из архива
GET /directories/company-groups/{group_id}/history DIRECTORY_VIEW история ГК, новые первыми
GET, POST /directories/company-groups/{company_group_id}/related-groups DIRECTORY_VIEW / DIRECTORY_EDIT ГСК/ГСЗ группы компаний (простой массив) / создать
GET /directories/related-groups/{group_id} DIRECTORY_VIEW карточка ГСК/ГСЗ с текущими членами
PATCH /directories/related-groups/{group_id} DIRECTORY_EDIT изменить ГСК/ГСЗ
DELETE /directories/related-groups/{group_id} DIRECTORY_DELETE удалить пустую ГСК/ГСЗ (GROUP_NOT_EMPTY)
POST /directories/related-groups/{group_id}/members DIRECTORY_EDIT добавить ЮЛ (только из той же ГК)
DELETE /directories/related-groups/{group_id}/members/{legal_entity_id} DIRECTORY_EDIT исключить ЮЛ (членство закрывается, не удаляется)
GET /directories/legal-entities DIRECTORY_VIEW видимые ЮЛ/ИП: company_group_id, outside_group, inn, q, closed, limit/offset
POST /directories/legal-entities DIRECTORY_EDIT создать ЮЛ/ИП (ИНН: ЮЛ — 10 цифр, ИП — 12)
GET, PATCH /directories/legal-entities/{entity_id} DIRECTORY_VIEW / DIRECTORY_EDIT карточка (с членствами и алиасами) / изменить
POST /directories/legal-entities/{entity_id}/move DIRECTORY_EDIT перевести в другую ГК с датой effective_date ≥ сегодня (дата по Москве, Europe/Moscow); членства ГСК/ГСЗ закрываются
POST /directories/legal-entities/{entity_id}/withdraw DIRECTORY_EDIT вывести из ГК: CLOSE (с close_date) или REMOVE_FROM_GROUP
GET /directories/legal-entities/{entity_id}/history DIRECTORY_VIEW история ЮЛ/ИП, новые первыми
POST /directories/legal-entities/{entity_id}/aliases DIRECTORY_EDIT добавить алиас (normalized_alias по §3)
DELETE /directories/legal-entities/{entity_id}/aliases/{alias_id} DIRECTORY_DELETE удалить алиас
POST /directories/import DIRECTORY_ADMIN импорт выгрузки ЕЦП CRM (см. ниже)
GET /admin/users, POST /admin/users, PATCH /admin/users/{user_id}, PUT /admin/users/{user_id}/roles USERS_ADMIN пользователи и их роли (см. «Администрирование»)
GET /admin/roles, GET /admin/permissions, PUT /admin/roles/{code}/permissions USERS_ADMIN матрица «роль × право»

Списки верхнего уровня (ГК, ЮЛ, пользователи) отдают страницу {items, total, limit, offset} (limit 1–200, по умолчанию 50); список ГСК/ГСЗ одной ГК — простой массив. Каждая мутация пишет audit_event в той же транзакции, что и сама правка; повтор того же состояния ничего не пишет. Примеры:

TOKEN=$(curl -s -X POST localhost:8000/api/v1/dev/token -H 'Content-Type: application/json' \
  -d '{"email":"ka_go@armka.local"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')
curl -s 'localhost:8000/api/v1/directories/company-groups?q=бисквит' -H "Authorization: Bearer $TOKEN"

Импорт из ЕЦП CRM

POST /api/v1/directories/import (multipart) загружает справочник из выгрузки ЕЦП CRM: ГК, ЮЛ/ИП, филиалы и членство в ГСК/ГСЗ. Нужно право DIRECTORY_ADMIN (seed: ADMIN). Алгоритм — ../docs/context/05-domain-rules.md §13 (§13.1 формат файла, §13.2 «строка → решение», §13.3 транзакция и отчёт), чистое ядро — src/domain/directory_import.py.

Порядок работы.

  1. Сначала пробный прогон: dry_run=true (это значение по умолчанию). Прогон идёт тем же кодом, но транзакция откатывается; в БД, аудит и S3 ничего не попадает, отчёт тот же, что будет при записи.
  2. Прочитайте отчёт: счётчики и построчный список lines (row, inn, code, message). Пропущенные строки (errors) исправляются в файле и прогоняются заново.
  3. Когда отчёт устраивает — тот же файл с dry_run=false. Один импорт — одна транзакция; результат — отчёт в ответе, audit_event(DIRECTORY_IMPORT) с отчётом в after_value и файл в S3 (uploads/imports/{audit_event_id}/{имя}).
  4. Повторный импорт того же файла при on_existing=skip идемпотентен: все ранее принятые строки получают коды *_EXISTS; ранее пропущенные (ошибочные) строки остаются ошибками.
# шаг 1: пробный прогон (dry_run по умолчанию true, on_existing по умолчанию skip)
curl -s -X POST localhost:8000/api/v1/directories/import -H "Authorization: Bearer $ADMIN_TOKEN" \
  -F file=@ecp-export.xlsx -F dry_run=true -F on_existing=skip
# шаг 3: запись
curl -s -X POST localhost:8000/api/v1/directories/import -H "Authorization: Bearer $ADMIN_TOKEN" \
  -F file=@ecp-export.xlsx -F dry_run=false -F on_existing=update

Формат файла (§13.1). xlsx (первый лист) или csv (UTF-8, разделитель ; или ,); формат выбирается по расширению имени. Заголовки — первая непустая строка, сопоставляются без регистра и пробелов, ё = е; порядок колонок не важен, лишние игнорируются.

Колонка Обязательна Смысл
ИНН да пробелы убираются; ЮЛ — 10 цифр, ИП — 12
iD ГК да company_group.crm_id
Группа компаний да имя ГК
Организация да имя ЮЛ/ИП
Тип да ЮЛ или ИП (без регистра)
Базовый РФ ГК в CRM нет филиал по точному имени
ГСК, ГСЗ нет флаги «входит в ГСК» / «входит в ГСЗ»

Нет любой обязательной колонки — файл отвергается целиком (422 MISSING_COLUMNS с перечнем в поле missing).

Параметр on_existing. skip (по умолчанию) — существующие ГК/ЮЛ не меняются; update — обновляются имя и филиал ГК и имя/тип ЮЛ (со снимками в историю и аудитом).

Коды отчёта (§13.2). У каждой строки ровно один итоговый код, плюс сопутствующие события той же строки. Пропуск строки (учитывается в errors): UNSUPPORTED_TYPE, INVALID_INN, DUPLICATE_IN_FILE, LE_IN_OTHER_GROUP, GK_NAME_CONFLICT (разные имена одной ГК в файле или пустой iD ГК). Итоговые: LE_CREATED, LE_UPDATED, LE_EXISTS. Сопутствующие: RF_CREATED, GK_CREATED, GK_UPDATED, GK_EXISTS, GSK_CREATED, GSZ_CREATED, MEMBER_ADDED, UNKNOWN_FLAG.

Допущения (открытые вопросы, в коде помечены ASSUMPTION(OQ-…), список — ../docs/context/07-open-questions.md).

  • OQ-87: типы вне {ЮЛ, ИП} (нерез., ФЛ, нет инн, пусто) пропускаются — UNSUPPORTED_TYPE.
  • OQ-88: ИНН проверяется только по длине и составу (ASCII-цифры), контрольные разряды не считаются; нечисловые значения (LV…, РНН…) — INVALID_INN.
  • OQ-89: филиала из файла нет в справочнике — он создаётся как обычный (is_head_office=false), в том числе «ЦКБ» и «Головная организация» (RF_CREATED).
  • OQ-90: ЮЛ, которое уже состоит в другой ГК (или вне ГК), импорт не переносит: LE_IN_OTHER_GROUP, перевод — только POST …/move. Сообщение называет имя этого ЮЛ из другой группы — импорт доступен только DIRECTORY_ADMIN, который видит весь справочник.
  • OQ-91: флаги ГСК/ГСЗ создают не более одной ГСК и одной ГСЗ на ГК с именем ГК; отсутствие флага членство не закрывает.

Особенности, о которых стоит знать.

  • ИНН — только текстом. Число из ячейки xlsx теряет ведущий ноль (0123456789 → 123456789) и получает INVALID_INN; в Excel задавайте текстовый формат колонки (или используйте csv).
  • Пустые «Организация» или «Группа компаний» при непустом iD ГК не отвергаются: создаётся запись с пустым именем (допущение, правила на пустое имя в §13 нет).
  • У существующей ГК при skip новый филиал из файла всё равно создаётся (RF_CREATED), но ГК не меняется.
  • Архивные ГК входят в снимок справочника; update не возвращает ГК из архива.
  • Пустые строки пропускаются, но входят в счётчик строк MAX_IMPORT_ROWS.

Пределы.

Предел Значение Результат при превышении
IMPORT_MAX_BYTES (настройка) 20 МиБ 413, файл не читается в память целиком
MAX_IMPORT_ROWS 100 000 строк листа/записей csv, включая пустые 422 UNSUPPORTED_FILE
MAX_UNCOMPRESSED_BYTES 200 МиБ суммарно по частям xlsx (zip), проверяется до разбора 422 UNSUPPORTED_FILE

Требование к ingress / reverse proxy (M-1). FastAPI/Starlette разбирают multipart-тело до аутентификации: файл сбрасывается во временный файл на диске ещё до ответов 401/403 и до проверки IMPORT_MAX_BYTES. Поэтому лимит тела запроса должен ставить ingress: client_max_body_size (или аналог) не меньше 20 МиБ — только для пути /api/v1/directories/import, а для остальных путей — небольшой (например, 1 МиБ).

Остальные причины UNSUPPORTED_FILE: расширение не xlsx/csv, повреждённый xlsx, csv не в UTF-8.

Администрирование и экран /admin

Бэкенд — /api/v1/admin/*, все маршруты требуют USERS_ADMIN (без права — 403 до любых проверок тела и существования объектов). Фронтенд — страница /admin («Пользователи и роли» в боковом меню; видна только с USERS_ADMIN): таблица пользователей (создание, смена ролей и активности) и вкладка «Матрица прав» (роль × право). Без права экран показывает «Нет доступа» и запросов к /admin/* не делает (настоящую защиту обеспечивает сервер).

Форма создания пользователя берёт список филиалов из GET /directories/regional-offices (право DIRECTORY_VIEW). Матрица прав на этом же экране позволяет снять DIRECTORY_VIEW с роли администратора — тогда форма получит пустой список / «недостаточно прав»; держите DIRECTORY_VIEW у роли администратора.

Как запустить.

make migrate && make seed-dev       # схема, справочники и dev-пользователи
make up                             # если инфраструктура ещё не поднята
cd api && .venv/bin/python run_server.py   # бэкенд, :8000 (в api/.env — DEBUG=true и AUTH_DEV_ISSUER=true)
cd web && npm install && npm run start     # фронтенд (rsbuild dev), откроется браузер

Вход — dev-форма «Вход для разработки» (web/src/auth/DevLogin.tsx): введите admin@armka.local; форма вызывает POST /api/v1/dev/token, токен хранится в sessionStorage вкладки. В production-сборке формы нет — вход через SSO РАИСА (параметры JWT — открытый вопрос).

Что нужно знать.

  • Роли и права — данные. Права роли правятся матрицей (PUT /admin/roles/{code}/permissions), набор ролей пользователя — PUT /admin/users/{user_id}/roles; код роли нигде в логике не проверяется. Новых прав и ролей через API создать нельзя: неизвестный код — 422 UNKNOWN_PERMISSION / UNKNOWN_ROLE (неактивная роль не назначается).
  • LAST_ADMIN (409). После операции хотя бы у одного активного пользователя должно остаться право USERS_ADMIN через активную роль. Защищены: деактивация пользователя (в том числе себя), замена его ролей и снятие права с роли; параллельные запросы двух администраторов идут по очереди (блокировка строки права).
  • Email. В теле запроса — EmailStr, поэтому адрес с зарезервированным доменом *.local (x@corp.local) через API и UI отклоняется (422); dev-пользователи *@armka.local создаёт make seed-dev напрямую в БД. Для ручной проверки заводите пользователей с example.com. Email уникален без учёта регистра (409 EMAIL_EXISTS), хранится как введён.
  • Seed не трогает заполненную матрицу прав. Связи «роль × право» из api/seed/core/role_permissions.yaml make seed / make seed-dev загружают целиком только в пустую role_permission (первый запуск на новой БД). Если связи уже есть, seed добавляет лишь пары, у которых роль или право впервые созданы этим же запуском (новая роль или новое право в файлах seed — их администратор снять не мог); остальные пары не трогаются — в отчёте role_permission: пропущено — матрица прав уже заполнена… (или число добавленных пар новых ролей и прав). Право, снятое администратором с существующей роли, не возвращается, добавленное сверх файла не отзывается; новые пары существующих ролей и прав из файла выдаёт администратор (PUT /admin/roles/{code}/permissions). Роли и права (role, permission) по-прежнему приводятся к файлам seed. Пограничный случай: если администратор снял все связи, таблица пуста, и следующий seed зальёт матрицу файла заново.
  • Профиль /me не перечитывается после того, как администратор правит собственные роли или права: перезагрузите страницу или войдите заново.
  • Имя и филиал ГК одним PATCH пишут одно событие аудита ASSIGNMENT_CHANGE с обоими полями в before/after (решение №35): выборка журнала по GROUP_CHANGE такое переименование не найдёт.
  • Один выбор роли — один запрос. Мульти-выбор ролей в строке таблицы отправляет по одному PUT …/roles на каждое нажатие, поэтому каждое нажатие — отдельное событие audit_event(USER_ROLE_CHANGE).
  • Показываются первые 200 пользователей (limit=200, максимум страницы); постраничного просмотра в UI пока нет — остальных ищет GET /admin/users?q=… (по email/ФИО).
  • Аудит. USER_CHANGE (создание, правка полей, деактивация) и USER_ROLE_CHANGE — раздел USERS; ROLE_PERMISSION_CHANGE — раздел ACCESS; before_value/after_value — прежнее и новое состояние.

Первичные документы: загрузка

Срез 2c: файл первичной отчётности (ОСВ) попадает в ядро, уходит на распознавание, а результат разбирается в документы и строки ОСВ. Тип документа и период при загрузке не спрашиваются (макет 40): их определяет парсер по содержимому файла, ядро только раскладывает результат. Путь файла:

POST /legal-entities/{id}/files → S3 uploads/… + stored_file(UPLOADED) + recognition_job(queued) + audit FILE_UPLOAD
  → публикатор (outbox) → NATS recognition.tabular.request → tabular-parser (читает S3, пишет results/…)
  → recognition.result → inbox (задание done, файл PROCESSING) → цикл маппинга (document + osv_entry, файл PROCESSED)
  → автозамена прежних версий (SUPERSEDED) → GET /files/{id}, GET /documents/{id}/entries → POST /documents/{id}/confirm

Загрузка — multipart/form-data, одно или несколько полей files; право DOCUMENT_EDIT, ЮЛ в области видимости пользователя (чужое — 404, как и несуществующее). Поддерживаются xls (BIFF8), xlsx, xlsb, pdf, png, jpg (.jpeg — как jpg): тип определяется по расширению и сверяется с сигнатурой содержимого. Таблицы (xls/xlsx/xlsb) идут в конвейер tabular (3 попытки), изображения и PDF — в image (1 попытка). Файл больше UPLOAD_MAX_BYTES (50 МиБ) отклоняется, тот же sha256 в пределах одного ЮЛ — тоже (DUPLICATE_FILE, в ответе file_id прежнего файла; заменённый SUPERSEDED файл дублем не считается, у другого ЮЛ тот же файл принимается).

Файлы обрабатываются по одному (в SAVEPOINT одной транзакции запроса), ответ — частичный успех: 201, если принят хотя бы один, иначе 422. Объект кладётся в S3 по ключу uploads/{file_id}/{job_id}/{безопасное имя}; имя файла очищается от путей и управляющих символов.

# ЮЛ — любое видимое пользователю (создайте через POST /directories/legal-entities или импортом ЕЦП, см. выше);
# название должно совпадать с шапкой ОСВ, иначе counterparty_check = MISMATCH и документ NEEDS_CHECK
TOKEN=$(curl -s -X POST localhost:8000/api/v1/dev/token -H 'Content-Type: application/json' \
  -d '{"email":"ka_go@armka.local"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')
LE=$(curl -s 'localhost:8000/api/v1/directories/legal-entities?q=бисквит' -H "Authorization: Bearer $TOKEN" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["items"][0]["id"])')
curl -s -X POST "localhost:8000/api/v1/legal-entities/$LE/files" -H "Authorization: Bearer $TOKEN" \
  -F 'files=@../fixtures/osv/rb/2024-FY/ОСВ_66 за 2024.xls'
# {"accepted":[{"file_id":"…","job_id":"…","name":"ОСВ_66 за 2024.xls"}],"rejected":[]}
curl -s "localhost:8000/api/v1/files/<file_id>" -H "Authorization: Bearer $TOKEN"     # статус, задание, документы

Нужны запущенное ядро с NATS_ENABLED=true, MinIO и tabular-parser (профиль services, см. «Сквозной прогон»). Без подключённого S3 загрузка отвечает 503 STORAGE_UNAVAILABLE.

Статусы файла и документа

Файл (stored_file.status) меняется только по текущему заданию (current_recognition_job_id): результат, ack, таймаут или dlq устаревшего задания файл не трогают (services/file_status.py).

Статус Когда
UPLOADED файл принят, задание в outbox
PROCESSING сервис подтвердил (ack) либо пришёл результат done/needs_check (если ack потерян — core NATS без JetStream)
PROCESSED маппинг завершён: документы и строки созданы
ERROR задание error, timeout (вахтёр, TIMEOUT), dlq или неповторяемая/исчерпавшая попытки ошибка маппинга; причина — в карточке файла (job.error_code/error_message, job.mapping_error)
SUPERSEDED файл заменён более новым с теми же документами (автозамена)

PROCESSED и SUPERSEDED события обмена не перезаписывают; запоздавший ack не воскрешает ERROR.

Документ (document.status) создаётся маппером на каждый лист ОСВ:

  • RECOGNIZED — задание done, проверки парсера без нарушений, шапка не противоречит владельцу (counterparty_check ≠ MISMATCH), предупреждений маппинга у листа нет;
  • NEEDS_CHECK — иначе: задание needs_check, нарушения checks, counterparty_check = MISMATCH, повтор счёта в файле, строки без родителя, нарастающая по данным Q1;
  • CONFIRMED — аналитик нажал «Подтвердить» (POST /documents/{id}/confirm, confirmed_by/at, аудит);
  • ERROR — зарезервирован: в Срезе 2c ядро документ в ERROR не создаёт (файл без разбираемой ОСВ → файл ERROR).

counterparty_check (MATCH / MISMATCH / UNKNOWN) сверяет шапку (ИНН, иначе имя или алиас) с ЮЛ — владельцем файла; period_check — UNKNOWN до версии консолидации (Срез 4). Допущения помечены ASSUMPTION(OQ-nn) в коде.

Цикл маппинга

Фоновая задача lifespan (services/result_mapping.py:run_mapping_loop, включается при NATS_ENABLED и подключённом S3 (storage); без S3 — предупреждение в логе, цикл не запускается) отделена от inbox: inbox только фиксирует результат и регистрирует файл результата в s3_object, не читает его. Цикл с периодом MAPPING_POLL_SECONDS берёт одно задание done/needs_check, чей файл PROCESSING и для которого оно текущее (FOR UPDATE SKIP LOCKED), затем:

  1. проверяет ключ результата: бакет хранилища и префикс results/{file_id}/{job_id}/ (OQ-70; ./../пустые сегменты отвергаются);
  2. читает объект из S3 вне транзакции и валидирует RecognitionResult v1.x (job_id/file_id должны совпасть с заданием);
  3. в одной транзакции (порядок блокировок «задание → файл») удаляет прежние строки файла (идемпотентность), создаёт document на каждый лист ОСВ и пакетами вставляет osv_entry (без строки TOTAL; parent_entry_id, account_root, account_id, counterparty_id), пишет recognition_job.group_schema и metrics.mapping_warnings, переводит файл в PROCESSED и выполняет автозамену (см. «Удаление и замена»).

Ошибки: неповторяемые (ключ вне папки задания, нет объекта, результат другого задания, невалидный результат, нет листов ОСВ) сразу переводят файл в ERROR с metrics.mapping_error; повторяемые (БД, S3) откатывают транзакцию, увеличивают metrics.mapping_attempts, цикл спит MAPPING_POLL_SECONDS и пробует снова; после MAPPING_MAX_ATTEMPTS — ERROR. Пока попытки не исчерпаны, файл остаётся PROCESSING; повторный маппинг начинается с очистки строк файла, дублей нет.

Переменная По умолчанию Назначение
UPLOAD_MAX_BYTES 52428800 (50 МиБ) предельный размер файла при загрузке; больше — FILE_TOO_LARGE в rejected
MAPPING_POLL_SECONDS 5 период опроса цикла маппинга (и пауза после повторяемой неудачи)
MAPPING_MAX_ATTEMPTS 3 число попыток маппинга файла до ERROR

Что именно делает маппинг (нормализация кодов счетов, резолв контрагента, статус документа, нарастающая по Q1) — ../docs/context/05-domain-rules.md §1.3, §1.5, §3 п. 4, §9.2–9.5; код — src/domain/osv_mapping.py (чистые функции) и src/repositories/counterparties.py (индекс ЮЛ для резолва, без policy видимости).

API файлов и документов

Все маршруты — под /api/v1; объект вне видимости пользователя — 404 с тем же телом, что у несуществующего.

Метод и путь Право Что делает
POST /legal-entities/{legal_entity_id}/files DOCUMENT_EDIT загрузка файлов (multipart files), ответ {accepted[], rejected[]}
GET /legal-entities/{legal_entity_id}/files DOCUMENT_VIEW файлы ЮЛ, новые первыми (limit/offset; include_superseded=true — с заменёнными)
GET /files/{file_id} DOCUMENT_VIEW карточка файла: статус, job (error_code, error_message, mapping_error, warnings, mapping_warnings), документы
DELETE /files/{file_id} DOCUMENT_EDIT удалить файл (см. «Удаление и замена»), 204
GET /documents/{document_id} DOCUMENT_VIEW документ: статус, проверки, locator, group_schema листа, warnings, file_id
GET /documents/{document_id}/entries DOCUMENT_VIEW строки ОСВ по row_number (limit ≤ 2000, по умолчанию 500); дерево — по parent_entry_id
POST /documents/{document_id}/confirm DOCUMENT_EDIT RECOGNIZED/NEEDS_CHECK → CONFIRMED, аудит DOCUMENT_CONFIRM

Страницы — {items, total, limit, offset}. Суммы строк — десятичные значения без округления, как в результате парсера. Коды ошибок этих маршрутов — в таблице «Ошибки API» (FILE_TOO_LARGE, UNSUPPORTED_FILE, DUPLICATE_FILE, STORAGE_UNAVAILABLE, FILE_SUPERSEDED, ALREADY_CONFIRMED, DOCUMENT_NOT_CONFIRMABLE, FILE_HAS_CONFIRMED_DOCUMENTS, FILE_SUPERSEDES_OTHERS). События аудита: FILE_UPLOAD, FILE_REPLACE, FILE_DELETE, DOCUMENT_CONFIRM (раздел PRIMARY_DOCUMENTS).

Удаление и замена

Автозамена. После успешного маппинга ядро ищет другие файлы того же ЮЛ (не SUPERSEDED), у которых есть документ с теми же document_type, osv_account_code, period_year, period_quarter, и переводит их в SUPERSEDED (superseded_by_file_id — новый файл); новый файл получает version_no = максимум заменённых + 1. Документы и строки заменённых файлов остаются (история), но список файлов их скрывает. Заменяется и файл с CONFIRMED документом (решение владельца 05.10.2026); системное событие FILE_REPLACE (user_id пуст) фиксирует перечень документов и confirmed=true. Документ без года или квартала в замене не участвует. Если новый файл содержит лишь часть документов прежнего, прежний уходит в SUPERSEDED целиком (OQ-115). Автозамены одного ЮЛ идут под advisory-блокировкой.

Удаление. DELETE /files/{id} удаляет строки ОСВ, документы, задания и stored_file, проставляет объектам S3 (uploads, results) из папок этого файла (uploads/{file_id}/, results/{file_id}/) expires_at = now() (физически объект не удаляется — ретеншн OQ-22, OQ-111) и пишет FILE_DELETE с карточкой файла в before_value. Нельзя удалить файл с документом CONFIRMED (409 FILE_HAS_CONFIRMED_DOCUMENTS) и файл, который заменил другие (409 FILE_SUPERSEDES_OTHERS); SUPERSEDED файл удалить можно. Блокировки — в порядке «задания → файл → документы».

Ограничения Среза 2c

  • Аудит обмена (смена статусов задания/файла в inbox, вахтёре, цикле маппинга) не пишется — ASSUMPTION(OQ-55); audit_event пишут только загрузка, автозамена, удаление, подтверждение.
  • Объекты S3 при удалении не стираются (OQ-22, OQ-111): растёт бакет; e2e оставляет ~2 объекта за прогон.
  • Общий размер multipart-запроса POST /legal-entities/{id}/files и число файлов в нём приложением не ограничены (Starlette спулит части на диск, UPLOAD_MAX_BYTES — лимит одного файла); лимит запроса задаётся ingress платформы.
  • Маппер Среза 2c разбирает только листы ОСВ (osvDocument); ББ/ОФР и результаты scan-gateway — следующие срезы, прочие документы результата (BALANCE/INCOME/OTHER) игнорируются без предупреждения.
  • Корневой сегмент счёта (account_root) — только цифровой (ASSUMPTION(OQ-103)): забалансовые «МЦ.04», «ГТД» получают account_root = NULL; account_id ищется точным совпадением с планом счетов seed, субсчетов в seed неполный набор — часть строк остаётся с account_id = NULL.
  • ИНН контрагента в тексте строки парсер не извлекает (OQ-100): резолв идёт по ИНН из колонки, иначе по имени и алиасу.
  • ОСВ «нарастающая по данным Q1» (Q2–Q4 с opening корня, равным opening Q1) получает предупреждение маппинга, поэтому документ остаётся NEEDS_CHECK: аналитик подтверждает его вручную (решение R-17, ждёт подтверждения владельца).
  • period_check — UNKNOWN до Среза 4; удаление файла после консолидаций (FK на document из ББ/ОФР и версий) в этом срезе не рассматривается; EntryOut не отдаёт is_modified (панель правки — подсрез 2d).
  • Два экземпляра ядра могут одновременно мапить один файл (второй проиграет блокировку и пропустит кандидата); при уменьшении MAPPING_MAX_ATTEMPTS файлы с исчерпанным счётчиком остаются PROCESSING до ручного вмешательства.

Ошибки API

Доменные ошибки (src/common/exceptions.py, обработчик — src/core/exception_handlers.py) отдаются объектом:

{"detail": {"code": "ALIAS_TAKEN", "message": "Такой алиас уже закреплён за другим ЮЛ", "legal_entity_id": "…"}}

code — машинный код (по нему работает клиент), message — текст по-русски, дополнительные поля зависят от ошибки (например, legal_entity_id, related_group_id, permissions, roles, missing). Остальные ответы — {"detail": …} другой формы: 401/403/413 — строка, 422 валидации тела — массив ошибок pydantic, 409 нарушения целостности БД — строка. Объект вне видимости — 404 NOT_FOUND с тем же телом, что у несуществующего.

Гонки уникальных ограничений. Если параллельный запрос успел вставить запись между проверкой сервиса и INSERT, БД отклоняет вставку (unique_violation), и ответ тот же 409 с тем же кодом, что у проверки (реестр — src/common/constraint_conflicts.py): CRM_ID_EXISTS (ГК и ЮЛ), INN_EXISTS, ALIAS_EXISTS, EMAIL_EXISTS, MEMBER_EXISTS/MEMBER_OF_OTHER_GROUP. В теле гонки — только код и постоянный текст, без дополнительных полей (legal_entity_id, related_group_id) и без значений ключей. Особенности: повторное добавление в ту же группу отдаёт MEMBER_OF_OTHER_GROUP с нейтральным текстом «ЮЛ уже входит в группу этого вида» (его раньше срабатывает индекс «ЮЛ + вид»); гонка за один алиас у разных ЮЛ (ALIAS_TAKEN) в БД не защищена (уникален только (ЮЛ, алиас)); email без учёта регистра проверяет только сервис, поэтому гонка с другим регистром остаётся открытым ограничением. Любое иное нарушение целостности (FK, check, неизвестное unique) — прежняя строка нарушение целостности данных в БД.

Код HTTP Где
NOT_FOUND 404 объекта нет или он вне видимости
OFFICE_NOT_FOUND 422 филиал из тела запроса не существует (ГК, ЮЛ, пользователь)
OFFICE_NOT_ALLOWED 422 ГК можно закрепить только за своим филиалом
INVALID_INN 422 ИНН не из 10 (ЮЛ) / 12 (ИП) цифр
EFFECTIVE_DATE_IN_PAST 422 дата перевода/вывода раньше сегодняшней (по Москве)
EMPTY_ALIAS 422 алиас пуст после нормализации (одни ОПФ и знаки препинания)
DIFFERENT_COMPANY_GROUP 422 ЮЛ не входит в ГК этой ГСК/ГСЗ
UNKNOWN_ROLE / UNKNOWN_PERMISSION 422 неизвестная или неактивная роль / неизвестный код права (поля roles / permissions)
MISSING_COLUMNS / UNSUPPORTED_FILE 422 файл импорта целиком (missing — перечень колонок)
CRM_ID_EXISTS 409 ГК или ЮЛ с таким crm_id уже есть
INN_EXISTS 409 ЮЛ с таким ИНН уже есть (без дополнительных полей)
LEGAL_ENTITY_CLOSED 409 перевод закрытого ЮЛ
SAME_COMPANY_GROUP 409 перевод в ту же ГК
ALREADY_CLOSED 409 ЮЛ уже закрыто
NOT_IN_COMPANY_GROUP 409 ЮЛ не входит в ГК (вывод невозможен)
GROUP_NOT_EMPTY 409 удаление ГСК/ГСЗ с текущими членами
MEMBER_EXISTS 409 ЮЛ уже входит в эту группу
MEMBER_OF_OTHER_GROUP 409 ЮЛ уже входит в другую группу этого вида (related_group_id)
ALIAS_EXISTS 409 у ЮЛ уже есть такой алиас
ALIAS_TAKEN 409 алиас закреплён за другим ЮЛ (legal_entity_id)
EMAIL_EXISTS 409 пользователь с таким email уже есть
LAST_ADMIN 409 операция оставила бы систему без активного USERS_ADMIN
UNSUPPORTED_FILE 422 загрузка: тип не из xls/xlsx/xlsb/pdf/png/jpg, расширение не совпало с сигнатурой, пустое имя; строка rejected[] ответа загрузки (при импорте — файл целиком)
FILE_TOO_LARGE 422 загрузка: файл больше UPLOAD_MAX_BYTES; строка rejected[]
DUPLICATE_FILE 422 загрузка: тот же sha256 у файла этого ЮЛ (не SUPERSEDED); строка rejected[] с file_id прежнего файла
STORAGE_UNAVAILABLE 503 / 422 S3 не подключено — 503 на весь запрос; сбой записи одного файла — строка rejected[] (если отклонены все — 422)
ALREADY_CONFIRMED 409 подтверждение: документ уже CONFIRMED
FILE_SUPERSEDED 409 подтверждение: документ файла, заменённого новой версией
DOCUMENT_NOT_CONFIRMABLE 409 подтверждение: статус документа не RECOGNIZED/NEEDS_CHECK (например, ERROR)
FILE_HAS_CONFIRMED_DOCUMENTS 409 удаление файла с CONFIRMED документом
FILE_SUPERSEDES_OTHERS 409 удаление файла, на который ссылаются superseded_by_file_id других файлов

Файл импорта больше IMPORT_MAX_BYTES — 413 со строкой в detail; при загрузке первичных файлов лимит UPLOAD_MAX_BYTES отдаётся не 413, а строкой rejected[] с кодом FILE_TOO_LARGE. Недостаточно прав — 403 строкой (недостаточно прав: требуется <ПРАВО>), нет токена или он неверен — 401. Под /api/v1 SPA не отвечает: неверный метод на существующий маршрут — 405 {"detail": "метод не поддерживается этим маршрутом"} с заголовком Allow (например, POST /api/v1/me), несуществующий путь (и dev-маршрут при выключенных флагах) — 404 {"detail": "Not Found"}. Все остальные пути — SPA: GET / и клиентские маршруты (/admin) отдают index.html без редиректов.

Конфигурация

Настройки — pydantic-settings; читаются из переменных окружения и файла api/.env. Вложенные настройки разделяются __. Файл окружения выбирается переменной ENV_FILE (по умолчанию .env, путь относительно текущего каталога — api/; значение читается при импорте). Тесты используют api/.env.test (pytest.ini). Полный пример — api/.env.example.

Переменная По умолчанию Назначение
ENV_FILE .env файл окружения (относительно api/)
DEBUG false режим разработки. true необходим dev-эмитенту токенов и make seed-dev, включает документацию API (/docs, /redoc, /openapi.json) и CORS *; при false документации нет (404), CORS — из CORS_ORIGINS, пакеты РАИСА подгружаются условно
FORWARDED_ALLOW_IPS 127.0.0.1 доверенные прокси для X-Forwarded-For/X-Forwarded-Proto (ProxyHeadersMiddleware): адреса через запятую или *. Адрес клиента (audit_event.ip_address) берётся из заголовка только от них, от остальных заголовок игнорируется. На платформе — адреса ingress/балансировщика (OQ-65)
CORS_ORIGINS пусто при DEBUG=false — источники, которым разрешены кросс-доменные запросы: строка через запятую (https://a.example.ru,https://b.example.ru) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); allow_credentials — только для явного списка без *. При DEBUG=true не используется (всегда * без credentials: токен передаётся заголовком, cookie не нужны)
SERVER__HOST, SERVER__PORT 0.0.0.0, 8000 адрес и порт сервера (run_server.py); в .env.example — 127.0.0.1 (dev-запуск только на loopback)
ROOT_PATH / корневой путь за прокси (должен совпадать с REACT_APP_ROOT_PATH фронта)
VERSION 1.0.0 версия в /healthcheck и OpenAPI
OPENBAO__DATABASE__DATABASE_URL — (обязательно) PostgreSQL, драйвер asyncpg: postgresql+asyncpg://armka:armka@localhost:5432/armka
OPENBAO__MINIO__MINIO_URL — (обязательно) адрес MinIO без схемы (host:port, так ждёт miniopy-async)
OPENBAO__MINIO__MINIO_ADMIN_LOGIN — (обязательно) логин MinIO
OPENBAO__MINIO__MINIO_ADMIN_PASSWORD — (обязательно) пароль MinIO
OPENBAO__MINIO__MINIO_BUCKET — (обязательно) бакет MinIO (arm-ka)
OPENBAO__MINIO__MINIO_SECURE true true — MinIO по https (платформа), false — по http (MinIO из compose; так в .env.example)
OPENBAO__NATS__NATS_URL — (обязательно) адрес NATS (nats://localhost:4222)
OPENBAO__NATS__NATS_SEED пусто nkey seed; пусто — подключение без учётных данных (compose, тесты). Задаётся вместе с NATS_JWT или не задаётся вовсе
OPENBAO__NATS__NATS_JWT пусто JWT NATS; см. NATS_SEED
OPENBAO__SMB__SMB_HOST пусто SMB из шаблона, необязателен (ядру в Срезе 0 не нужен). Пусто — сессия SMB не регистрируется; задан — регистрация при старте (в отдельном потоке) и сброс кэша соединений при остановке
OPENBAO__SMB__SMB_PATH пусто путь SMB (в Срезе 0 не используется)
OPENBAO__SMB__SMB_LOGIN пусто логин SMB
OPENBAO__SMB__SMB_PASSWORD пусто пароль SMB
NATS_ENABLED false подключение к NATS и обмен через recognition_job: фоновые outbox-публикатор, вахтёр сроков ответа (период 30 с, processing/published с прошедшим reply_deadline → timeout) и цикл маппинга результатов (при подключённом S3, MAPPING_POLL_SECONDS); без него фоновых задач нет, /healthcheck показывает nats=off
OUTBOX_POLL_SECONDS 5 период опроса outbox (queued → публикация → published)
ACK_TIMEOUT_SECONDS 30 ожидание ack сервиса распознавания до повторной публикации
OUTBOX_MAX_REPUBLISH 3 число повторных публикаций до перевода в dlq
IMPORT_MAX_BYTES 20971520 (20 МиБ) предельный размер файла импорта справочников; больше — 413
UPLOAD_MAX_BYTES 52428800 (50 МиБ) предельный размер файла первичной отчётности при загрузке; больше — FILE_TOO_LARGE в rejected
MAPPING_POLL_SECONDS 5 период опроса цикла маппинга результатов распознавания
MAPPING_MAX_ATTEMPTS 3 число попыток маппинга файла до ERROR
AUTH_DEV_ISSUER false dev-эмитент токенов: работает только при AUTH_DEV_ISSUER=true и DEBUG=true
AUTH_DEV_SECRET пусто секрет HS256 dev-эмитента (не короче 32 байт); только для разработки
AUTH_JWKS_URL или AUTH_PUBLIC_KEY пусто проверка токенов РАИСА: JWKS по URL либо публичный ключ (PEM)
AUTH_ISSUER, AUTH_AUDIENCE пусто проверка iss и aud токена РАИСА
AUTH_EMAIL_CLAIM email claim токена с email пользователя (сопоставляется с app_user.email)

Параметры JWT РАИСА (алгоритм, claim, эмитент) пока неизвестны — это открытый вопрос (../docs/context/07-open-questions.md); AUTH_JWKS_URL/AUTH_PUBLIC_KEY и связанные поля — каркас под него. Если не настроено ни dev-эмитента, ни ключей РАИСА, токены не принимаются вовсе (/me отвечает 401).

Порты по умолчанию: приложение 8000; compose — PostgreSQL 5432, NATS 4222 (мониторинг 8222), MinIO 9000 (консоль 9001). Порты compose меняются в ../docs/dev/.env, тогда же — соответствующие OPENBAO__* в api/.env.

Команды make

Все цели выполняются из корня репозитория (сами переходят в api/).

Цель Что делает
make venv api/.venv на Python 3.11 через uv, зависимости без raisa_*
make up поднимает compose из ../docs/dev (PostgreSQL, NATS, MinIO, бакет)
make migrate alembic upgrade head на БД из api/.env
make seed загрузка справочников продукта (идемпотентна)
make seed-dev то же и dev-пользователи/филиалы (--dev) — только для разработки, требует DEBUG=true
make lint ruff check, ruff format --check, mypy src scripts
make test юнит-тесты: pytest -m "not integration", Docker не нужен
make test-integration интеграционные тесты: pytest -m "integration and not e2e", нужны PostgreSQL (и NATS)
make e2e сквозной прогон загрузки ОСВ: pytest -m e2e, нужны compose и tabular-parser из профиля services (без него — skip)
make dbml-check сверка ORM с ../docs/model/data-model-v5.16.dbml
make migration-cycle миграции на временной БД: upgrade head → downgrade base → upgrade head, пустой autogenerate
make check быстрая проверка без Docker: lint + test
make docker-build сборка образа: docker build -t arm-core .
make web-check фронт: npm install, biome check src, tsc --noEmit, npm run build
make check-infra быстрая проверка, что PostgreSQL и NATS из compose доступны (иначе: make up)
make check-full полная проверка перед PR: check + dbml-check + check-infra + test-integration (в нём цикл миграций) + e2e (без профиля services пропускается) + docker-build + web-check; нужна поднятая инфраструктура (make up) и Docker

Тесты

  • Юнит-тесты (make test) не обращаются ни к БД, ни к NATS, Docker не нужен.
  • Интеграционные (make test-integration) требуют PostgreSQL, тесты обмена через NATS — ещё и NATS. Рабочую БД armka они не трогают: на каждую сессию pytest создаётся свежая БД armcore_pytest_<hex>, мигрируется alembic и удаляется в конце. Параметры подключения берутся из api/.env.test (pytest.ini задаёт ENV_FILE=.env.test). Файл указывает на стандартные порты и учётные данные compose из ../docs/dev (PostgreSQL 5432, NATS 4222, MinIO 9000), поэтому после make up тесты работают «из коробки». То же относится к make migration-cycle и make check-full: они тоже читают .env.test через pytest.ini.
  • Реальные данные — репозиторий ../fixtures/ (путь можно задать переменной ARM_KA_FIXTURES). Тесты с маркером fixtures без каталога пропускаются с понятной причиной.

Сквозной прогон (e2e)

Тест api/tests/e2e/test_osv_ingest_e2e.py (маркеры integration, e2e, fixtures) проверяет весь путь Среза 2c с РЕАЛЬНЫМ tabular-parser: POST /legal-entities/{id}/files (ОСВ 66 за 2024 из ../fixtures) → outbox → NATS → tabular-parser (MinIO) → inbox → маппинг → GET /files/{id} = PROCESSED, документ 66/2024/Q4 (нарастающий, RECOGNIZED, counterparty_check = MATCH) и 9 строк ОСВ. Приложение — настоящий src.app.app с lifespan на отдельной временной БД с seed, NATS и MinIO — из api/.env.test; в конце файл удаляется через DELETE /files/{id} (объекту S3 ставится expires_at), БД снимается.

Предусловие — сервис tabular-parser из профиля services на localhost:8001. Без него тест пропускается (профиль services не поднят), make check-full при этом зелёный. Инфраструктура уже поднята (make up); поднимайте только парсер, без зависимостей (--no-deps), чтобы не пересоздавать PostgreSQL/NATS/MinIO:

docker compose -f ../docs/dev/docker-compose.yml -p arm-ka --profile services up -d --build --no-deps tabular-parser
curl -s localhost:8001/healthcheck      # {"uptime": "...", "version": "...", "nats": "ok"}
make e2e                                # PASSED
docker compose -f ../docs/dev/docker-compose.yml -p arm-ka --profile services stop tabular-parser

Образ собирается из ../tabular-parser (main соседнего репозитория workspace).

Перед e2e остановите другие экземпляры ядра, подписанные на recognition.result (контейнер arm-core из профиля services, локальный make run/run_server.py с NATS_ENABLED=true): тест подписывается на реальный subject в боевой queue group arm-core, подписчики группы получают сообщения по очереди (round-robin), и ack/результат парсера уйдут чужому экземпляру (он отбросит их как unknown_job, у него другая БД) — тест упадёт по таймауту 60 с. Поднимайте только tabular-parser (--no-deps), как в команде выше.

Проверка ORM и DBML

Модель данных — ../docs/model/data-model-v5.16.dbml. ORM обязана совпадать с ней один в один:

make dbml-check     # пустой вывод и код 0 — совпадение; расхождения печатаются построчно

Другой путь к DBML: make dbml-check DBML=<путь относительно api/>.

Миграции

make migrate                                  # применить: alembic upgrade head
cd api && .venv/bin/alembic downgrade base    # откатить до пустой БД
make migration-cycle                          # проверить цикл upgrade -> downgrade -> upgrade на временной БД

Ревизии лежат в api/alembic/versions/; автогенерация — только черновик, финал сверяется с DBML (make dbml-check).

Раскладка репозитория

api/
  run_server.py        точка входа (uvicorn; хост и порт — из настроек)
  alembic/             миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16)
  seed/core/           справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист)
  seed/dev/            dev-данные: филиалы и пользователи по ролям
  scripts/             check_dbml_vs_orm.py (ORM <-> DBML), seed.py и seed_lib/ (загрузчик справочников)
  src/
    app.py             FastAPI-приложение: lifespan (S3, NATS, SMB), /healthcheck, раздача SPA
    api/endpoints/     эндпоинты (me.py, dev_auth.py, directories/, admin/); router.py собирает /api/v1
    api/dependencies/  зависимости FastAPI (current_user, проверка прав)
    core/              config, auth (JWT), clients (S3), nats (подключение, publish, подписка),
                       logger, exception_handlers
    db/                движок и сессия (схема dkb)
    models/            ORM-модели, 1:1 с DBML
    repositories/      доступ к данным
    schemas/           pydantic-схемы запросов и ответов
    services/          бизнес-логика; primary_files (загрузка/удаление), result_mapping (цикл маппинга и автозамена),
                       file_status (статус файла по заданию), documents (карточки, подтверждение), recognition_exchange (обмен)
    common/            доменные ошибки (`{detail: {code, message}}`), часы
    domain/            чистые функции по §§ правил, без БД: policy (видимость), directory_import (§13), aliases (§3), inn,
                       file_types (тип файла по расширению и сигнатуре), osv_mapping (результат парсера → документы/строки, §1.3, §1.5, §9.3)
  tests/               pytest (юнит и integration/fixtures по маркерам)
web/                   фронтенд (React 18 + TypeScript, rsbuild, antd): api/ (клиент), auth/ (вход, профиль), layouts/Admin (/admin); сборка web/dist -> api/web/
setup/                 бутстрап шаблона через Nexus банка (pip.conf, setup.sh)
openspec/              спецификации и изменения (OpenSpec)
docs/superpowers/      планы реализации
raisa_config.yml       ресурсы для платформы
Dockerfile             только для локального compose

Слои: src/api/endpoints → services → repositories → models.

Работа без Nexus

Пакеты raisa_* (Python) и raisa-* (npm) лежат в Nexus банка и вне банка недоступны. Что с этим сделано:

  • make venv ставит requirements.txt без строк raisa_* (фильтр grep -v '^raisa_') и поэтому требует uv, а не setup/setup.sh (тот рассчитан на pip.conf с Nexus).
  • Код импортирует raisa_* условно: пакет middleware РАИСА импортируется только при DEBUG=false, и ImportError допустим — приложение работает без него. Аутентификация Среза 0 — собственная проверка JWT (api/src/core/auth.py).
  • В web/package.json пакеты raisa-* вынесены в optionalDependencies: npm install не падает, если Nexus недоступен. Файл .npmrc с адресом реестра (см. setup/setup.sh) вне банка не нужен.

Безопасность dev-режима

  • POST /api/v1/dev/token выдаёт токен любому, кто знает email существующего активного пользователя: пароля нет. Только для локальной разработки и тестовых стендов.
  • Маршрут существует лишь при AUTH_DEV_ISSUER=true и DEBUG=true; при любом другом сочетании его нет вовсе (404), а dev-проверяющий токенов не создаётся даже при заданном AUTH_DEV_SECRET. В проде DEBUG=false, поэтому dev-эмитент там невозможен.
  • AUTH_DEV_SECRET из api/.env.example — публичный пример, в проде не использовать. make seed-dev (python -m scripts.seed --dev) при DEBUG=false отказывает (код 2), dev-пользователи в прод не попадут.
  • Порты compose по умолчанию публикуются только на 127.0.0.1 (BIND_ADDR); пароли armka/minioadmin — только dev.
  • Аудит входа (OQ-64; src/services/session_audit.py), раздел SESSION, object_type = app_user, IP и user-agent запроса:
    • LOGIN — выдача dev-токена (description «выдан dev-токен», в транзакции запроса) и первый успешный запрос пользователя с токеном после старта процесса («первый запрос с токеном»; учёт — в памяти процесса, после рестарта и на каждом экземпляре приложения первый запрос пишется снова);
    • LOGIN_FAILED — 401 (токен не принят: user_id пуст, в description причина, сам токен не пишется) и 403 аутентификации (пользователь не зарегистрирован, отключён или неоднозначен: email в description, user_id — если пользователь найден). Запись делается в отдельной транзакции и остаётся, хотя транзакция запроса откатилась.
    • Не события входа: запрос без заголовка Authorization или со схемой не Bearer (401 «требуется заголовок…») и 403 «недостаточно прав». Ошибка записи аудита входа ответ не меняет (только лог). Выход (LOGOUT) не пишется: сессий на сервере нет.
    • Поля, которые присылает клиент, обрезаются: user_agent — до 512 символов, description — до 1000.
  • audit_event.ip_address достоверен только за доверенным прокси. X-Forwarded-For принимается лишь от адресов FORWARDED_ALLOW_IPS (по умолчанию 127.0.0.1); без прокси в журнал попадает адрес TCP-соединения, а FORWARDED_ALLOW_IPS=* позволяет любому клиенту подставить свой адрес — в проде так не настраивать.

Соседние документы