Andrey Epifancev 0eedb822e2 fix: /docs и OpenAPI только при DEBUG, CORS из CORS_ORIGINS в проде (OQ-65, OQ-66, #9 п.2)
Приложение собирает `create_app(settings, web_dir=...)`: при DEBUG=false нет
`/docs`, `/redoc`, `/openapi.json` (SPA-заглушка на эти пути отвечает 404, а не
index.html); CORS — `cors_options`: DEBUG — `*` с credentials, иначе
`CORS_ORIGINS` (пусто — middleware не подключается, credentials только для
списка без `*`). Настройка `CORS_ORIGINS` читает JSON-список или строку через
запятую. `.env.example` и README «Конфигурация» дополнены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 13:27:38 +04:00

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».

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

  • 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 идемпотентен: повторный запуск обновляет данные и ничего не дублирует. 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 ≥ сегодня; членства ГСК/ГСЗ закрываются
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 возвращает снятые права. make seed / make seed-dev идемпотентны и только добавляют недостающие связи «роль × право» из api/seed/core/role_permissions.yaml: право, которое администратор снял с роли в матрице, следующий запуск seed выдаст снова (granted_by — пусто); права, добавленные администратором сверх seed, seed не отзывает. На стенде с правками матрицы seed повторно не запускайте (follow-up к M-8 Среза 0).
  • Профиль /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 — прежнее и новое состояние.

Ошибки 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

Файл импорта больше IMPORT_MAX_BYTES — 413 со строкой в detail. Недостаточно прав — 403 строкой (недостаточно прав: требуется <ПРАВО>), нет токена или он неверен — 401.

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

Настройки — 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, пакеты РАИСА подгружаются условно
CORS_ORIGINS пусто при DEBUG=false — источники, которым разрешены кросс-доменные запросы: строка через запятую (https://a.example.ru,https://b.example.ru) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); allow_credentials — только для явного списка без *. При DEBUG=true не используется (всегда *)
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); без него фоновых задач нет, /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
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, нужны PostgreSQL (и NATS)
make dbml-check сверка ORM с ../docs/model/data-model-v5.14.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 (в нём цикл миграций) + 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 без каталога пропускаются с понятной причиной.

Проверка ORM и DBML

Модель данных — ../docs/model/data-model-v5.14.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)
  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/          бизнес-логика
    common/            доменные ошибки (`{detail: {code, message}}`), часы
    domain/            чистые функции по §§ правил, без БД: policy (видимость), directory_import (§13), aliases (§3), inn
  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.

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

Description
No description provided
Readme 14 MiB
Languages
Python 83.9%
TypeScript 15.7%
CSS 0.2%