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

182 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 | нет |