Andrey Epifancev b018b6b055 docs: design.md — решения 14–23 (рулинги пред-проверки SDD)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 18:00:01 +04:00

24 KiB
Raw Blame History

Design: Срез 0 — скелет ядра arm-core

Context

См. proposal.md — Why. Что задано и не обсуждается здесь:

  • Шаблон команды (../docs/context/03-architecture.md, «Соглашения шаблона команды»): раскладка api/src/, pydantic-settings с OPENBAO__*, схема dkb, Alembic с URL из настроек, DI ручными Depends, loguru, обработчики ошибок {"detail": …}, SPA из api/web/, DEBUG=true отключает NATS/РАИСА.
  • DBML v5.13 — источник имён и структур; поля вне DBML не вводятся.
  • Контракт v1.0 — родительский change в docs (конверты сообщений, ack processing, файл результата, Nats-Msg-Id).
  • Платформа (ответы 03.10.2026): Python 3.11, PostgreSQL 16, NATS без JetStream, Nexus недоступен до прода, CI в Gitea нет — пайплайн платформы вне репозитория, параметры JWT РАИСА неизвестны.
  • Инварианты CLAUDE.md: права — данные; единственный писатель в БД — ядро; append-only аудит; обмен только через recognition_job.

Goals / Non-Goals

Goals: рабочая схема БД и миграции; пользователь с правами из БД через JWT; outbox/inbox на core NATS, доказанные интеграционным тестом (включая потерю запроса и повторную публикацию); make check-full, который ловит расхождение ORM ↔ DBML и невозвратные миграции.

Non-Goals: см. proposal. Дополнительно: не оптимизируем производительность outbox (одна инстанция, 5-секундный опрос достаточно для Среза 0–3); не проектируем multi-tenant.

Decisions

D-1. ORM: модуль на часть DBML, enum'ы отдельно, имена — буквально из DBML

src/models/: enums.py (27 enum.Enum + фабрика pg_enum(name) → sqlalchemy.Enum(..., name=…, schema="dkb", native_enum=True, values_callable=lambda e: [m.value for m in e]), значения — строки DBML, включая кириллические "ЮЛ"/"ИП"), далее directories.py (часть 01), access.py (02), accounting.py (03), files.py (04), osv.py (05), statements.py (06), consolidation.py (07), kfo.py (08), audit_ex.py (09 + required_document_rule). Имена классов — CamelCase от имени таблицы (RegionalOffice), __tablename__ — как в DBML. Типы: uuid → UUID(as_uuid=True), varchar → String (без длины, как в DBML), text → Text, numeric(p,s) → Numeric(p, s), timestamptz → DateTime(timezone=True), date → Date, smallint/int/bigint → SmallInteger/Integer/BigInteger, boolean → Boolean, jsonb → JSONB. Nullability и default — буквально из DBML (default → server_default). Константа DBML_VERSION = "5.13" в models/__init__.py. Альтернатива — генерировать ORM из DBML скриптом: отвергнуто, DBML содержит notes-семантику (partial unique, NULLS NOT DISTINCT), которую проще выразить руками и проверять скриптом.

D-2. Миграция 0001 — одна ревизия, с нуля, откат до пустой схемы

alembic/versions/0001_dbml_v5_13.py: CREATE SCHEMA IF NOT EXISTS dkb (в env.py, до миграций), enum'ы → таблицы → FK → индексы. Циклическая связь stored_file.current_recognition_job_id ↔ recognition_job.file_id — FK с use_alter=True. Частичные уникальные индексы — Index(..., unique=True, postgresql_where=text("is_current")); document — Index(..., unique=True, postgresql_nulls_not_distinct=True) (PostgreSQL ≥ 15; на платформе 16). downgrade() удаляет индексы, таблицы, enum'ы (DROP TYPE) и оставляет пустую схему. Автогенерация используется как черновик, итог правится руками и проверяется шагом «upgrade → downgrade → upgrade» и check_dbml_vs_orm.py в make check-full.

D-3. check_dbml_vs_orm.py — pydbml + сравнение метаданных, notes читаются по шаблонам

Парсер — библиотека pydbml (dev-зависимость). Сравниваются: множество таблиц; для каждой — колонки (имя, тип по таблице соответствий D-1, nullability, PK, unique, default), FK (целевая таблица.колонка), индексы (колонки, unique), enum'ы (имя и упорядоченный список значений). Notes интерпретируются только по трём шаблонам: partial UNIQUE WHERE <expr>, NULLS NOT DISTINCT, [pk] на составных индексах; остальное — текст. Вывод — список расхождений таблица.колонка: ожидалось / фактически, код возврата 1 при любом расхождении. Скрипт также запускается как pytest-тест (tests/test_dbml_parity.py). Если pydbml окажется неустановим — fallback: собственный парсер подмножества DBML (Table/Enum/Indexes/Ref), ~200 строк.

D-4. Seed — данные в файлах, upsert по натуральному ключу, разделение prod/dev

api/seed/core/: regional_office.yaml (только ГО), roles.yaml, permissions.yaml, role_permissions.yaml, chart_of_accounts.csv (code, parent_code, name, kind, row_type, is_off_balance), elimination_rules_v1.yaml (правило + счета сторон + исключения), required_document_rules.yaml. api/seed/dev/: regional_offices.yaml (2 РФ), users.yaml (по одному на роль, email <role>@armka.local). python -m scripts.seed [--dev] — идемпотентно: upsert по code/inn/email/(statement_type, rule_no, version_no); удаление не делает. Коды прав — <OBJECT>_<ACTION> (DIRECTORY_EDIT, KFO_VERIFY), при совпадении объекта и действия — одно слово (EXPORT). role_permission.granted_by = NULL (системное действие, как в DBML для *_by). elimination_rule.valid_from = 2024-01-01 — покрывает период фикстур.

D-5. Аутентификация: TokenVerifier + dev-эмитент, РАИСА — каркас до ответа платформы

src/core/auth.py: Claims(subject, email, exp); протокол TokenVerifier.verify(token) -> Claims; DevHs256Verifier (секрет AUTH_DEV_SECRET, включается AUTH_DEV_ISSUER=true) и JwksVerifier (AUTH_JWKS_URL | AUTH_PUBLIC_KEY, AUTH_ISSUER, AUTH_AUDIENCE, AUTH_EMAIL_CLAIM = email; алгоритмы RS256/ES256) на PyJWT. Зависимости в api/dependencies/auth.py: current_user (Bearer → verify → app_user по email, is_active, иначе 403), require(code) (403, если code ∉ effective_permissions). effective_permissions — один запрос по user_role ⋈ role_permission ⋈ permission. Dev-эмитент — POST /api/v1/dev/token {email} → HS256 JWT на 8 часов; роутер подключается только при AUTH_DEV_ISSUER=true и DEBUG=true (в проде невозможен). Шаблонный middleware raisa_fastapi_protected_api остаётся как в шаблоне (импорт при DEBUG=false; пакет недоступен вне банка до прода) — он защищает периметр, наш слой извлекает личность. Альтернатива — только middleware РАИСА: отвергнуто, пакет недоступен и его API неизвестен.

D-6. Outbox/inbox на core NATS — фоновые задачи в lifespan, ack и повторы на уровне приложения

JetStream на платформе нет: нет durable consumer'ов, серверного дедупа, redelivery, max_deliver. Схема:

  • src/core/nats.py: подключение (пустые NATS_SEED/NATS_JWT → без учётных данных; иначе nkeys/JWT как в шаблоне), publish(subject, payload, msg_id) с заголовком Nats-Msg-Id, подписка recognition.result с queue group arm-core (несколько инстанций ядра получают результат по одному разу). Stream'ов и consumer'ов нет; переподключение — встроенное в nats-py (max_reconnect_attempts=-1, reconnect_time_wait=2).
  • src/services/recognition_exchange.py:
    • publish_pending() — SELECT … FROM recognition_job WHERE status='queued' OR (status='published' AND published_at < now() - ACK_TIMEOUT_SECONDS) FOR UPDATE SKIP LOCKED LIMIT 50; для queued — первая публикация (published, published_at, request_msg_id = <job_id>:<attempt>, request_subject); для published без ack — повторная публикация с тем же Nats-Msg-Id и обновлением published_at, счётчик повторов — в metrics.republish_count (jsonb, поле контракта метрик ядра); при republish_count ≥ OUTBOX_MAX_REPUBLISH → status = dlq, error_code = NO_ACK. attempt при этом не растёт — обработка не начиналась.
    • handle_message(msg) — конверт → pydantic; status = processing → задание published → processing, started_at, service_name/version (повторный ack игнорируется; ack для задания не в published — лог); иначе результат: job не найден → лог; result_msg_id == Nats-Msg-Id → дубликат, игнор; иначе s3_object (роль results, created_by = service.name) → обновление job (status, finished_at, metrics, error_*, result_schema_version, result_s3_object_id, result_msg_id) → commit. Ошибка БД — лог и повтор по следующему сообщению не предусмотрен (брокер не переотправит): задание дойдёт до timeout по reply_deadline и будет replay'нуто — осознанный компромисс без JetStream.
    • expire_deadlines() — processing (и published) с reply_deadline < now() → timeout.
    • Период опроса OUTBOX_POLL_SECONDS=5, ACK_TIMEOUT_SECONDS=30, OUTBOX_MAX_REPUBLISH=3, вахтёр — 30 с.
  • Запуск — asyncio.create_task в lifespan при NATS_ENABLED=true (в dev NATS нужен, шаблонный DEBUG его выключал — добавлен явный флаг). Маппинг документов — протокол ResultMapper с реализацией NoopMapper (Срез 2 заменит). Альтернатива — отдельный процесс-воркер: отвергнуто для Среза 0 (одна инстанция; SKIP LOCKED делает многоинстансность безопасной позже без изменений). Если JetStream появится — publish и подписка переезжают на JS API, остальное без изменений.

D-7. Тесты — PostgreSQL/NATS/MinIO из compose, маркеры integration и fixtures

tests/conftest.py: engine на OPENBAO__DATABASE__DATABASE_URL из .env.test; схема создаётся миграциями один раз на сессию; каждый тест — в транзакции с откатом (SAVEPOINT-паттерн SQLAlchemy). domain/ — без БД. Маркеры: integration (нужны NATS/MinIO), fixtures (нужен ARM_KA_FIXTURES; пропуск с причиной). SQLite не используется: enum'ы, partial unique, jsonb, NULLS NOT DISTINCT.

D-8. Линтеры и проверки: ruff + mypy в api/pyproject.toml, make check / make check-full

ruff: line-length = 120, правила E,F,I,UP,B,PL (подмножество pylint), формат по умолчанию (black-совместимый). mypy: strict = false на старте, check_untyped_defs = true, плагин pydantic; src/models и src/domain — strict = true. Шаблон декларировал black/isort/pylint без конфигов; откат — замена файла конфига. CI в Gitea нет: make check (без Docker) и make check-full (compose, миграции, DBML, web) — по ../docs/dev/checks.md; make check-full перед PR.

D-9. План счетов для seed — стандартный 94н + типовые субсчета 1С БП 3.0

Полный план конкретной конфигурации клиента недоступен (fixtures README: «план счетов из бандла макета» отсутствует). Seed содержит синтетические счета 01–99 и забалансовые 001–011 (Приказ 94н) с kind по плану и все субсчета, на которые ссылаются §4 (правила), §6 (шахматка) и required_document_rule; row_type — по глубине кода; parent_code — префикс. Редкие субсчета клиентов добавляет администратор (ARCH-004, v5.6). Проверка: все счета elimination_rules_v1.yaml и required_document_rules.yaml резолвятся в chart_of_account (OQ-47).

D-10. API — префикс /api/v1, формат ошибок шаблона

Все новые роутеры под APIRouter(prefix="/api/v1") в api/router.py; ответы ошибок — {"detail": …} по-русски, как в шаблонных обработчиках; 401 — нет/невалидный токен, 403 — пользователь не найден/неактивен/нет права.

D-11. Dev без Nexus: Python 3.11, фильтр raisa_*, optionalDependencies

Dockerfile — python:3.11-slim; make venv — python3.11 -m venv + grep -v '^raisa_' requirements.txt | pip install -r /dev/stdin + requirements-dev.txt; Dockerfile ставит так же. requirements.txt для платформы не меняется. web/package.json: raisa-ui, raisa-fastapi-protected-web → optionalDependencies (с Nexus ставятся, без — пропускаются).

Risks / Trade-offs

  • [Без JetStream результат, опубликованный при простое ядра, теряется] → reply_deadline → timeout → replay (Срез 3+); для Среза 0 это задокументированный компромисс, покрыт интеграционным тестом «ядро не слушало».
  • [Повторная публикация без ack дублирует запрос работающему сервису] → сервисы держат множество job_id в работе и отвечают повторным ack; тот же Nats-Msg-Id.
  • [Автогенерация Alembic пропускает partial-индексы и enum-изменения] → финальная ревизия пишется руками; шаг «upgrade → downgrade → upgrade» и сверка с DBML в make check-full.
  • [pydbml не разбирает какую-то конструкцию DBML (кириллические enum-значения в кавычках)] → проверка на первом шаге задачи 2.5; при провале — собственный парсер (D-3).
  • [Dev-эмитент попадёт в прод] → роутер подключается только при AUTH_DEV_ISSUER=true and DEBUG=true; тест проверяет, что при DEBUG=false маршрута нет.
  • [Шаблон создаёт settings при импорте] → тесты всегда идут с .env.test; ENV_FILE читается в Settings.model_config.
  • [Нет CI — проверки можно забыть] → make check-full в DoD каждой задачи и в описании PR.

Migration Plan

Пустая БД → alembic upgrade head → python -m scripts.seed [--dev]. Откат — alembic downgrade base (все объекты удаляются). Данных для переноса нет. В проде seed core выполняется при каждом деплое (идемпотентен), --dev — никогда.

Open Questions

Остались (в родительском change): версия Node, параметры JWT РАИСА (нужны к первому деплою; до этого dev-эмитент), появится ли JetStream (меняет только core/nats.py). Ни один не меняет структуру задач.

Decisions made without discussion

№ Дата Решение Обоснование Цена ошибки
1 2026-10-03 D-1: раскладка ORM по частям DBML, varchar → String без длины DBML не задаёт длины; меньше шума в сверке добавить длины — правка типов в одном месте + миграция
2 2026-10-03 D-2: одна миграция 0001 на весь DBML, FK-цикл через use_alter срез 0 стартует с пустой БД нет — дальнейшие изменения идут новыми ревизиями
3 2026-10-03 D-3: сверка через pydbml, notes по трём шаблонам писать полный парсер дорого собственный парсер ~200 строк
4 2026-10-03 D-4: коды прав <OBJECT>_<ACTION>, EXPORT для совпадения; valid_from правил = 2024-01-01; seed разделён на core/dev предсказуемость кодов; фикстуры 2024 г. переименование seed-данных до первого деплоя бесплатно
5 2026-10-03 D-5: dev-эмитент HS256 за двумя флагами; РАИСА — конфигурируемый JWKS-каркас пакет РАИСА недоступен, параметры неизвестны дописать RaisaVerifier под реальные claims
6 2026-10-03 D-6: core NATS — queue group arm-core, ack processing, повторная публикация без ack 30 с × 3 → dlq с error_code = NO_ACK, счётчик в metrics.republish_count, attempt не растёт; ошибка БД при приёме результата не ретраится (доходит до timeout) JetStream нет (ответ 03.10); DBML не имеет колонки для счётчика повторов при JetStream — замена publisher/subscriber; при необходимости — колонка в DBML v5.14
7 2026-10-03 D-6: stored_file.status в Срезе 0 не меняется, только recognition_job жизненный цикл файла — Срез 2 нет
8 2026-10-03 D-7: тесты только на PostgreSQL 16, транзакция с откатом SQLite не воспроизводит схему нет
9 2026-10-03 D-8: ruff + mypy вместо black/isort/pylint из бейджей README; make check/make check-full вместо CI один инструмент, black-совместимый формат; CI в Gitea нет замена конфига; YAML под пайплайн платформы при необходимости
10 2026-10-03 D-9: план счетов — 94н + типовые субсчета 1С, не полный план клиента полного плана нет; ARCH-004 дозагрузка CSV администратором или новым seed
11 2026-10-03 ASSUMPTION(OQ-46): все ОСВ расширенного подхода is_mandatory = true иначе EXTENDED ничем не отличается от BASIC в чек-листе правка одного yaml
12 2026-10-03 Settings читает ENV_FILE; requirements-dev.txt → -r requirements.txt + инструменты шаблонный pytest.ini уже ссылается на ENV_FILE; dev-пины конфликтовали с prod нет
13 2026-10-03 D-11: Python 3.11, фильтр raisa_* при dev-установке, npm raisa-* → optionalDependencies ответы пользователя: 3.11, Nexus недоступен до прода вернуть две строки в dependencies
14 2026-10-03 R-1: Makefile в корне arm-core (backend-цели cd api, web-цели cd web), venv — api/.venv check-full включает docker build и web; spec quality-checks требует выполнения команд из api/ — соблюдено перенос файла и правка путей
15 2026-10-03 R-2: шаблонный код автоформатируется/чинится ruff отдельным коммитом (без смены поведения), остаточные правила — точечный noqa/per-file-ignores make lint должен быть зелёным на каркасе правка конфига
16 2026-10-03 R-3: подключение NATS переезжает из app.py в src/core/nats.py в задаче 5.3; core/nats_callback.py удаляется в 5.5 не дублировать connect-логику между задачами 1.4 и 5.3 небольшой рефакторинг
17 2026-10-03 R-4: required_document_rule — в models/audit_ex.py (D-1, секция DBML «ПРОЧЕЕ»), а не в accounting.py design D-1 — источник; план в этом месте расходился перенос класса
18 2026-10-03 R-5: seed — функция run_seed(session, *, dev) без commit, CLI коммитит тесты идут в транзакции с откатом (D-7) нет
19 2026-10-03 R-6: пути api/dependencies/, api/endpoints/ из tasks.md = src/api/dependencies/, src/api/endpoints/ раскладка шаблона нет
20 2026-10-03 R-7: обмен recognition_job не пишет audit_event в Срезе 0 в audit_action нет действия для статусов задания; spec обмена аудит не требует добавить действие в DBML v5.14 и вызовы
21 2026-10-03 R-8: правка ../docs (07-open-questions.md) не выполняется исполнителем; текст допущений — в отчёте для PR в docs ../docs ведёт другой контроллер отдельный PR в docs
22 2026-10-03 R-11: PyJWT[crypto] добавлен в requirements.txt (прод-зависимость), а не только в dev core/auth.py импортирует jwt в проде платформа ставит лишний пакет; обратное — падение при импорте
23 2026-10-03 R-12: подписка (5.4) вызывает handle_message, заглушку до 5.5 порядок задач: lifespan раньше inbox нет