Reviewed-on: #52
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)».
Срез 3b («формы ББ/ОФР в ядре и API до сводной ОСВ»): маппинг форм ББ/ОФР из результата парсера в ядро, форма для
экрана «Проверка распознавания» с проверками §7, правка формы до подтверждения и акцепт проверок, условия подтверждения
формы, комментарии к документам, исходный файл потоком, имена пользователей, documents_count у ЮЛ, CSV чек-листа.
Спецификация — openspec/changes/archive/2026-10-07-slice-3b-statements-api/; разделы ниже: «API файлов и документов», «Формы ББ/ОФР (Срез 3b)»,
«Ошибки API».
Срез 4 («консолидация»): схема по DBML v5.17, версии консолидации ГК за период, периметр, блокировка «взять в работу»,
статусы, привязка подтверждённых документов к версии, чек-лист, сводная ОСВ, ручное сопоставление контрагента, экспорт xlsx.
Спецификация — openspec/changes/slice-4-consolidation/; раздел ниже: «Консолидация (Срез 4)».
Предварительные требования
- 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-пользователи (по одному на каждую роль) + dev-ГК под документы fixtures
make seed загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без
dev-пользователей и dev-ГК. make seed-dev дополнительно создаёт три dev-филиала и группы компаний под документы из
fixtures (api/seed/dev/company_groups.yaml): ГК «Русский Бисквит» ровно как в выгрузке ЕЦП CRM (20 ЮЛ/ИП, ГСК из РБ и
Счастливочной, алиасы под имена контрагентов из ОСВ) и ГК владельцев форм пакета ДКБ — ОСВ и формы из fixtures после
этого загружаются на «свои» ЮЛ без импорта справочников. 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):
| Роль | Филиал | |
|---|---|---|
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).
Филиал в ответах ЮЛ и ГК приходит объектом рядом с *_id: regional_office (ЮЛ, их история) и base_regional_office
(ГК, их история) — {id, name, is_head_office} или null; неактивный филиал тоже отдаётся (имя нужно для истории).
| Метод и путь | Право | Что делает |
|---|---|---|
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; в каждой записи documents_count |
POST /directories/legal-entities |
DIRECTORY_EDIT |
создать ЮЛ/ИП (ИНН: ЮЛ — 10 цифр, ИП — 12; kpp необязателен, 9 символов, у ИП запрещён — INVALID_KPP) |
GET, PATCH /directories/legal-entities/{entity_id} |
DIRECTORY_VIEW / DIRECTORY_EDIT |
карточка (с членствами, алиасами и documents_count) / изменить (в т.ч. kpp; null очищает; прежнее значение уходит в историю) |
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 |
матрица «роль × право» |
GET /users/brief?ids=<uuid>,<uuid> |
любой аутентифицированный (права не нужны) | [{id, full_name, is_active}] для найденных пользователей (в т. ч. неактивных; неизвестные id пропускаются) — чтобы фронт показывал имена вместо UUID (confirmed_by, locked_by, авторы комментариев); до 200 id, больше — TOO_MANY_IDS, некорректный UUID — INVALID_IDS |
documents_count у ЮЛ — число документов его файлов, кроме файлов SUPERSEDED (подсчёт одним запросом на страницу).
Списки верхнего уровня (ГК, ЮЛ, пользователи) отдают страницу {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.
Порядок работы.
- Сначала пробный прогон:
dry_run=true(это значение по умолчанию). Прогон идёт тем же кодом, но транзакция откатывается; в БД, аудит и S3 ничего не попадает, отчёт тот же, что будет при записи. - Прочитайте отчёт: счётчики и построчный список
lines(row,inn,code,message). Пропущенные строки (errors) исправляются в файле и прогоняются заново. - Когда отчёт устраивает — тот же файл с
dry_run=false. Один импорт — одна транзакция; результат — отчёт в ответе,audit_event(DIRECTORY_IMPORT)с отчётом вafter_valueи файл в S3 (uploads/imports/{audit_event_id}/{имя}). - Повторный импорт того же файла при
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 |
Группа компаний |
да | имя ГК |
Организация |
да | имя ЮЛ/ИП |
Тип |
да | ЮЛ или ИП (без регистра) |
КПП |
нет | v5.17: 9 символов (4 цифры, 2 цифры/латинские A–Z, 3 цифры); пусто допустимо; у ИП непустой КПП — INVALID_KPP, строка пропускается; колонки нет — не ошибка |
Базовый РФ ГК в CRM |
нет | филиал по точному имени |
ГСК, ГСЗ |
нет | флаги «входит в ГСК» / «входит в ГСЗ» |
Нет любой обязательной колонки — файл отвергается целиком (422 MISSING_COLUMNS с перечнем в поле missing).
Параметр on_existing. skip (по умолчанию) — существующие ГК/ЮЛ не меняются; update — обновляются имя и
филиал ГК и имя/тип/КПП ЮЛ (непустой КПП из файла; пустая ячейка существующий КПП не стирает; со снимками в историю и аудитом).
Коды отчёта (§13.2). У каждой строки ровно один итоговый код, плюс сопутствующие события той же строки.
Пропуск строки (учитывается в errors): UNSUPPORTED_TYPE, INVALID_INN, INVALID_KPP, 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 создать нельзя: неизвестный код — 422UNKNOWN_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 уникален без учёта регистра (409EMAIL_EXISTS), хранится как введён. - Seed не трогает заполненную матрицу прав. Связи «роль × право» из
api/seed/core/role_permissions.yamlmake 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) создаётся маппером на каждый лист ОСВ, на каждую форму ББ/ОФР (Срез 3b, D-2) и по одному на
тип DISCLOSURE/OTHER результата (D-3; без данных, сразу RECOGNIZED). Условия для листа ОСВ:
RECOGNIZED— заданиеdone, проверки парсера без нарушений, шапка не противоречит владельцу (counterparty_check ≠ MISMATCH), предупреждений маппинга у листа нет;NEEDS_CHECK— иначе: заданиеneeds_check, нарушенияchecks,counterparty_check = MISMATCH, повтор счёта в файле, строки без родителя, нарастающая по данным Q1;- для формы ББ/ОФР условия
RECOGNIZED/NEEDS_CHECK— правило D-6 (раздел «Формы ББ/ОФР»): проверки §7 по суммам формы вместо проверок парсера; CONFIRMED— аналитик нажал «Подтвердить» (POST /documents/{id}/confirm,confirmed_by/at, аудит);ERROR— зарезервирован: в Срезе 2c ядро документ вERRORне создаёт (результат без документов — «в результате нет документов» — переводит вERRORфайл).
counterparty_check (MATCH / MISMATCH / UNKNOWN) сверяет шапку (ИНН, иначе имя или алиас) с ЮЛ — владельцем файла;
period_check — UNKNOWN: подходящесть документа версии консолидации — функция (документ, версия), а не свойство документа (Срез 4, OQ-120). Допущения помечены 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), затем:
- проверяет ключ результата: бакет хранилища и префикс
results/{file_id}/{job_id}/(OQ-70;./../пустые сегменты отвергаются); - читает объект из S3 вне транзакции и валидирует
RecognitionResultv1.x (job_id/file_idдолжны совпасть с заданием); - в одной транзакции (порядок блокировок «задание → файл») удаляет прежние строки файла (идемпотентность), создаёт
documentна каждый лист ОСВ и на каждую форму ББ/ОФР (второйBALANCE/INCOMEфайла пропускается с предупреждением; шапка, колонки, строки, значения; см. «Формы ББ/ОФР»), пакетами вставляет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; для формы ББ/ОФР — ещё PERIOD_REQUIRED и CHECKS_NOT_PASSED |
GET /documents/{document_id}/statement |
DOCUMENT_VIEW |
форма ББ/ОФР: шапка, колонки, строки со значениями, редакция, проверки §7 (см. «Формы ББ/ОФР») |
PATCH /documents/{document_id}/statement |
DOCUMENT_EDIT |
правка значений и шапки (inn, report_date, units) до подтверждения; аудит RECOGNIZED_DATA_EDIT |
POST /documents/{document_id}/statement/checks/accept |
DOCUMENT_EDIT |
акцепт непройденных жёстких проверок с комментарием ({comment}) |
GET /documents/{document_id}/comments |
DOCUMENT_VIEW |
комментарии по возрастанию created_at: {id, text, author {id, full_name}, created_at} |
POST /documents/{document_id}/comments |
DOCUMENT_EDIT |
добавить комментарий ({text}, 1–4000 символов после обрезки пробелов), 201; в любом статусе документа |
GET /files/{file_id}/content |
DOCUMENT_VIEW |
исходный файл потоком из S3; ?disposition=attachment (по умолчанию) или inline; имя — Content-Disposition по RFC 5987, X-Content-Type-Options: nosniff |
Карточка документа и документ в карточке файла несут comments_count. Просмотр и скачивание первички (/files/{id}/content)
и CSV чек-листа, как и комментарии, в аудит не пишутся (в отличие от экспорта сводной ОСВ в xlsx: он пишет EXPORT; OQ-153). Тело потока из S3 освобождается детерминированно (common/streaming.py: ClosingStreamingResponse, ChainedStream), в том числе при обрыве клиента. Чужой и несуществующий файл — 404 NOT_FOUND; файл есть, а объекта
в S3 нет (истёк или пропал) — 404 FILE_CONTENT_NOT_FOUND; S3 не подключено — 503 STORAGE_UNAVAILABLE.
Страницы — {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_CONTENT_NOT_FOUND) и форм ББ/ОФР (DOCUMENT_NOT_STATEMENT,
DOCUMENT_READONLY, UNKNOWN_CELL, DUPLICATE_CELL, INVALID_INN, NOTHING_TO_ACCEPT, PERIOD_REQUIRED,
CHECKS_NOT_PASSED). События аудита: FILE_UPLOAD, FILE_REPLACE, FILE_DELETE, DOCUMENT_CONFIRM (раздел
PRIMARY_DOCUMENTS), RECOGNIZED_DATA_EDIT (раздел RECOGNITION; правка формы и акцепт).
Формы ББ/ОФР (Срез 3b)
Цикл маппинга разбирает не только листы ОСВ, но и формы ББ/ОФР результата парсера: на каждую форму создаются document
(BALANCE/INCOME; второй BALANCE/INCOME того же файла пропускается с предупреждением маппинга, OQ-77, D-3, а первый получает
NEEDS_CHECK) и шапка financial_statement_meta с колонками, строками и значениями (financial_statement_column,
_line, _value). Первичка не мутируется: правка аналитика пишется рядом (is_modified, original_amount,
modified_by/at; у полей шапки — *_modified, *_original_value). Период формы — по дате отчёта, а при её отсутствии по самой
поздней дате колонок; ББ/ОФР всегда нарастающие. Статус документа формы — RECOGNIZED только если задание done, нет нарушений
проверок §7 (или они акцептованы), контрагент не MISMATCH, период определён, значения разобраны, есть строки и нет
предупреждений; иначе NEEDS_CHECK (правило D-6 спеки). Проверки §7 (domain/statement_checks.py) считаются на лету по
текущим суммам формы и не хранятся; словарь — api/rules/statement_checks.yaml (копия словаря tabular-parser — правится
в обоих репозиториях).
GET /documents/{id}/statement—{document, edition, header, columns[], lines[], checks}:header—inn,report_date,unitsвида{value, original_value, confidence, verified, modified}(confidenceесть у ИНН, у даты и единиц —null) и справочные поля формы (organization_name,okud,okpo, …);lines[].values[]— суммы строкой,is_modified,original_amount;checks—{failed, soft_failed, cross_form, accepted}(нарушение —{column_index, relation, expected, actual}, у межформенной проверкиcolumn_index = -1). Не форма (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) — 409DOCUMENT_NOT_STATEMENT. Подтверждённая форма читается.PATCH /documents/{id}/statement—{header?: {inn?, report_date?, units?}, values?: [{line_id, column_id, amount}]}(до 5000 ячеек;amount: nullочищает ячейку; не более двух знаков;units— непустая строка после обрезки пробелов до 50 символов, пробельная строка — 422,null— «не менять»;inn: null— 422INVALID_INN, ИНН не очищается). Правится только неподтверждённая форма (RECOGNIZED/NEEDS_CHECK):CONFIRMED— 409DOCUMENT_READONLY(исправление — новым файлом), заменённый файл — 409FILE_SUPERSEDED, статусERROR— 409DOCUMENT_NOT_CONFIRMABLE. Ячейка не из этой формы — 422UNKNOWN_CELL, одна ячейка дважды — 422DUPLICATE_CELL, ИНН не из 10 или 12 цифр — 422INVALID_INN. Один запрос — одна транзакция и одно событиеRECOGNIZED_DATA_EDITсbefore/after; возврат ячейки к исходному значению снимаетis_modified; правка ИНН пересчитываетcounterparty_check, даты отчёта — период документа; любая правка сбрасывает акцепт и пересчитывает статус. Запрос без изменений — 200 без аудита. Ответ — та же форма, что уGET.POST /documents/{id}/statement/checks/accept—{comment}(1–2000 символов): акцепт нарушенных жёстких проверок (checks_accepted_by/at/comment). Нарушений нет — 409NOTHING_TO_ACCEPT. Акцепт сбрасывается любой правкой формы (вbefore_valueсобытия аудита сброса остаётся комментарий сброшенного акцепта).- Подтверждение формы (
POST /documents/{id}/confirm) дополнительно требует определённый период (иначе 409PERIOD_REQUIRED) и пройденные или акцептованные жёсткие проверки §7 (иначе 409CHECKS_NOT_PASSED; вdetail.failures[]—{column_index, relation, expected, actual}). Мягкие проверки (в т. ч. межформенная1370 − 1370(начало года) = 2400) подтверждение не блокируют. Подтверждённая форма привязывается к версиям консолидации и закрывает ячейки ББ/ОФР чек-листа, как ОСВ. - Порядок блокировок правки и акцепта — «файл → документ» (как у подтверждения); шапка перечитывается под блокировкой. Комментарии аудит не пишут; при удалении файла комментарии его документов удаляются вместе с документами (полиморфная связь без FK; решение №18 закрыто).
Расчёт — src/domain/statement_mapping.py и src/domain/statement_checks.py (чистые функции), доступ к данным —
src/repositories/statements.py, правка и акцепт — src/services/statements.py.
Удаление и замена
Автозамена. После успешного маппинга ядро ищет другие файлы того же ЮЛ (не 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); с Среза 3b он мапит и формы ББ/ОФР (раздел «Формы ББ/ОФР»). ДокументыDISCLOSURE/OTHERрезультата не игнорируются: на каждый тип создаётся один документ без данных (RECOGNIZED,locator— перечень листов через запятую; OQ-23). Отдельной обработки по конвейеру (tabular/image) в маппере нет: разбирается любой результат, проходящий схемуrecognition-result. - Корневой сегмент счёта (
account_root) — только цифровой (ASSUMPTION(OQ-103)): забалансовые «МЦ.04», «ГТД» получаютaccount_root = NULL;account_idищется точным совпадением с планом счетов seed, субсчетов в seed неполный набор — часть строк остаётся сaccount_id = NULL. - ИНН контрагента в тексте строки парсер не извлекает (OQ-100): резолв идёт по ИНН из колонки, иначе по имени и алиасу.
- ОСВ «нарастающая по данным Q1» (Q2–Q4 с
openingкорня, равнымopeningQ1) получает предупреждение маппинга, поэтому документ остаётсяNEEDS_CHECK: аналитик подтверждает его вручную (решение R-17, ждёт подтверждения владельца). period_check—UNKNOWN(OQ-120); к версии консолидации привязываются толькоCONFIRMEDдокументы, а их файл удалить нельзя — удаление файла с документом в версии невозможно (OQ-110, Срез 4); FKex_snapshot_item— Срез 7;EntryOutне отдаётis_modified(панель правки — подсрез 2d).- Два экземпляра ядра могут одновременно мапить один файл (второй проиграет блокировку и пропустит кандидата);
при уменьшении
MAPPING_MAX_ATTEMPTSфайлы с исчерпанным счётчиком остаютсяPROCESSINGдо ручного вмешательства.
Консолидация (Срез 4)
Срез 4: версии консолидации ГК за период, периметр, блокировка «взять в работу», статусы (автоматические по событиям
документов и ручные), привязка подтверждённых документов к версии, чек-лист комплекта, сводная ОСВ (чистая функция от
версии, в БД не хранится), ручное сопоставление контрагента и экспорт в xlsx. Спецификация —
openspec/changes/slice-4-consolidation/ (design.md: D-1…D-16 и таблица «Decisions made without discussion»);
контракт для фронта — ../docs/dev/api-contract-slice-4.md; правила — ../docs/context/05-domain-rules.md §2, §3, §9, §10.
Эндпоинты
Все маршруты — под /api/v1; права — CONSOLIDATION_VIEW (чтение) и CONSOLIDATION_EDIT (правки). Порядок проверок:
право → видимость ГК (чужое и несуществующее — один 404) → статус «в работе» и своя блокировка → операция.
| Метод и путь | Право | Что делает |
|---|---|---|
POST /consolidations |
EDIT | создать консолидацию ГК за период и версию №1 (INITIATED); периметр — текущие члены ГСК (≥ 2) или выбранные ЮЛ ГК legal_entity_ids (≥ 2, #42) |
GET /company-groups/{id}/consolidations |
VIEW | периоды ГК и их версии (in_work_only) |
GET /consolidations/versions/{id} |
VIEW | карточка версии: периметр (тип ЮЛ, счета ОСВ к периоду версии), чек-лист по ЮЛ (самоописывающие ячейки), история статусов, блокировка |
POST /consolidations/versions/{id}/lock |
EDIT | «взять в работу»; {takeover_reason} — перехват чужой блокировки с причиной |
DELETE /consolidations/versions/{id}/lock |
EDIT | снять свою блокировку (204; нет активной — тоже 204) |
POST /consolidations/versions/{id}/status |
EDIT | ручной переход: CANCELLED (с комментарием) или возврат в INITIATED |
PUT /consolidations/versions/{id}/members |
EDIT | заменить периметр (ЮЛ должны принадлежать ГК, ≥ 2) |
GET /consolidations/versions/{id}/checklist |
VIEW | комплект документов: ЮЛ × правила, статус ячейки; ?format=csv — тот же чек-лист файлом CSV |
GET /consolidations/versions/{id}/osv |
VIEW | страница сводной ОСВ и фильтры (limit ≤ 2000, по умолчанию 500) |
GET /consolidations/versions/{id}/osv/summary |
VIEW | rows_total, unmapped_total, auto_matched_total, documents_total, gaps[] |
GET /consolidations/versions/{id}/osv/export |
VIEW | xlsx: лист «Сводная ОСВ» и лист «Пробелы» |
GET /consolidations/versions/{id}/counterparty-candidates |
VIEW | кандидаты для сопоставления: scope=group|all, q, entry_id |
POST /consolidations/versions/{id}/osv/entries/{entry_id}/counterparty |
EDIT | ручное сопоставление контрагента строки свода |
POST /legal-entities/{id}/files?consolidation_version_id= |
DOCUMENT_EDIT |
загрузка в контексте версии (необязательный параметр, Срез 2c) |
Деньги в ответах — строки JSON с двумя знаками ("100.00"); версия любого статуса (включая CANCELLED) читается.
Доработка #42 (openspec/changes/front-api-gaps/): создание с выбранным периметром (legal_entity_ids), ячейки чек-листа с
document_type/account, members[].entity_type/osv_accounts; контракт — ../docs/dev/api-contract-slice-4.md, файлы и
справочники — ../docs/dev/api-contract-files-directories.md.
Статусы версии
Автоматические переходы (services/consolidation_status.transition — единственное место смены consolidation_version.status;
пишет consolidation_status_history и audit_event(CONSOLIDATION_STATUS_CHANGE)):
| Событие | Переход | changed_by |
|---|---|---|
загрузка с consolidation_version_id, принят ≥ 1 файл |
INITIATED → DOCUMENTS_PROCESSING |
загрузивший |
маппинг создал документ NEEDS_CHECK, подходящий версии |
DOCUMENTS_PROCESSING → DATA_CORRECTION |
uploaded_by файла (нет — переход пропускается, лог) |
| подтверждение документа, привязанного к версии | INITIATED/DOCUMENTS_PROCESSING → DATA_CORRECTION |
подтвердивший |
POST …/status {CANCELLED, comment} |
любой «в работе» → CANCELLED (снимает блокировку) |
пользователь |
POST …/status {INITIATED} |
DOCUMENTS_PROCESSING/DATA_CORRECTION → INITIATED (привязки сохраняются) |
пользователь |
«В работе» — INITIATED, DOCUMENTS_PROCESSING, DATA_CORRECTION. Остальные переходы (маршрут согласования) — Срез 7;
вручную они дают 409 TRANSITION_NOT_ALLOWED. Загрузка и подтверждение документа блокировкой не ограничиваются.
Блокировка
consolidation_lock: одна активная блокировка на версию (частичный UNIQUE released_at IS NULL). POST …/lock без тела:
свободна — 201, своя — 200 (идемпотентно), чужая — 409 LOCKED_BY_OTHER. С takeover_reason (непустая) чужая
блокировка снимается, создаётся новая (taken_over_from), audit_event(CONSOLIDATION_LOCK_TAKEOVER). Любая правка версии
(PUT …/members, POST …/status, ручное сопоставление) требует статус «в работе» и свою активную блокировку.
Блокировка не истекает по времени (OQ-119); CANCELLED её снимает.
Привязка документов
Версия владеет явным комплектом документов (consolidation_document). Документ подходит версии, если ЮЛ — член
периметра, period_year = год версии и: нарастающий (ББ/ОФР всегда) — period_quarter = квартал версии, квартальный —
period_quarter ≤ квартал версии. Исключение для ОСВ (R-14): документ ОСВ за Q1 равнозначен нарастающему и
квартальному и подходит версии любого квартала года (парсер помечает ОСВ за Q1 как нарастающую); ББ/ОФР за Q1 версиям
Q2–Q4 не подходят (R-14a). Привязываются только CONFIRMED документы незаменённых файлов.
- Привязка: подтверждение документа (ко всем версиям «в работе» ГК ЮЛ), создание версии и добавление ЮЛ в периметр
(подхват: на ключ «тип, счёт, квартал, признак нарастающего» — документ с самым поздним
confirmed_at). - Отвязка: автозамена файла (привязки всех документов заменённого файла; версии — в
FILE_REPLACE.after_value.unbound_version_ids) и удаление ЮЛ из периметра. ВерсииCANCELLEDне трогаются. - Файл с документом в версии удалить нельзя (привязываются только
CONFIRMED, а их файл удалять запрещено — OQ-110). period_checkдокумента остаётсяUNKNOWN— подходящесть — функция (документ, версия), а не свойство документа (OQ-120).
Чек-лист
GET …/checklist и members[].checklist карточки: строки — члены периметра, колонки — required_document_rule подхода
версии (BASIC: ББ, ОФР, ОСВ 01, 58, 60, 62, 66, 67, 76, 90). Статус ячейки: CONFIRMED (пара покрыта привязанными
документами), NEEDS_CHECK/RECOGNIZED (есть неподтверждённый подходящий), PROCESSING (у ЮЛ есть файл в обработке),
ERROR (последний файл ЮЛ в ERROR), MISSING; missing_quarters — пропущенные кварталы при частичном покрытии ОСВ.
PROCESSING ставится по всем недостающим столбцам ЮЛ, пока файл не смаплен (до маппинга счёт файла неизвестен).
GET …/checklist?format=csv (Срез 3b, право CONSOLIDATION_VIEW) отдаёт чек-лист файлом text/csv: UTF-8 с BOM, разделитель ;,
строки через CRLF, заголовок — «Организация;ИНН;<правило>…» (подписи правил: «Бух. баланс», «ОФР», «ОСВ 66»), строки — ЮЛ, в ячейках — подписи
статусов («Загружен», «Обработка», «Требуется проверка», «Ошибка загрузки», «Отсутствует»; при пропущенных кварталах —
«… (нет: Q1, Q2)»); имя файла — checklist-<ГК>-<год>-<квартал>.csv
в Content-Disposition по RFC 5987. Формат по умолчанию — json; другое значение format — 422 валидации. Скачивание в аудит не пишется
(domain/checklist_csv.py, чистая функция).
Сводная ОСВ
Чистая функция от версии (domain/consolidated_osv.build + domain/consolidation_periods.select_sources), в БД не пишется
(DECISION-5); одна и та же для страницы, сводки и экспорта (services/consolidated_osv.py:_build, весь свод версии в памяти —
порог для кэша OQ-121). Источники — строки привязанных ОСВ-документов (group_type = COUNTERPARTY, data_type BU/SUM).
- На пару (ЮЛ, счёт): нарастающий документ за квартал версии — как есть; иначе квартальные документы
Q1..Qnсклеиваются по ключу (счёт, имя контрагента, ИНН): сальдо начала — из Q1, обороты — сумма, сальдо конца — из Qn, отсутствующая в квартале строка — нули; нарастающий и квартальные вместе — нарастающий. Нет квартала — пробел (gaps[]по паре, строк нет). Q1-эквивалентность ОСВ — см. «Привязка документов». - Контрагент строки: хранимый
osv_entry.counterparty_idлибо резолв на лету (ИНН → имя → алиас; в БД не пишется,match_stored = false);match_kind—INN/NAME/ALIAS(шаг резолва),MANUAL,PROPAGATED. Тип контрагента:GSK(член периметра),GK(та же ГК), иначеEXTERNAL. - Фильтры:
legal_entity_id,account_root,counterparty_type,unmapped,match_kind(повторяемый),q(имя — подстрока без регистра, ИНН — префикс). Порядок: имя ЮЛ, счёт (натуральный), номер строки. warningsстрок — диагностика (расхождениеcounterparty_idмежду кварталами), не контракт.
Сопоставление контрагента
GET …/counterparty-candidates — до 100 ЮЛ: scope=group (периметр и ГК, q необязателен) или scope=all (весь справочник,
q ≥ 3 символов, иначе 422 QUERY_TOO_SHORT); recommended — нормализованное имя/алиас совпало с именем строки entry_id.
POST …/osv/entries/{entry_id}/counterparty {legal_entity_id, move_alias} — одна транзакция:
- строка должна быть строкой свода версии (
COUNTERPARTY,BU/SUM, документ привязан;NU/ACCOUNT— 404); - строка получает
counterparty_idиmatch_kind = MANUAL; имя/ИНН из файла не меняются; - создаётся алиас имени строки у выбранного ЮЛ (
LEGAL_ENTITY_CHANGE); алиас, закреплённый за другим ЮЛ, —409 ALIAS_TAKEN(owners[]), сmove_alias = trueпереносится (правоDIRECTORY_DELETEне нужно); - автоприменение: строки всех ОСВ версии с тем же именем, пустым
counterparty_idи ИНН пустым или равным —PROPAGATED; audit_event(OSV_COUNTERPARTY_MAP)сbefore/afterиpropagated_entry_ids.
Ответ — {row, propagated, alias_created, alias_moved_from}; row бывает null (строка привязанного документа, не попавшая
в свод). Строки osv_entry общие у версий одного документа: сопоставление в одной версии меняет строку для всех (OQ-123).
Экспорт
GET …/osv/export — xlsx: лист «Сводная ОСВ» (фильтры как у GET …/osv, без пагинации; суммы — числовые ячейки #,##0.00) и
лист «Пробелы» (по своду целиком, от фильтров не зависит). Имя — заголовок Content-Disposition
(filename="svod-osv.xlsx"; filename*=UTF-8''svod-osv_<ГК>_<год>-<Qn>_v<n>.xlsx). Текстовые ячейки пишутся как текст
(без формул из внешних строк). Пишет audit_event(EXPORT).
События аудита
CONSOLIDATION_CREATE, CONSOLIDATION_STATUS_CHANGE, CONSOLIDATION_LOCK, CONSOLIDATION_LOCK_TAKEOVER, MEMBERSHIP_CHANGE
(замена периметра; состав не менялся — события нет), OSV_COUNTERPARTY_MAP, LEGAL_ENTITY_CHANGE (алиасы сопоставления),
EXPORT; FILE_REPLACE дополнен unbound_version_ids.
Порядок блокировок и инварианты
- Блокировки строк: файл → документ → advisory-lock ГК → версии (по возрастанию
id); правка версии начинается сconsolidation_version FOR UPDATE.lock_versions_for_entityблокирует надмножество версий «в работе» (член ИЛИ та же ГК), создание версии берётpg_advisory_xact_lockпо ГК (R-2) — иначе привязка тихо теряется. - Документы под
FOR NO KEY UPDATE(R-3): вставкаconsolidation_documentберётFOR KEY SHAREна документ и конфликтует сFOR UPDATE. Сопоставление: версия → advisory по алиасу → алиасы → строкиosv_entryс тем же именем. - Статус версии меняет только
consolidation_status.transition, версия подFOR UPDATEу вызывающего.
Ограничения Среза 4
- Маршрут согласования (
CONSOLIDATION_RFи далее), «Добавить версию»,CONFIRM,EXTENDED, гудвилл — Срезы 6–7; шахматка и ВГО — Срез 5; ББ/ОФР в своде не участвуют; UI — отдельный исполнитель. - Открытые вопросы —
../docs/context/07-open-questions.md: OQ-118 (запрет загрузки/подтверждения послеDATA_CORRECTION), OQ-119 (истечение блокировки), OQ-120 (period_check), OQ-121 (порог размера свода), OQ-122 (ячейкаPROCESSING), OQ-123 (общие строки у версий), OQ-124 (Q1-эквивалентность ОСВ), OQ-125 (склейка Q1 версии Q1), OQ-126 (область ручного сопоставления), OQ-127 (рекомендация кандидата), OQ-128 (неоднозначность дорезолва), OQ-129 (формулировка «1 + propagated»). - Экспорт: числа xlsx — IEEE-754, суммы свыше ~10^13 могут потерять младшую копейку.
- Тест на реальных данных —
api/tests/test_consolidation_fixtures.py(маркерfixtures): ОСВ РБ и Счастливочной за Q1–Q3 2024 → версия Q3 → свод (1385 строк), 22 документа привязано, пробелы по остальным счетам.
Ошибки 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) БД не ловит (уникален только (ЮЛ, алиас)) — её
сериализует advisory-блокировка по нормализованному алиасу (lock_alias_key: добавление и удаление алиаса, ручное
сопоставление контрагента; удаление перечитывает алиас под блокировкой и отвечает 404, если его уже убрал перенос);
email без учёта регистра проверяет только сервис, поэтому гонка с другим регистром остаётся открытым ограничением.
Любое иное нарушение целостности (FK, check, неизвестное unique) — прежняя строка нарушение целостности данных в БД.
| Код | HTTP | Где |
|---|---|---|
NOT_FOUND |
404 | объекта нет или он вне видимости; в консолидациях различие — по тексту: «версия консолидации не найдена», «ГК не найдена», «ГСК/ГСЗ не найдена», «строка ОСВ не найдена в версии», «ЮЛ не найдено» |
OFFICE_NOT_FOUND |
422 | филиал из тела запроса не существует (ГК, ЮЛ, пользователь) |
OFFICE_NOT_ALLOWED |
422 | ГК можно закрепить только за своим филиалом |
INVALID_INN |
422 | ИНН не из 10 (ЮЛ) / 12 (ИП) цифр; в правке формы (PATCH …/statement) — не из 10 или 12 цифр |
INVALID_KPP |
422 | КПП не из 9 символов формата NNNN[0-9A-Z]{2}NNN или КПП у ИП (DBML v5.17, OQ-31) |
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; в ручном сопоставлении контрагента — owners[] {legal_entity_id, name}) |
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 на весь запрос (и на GET /files/{id}/content); сбой записи одного файла — строка rejected[] (если отклонены все — 422) |
ALREADY_CONFIRMED |
409 | подтверждение: документ уже CONFIRMED |
FILE_SUPERSEDED |
409 | подтверждение: документ файла, заменённого новой версией |
DOCUMENT_NOT_CONFIRMABLE |
409 | подтверждение: статус документа не RECOGNIZED/NEEDS_CHECK (например, ERROR) |
FILE_HAS_CONFIRMED_DOCUMENTS |
409 | удаление файла с CONFIRMED документом |
FILE_CONTENT_NOT_FOUND |
404 | GET /files/{id}/content: файл есть, объекта нет в S3 (истёк или пропал) |
DOCUMENT_NOT_STATEMENT |
409 | …/statement: документ — не форма ББ/ОФР (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) |
DOCUMENT_READONLY |
409 | правка/акцепт формы: форма уже CONFIRMED (исправление — новым файлом) |
UNKNOWN_CELL |
422 | PATCH …/statement: line_id/column_id не принадлежат этой форме |
DUPLICATE_CELL |
422 | PATCH …/statement: одна ячейка указана в запросе дважды |
NOTHING_TO_ACCEPT |
409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) |
PERIOD_REQUIRED |
409 | подтверждение формы: период не определён — поправьте дату отчёта |
CHECKS_NOT_PASSED |
409 | подтверждение формы: жёсткие проверки §7 не пройдены и не акцептованы; detail.failures[] {column_index, relation, expected, actual} |
TOO_MANY_IDS |
422 | GET /users/brief: больше 200 id |
INVALID_IDS |
422 | GET /users/brief: элемент ids — не UUID |
FILE_SUPERSEDES_OTHERS |
409 | удаление файла, на который ссылаются superseded_by_file_id других файлов |
CONSOLIDATION_EXISTS |
409 | консолидация этой ГК за год и квартал уже создана (в т. ч. гонка двух POST /consolidations) |
PERIMETER_TOO_SMALL |
422 | в ГСК (при создании без списка), в выбранных ЮЛ (legal_entity_ids при создании) или в новом периметре (PUT …/members) меньше двух ЮЛ; ничего не создаётся и не меняется |
RELATED_GROUP_MISMATCH |
422 | ГСК принадлежит другой ГК или имеет вид gsz (невидимая/неизвестная ГСК — 404 NOT_FOUND) |
ENTITY_NOT_IN_GROUP |
422 | POST /consolidations с legal_entity_ids и PUT …/members: ЮЛ не из ГК консолидации, без ГК или несуществующее (legal_entity_ids — перечень); проверка раньше PERIMETER_TOO_SMALL |
ENTITY_NOT_IN_PERIMETER |
422 | загрузка с consolidation_version_id: ЮЛ файла не входит в периметр версии |
QUERY_TOO_SHORT |
422 | кандидаты scope=all: q короче 3 символов после обрезки |
COMMENT_REQUIRED |
422 | POST …/status в CANCELLED без комментария (из одних пробелов — тоже) |
VERSION_NOT_EDITABLE |
409 | версия не «в работе» (правки, загрузка с версией) либо CANCELLED при «взять в работу» |
LOCK_REQUIRED |
409 | правка версии без своей активной блокировки |
LOCKED_BY_OTHER |
409 | версию держит другой пользователь: из POST …/lock — locked_by, locked_by_name, locked_at; из правок (ensure_version_editable) — только locked_by, locked_at; гонка двух взятий (частичный UNIQUE) — то же 409 |
NOT_LOCK_OWNER |
409 | DELETE …/lock: блокировка чужая (locked_by, locked_at) |
TRANSITION_NOT_ALLOWED |
409 | ручной переход вне таблицы Среза 4, в т. ч. повтор того же статуса (from_status, to_status) |
Файл импорта больше 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.
| Переменная | По умолчанию | Назначение |
|---|---|---|
OPENBAO__APP__<ИМЯ> |
— | контур платформы: туда передаются только секреты OpenBao (OPENBAO__*), обычные переменные окружения задать нельзя. Любая настройка верхнего уровня из этой таблицы (NATS_ENABLED, DEBUG, AUTH_DEV_ISSUER, FORWARDED_ALLOW_IPS, CORS_ORIGINS, …) может прийти как OPENBAO__APP__<ИМЯ>, например OPENBAO__APP__NATS_ENABLED=true. Значения разбираются как из обычного env (bool, числа, списки). Приоритет: обычный env → OPENBAO__APP__* → .env → значение по умолчанию. Неизвестное имя — ошибка конфигурации при старте (опечатка не игнорируется); OPENBAO__APP__OPENBAO__… не допускается. Читается только из окружения процесса (в .env не действует). Реализация — api/src/core/settings_sources.py |
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.17.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(PostgreSQL5432, NATS4222, MinIO9000), поэтому послеmake upтесты работают «из коробки». То же относится кmake migration-cycleиmake check-full: они тоже читают.env.testчерезpytest.ini. - Реальные данные — репозиторий
../fixtures/(путь можно задать переменнойARM_KA_FIXTURES). Тесты с маркеромfixturesбез каталога пропускаются с понятной причиной. Срез 4:tests/test_consolidation_fixtures.py— сквозной сценарий на ОСВ РБ и Счастливочной (golden-снимки, без tabular-parser).
Сквозной прогон (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.17.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, 0005_dbml_v5_17.py — v5.17)
rules/ statement_checks.yaml — словарь проверок §7 формы ББ/ОФР (копия словаря tabular-parser; меняется в обоих репозиториях)
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/, files/, consolidations/); 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 (карточки, подтверждение, комментарии), statements (форма ББ/ОФР: чтение, правка, акцепт; Срез 3b),
recognition_exchange (обмен);
Срез 4 — consolidations (версии, периметр), consolidation_guard (общий пролог правок), consolidation_locks,
consolidation_status (`transition`), consolidation_documents (привязка), consolidated_osv (свод, кандидаты),
counterparty_mapping (ручное сопоставление), osv_export (xlsx)
common/ доменные ошибки (`{detail: {code, message}}`), часы
domain/ чистые функции по §§ правил, без БД: policy (видимость), directory_import (§13), aliases (§3), inn,
file_types (тип файла по расширению и сигнатуре), osv_mapping (результат парсера → документы/строки, §1.3, §1.5, §9.3);
Срез 3b — statement_mapping (формы ББ/ОФР: период, статус, §9.3, §9.5), statement_checks (проверки §7),
checklist_csv (CSV чек-листа);
Срез 4 — consolidation_periods (выбор источников, §2), consolidated_osv (свод), checklist (чек-лист), policy
(`ensure_version_editable`, §10)
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=*позволяет любому клиенту подставить свой адрес — в проде так не настраивать.
Соседние документы
CLAUDE.md— соглашения этого репозитория;../CLAUDE.md— workspace: инварианты, источники истины, процесс;../docs/dev/README.md— окружение разработки (compose), порты, типичные проблемы;../docs/context/03-architecture.md— архитектура, соглашения шаблона, NATS и S3;../docs/context/04-data-model-guide.mdи../docs/model/data-model-v5.17.dbml— модель данных;../docs/context/05-domain-rules.md— алгоритмы предметной области;../docs/context/07-open-questions.md— открытые вопросы;../docs/context/09-delivery-plan.md— план срезов;../docs/contracts/— JSON-схемы обмена с сервисами распознавания;openspec/changes/slice-0-skeleton/— спецификация и решения Среза 0.