Срез 0 (issue #1): раздел «Соглашения шаблона» (что оставлено от шаблона команды, что вырезается, что добавляется); change openspec/changes/slice-0-skeleton — proposal, design с таблицей «Decisions made without discussion», спеки и tasks; в openspec/config.yaml строки с двоеточием взяты в кавычки (YAML разбирал их как словари). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
151 lines
18 KiB
Markdown
151 lines
18 KiB
Markdown
# 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` (конверты сообщений, файл результата, `Nats-Msg-Id`).
|
||
- Инварианты CLAUDE.md: права — данные; единственный писатель в БД — ядро; append-only аудит; обмен только через `recognition_job`.
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:** рабочая схема БД и миграции; пользователь с правами из БД через JWT; outbox/inbox, доказанные интеграционным
|
||
тестом; CI, который ловит расхождение 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). `downgrade()` удаляет индексы, таблицы,
|
||
enum'ы (`DROP TYPE`) и оставляет пустую схему. Автогенерация используется как черновик, итог правится руками и
|
||
проверяется CI-шагом «upgrade → downgrade → upgrade» и `check_dbml_vs_orm.py`.
|
||
|
||
### 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` недоступен в Nexus* — 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 — фоновые задачи в lifespan, JetStream через nats-py
|
||
`src/core/nats.py`: подключение (пустые `NATS_SEED/NATS_JWT` → без учётных данных; иначе nkeys/JWT как в шаблоне),
|
||
`ensure_stream()` при `NATS_MANAGE_STREAM=true` (stream `RECOGNITION`, subjects `recognition.>`, `duplicate_window` 2 мин),
|
||
durable pull-consumer `arm-core-results` (`recognition.result`, `max_deliver` 5, `ack_wait` 60 с).
|
||
`src/services/recognition_exchange.py`: `publish_pending()` — `SELECT … FROM recognition_job WHERE status='queued'
|
||
FOR UPDATE SKIP LOCKED LIMIT 50`, сборка запроса из `stored_file ⋈ s3_object`, `js.publish(subject, payload,
|
||
headers={"Nats-Msg-Id": f"{job_id}:{attempt}"})`, при ack → `published`, `published_at`, `request_msg_id`, `request_subject`;
|
||
при ошибке — остаётся `queued` (повтор следующим циклом). `handle_result(msg)` — конверт → pydantic; job не найден → лог +
|
||
ack; `result_msg_id == Nats-Msg-Id` → ack (дубликат); иначе `s3_object` (роль `results`, `created_by = service.name`) →
|
||
обновление job (`status`, `service_*`, `finished_at`, `metrics`, `error_*`, `result_schema_version`, `result_s3_object_id`,
|
||
`result_msg_id`) → commit → ack; ошибка БД → nak; `num_delivered ≥ max_deliver` → `status = dlq` + ack. `expire_deadlines()` —
|
||
`published|processing` с `reply_deadline < now()` → `timeout`. Период опроса `OUTBOX_POLL_SECONDS=5`, вахтёр — 30 с.
|
||
Запуск — `asyncio.create_task` в lifespan при `DEBUG=false` **или** `NATS_ENABLED=true` (в dev NATS нужен, шаблонный
|
||
`DEBUG` его выключал — добавлен явный флаг). Маппинг документов — протокол `ResultMapper` с реализацией `NoopMapper`
|
||
(Срез 2 заменит). *Альтернатива* — отдельный процесс-воркер: отвергнуто для Среза 0 (одна инстанция; SKIP LOCKED делает
|
||
многоинстансность безопасной позже без изменений).
|
||
|
||
### 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`
|
||
`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.
|
||
|
||
### 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`.
|
||
|
||
### D-10. API — префикс `/api/v1`, формат ошибок шаблона
|
||
Все новые роутеры под `APIRouter(prefix="/api/v1")` в `api/router.py`; ответы ошибок — `{"detail": …}` по-русски, как
|
||
в шаблонных обработчиках; 401 — нет/невалидный токен, 403 — пользователь не найден/неактивен/нет права.
|
||
|
||
## Risks / Trade-offs
|
||
|
||
- [PostgreSQL банка < 15 → `NULLS NOT DISTINCT` недоступен] → миграция падает явно; fallback — уникальный индекс по
|
||
`COALESCE(osv_account_code, '')` (одно место, помечено комментарием в миграции).
|
||
- [Автогенерация Alembic пропускает partial-индексы и enum-изменения] → финальная ревизия пишется руками; CI-шаг
|
||
«upgrade → downgrade → upgrade» и сверка с DBML.
|
||
- [`pydbml` не разбирает какую-то конструкцию DBML (кириллические enum-значения в кавычках)] → проверка на первом шаге
|
||
задачи 2.4; при провале — собственный парсер (D-3).
|
||
- [Dev-эмитент попадёт в прод] → роутер подключается только при `AUTH_DEV_ISSUER=true and DEBUG=true`; тест проверяет,
|
||
что при `DEBUG=false` маршрута нет.
|
||
- [Consumer ack'ает до commit'а] → порядок: commit → ack; при падении между ними — повтор и дедуп по `result_msg_id`.
|
||
- [Шаблон создаёт `settings` при импорте] → тесты всегда идут с `.env.test`; `ENV_FILE` читается в `Settings.model_config`.
|
||
|
||
## Migration Plan
|
||
|
||
Пустая БД → `alembic upgrade head` → `python -m scripts.seed [--dev]`. Откат — `alembic downgrade base` (все объекты
|
||
удаляются). Данных для переноса нет. В проде seed `core` выполняется при каждом деплое (идемпотентен), `--dev` — никогда.
|
||
|
||
## Open Questions
|
||
|
||
Все — в родительском change (`../docs/openspec/changes/slice-0-skeleton/design.md`, «Open Questions»): версии
|
||
Python/PostgreSQL/NATS, параметры JWT РАИСА, доступ к Nexus, кто создаёт stream. Ни один не меняет структуру задач:
|
||
до ответов работает dev-эмитент и `NATS_MANAGE_STREAM=true`.
|
||
|
||
## 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: фоновые задачи в lifespan, pull-consumer, флаг `NATS_ENABLED` для dev | шаблонный `DEBUG` выключал NATS целиком | вынести в отдельный процесс — перенос двух функций |
|
||
| 7 | 2026-10-03 | D-6: `stored_file.status` в Срезе 0 не меняется, только `recognition_job` | жизненный цикл файла — Срез 2 | нет |
|
||
| 8 | 2026-10-03 | D-7: тесты только на PostgreSQL, транзакция с откатом | SQLite не воспроизводит схему | нет |
|
||
| 9 | 2026-10-03 | D-8: ruff + mypy вместо black/isort/pylint из бейджей README | один инструмент, black-совместимый формат | замена конфига и шага CI |
|
||
| 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 | нет |
|