From 9fcc59ddea3c6c3681bfdb53b413357e3e667ad6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 17:53:56 +0400 Subject: [PATCH 01/97] =?UTF-8?q?chore:=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?SDD=20=D0=B8=D0=B7=20tasks.md=20change'=D0=B0,=20=D1=81=D0=BB?= =?UTF-8?q?=D1=83=D0=B6=D0=B5=D0=B1=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=B3=D0=B8=20=D0=B2=20.gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/superpowers/plans/2026-10-03-slice-0-skeleton*.md сгенерирован 1:1 из openspec/changes/slice-0-skeleton/tasks.md (заголовки «Task N» для скриптов subagent-driven-development, Global Constraints из спеки и CLAUDE.md). .gitignore: .codegraph/, .superpowers/, .bounded, .DS_Store. Co-Authored-By: Claude Fable 5.1 --- .gitignore | 4 +- .../plans/2026-10-03-slice-0-skeleton.md | 129 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-10-03-slice-0-skeleton.md diff --git a/.gitignore b/.gitignore index c09eafc..34c68f6 100644 --- a/.gitignore +++ b/.gitignore @@ -327,4 +327,6 @@ docker-compose.yml .dockerignore .vscode my_project_env -.cursorignore \ No newline at end of file +.cursorignore.codegraph/ +.superpowers/ +.bounded diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md new file mode 100644 index 0000000..a43c91a --- /dev/null +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -0,0 +1,129 @@ +# Срез 0 — скелет ядра arm-core — план исполнения (Срез 0) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Branch:** `feat/1-slice-0-skeleton` (worktree `.wt/slice-0/arm-core`). **Issue:** #1. +**Spec:** `openspec/changes/slice-0-skeleton/` — proposal.md, design.md, specs/*/spec.md этого change'а. Спека — единственный источник истины при рулингах; этот план сгенерирован из её `tasks.md` 1:1 (номера в скобках — исходные номера задач tasks.md; по завершении задачи отметить её `- [x]` и в `tasks.md`). + +## Global Constraints + +- Имена таблиц, колонок, enum'ов и их значений — ровно как в `../docs/model/data-model-v5.13.dbml` (43 таблицы, 27 enum, схема `dkb`); поля вне DBML не вводить. Деньги — `Numeric(18,2)` / `Decimal`, никаких float. +- Раскладка шаблона: все команды из `api/`; `api/src/{api,core,db,models,schemas,repositories,services,domain}`, `api/alembic/`, `api/scripts/`, `api/seed/`, `api/tests/`. Шаблон не переделывать; `settings` создаётся при импорте — тесты через `ENV_FILE=.env.test`. +- Платформа: Python 3.11 (`python:3.11-slim`, `uv venv --python 3.11`), PostgreSQL 16, NATS **без JetStream** (queue group `arm-core`, ack `processing`, повторная публикация 30 с × 3 → `dlq`/`NO_ACK`, дедуп по `result_msg_id`), Nexus недоступен (`make venv` без `raisa_*`), CI нет. +- Контракт: копии схем из `../docs/contracts/` в `api/contracts/`, байт в байт; pydantic v2. +- Алгоритмы — только из `../docs/context/05-domain-rules.md` (§4 правила элиминации, §10 матрица прав), номер § в docstring. Допущения: `ASSUMPTION(OQ-16)`, `ASSUMPTION(OQ-46)`, OQ-47. +- Права — данные (`permission` × `role_permission`), код роли в логике не проверять. `audit_event` append-only. Единственный писатель в БД — ядро. +- Ответы ошибок `{"detail": …}` по-русски; `/api/v1` префикс; dev-эмитент только при `AUTH_DEV_ISSUER=true and DEBUG=true`. +- Линтеры: ruff (line-length 120, E,F,I,UP,B,PL) + mypy; тесты pytest-asyncio auto; БД-тесты на compose-PostgreSQL, маркеры `integration`/`fixtures`. +- Язык кода-комментариев/коммитов — русский, идентификаторы — английские; коммит заканчивается строкой `Co-Authored-By: Claude Fable 5.1 `. + +## Задачи + +### Task 1: [1.1] `api/pyproject.toml` (ruff + +- [ ] `api/pyproject.toml` (ruff: line-length 120, правила E,F,I,UP,B,PL; mypy с плагином pydantic), `requirements-dev.txt` = `-r requirements.txt` + ruff, mypy, pytest, pytest-asyncio, pytest-env, pytest-cov, httpx, PyJWT[crypto], pydbml, PyYAML, types-PyYAML; `Makefile`: `venv` (python3.11, `requirements.txt` без строк `raisa_*`, затем dev), `lint`, `test`, `test-integration`, `check`, `check-full`, `up`, `migrate`, `seed`, `seed-dev`; проверка: `make venv && make lint` зелёные на текущем каркасе без доступа к Nexus + +### Task 2: [1.2] `src/core/config.py` + +- [ ] `src/core/config.py`: `Settings.model_config` читает `env_file` из `ENV_FILE` (по умолчанию `.env`); новые поля `AUTH_DEV_ISSUER=false`, `AUTH_DEV_SECRET`, `AUTH_JWKS_URL`, `AUTH_PUBLIC_KEY`, `AUTH_ISSUER`, `AUTH_AUDIENCE`, `AUTH_EMAIL_CLAIM="email"`, `NATS_ENABLED=false`, `OUTBOX_POLL_SECONDS=5`, `ACK_TIMEOUT_SECONDS=30`, `OUTBOX_MAX_REPUBLISH=3`; `.env.example`, `.env.test`; проверка: `ENV_FILE=.env.test python -c "from src.core.config import settings"` читает тестовые значения + +### Task 3: [1.3] `Dockerfile` (`python + +- [ ] `Dockerfile` (`python:3.11-slim`, установка `requirements.txt` без `raisa_*`, `CMD python run_server.py`), убрать строку `Dockerfile` из `.gitignore`; `web/package.json`: `raisa-ui`, `raisa-fastapi-protected-web` → `optionalDependencies`; проверка: `docker build -t arm-core .` успешен без Nexus; `npm install` в `web/` проходит, `npm run build` собирает + +### Task 4: [1.4] `src/app.py` + +- [ ] `src/app.py`: NATS — пустые `NATS_SEED/NATS_JWT` → подключение без nkeys (перенести `nkeys.from_seed` из модуля в функцию), подключение при `NATS_ENABLED=true` независимо от `DEBUG`, `/healthcheck` отдаёт `nats` (`ok`/`down`/`off`); `api/router.py` — `APIRouter(prefix="/api/v1")`; проверка: `python run_server.py` стартует против compose без учётных данных, `/healthcheck` 200 с `nats = ok` + +### Task 5: [2.1] `src/models/enums.py` + +- [ ] `src/models/enums.py`: 27 enum'ов DBML как `enum.Enum` + фабрика `pg_enum`; `DBML_VERSION = "5.13"` в `models/__init__.py`; проверка: тест перечисляет имена и значения против списка из DBML (парсинг `Enum {}` блоков через pydbml) + +### Task 6: [2.2] Части 01–03 + +- [ ] Части 01–03: `directories.py` (`regional_office`, `company_group(+_history)`, `legal_entity(+_history)`, `legal_entity_alias`, `related_group`, `related_group_member` с partial unique), `access.py` (`app_user`, `role`, `permission`, `role_permission`, `user_role`), `accounting.py` (`chart_of_account`, `elimination_rule`, `elimination_rule_account`, `required_document_rule`); проверка: `Base.metadata.create_all` на тестовой БД без ошибок + +### Task 7: [2.3] Части 04–06 + +- [ ] Части 04–06: `files.py` (`s3_object`, `stored_file`, `document` с `NULLS NOT DISTINCT`, `recognition_job`; FK-цикл через `use_alter`), `osv.py` (`osv_entry`), `statements.py` (`financial_statement_meta/column/line/value`); проверка: как 2.2 + +### Task 8: [2.4] Части 07–09 + +- [ ] Части 07–09: `consolidation.py` (`consolidation`, `_version`, `_status_history`, `_lock` с partial unique, `_member`, `_document`), `kfo.py` (`correction`, `goodwill`, `kfo_result`, `kfo_result_line`, `kfo_result_line_company`, `client_kfo_line`), `audit_ex.py` (`comment`, `audit_event`, `ex_snapshot`, `ex_snapshot_member`, `ex_snapshot_item`); проверка: как 2.2, всего 43 таблицы в `Base.metadata.tables` + +### Task 9: [2.5] `scripts/check_dbml_vs_orm.py` (pydbml + +- [ ] `scripts/check_dbml_vs_orm.py` (pydbml; сравнение таблиц, колонок, типов, nullability, PK/UNIQUE/FK, индексов с notes-шаблонами, enum'ов, версии) + `tests/test_dbml_parity.py`; проверка: 0 расхождений на 2.1–2.4; намеренно добавленная колонка даёт код 1 и понятное сообщение + +### Task 10: [2.6] `alembic/env.py` + +- [ ] `alembic/env.py`: `CREATE SCHEMA IF NOT EXISTS dkb` перед миграциями; ревизия `0001_dbml_v5_13.py` (черновик autogenerate → ручная правка: enum'ы, partial-индексы, `NULLS NOT DISTINCT`, `use_alter`, полный `downgrade`); проверка: `alembic upgrade head && alembic downgrade base && alembic upgrade head` на compose-PostgreSQL 16; `alembic revision --autogenerate` после — пустая ревизия + +### Task 11: [2.7] `tests/conftest.py` + +- [ ] `tests/conftest.py`: engine из `.env.test`, миграции один раз на сессию, транзакция с откатом на тест, маркеры `integration`/`fixtures` (skip с причиной при отсутствии `ARM_KA_FIXTURES`); проверка: тест чтения `Decimal` из `osv_entry.closing_debit` проходит; `make check` зелёный + +### Task 12: [3.1] `seed/core/roles.yaml`, `permissions.yaml` (16 кодов), `role_permissions.yaml` ( + +- [ ] `seed/core/roles.yaml`, `permissions.yaml` (16 кодов), `role_permissions.yaml` (матрица §10, ASSUMPTION(OQ-16)), `regional_office.yaml` (ГО); `scripts/seed.py` — загрузчик yaml/csv с upsert по натуральному ключу, флаг `--dev`; проверка: двойной запуск — одинаковое число строк; тест сравнивает `role_permission` с таблицей §10 + +### Task 13: [3.2] `seed/core/chart_of_accounts.csv` + +- [ ] `seed/core/chart_of_accounts.csv`: План счетов 94н (01–99, 001–011, `kind`, `row_type`, `is_off_balance`) + субсчета, упомянутые в §4/§6 правил и чек-листе (60.xx, 62.xx, 58.01/58.03, 66.xx/67.xx, 76.03/.05/.07.x/.08/.09, 90.01/90.03, 91.x, 80, 83, 03); проверка: все `parent_code` существуют, код уникален, нормализован (`60.01`, не `60.1`) + +### Task 14: [3.3] `seed/core/elimination_rules_v1.yaml` — 8 правил ББ из §4 со счетами сторон и ис + +- [ ] `seed/core/elimination_rules_v1.yaml` — 8 правил ББ из §4 со счетами сторон и исключениями; `seed/core/required_document_rules.yaml` — BASIC (10) и EXTENDED (27, ASSUMPTION(OQ-46)); загрузчик падает до записи при неизвестном счёте; проверка: сценарии «Правило № 6» и «Ссылка на несуществующий счёт» из spec + +### Task 15: [3.4] `seed/dev/regional_offices.yaml` (2 РФ), `seed/dev/users.yaml` (7 пользователей + +- [ ] `seed/dev/regional_offices.yaml` (2 РФ), `seed/dev/users.yaml` (7 пользователей `@armka.local` + `user_role`); `make seed` / `make seed-dev`; проверка: без `--dev` `app_user` пуст; с `--dev` — 7 пользователей по одной роли + +### Task 16: [4.1] `src/core/auth.py` + +- [ ] `src/core/auth.py`: `Claims`, протокол `TokenVerifier`, `DevHs256Verifier`, `JwksVerifier` (PyJWT, RS256/ES256, кэш JWKS), фабрика по настройкам; проверка: unit-тесты — валидный/истёкший/чужой ключ, dev-verifier не создаётся при `AUTH_DEV_ISSUER=false` + +### Task 17: [4.2] `src/repositories/users.py` (пользователь по email, эффективные права одним запр + +- [ ] `src/repositories/users.py` (пользователь по email, эффективные права одним запросом), `api/dependencies/auth.py` (`current_user` → 401/403, `require(code)`), `src/schemas/me.py`; проверка: тесты на dev-пользователях seed — сценарии «Право есть», «Права нет», «Две роли» + +### Task 18: [4.3] `api/endpoints/me.py` (`GET /api/v1/me`), `api/endpoints/dev_auth.py` (`POST /ap + +- [ ] `api/endpoints/me.py` (`GET /api/v1/me`), `api/endpoints/dev_auth.py` (`POST /api/v1/dev/token`, подключается только при `AUTH_DEV_ISSUER and DEBUG`); проверка: тесты — ответ `/me` для `ka_rf@armka.local` равен правам §10; при `DEBUG=false` маршрут `dev/token` → 404 и dev-токен → 401 + +### Task 19: [4.4] `README.md` репозитория + +- [ ] `README.md` репозитория: замена шаблонного текста — назначение, команды (`make`), env, работа без Nexus, получение dev-токена, запуск против compose; проверка: новый разработчик проходит README до зелёного `/me` + +### Task 20: [5.1] `api/contracts/` — копии трёх схем из `../docs/contracts/` + `examples/` + +- [ ] `api/contracts/` — копии трёх схем из `../docs/contracts/` + `examples/`; `src/contract/` — pydantic-модели запроса, конверта (статусы `processing | done | needs_check | error`), файла результата (Decimal как строка); `tests/test_contract.py` — идентичность с `../docs/contracts` (skip с предупреждением без docs), валидация примеров; проверка: тесты зелёные + +### Task 21: [5.2] `src/services/audit.py` — `AuditService.record(...)` в текущей сессии + +- [ ] `src/services/audit.py` — `AuditService.record(...)` в текущей сессии; тест: системное событие с `user_id = NULL`, откат транзакции не оставляет записи; статический тест — в `src/` нет `update()/delete()` по `AuditEvent` + +### Task 22: [5.3] `src/core/nats.py` + +- [ ] `src/core/nats.py`: подключение (с nkeys/JWT или без; автопереподключение), `publish(subject, payload, msg_id)` с заголовком `Nats-Msg-Id`, подписка `recognition.result` в queue group `arm-core`; проверка: интеграционный тест против compose-NATS (без JetStream) — публикация и получение сообщения с заголовком; два подписчика в одной группе получают сообщение один раз + +### Task 23: [5.4] `src/services/recognition_exchange.py` + +- [ ] `src/services/recognition_exchange.py`: `publish_pending()` (SKIP LOCKED; первая публикация `queued → published`; повторная публикация `published` без ack через `ACK_TIMEOUT_SECONDS` с тем же `Nats-Msg-Id`, `metrics.republish_count`; после `OUTBOX_MAX_REPUBLISH` → `dlq`, `error_code = NO_ACK`), `expire_deadlines()`; фоновые задачи в lifespan при `NATS_ENABLED`; проверка: интеграционный тест — вставка `s3_object` + `stored_file` + `recognition_job(queued)` → сообщение получено тестовым подписчиком и проходит схему → строка `published`; без ack (с укороченными таймаутами) — три повтора с одним `Nats-Msg-Id` и `dlq`; задание с прошедшим `reply_deadline` → `timeout` + +### Task 24: [5.5] `handle_message()` + +- [ ] `handle_message()`: ack `processing` → `processing`/`started_at`/`service_*`; результат → `ResultMapper`/`NoopMapper` + регистрация `s3_object` + обновление задания; дубликат по `result_msg_id`, неизвестный `job_id`, результат для финального статуса — лог без изменений; проверка: интеграционный тест полного цикла queued → published → processing → done с `s3_object`; повтор не меняет строки; `error NOT_IMPLEMENTED` → `error_code`; `done` после `timeout` игнорируется + +### Task 25: [6.1] `make check-full` + +- [ ] `make check-full`: `make check` + `pytest -m integration` + цикл миграций + `check_dbml_vs_orm` + `docker build` + `web`: `npm install`, `npx biome check src`, `npx tsc --noEmit`, `npm run build`; проверка: цель зелёная против compose docs на ветке `feat/1-slice-0-skeleton` + +### Task 26: [6.2] Прогон DoD + +- [ ] Прогон DoD: `make up` (compose docs) → `make migrate` → `make seed-dev` → `make check-full` → `curl /api/v1/me` с dev-токеном возвращает права; `openspec validate slice-0-skeleton --strict`; проверка: всё зелёное, вывод `make check-full` вписан в описание PR + +### Task 27: [6.3] Дополнить `CLAUDE.md` (команды `make`), зафиксировать новые допущения в `../docs + +- [ ] Дополнить `CLAUDE.md` (команды `make`), зафиксировать новые допущения в `../docs/context/07-open-questions.md` через PR в docs (OQ-46 и всё, что всплыло); проверка: ссылки из README/CLAUDE.md ведут на существующие файлы + -- 2.47.2 From 23d2ebd5e792d4b23526dfb01e4a6a0bc583b2ac Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 17:54:50 +0400 Subject: [PATCH 02/97] =?UTF-8?q?chore:=20.gitignore=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20.cursorignore?= =?UTF-8?q?=20(=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=20=D0=B1=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B0=D1=8E=D1=89=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 34c68f6..c2b4ca0 100644 --- a/.gitignore +++ b/.gitignore @@ -327,6 +327,7 @@ docker-compose.yml .dockerignore .vscode my_project_env -.cursorignore.codegraph/ +.cursorignore +.codegraph/ .superpowers/ .bounded -- 2.47.2 From b018b6b055348073a4d8d9523a9c4b33632dfec9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 18:00:01 +0400 Subject: [PATCH 03/97] =?UTF-8?q?docs:=20design.md=20=E2=80=94=20=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=2014=E2=80=9323=20(=D1=80?= =?UTF-8?q?=D1=83=D0=BB=D0=B8=D0=BD=D0=B3=D0=B8=20=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?-=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20SDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- openspec/changes/slice-0-skeleton/design.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index b5834c3..a09890d 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -169,3 +169,13 @@ Seed содержит синтетические счета 01–99 и заба | 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 | нет | -- 2.47.2 From bb20d323d04833972f69dc4bbdd4883ef94960a1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 18:01:18 +0400 Subject: [PATCH 04/97] =?UTF-8?q?chore:=20=D0=B8=D0=BD=D1=81=D1=82=D1=80?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D1=8B=20=D1=88=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=BE=D0=BD=D0=B0=20=E2=80=94=20pyproject=20(ruff,=20mypy),=20?= =?UTF-8?q?requirements-dev,=20Makefile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- Makefile | 40 ++++++++++++++++++++++++++ api/pyproject.toml | 28 ++++++++++++++++++ api/pytest.ini | 3 ++ api/requirements-dev.txt | 62 ++++++++-------------------------------- 4 files changed, 83 insertions(+), 50 deletions(-) create mode 100644 Makefile create mode 100644 api/pyproject.toml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8a54113 --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +# Команды разработки arm-core. Backend — api/.venv; запуск из корня репозитория. +VENV := api/.venv +BIN := $(CURDIR)/$(VENV)/bin +COMPOSE_FILE ?= ../docs/dev/docker-compose.yml + +.PHONY: venv lint test test-integration check check-full up migrate seed seed-dev + +# Python 3.11 (версия платформы); внутренние пакеты raisa_* вне банка недоступны — пропускаем их +venv: + cd api && uv venv --python 3.11 .venv + cd api && grep -v '^raisa_' requirements.txt | uv pip install --python .venv/bin/python -r - + cd api && grep -v '^-r' requirements-dev.txt | uv pip install --python .venv/bin/python -r - + +lint: + cd api && $(BIN)/ruff check . + cd api && $(BIN)/ruff format --check . + cd api && $(BIN)/mypy src + +test: + cd api && $(BIN)/pytest -m "not integration" + +test-integration: + cd api && $(BIN)/pytest -m integration + +check: lint test + +# TODO (задача 25): добавить фронт (lint/test/build web/), проверку миграций с нуля и откат, актуальность OpenAPI +check-full: check test-integration + +up: + docker compose -f $(COMPOSE_FILE) up -d + +migrate: + cd api && $(BIN)/alembic upgrade head + +seed: + cd api && $(BIN)/python -m scripts.seed + +seed-dev: + cd api && $(BIN)/python -m scripts.seed --dev diff --git a/api/pyproject.toml b/api/pyproject.toml new file mode 100644 index 0000000..45bc777 --- /dev/null +++ b/api/pyproject.toml @@ -0,0 +1,28 @@ +# Настройки инструментов качества. Зависимости — в requirements*.txt, pytest — в pytest.ini. + +[tool.ruff] +line-length = 120 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "PL"] + +[tool.ruff.lint.isort] +known-first-party = ["src", "scripts"] + +[tool.mypy] +python_version = "3.11" +strict = false +check_untyped_defs = true +plugins = ["pydantic.mypy"] +mypy_path = "." +explicit_package_bases = true + +[[tool.mypy.overrides]] +module = ["src.models.*", "src.domain.*"] +strict = true + +# Сторонние пакеты без типов (или недоступные вне банка) +[[tool.mypy.overrides]] +module = ["smbclient.*", "nkeys.*", "miniopy_async.*", "nats.*", "raisa_*", "pydbml.*"] +ignore_missing_imports = true diff --git a/api/pytest.ini b/api/pytest.ini index 2dc434a..7ae5279 100644 --- a/api/pytest.ini +++ b/api/pytest.ini @@ -19,3 +19,6 @@ env = APP_ENV=test ENV_FILE=.env.test norecursedirs = .git __pycache__ .venv env build dist +markers = + integration: тесты, которым нужны PostgreSQL/NATS (docker compose); запускаются отдельно от юнит-тестов + fixtures: тесты на реальных данных из ../fixtures (внутренний доступ) diff --git a/api/requirements-dev.txt b/api/requirements-dev.txt index e4d6841..4b595ce 100644 --- a/api/requirements-dev.txt +++ b/api/requirements-dev.txt @@ -1,50 +1,12 @@ -aiosqlite -alembic -annotated-types==0.7.0 -anyio==4.8.0 -argon2-cffi -asyncpg -certifi==2024.12.14 -click==8.1.8 -colorama==0.4.6 -dnspython==2.7.0 -email_validator==2.2.0 -et_xmlfile==2.0.0 -fastapi==0.115.7 -fastapi-cli==0.0.7 -h11==0.14.0 -httpcore==1.0.7 -httptools==0.6.4 -httpx==0.27.2 -idna==3.10 -itsdangerous==2.2.0 -Jinja2==3.1.5 -markdown-it-py==3.0.0 -MarkupSafe==3.0.2 -mdurl==0.1.2 -openpyxl==3.1.5 -orjson==3.10.15 -pydantic==2.10.6 -pydantic-extra-types==2.10.2 -pydantic-settings==2.7.1 -pydantic_core==2.27.2 -Pygments==2.19.1 -python-dotenv==1.0.1 -python-jose -python-multipart==0.0.20 -PyYAML==6.0.2 -rich==13.9.4 -rich-toolkit==0.13.2 -shellingham==1.5.4 -sniffio==1.3.1 -sqlalchemy -starlette==0.45.3 -typer==0.15.1 -typing_extensions==4.12.2 -ujson==5.10.0 -uvicorn==0.34.0 -watchfiles==1.0.4 -websockets==14.2 -pytest-asyncio -pytest-env -cosmic-ray \ No newline at end of file +# Инструменты разработки и проверок. Платформенные зависимости — в requirements.txt (не меняем). +# Нижние границы мажорных версий: воспроизводимо, но без конфликта с пинами платформы. +-r requirements.txt +ruff>=0.8 +mypy>=1.13 +pytest>=8.3 +pytest-asyncio>=0.24 +pytest-env>=1.1 +pytest-cov>=6.0 +PyJWT[crypto]>=2.9 +pydbml>=1.1 +types-PyYAML>=6.0 -- 2.47.2 From bf63d3a9dc45b67c07340f5d5f90567ac73bad15 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 18:01:48 +0400 Subject: [PATCH 05/97] =?UTF-8?q?chore:=20mypy=20=E2=80=94=20=D1=8F=D0=B2?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D1=84=D0=BB=D0=B0=D0=B3=D0=B8=20strict=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20models/domain=20(strict=20=D0=B2=20overrid?= =?UTF-8?q?es=20=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D1=83=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B3=D0=BB=D0=BE=D0=B1=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/pyproject.toml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 45bc777..071036f 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -18,11 +18,23 @@ plugins = ["pydantic.mypy"] mypy_path = "." explicit_package_bases = true +# Строгий режим для ORM-моделей и чистого домена. `strict = true` в overrides mypy (2.x) применяет глобально, +# а не к модулю, поэтому флаги семейства strict перечислены явно. [[tool.mypy.overrides]] module = ["src.models.*", "src.domain.*"] -strict = true +disallow_any_generics = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +disallow_untyped_decorators = true +warn_return_any = true +warn_unused_ignores = true +no_implicit_reexport = true +strict_equality = true +extra_checks = true # Сторонние пакеты без типов (или недоступные вне банка) [[tool.mypy.overrides]] -module = ["smbclient.*", "nkeys.*", "miniopy_async.*", "nats.*", "raisa_*", "pydbml.*"] +module = ["smbclient.*", "nkeys.*", "miniopy_async.*", "nats.*", "raisa_fastapi_protected_api", "raisa_fastapi_protected_api.*", "pydbml.*"] ignore_missing_imports = true -- 2.47.2 From 72415ab891a5b4830c78306e241377708517f27b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 18:02:19 +0400 Subject: [PATCH 06/97] =?UTF-8?q?style:=20ruff=20format=20=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BB=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=20=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=80=D0=BA=D0=B0?= =?UTF-8?q?=D1=81=D0=B5=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Без смены поведения: форматирование, сортировка импортов, удаление неиспользуемых импортов, __all__ в src/db, datetime.UTC, HTTPStatus.NOT_FOUND вместо 404, аннотация lvl в logger, noqa F401 на проверке доступности raisa_fastapi_protected_api, mypy: miniopy_async не подтягивается (follow_imports=skip). Co-Authored-By: Claude Fable 5.1 --- api/alembic/env.py | 20 ++++++++++---------- api/pyproject.toml | 8 +++++++- api/run_server.py | 2 +- api/src/__init__.py | 6 +++--- api/src/app.py | 10 ++++++---- api/src/common/exceptions.py | 3 ++- api/src/core/clients.py | 7 ++++--- api/src/core/config.py | 16 ++++++++++++---- api/src/core/logger.py | 19 +++++++++++++++---- api/src/core/nats_callback.py | 1 + api/src/db/__init__.py | 4 +++- api/src/db/db.py | 9 ++++----- api/src/models/__init__.py | 1 + 13 files changed, 69 insertions(+), 37 deletions(-) diff --git a/api/alembic/env.py b/api/alembic/env.py index b89fbff..2a949b4 100644 --- a/api/alembic/env.py +++ b/api/alembic/env.py @@ -1,12 +1,13 @@ import asyncio from logging.config import fileConfig -from sqlalchemy import engine_from_config -from sqlalchemy import pool +from sqlalchemy import engine_from_config, pool from sqlalchemy.ext.asyncio import AsyncEngine + from alembic import context from src.core.config import settings from src.models import Base # noqa: F401 — модели регистрируются в src/models/__init__.py + # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config @@ -28,9 +29,10 @@ target_metadata = Base.metadata # my_important_option = config.get_main_option("my_important_option") # ... etc. + def include_name(name, type_, parent_names): - if type_ == 'schema': - return name == 'dkb' + if type_ == "schema": + return name == "dkb" return True @@ -41,12 +43,13 @@ def do_run_migrations(connection) -> None: compare_type=True, version_table_schema="dkb", include_schemas=True, - include_name=include_name + include_name=include_name, ) with context.begin_transaction(): context.run_migrations() + def run_migrations_offline() -> None: """Run migrations in 'offline' mode. @@ -67,7 +70,7 @@ def run_migrations_offline() -> None: dialect_opts={"paramstyle": "named"}, version_table_schema="dkb", include_schemas=True, - include_name=include_name + include_name=include_name, ) with context.begin_transaction(): @@ -83,10 +86,7 @@ async def run_migrations_online() -> None: """ connectable = AsyncEngine( engine_from_config( - config.get_section(config.config_ini_section), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - future=True + config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool, future=True ) ) diff --git a/api/pyproject.toml b/api/pyproject.toml index 071036f..d9ed6ed 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -36,5 +36,11 @@ extra_checks = true # Сторонние пакеты без типов (или недоступные вне банка) [[tool.mypy.overrides]] -module = ["smbclient.*", "nkeys.*", "miniopy_async.*", "nats.*", "raisa_fastapi_protected_api", "raisa_fastapi_protected_api.*", "pydbml.*"] +module = ["smbclient.*", "nkeys.*", "nats.*", "raisa_fastapi_protected_api", "raisa_fastapi_protected_api.*", "pydbml.*"] +ignore_missing_imports = true + +# miniopy_async: пакет типизирован с ошибкой (Minio не виден mypy при реэкспорте) — типы не подтягиваем, Minio = Any +[[tool.mypy.overrides]] +module = ["miniopy_async", "miniopy_async.*"] +follow_imports = "skip" ignore_missing_imports = true diff --git a/api/run_server.py b/api/run_server.py index f2449e1..37a9775 100644 --- a/api/run_server.py +++ b/api/run_server.py @@ -1,4 +1,5 @@ import uvicorn + from src import app, get_settings if __name__ == "__main__": @@ -9,4 +10,3 @@ if __name__ == "__main__": port=settings.server.PORT, lifespan="on", ) - diff --git a/api/src/__init__.py b/api/src/__init__.py index 61c3faa..97b1796 100644 --- a/api/src/__init__.py +++ b/api/src/__init__.py @@ -2,6 +2,6 @@ from .app import app from .core.config import get_settings __all__ = [ - 'app', - 'get_settings', -] \ No newline at end of file + "app", + "get_settings", +] diff --git a/api/src/app.py b/api/src/app.py index 845e7c0..529b7d3 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -5,10 +5,12 @@ middleware (gzip, proxy headers, CORS, РАИСА при DEBUG=False), healthche Что добавляет Срез 0: роутеры в src/api/router.py, outbox-паблишер и consumer recognition.result (docs/context/03-architecture.md), зависимость require(permission). """ + import base64 import os from contextlib import asynccontextmanager -from datetime import datetime, timezone +from datetime import UTC, datetime +from http import HTTPStatus import nkeys import smbclient @@ -30,7 +32,7 @@ from src.core.exception_handlers import register_exception_handlers settings = get_settings() VERSION = settings.VERSION -UP_TIME = datetime.now(timezone.utc) +UP_TIME = datetime.now(UTC) # --- NATS: аутентификация по nkeys/JWT --------------------------------------------------------- @@ -51,7 +53,7 @@ def sig_cb(nonce): protected_middleware_available = False if not settings.DEBUG: try: - from raisa_fastapi_protected_api import ( # pyright: ignore[reportMissingImports] + from raisa_fastapi_protected_api import ( # noqa: F401 # pyright: ignore[reportMissingImports] AuthorizationMiddleware, OpenEndpoint, ProtectedOAuthSettings, @@ -177,7 +179,7 @@ class SPAStaticFiles(StaticFiles): try: return await super().get_response(path, scope) except (HTTPException, StarletteHTTPException) as ex: - if ex.status_code == 404: + if ex.status_code == HTTPStatus.NOT_FOUND: if "images" in path: return await super().get_response(sep_static(path, "images"), scope) if "static" in path: diff --git a/api/src/common/exceptions.py b/api/src/common/exceptions.py index 2eca689..3eaa18e 100644 --- a/api/src/common/exceptions.py +++ b/api/src/common/exceptions.py @@ -1,5 +1,6 @@ class NotFoundError(Exception): pass + class CouldntCreateError(Exception): - pass \ No newline at end of file + pass diff --git a/api/src/core/clients.py b/api/src/core/clients.py index 8f07d24..ceb87e5 100644 --- a/api/src/core/clients.py +++ b/api/src/core/clients.py @@ -1,6 +1,7 @@ """Клиенты инфраструктуры. Каркас: оставлен S3Storage; outbox-паблишер (PollWorker) реализуется в Срезе 0 по docs/context/03-architecture.md (референс — reference-old-arm/api/src/core/clients.py). """ + from io import BytesIO from miniopy_async import Minio @@ -10,11 +11,11 @@ class S3Storage: def __init__(self, client: Minio, bucket_name: str): self.client = client self.bucket = bucket_name - + async def put_object(self, obj_name, file_data): try: stream = BytesIO(file_data) - await self.client.put_object(self.bucket, obj_name, stream, length=len(file_data)) + await self.client.put_object(self.bucket, obj_name, stream, length=len(file_data)) except: raise @@ -23,5 +24,5 @@ class S3Storage: response = await self.client.get_object(self.bucket, obj_name) # file_bytes = await response.content.read() return response - except: + except: raise diff --git a/api/src/core/config.py b/api/src/core/config.py index d57c41f..f2dd1cf 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -1,42 +1,48 @@ -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings class DBSettings(BaseSettings): DATABASE_URL: str + class MinioSettings(BaseSettings): MINIO_URL: str MINIO_ADMIN_LOGIN: str MINIO_ADMIN_PASSWORD: str MINIO_BUCKET: str + class NATSSettings(BaseSettings): NATS_URL: str NATS_SEED: str NATS_JWT: str + class SMBSettings(BaseSettings): SMB_HOST: str SMB_PATH: str SMB_LOGIN: str SMB_PASSWORD: str + class OpenBAOSettings(BaseSettings): DATABASE: DBSettings MINIO: MinioSettings NATS: NATSSettings SMB: SMBSettings + class ServerSettings(BaseSettings): HOST: str = "0.0.0.0" PORT: int = 8000 + class Settings(BaseSettings): OPENBAO: OpenBAOSettings DEBUG: bool = False server: ServerSettings = ServerSettings() - ROOT_PATH: str = '/' - VERSION: str = '1.0.0' + ROOT_PATH: str = "/" + VERSION: str = "1.0.0" class Config: extra = "ignore" @@ -44,7 +50,9 @@ class Settings(BaseSettings): env_nested_delimiter = "__" nested_model_default_partial_update = False + def get_settings() -> Settings: return Settings() -settings = get_settings() \ No newline at end of file + +settings = get_settings() diff --git a/api/src/core/logger.py b/api/src/core/logger.py index 2f99033..e0ad71d 100644 --- a/api/src/core/logger.py +++ b/api/src/core/logger.py @@ -5,7 +5,6 @@ import sys from loguru import logger - # Периодические пробы мониторинга/оркестратора и служебные doc-эндпоинты. NOISE_PATHS: tuple[str, ...] = ( "/healthcheck", @@ -22,8 +21,20 @@ _NOISE_MARKERS: tuple[str, ...] = ("/static/", "/static-swagger/") # Расширения статических ассетов (картинки, стили, скрипты, шрифты). _STATIC_SUFFIXES: tuple[str, ...] = ( - ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp", - ".css", ".js", ".map", ".woff", ".woff2", ".ttf", ".eot", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".svg", + ".ico", + ".webp", + ".css", + ".js", + ".map", + ".woff", + ".woff2", + ".ttf", + ".eot", ) @@ -74,7 +85,7 @@ def setup_logger( class InterceptHandler(logging.Handler): def emit(self, record: logging.LogRecord) -> None: try: - lvl = logger.level(record.levelname).name + lvl: str | int = logger.level(record.levelname).name except ValueError: lvl = record.levelno diff --git a/api/src/core/nats_callback.py b/api/src/core/nats_callback.py index 2f2e05b..e3420b5 100644 --- a/api/src/core/nats_callback.py +++ b/api/src/core/nats_callback.py @@ -4,6 +4,7 @@ маппинг JSON из S3 в document/osv_entry/financial_statement_*) реализуется в Срезе 0–2 по docs/context/03-architecture.md. Для референса — reference-old-arm/api/src/core/nats_callback.py. """ + from loguru import logger from nats.aio.client import Client as NATS diff --git a/api/src/db/__init__.py b/api/src/db/__init__.py index 49ce0c1..ddec262 100644 --- a/api/src/db/__init__.py +++ b/api/src/db/__init__.py @@ -1 +1,3 @@ -from .db import get_async_session \ No newline at end of file +from .db import get_async_session + +__all__ = ["get_async_session"] diff --git a/api/src/db/db.py b/api/src/db/db.py index be73f6b..44bf885 100644 --- a/api/src/db/db.py +++ b/api/src/db/db.py @@ -1,11 +1,11 @@ -from contextlib import asynccontextmanager +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine, AsyncSession -from fastapi import Depends # from fastapi_users.db import SQLAlchemyUserDatabase, SQLAlchemyBaseUserTableUUID from src.core.config import settings -engine = create_async_engine(settings.OPENBAO.DATABASE.DATABASE_URL, connect_args={"server_settings": {"search_path": 'dkb'}}) +engine = create_async_engine( + settings.OPENBAO.DATABASE.DATABASE_URL, connect_args={"server_settings": {"search_path": "dkb"}} +) async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) @@ -13,4 +13,3 @@ async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_ async def get_async_session(): async with async_session_maker() as session: yield session - diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index 36fbcbd..86139b4 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -1,4 +1,5 @@ """ORM-модели. Источник истины — docs/model/data-model-v5.13.dbml; заполняется в Срезе 0.""" + from .base import Base, UUIDPrimaryMixin __all__ = ["Base", "UUIDPrimaryMixin"] -- 2.47.2 From 25a594503a4b94ca24a0eb17783a6aad2298848a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 18:02:43 +0400 Subject: [PATCH 07/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index a43c91a..27a0f44 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -21,7 +21,7 @@ ### Task 1: [1.1] `api/pyproject.toml` (ruff -- [ ] `api/pyproject.toml` (ruff: line-length 120, правила E,F,I,UP,B,PL; mypy с плагином pydantic), `requirements-dev.txt` = `-r requirements.txt` + ruff, mypy, pytest, pytest-asyncio, pytest-env, pytest-cov, httpx, PyJWT[crypto], pydbml, PyYAML, types-PyYAML; `Makefile`: `venv` (python3.11, `requirements.txt` без строк `raisa_*`, затем dev), `lint`, `test`, `test-integration`, `check`, `check-full`, `up`, `migrate`, `seed`, `seed-dev`; проверка: `make venv && make lint` зелёные на текущем каркасе без доступа к Nexus +- [x] `api/pyproject.toml` (ruff: line-length 120, правила E,F,I,UP,B,PL; mypy с плагином pydantic), `requirements-dev.txt` = `-r requirements.txt` + ruff, mypy, pytest, pytest-asyncio, pytest-env, pytest-cov, httpx, PyJWT[crypto], pydbml, PyYAML, types-PyYAML; `Makefile`: `venv` (python3.11, `requirements.txt` без строк `raisa_*`, затем dev), `lint`, `test`, `test-integration`, `check`, `check-full`, `up`, `migrate`, `seed`, `seed-dev`; проверка: `make venv && make lint` зелёные на текущем каркасе без доступа к Nexus ### Task 2: [1.2] `src/core/config.py` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index d226f3d..667c6f2 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -6,7 +6,7 @@ ## 1. Инструменты и окружение шаблона -- [ ] 1.1 `api/pyproject.toml` (ruff: line-length 120, правила E,F,I,UP,B,PL; mypy с плагином pydantic), +- [x] 1.1 `api/pyproject.toml` (ruff: line-length 120, правила E,F,I,UP,B,PL; mypy с плагином pydantic), `requirements-dev.txt` = `-r requirements.txt` + ruff, mypy, pytest, pytest-asyncio, pytest-env, pytest-cov, httpx, PyJWT[crypto], pydbml, PyYAML, types-PyYAML; `Makefile`: `venv` (python3.11, `requirements.txt` без строк `raisa_*`, затем dev), `lint`, `test`, `test-integration`, `check`, `check-full`, `up`, `migrate`, `seed`, `seed-dev`; -- 2.47.2 From 56ea8dce0cf0b9574abe88aab280fd8a32e88c83 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 18:08:37 +0400 Subject: [PATCH 08/97] =?UTF-8?q?fix:=20make=20venv=20=D0=B8=D0=B4=D0=B5?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D1=82=D0=B5=D0=BD=D1=82=D0=B5=D0=BD=20?= =?UTF-8?q?=E2=80=94=20uv=20venv=20--allow-existing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8a54113..bb2840d 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ COMPOSE_FILE ?= ../docs/dev/docker-compose.yml # Python 3.11 (версия платформы); внутренние пакеты raisa_* вне банка недоступны — пропускаем их venv: - cd api && uv venv --python 3.11 .venv + cd api && uv venv --python 3.11 --allow-existing .venv cd api && grep -v '^raisa_' requirements.txt | uv pip install --python .venv/bin/python -r - cd api && grep -v '^-r' requirements-dev.txt | uv pip install --python .venv/bin/python -r - -- 2.47.2 From dff1e050b9efc44c36374085ab4841ea5a7904ca Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 18:17:19 +0400 Subject: [PATCH 09/97] =?UTF-8?q?feat:=20Settings=20=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20ENV=5FFILE,=20=D0=BF=D0=BE=D0=BB=D1=8F=20a?= =?UTF-8?q?uth/NATS/outbox,=20.env.example=20=D0=B8=20.env.test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NATS_SEED/NATS_JWT необязательны (пусто — подключение без nkeys); в app.py разбор seed защищён от пустого значения (подключение переедет в задаче 22). Co-Authored-By: Claude Fable 5.1 --- api/.env.example | 41 ++++++++++++++++++++++++++++++++++++++ api/.env.test | 34 +++++++++++++++++++++++++++++++ api/src/app.py | 4 +++- api/src/core/config.py | 41 ++++++++++++++++++++++++++++++-------- api/tests/test_config.py | 43 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 api/.env.example create mode 100644 api/.env.test create mode 100644 api/tests/test_config.py diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..11c2988 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,41 @@ +# Пример локального окружения. Скопируйте в api/.env: cp .env.example .env +# Порты — стандартные для docs/dev/docker-compose.yml. + +DEBUG=true + +# PostgreSQL (драйвер asyncpg) +OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:5432/armka + +# MinIO (адрес без схемы — так ждёт miniopy-async) +OPENBAO__MINIO__MINIO_URL=localhost:9000 +OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin +OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=minioadmin +OPENBAO__MINIO__MINIO_BUCKET=arm-ka + +# NATS без JetStream; пустые NATS_SEED/NATS_JWT — подключение без nkeys +OPENBAO__NATS__NATS_URL=nats://localhost:4222 +OPENBAO__NATS__NATS_SEED= +OPENBAO__NATS__NATS_JWT= + +# SMB — заглушки (в Срезе 0 не используется) +OPENBAO__SMB__SMB_HOST=localhost +OPENBAO__SMB__SMB_PATH=share +OPENBAO__SMB__SMB_LOGIN=user +OPENBAO__SMB__SMB_PASSWORD=password + +# Аутентификация. Dev-эмитент работает только при AUTH_DEV_ISSUER=true И DEBUG=true. +AUTH_DEV_ISSUER=true +# ТОЛЬКО ДЛЯ РАЗРАБОТКИ: секрет HS256 dev-эмитента, в проде не использовать +AUTH_DEV_SECRET=dev-only-secret-do-not-use-in-production-0123456789 +# Проверка токенов РАИСА (прод): JWKS по URL либо публичный ключ PEM +AUTH_JWKS_URL= +AUTH_PUBLIC_KEY= +AUTH_ISSUER= +AUTH_AUDIENCE= +AUTH_EMAIL_CLAIM=email + +# Обмен с сервисами распознавания +NATS_ENABLED=true +OUTBOX_POLL_SECONDS=5 +ACK_TIMEOUT_SECONDS=30 +OUTBOX_MAX_REPUBLISH=3 diff --git a/api/.env.test b/api/.env.test new file mode 100644 index 0000000..e348f24 --- /dev/null +++ b/api/.env.test @@ -0,0 +1,34 @@ +# Окружение тестов (pytest.ini: ENV_FILE=.env.test). Только тестовые значения, секретов здесь нет. +# Порты — временная тестовая инфраструктура: PostgreSQL 55432, NATS 54222, MinIO 59000. + +DEBUG=true + +OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:55432/armka + +OPENBAO__MINIO__MINIO_URL=localhost:59000 +OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin +OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=minioadmin +OPENBAO__MINIO__MINIO_BUCKET=arm-ka + +OPENBAO__NATS__NATS_URL=nats://localhost:54222 +OPENBAO__NATS__NATS_SEED= +OPENBAO__NATS__NATS_JWT= + +OPENBAO__SMB__SMB_HOST=localhost +OPENBAO__SMB__SMB_PATH=share +OPENBAO__SMB__SMB_LOGIN=user +OPENBAO__SMB__SMB_PASSWORD=password + +AUTH_DEV_ISSUER=true +AUTH_DEV_SECRET=test-only-secret-not-for-production-0123456789 +AUTH_JWKS_URL= +AUTH_PUBLIC_KEY= +AUTH_ISSUER= +AUTH_AUDIENCE= +AUTH_EMAIL_CLAIM=email + +# Интеграционные тесты включают NATS сами +NATS_ENABLED=false +OUTBOX_POLL_SECONDS=5 +ACK_TIMEOUT_SECONDS=30 +OUTBOX_MAX_REPUBLISH=3 diff --git a/api/src/app.py b/api/src/app.py index 529b7d3..f8136c7 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -36,7 +36,8 @@ UP_TIME = datetime.now(UTC) # --- NATS: аутентификация по nkeys/JWT --------------------------------------------------------- -kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode("utf-8")) +# Пустой NATS_SEED — подключение без nkeys (разработка, тесты); подключение переедет в core/nats.py (задача 22) +kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode("utf-8")) if settings.OPENBAO.NATS.NATS_SEED else None def jwt_cb(): @@ -44,6 +45,7 @@ def jwt_cb(): def sig_cb(nonce): + assert kp is not None if isinstance(nonce, str): nonce = nonce.encode("utf-8") return base64.b64encode(kp.sign(nonce)) diff --git a/api/src/core/config.py b/api/src/core/config.py index f2dd1cf..7648784 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -1,4 +1,6 @@ -from pydantic_settings import BaseSettings +import os + +from pydantic_settings import BaseSettings, SettingsConfigDict class DBSettings(BaseSettings): @@ -14,8 +16,8 @@ class MinioSettings(BaseSettings): class NATSSettings(BaseSettings): NATS_URL: str - NATS_SEED: str - NATS_JWT: str + NATS_SEED: str = "" # пусто — подключение к NATS без nkeys (локальная разработка, тесты) + NATS_JWT: str = "" class SMBSettings(BaseSettings): @@ -38,17 +40,40 @@ class ServerSettings(BaseSettings): class Settings(BaseSettings): + """Настройки приложения. + + Файл окружения выбирается переменной ``ENV_FILE`` (по умолчанию ``.env``; значение читается при импорте модуля, + относительный путь считается от текущего каталога — ``api/``). Тесты используют ``.env.test``. + """ + OPENBAO: OpenBAOSettings DEBUG: bool = False server: ServerSettings = ServerSettings() ROOT_PATH: str = "/" VERSION: str = "1.0.0" - class Config: - extra = "ignore" - env_file = ".env" - env_nested_delimiter = "__" - nested_model_default_partial_update = False + # Аутентификация: dev-эмитент (только для разработки; роутер подключается при AUTH_DEV_ISSUER и DEBUG одновременно) + AUTH_DEV_ISSUER: bool = False + AUTH_DEV_SECRET: str = "" + # Проверка токенов РАИСА: JWKS по URL либо статический публичный ключ (PEM) + AUTH_JWKS_URL: str = "" + AUTH_PUBLIC_KEY: str = "" + AUTH_ISSUER: str = "" + AUTH_AUDIENCE: str = "" + AUTH_EMAIL_CLAIM: str = "email" # claim токена с email пользователя (сопоставляется с app_user.email) + + # NATS и outbox/inbox обмена recognition_job + NATS_ENABLED: bool = False + OUTBOX_POLL_SECONDS: int = 5 # период опроса outbox + ACK_TIMEOUT_SECONDS: int = 30 # ожидание ack сервиса распознавания до повторной публикации + OUTBOX_MAX_REPUBLISH: int = 3 # число повторных публикаций до перевода в dlq + + model_config = SettingsConfigDict( + extra="ignore", + env_file=os.environ.get("ENV_FILE", ".env"), + env_nested_delimiter="__", + nested_model_default_partial_update=False, + ) def get_settings() -> Settings: diff --git a/api/tests/test_config.py b/api/tests/test_config.py new file mode 100644 index 0000000..42fadda --- /dev/null +++ b/api/tests/test_config.py @@ -0,0 +1,43 @@ +"""Настройки: чтение ENV_FILE и значения по умолчанию (без БД и сети).""" + +import os +from pathlib import Path + +import pytest + +from src.core.config import Settings + +API_DIR = Path(__file__).resolve().parent.parent + + +@pytest.fixture +def clean_env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch: + """Убирает из окружения процесса переменные, которые перебили бы значения из env-файла.""" + for key in list(os.environ): + if key.startswith(("OPENBAO__", "AUTH_", "OUTBOX_", "ACK_")) or key in {"NATS_ENABLED", "DEBUG"}: + monkeypatch.delenv(key) + return monkeypatch + + +def test_env_test_is_read(clean_env: pytest.MonkeyPatch) -> None: + """.env.test читается: порты временной тестовой инфраструктуры.""" + s = Settings(_env_file=API_DIR / ".env.test") # type: ignore[call-arg] + assert s.OPENBAO.DATABASE.DATABASE_URL.endswith("localhost:55432/armka") + assert s.OPENBAO.NATS.NATS_URL == "nats://localhost:54222" + assert s.AUTH_DEV_ISSUER is True + assert s.NATS_ENABLED is False + + +def test_defaults(clean_env: pytest.MonkeyPatch) -> None: + """Значения по умолчанию: claim email и параметры outbox.""" + s = Settings(_env_file=API_DIR / ".env.test") # type: ignore[call-arg] + assert s.AUTH_EMAIL_CLAIM == "email" + assert (s.OUTBOX_POLL_SECONDS, s.ACK_TIMEOUT_SECONDS, s.OUTBOX_MAX_REPUBLISH) == (5, 30, 3) + + +def test_empty_nats_seed_and_jwt_allowed(clean_env: pytest.MonkeyPatch) -> None: + """Пустые NATS_SEED/NATS_JWT допустимы (подключение без nkeys).""" + s = Settings(_env_file=API_DIR / ".env.example") # type: ignore[call-arg] + assert s.OPENBAO.NATS.NATS_SEED == "" + assert s.OPENBAO.NATS.NATS_JWT == "" + assert s.NATS_ENABLED is True -- 2.47.2 From 3d1755b7fa50c42bc35b08eb7bcb43697a5348d7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 18:17:39 +0400 Subject: [PATCH 10/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 27a0f44..457f680 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -25,7 +25,7 @@ ### Task 2: [1.2] `src/core/config.py` -- [ ] `src/core/config.py`: `Settings.model_config` читает `env_file` из `ENV_FILE` (по умолчанию `.env`); новые поля `AUTH_DEV_ISSUER=false`, `AUTH_DEV_SECRET`, `AUTH_JWKS_URL`, `AUTH_PUBLIC_KEY`, `AUTH_ISSUER`, `AUTH_AUDIENCE`, `AUTH_EMAIL_CLAIM="email"`, `NATS_ENABLED=false`, `OUTBOX_POLL_SECONDS=5`, `ACK_TIMEOUT_SECONDS=30`, `OUTBOX_MAX_REPUBLISH=3`; `.env.example`, `.env.test`; проверка: `ENV_FILE=.env.test python -c "from src.core.config import settings"` читает тестовые значения +- [x] `src/core/config.py`: `Settings.model_config` читает `env_file` из `ENV_FILE` (по умолчанию `.env`); новые поля `AUTH_DEV_ISSUER=false`, `AUTH_DEV_SECRET`, `AUTH_JWKS_URL`, `AUTH_PUBLIC_KEY`, `AUTH_ISSUER`, `AUTH_AUDIENCE`, `AUTH_EMAIL_CLAIM="email"`, `NATS_ENABLED=false`, `OUTBOX_POLL_SECONDS=5`, `ACK_TIMEOUT_SECONDS=30`, `OUTBOX_MAX_REPUBLISH=3`; `.env.example`, `.env.test`; проверка: `ENV_FILE=.env.test python -c "from src.core.config import settings"` читает тестовые значения ### Task 3: [1.3] `Dockerfile` (`python diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 667c6f2..8342423 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -11,7 +11,7 @@ PyJWT[crypto], pydbml, PyYAML, types-PyYAML; `Makefile`: `venv` (python3.11, `requirements.txt` без строк `raisa_*`, затем dev), `lint`, `test`, `test-integration`, `check`, `check-full`, `up`, `migrate`, `seed`, `seed-dev`; проверка: `make venv && make lint` зелёные на текущем каркасе без доступа к Nexus -- [ ] 1.2 `src/core/config.py`: `Settings.model_config` читает `env_file` из `ENV_FILE` (по умолчанию `.env`); новые поля +- [x] 1.2 `src/core/config.py`: `Settings.model_config` читает `env_file` из `ENV_FILE` (по умолчанию `.env`); новые поля `AUTH_DEV_ISSUER=false`, `AUTH_DEV_SECRET`, `AUTH_JWKS_URL`, `AUTH_PUBLIC_KEY`, `AUTH_ISSUER`, `AUTH_AUDIENCE`, `AUTH_EMAIL_CLAIM="email"`, `NATS_ENABLED=false`, `OUTBOX_POLL_SECONDS=5`, `ACK_TIMEOUT_SECONDS=30`, `OUTBOX_MAX_REPUBLISH=3`; `.env.example`, `.env.test`; проверка: `ENV_FILE=.env.test python -c "from src.core.config -- 2.47.2 From e432c1c5429a056de6b7925c4a83d34b27722e51 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 18:23:21 +0400 Subject: [PATCH 11/97] =?UTF-8?q?test:=20test=5Fdefaults=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D0=B5=D1=82=20=D1=83=D0=BC=D0=BE?= =?UTF-8?q?=D0=BB=D1=87=D0=B0=D0=BD=D0=B8=D1=8F=20Settings=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20env-=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/tests/test_config.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/api/tests/test_config.py b/api/tests/test_config.py index 42fadda..e014a37 100644 --- a/api/tests/test_config.py +++ b/api/tests/test_config.py @@ -29,10 +29,34 @@ def test_env_test_is_read(clean_env: pytest.MonkeyPatch) -> None: def test_defaults(clean_env: pytest.MonkeyPatch) -> None: - """Значения по умолчанию: claim email и параметры outbox.""" - s = Settings(_env_file=API_DIR / ".env.test") # type: ignore[call-arg] + """Значения по умолчанию классов настроек: env-файла нет, заданы только обязательные OPENBAO__*.""" + required = { + "OPENBAO__DATABASE__DATABASE_URL": "postgresql+asyncpg://u:p@localhost:5432/d", + "OPENBAO__MINIO__MINIO_URL": "localhost:9000", + "OPENBAO__MINIO__MINIO_ADMIN_LOGIN": "login", + "OPENBAO__MINIO__MINIO_ADMIN_PASSWORD": "password", + "OPENBAO__MINIO__MINIO_BUCKET": "bucket", + "OPENBAO__NATS__NATS_URL": "nats://localhost:4222", + "OPENBAO__SMB__SMB_HOST": "host", + "OPENBAO__SMB__SMB_PATH": "path", + "OPENBAO__SMB__SMB_LOGIN": "login", + "OPENBAO__SMB__SMB_PASSWORD": "password", + } + for key, value in required.items(): + clean_env.setenv(key, value) + s = Settings(_env_file=None) # type: ignore[call-arg] + assert s.DEBUG is False + assert s.AUTH_DEV_ISSUER is False + assert s.AUTH_DEV_SECRET == "" + assert s.AUTH_JWKS_URL == "" + assert s.AUTH_PUBLIC_KEY == "" + assert s.AUTH_ISSUER == "" + assert s.AUTH_AUDIENCE == "" assert s.AUTH_EMAIL_CLAIM == "email" + assert s.NATS_ENABLED is False assert (s.OUTBOX_POLL_SECONDS, s.ACK_TIMEOUT_SECONDS, s.OUTBOX_MAX_REPUBLISH) == (5, 30, 3) + assert s.OPENBAO.NATS.NATS_SEED == "" + assert s.OPENBAO.NATS.NATS_JWT == "" def test_empty_nats_seed_and_jwt_allowed(clean_env: pytest.MonkeyPatch) -> None: -- 2.47.2 From 15a06a075d67106dbb5a4b5477116b1cfcd66022 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 18:28:43 +0400 Subject: [PATCH 12/97] =?UTF-8?q?build:=20Dockerfile=20=D0=BD=D0=B0=20pyth?= =?UTF-8?q?on:3.11-slim=20=D0=B1=D0=B5=D0=B7=20raisa=5F*,=20raisa-*=20npm?= =?UTF-8?q?=20=D0=B2=20optionalDependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .dockerignore | 15 +++++++++++++++ .gitignore | 2 -- Dockerfile | 19 +++++++++++++++++++ web/package.json | 6 ++++-- 4 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..336e34d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.superpowers +.claude +.codegraph +openspec +docs +setup +**/__pycache__ +**/*.pyc +api/.venv +api/.env +**/.mypy_cache +**/.ruff_cache +**/.pytest_cache +web/node_modules diff --git a/.gitignore b/.gitignore index c2b4ca0..8b6cf20 100644 --- a/.gitignore +++ b/.gitignore @@ -322,9 +322,7 @@ dist # Custom ignore .DS_Store -Dockerfile docker-compose.yml -.dockerignore .vscode my_project_env .cursorignore diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7d9538b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Образ для dev и `make check-full`. Контекст сборки — корень репозитория arm-core. +# Платформенные пакеты raisa_* (Nexus) в образ не ставятся — та же логика, что в `make venv`. +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app/api + +COPY api/requirements.txt ./requirements.txt +RUN grep -v '^raisa_' requirements.txt > /tmp/req.txt \ + && pip install --no-cache-dir -r /tmp/req.txt \ + && rm /tmp/req.txt + +COPY api/ ./ + +EXPOSE 8000 + +CMD ["python", "run_server.py"] diff --git a/web/package.json b/web/package.json index df8ff5d..cafdae1 100644 --- a/web/package.json +++ b/web/package.json @@ -14,8 +14,6 @@ "antd": "^6.5.0", "markdown-to-jsx": "^9.1.2", "pdfjs-dist": "^5.7.284", - "raisa-fastapi-protected-web": "^1.0.0", - "raisa-ui": "^1.0.0", "react": "^18.3.1", "react-daisyui": "^5.0.5", "react-dom": "^18.3.1", @@ -32,5 +30,9 @@ "daisyui": "^4.12.10", "tailwindcss": "^3.4.13", "typescript": "^5.7.3" + }, + "optionalDependencies": { + "raisa-fastapi-protected-web": "^1.0.0", + "raisa-ui": "^1.0.0" } } -- 2.47.2 From 353f3cd46a29f66eb09cdbc5ad70858e6a664dd4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 18:29:02 +0400 Subject: [PATCH 13/97] =?UTF-8?q?build:=20@biomejs/cli-linux-x64-musl=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=20optionalDependencies=20(EBADPLATFORM=20?= =?UTF-8?q?=D0=BD=D0=B0=20macOS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index cafdae1..9479090 100644 --- a/web/package.json +++ b/web/package.json @@ -22,7 +22,6 @@ }, "devDependencies": { "@biomejs/biome": "1.9.4", - "@biomejs/cli-linux-x64-musl": "^1.9.4", "@rsbuild/core": "1.6.7", "@rsbuild/plugin-react": "^1.1.0", "@types/react": "^18.3.1", @@ -32,6 +31,7 @@ "typescript": "^5.7.3" }, "optionalDependencies": { + "@biomejs/cli-linux-x64-musl": "^1.9.4", "raisa-fastapi-protected-web": "^1.0.0", "raisa-ui": "^1.0.0" } -- 2.47.2 From 56ab8df3fd20951f7f489177a1d0a14b9f42b6e7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 18:29:19 +0400 Subject: [PATCH 14/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 457f680..65223ca 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -29,7 +29,7 @@ ### Task 3: [1.3] `Dockerfile` (`python -- [ ] `Dockerfile` (`python:3.11-slim`, установка `requirements.txt` без `raisa_*`, `CMD python run_server.py`), убрать строку `Dockerfile` из `.gitignore`; `web/package.json`: `raisa-ui`, `raisa-fastapi-protected-web` → `optionalDependencies`; проверка: `docker build -t arm-core .` успешен без Nexus; `npm install` в `web/` проходит, `npm run build` собирает +- [x] `Dockerfile` (`python:3.11-slim`, установка `requirements.txt` без `raisa_*`, `CMD python run_server.py`), убрать строку `Dockerfile` из `.gitignore`; `web/package.json`: `raisa-ui`, `raisa-fastapi-protected-web` → `optionalDependencies`; проверка: `docker build -t arm-core .` успешен без Nexus; `npm install` в `web/` проходит, `npm run build` собирает ### Task 4: [1.4] `src/app.py` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 8342423..4153558 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -16,7 +16,7 @@ `AUTH_EMAIL_CLAIM="email"`, `NATS_ENABLED=false`, `OUTBOX_POLL_SECONDS=5`, `ACK_TIMEOUT_SECONDS=30`, `OUTBOX_MAX_REPUBLISH=3`; `.env.example`, `.env.test`; проверка: `ENV_FILE=.env.test python -c "from src.core.config import settings"` читает тестовые значения -- [ ] 1.3 `Dockerfile` (`python:3.11-slim`, установка `requirements.txt` без `raisa_*`, `CMD python run_server.py`), убрать +- [x] 1.3 `Dockerfile` (`python:3.11-slim`, установка `requirements.txt` без `raisa_*`, `CMD python run_server.py`), убрать строку `Dockerfile` из `.gitignore`; `web/package.json`: `raisa-ui`, `raisa-fastapi-protected-web` → `optionalDependencies`; проверка: `docker build -t arm-core .` успешен без Nexus; `npm install` в `web/` проходит, `npm run build` собирает -- 2.47.2 From 545994a525c6bdc5fa594c8ed729102dbaf98e55 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 18:36:42 +0400 Subject: [PATCH 15/97] =?UTF-8?q?feat:=20app.py=20=E2=80=94=20NATS=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20nkeys=20=D0=BF=D1=80=D0=B8=20=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D1=82=D1=8B=D1=85=20=D1=83=D1=87=D1=91=D1=82=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85,=20NATS=5FENABLED?= =?UTF-8?q?,=20nats=20=D0=B2=20/healthcheck,=20=D0=BF=D1=80=D0=B5=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=20/api/v1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/src/api/router.py | 2 +- api/src/app.py | 135 ++++++++++++++++++++++++++---------------- api/tests/test_app.py | 95 +++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 51 deletions(-) create mode 100644 api/tests/test_app.py diff --git a/api/src/api/router.py b/api/src/api/router.py index af9233c..91129c5 100644 --- a/api/src/api/router.py +++ b/api/src/api/router.py @@ -1,3 +1,3 @@ from fastapi import APIRouter -router = APIRouter() +router = APIRouter(prefix="/api/v1") diff --git a/api/src/app.py b/api/src/app.py index f8136c7..f3ff93e 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -1,7 +1,9 @@ """Точка входа FastAPI. Каркас из шаблона команды: инфраструктура без доменного кода. -Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT), SMB (горячие папки ИС Сканирование), -middleware (gzip, proxy headers, CORS, РАИСА при DEBUG=False), healthcheck, раздача собранного SPA. +Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT; пустые NATS_SEED/NATS_JWT — без учётных данных; +подключение при NATS_ENABLED=true независимо от DEBUG), SMB (горячие папки ИС Сканирование), middleware (gzip, proxy +headers, CORS, РАИСА при DEBUG=False), healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA. +Недоступность S3 или NATS при старте не роняет приложение: ошибка логируется, healthcheck показывает состояние. Что добавляет Срез 0: роутеры в src/api/router.py, outbox-паблишер и consumer recognition.result (docs/context/03-architecture.md), зависимость require(permission). """ @@ -11,10 +13,11 @@ import os from contextlib import asynccontextmanager from datetime import UTC, datetime from http import HTTPStatus +from typing import Any import nkeys import smbclient -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.exceptions import HTTPException, StarletteHTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles @@ -26,7 +29,7 @@ from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from src.api.router import router from src.core.clients import S3Storage -from src.core.config import get_settings +from src.core.config import NATSSettings, get_settings from src.core.exception_handlers import register_exception_handlers settings = get_settings() @@ -36,19 +39,31 @@ UP_TIME = datetime.now(UTC) # --- NATS: аутентификация по nkeys/JWT --------------------------------------------------------- -# Пустой NATS_SEED — подключение без nkeys (разработка, тесты); подключение переедет в core/nats.py (задача 22) -kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode("utf-8")) if settings.OPENBAO.NATS.NATS_SEED else None +def _build_nats_auth(nats_settings: NATSSettings) -> dict[str, Any]: + """Параметры аутентификации для ``NATS.connect``. + Пустые NATS_SEED и NATS_JWT — подключение без учётных данных (docker compose, тесты): ``{}``. Заданы оба — + колбэки nkeys/JWT, как в шаблоне. Задан только один — ошибка конфигурации. Подключение переедет в core/nats.py + (задача 22). + """ + seed = nats_settings.NATS_SEED + jwt = nats_settings.NATS_JWT + if not seed and not jwt: + return {} + if not seed or not jwt: + raise RuntimeError("Неполные учётные данные NATS: NATS_SEED и NATS_JWT задаются вместе или не задаются вовсе") -def jwt_cb(): - return settings.OPENBAO.NATS.NATS_JWT.encode("utf-8") + keypair = nkeys.from_seed(seed.encode("utf-8")) + def user_jwt_cb() -> bytes: + return jwt.encode("utf-8") -def sig_cb(nonce): - assert kp is not None - if isinstance(nonce, str): - nonce = nonce.encode("utf-8") - return base64.b64encode(kp.sign(nonce)) + def signature_cb(nonce: str | bytes) -> bytes: + if isinstance(nonce, str): + nonce = nonce.encode("utf-8") + return base64.b64encode(keypair.sign(nonce)) + + return {"user_jwt_cb": user_jwt_cb, "signature_cb": signature_cb} # --- РАИСА: защищённый API (только вне DEBUG) --------------------------------------------------- @@ -81,6 +96,52 @@ def _normalize_root_path(value: str) -> str: _APP_ROOT_PATH = _normalize_root_path(settings.ROOT_PATH) +async def _connect_s3() -> S3Storage | None: + """Клиент S3 с проверкой бакета; при недоступности — ``None`` (приложение всё равно стартует).""" + s3_client = Minio( + settings.OPENBAO.MINIO.MINIO_URL, + access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN, + secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD, + cert_check=False, + ) + try: + bucket_exists = await s3_client.bucket_exists(settings.OPENBAO.MINIO.MINIO_BUCKET) + except Exception as exc: + logger.exception( + "Не удалось проверить наличие бакета. error_category=s3_error, error_type={error_type}, error={error}", + error_type=type(exc).__name__, + error=repr(exc), + ) + await s3_client.close_session() + return None + logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists) + return S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET) + + +async def _connect_nats() -> NATS | None: + """Подключение к NATS при NATS_ENABLED=true; при ошибке — ``None`` (healthcheck покажет ``down``).""" + if not settings.NATS_ENABLED: + logger.info("NATS выключен (NATS_ENABLED=false)") + return None + nc = NATS() + try: + await nc.connect( + settings.OPENBAO.NATS.NATS_URL, + max_reconnect_attempts=-1, + reconnect_time_wait=2, + **_build_nats_auth(settings.OPENBAO.NATS), + ) + except Exception as exc: + logger.exception( + "Не удалось подключиться к NATS. error_category=nats_error, error_type={error_type}, error={error}", + error_type=type(exc).__name__, + error=repr(exc), + ) + return None + logger.info("Подключение к NATS произошло") + return nc + + @asynccontextmanager async def lifespan(app: FastAPI): app.state.nats_subs = [] @@ -94,42 +155,11 @@ async def lifespan(app: FastAPI): except Exception: logger.exception("Ошибка при регистрации сессии SMB") - s3_client = Minio( - settings.OPENBAO.MINIO.MINIO_URL, - access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN, - secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD, - cert_check=False, - ) try: - bucket_exists = await s3_client.bucket_exists(settings.OPENBAO.MINIO.MINIO_BUCKET) - logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists) - except Exception as exc: - logger.exception( - "Не удалось проверить наличие бакета. error_category=s3_error, error_type={error_type}, error={error}", - error_type=type(exc).__name__, - error=repr(exc), - ) - return - app.state.storage = S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET) + app.state.storage = await _connect_s3() + app.state.nats = await _connect_nats() + # Подписки (recognition.result) и outbox-паблишер — Срез 0, см. docs/context/03-architecture.md - nc = NATS() - if not settings.DEBUG: - try: - await nc.connect( - settings.OPENBAO.NATS.NATS_URL, user_jwt_cb=jwt_cb, signature_cb=sig_cb, max_reconnect_attempts=2 - ) - logger.info("Подключение к NATS произошло") - except Exception as exc: - logger.exception( - "Не удалось подключиться к NATS. error_category=nats_error, error_type={error_type}, error={error}", - error_type=type(exc).__name__, - error=repr(exc), - ) - return - app.state.nats = nc - # Подписки (recognition.result) и outbox-паблишер — Срез 0, см. docs/context/03-architecture.md - - try: logger.info("Приложение запущено") yield finally: @@ -137,7 +167,7 @@ async def lifespan(app: FastAPI): smbclient.reset_connection_cache() for sub in app.state.nats_subs: await sub.unsubscribe() - if app.state.nats and not settings.DEBUG: + if app.state.nats: await app.state.nats.close() if app.state.storage: await app.state.storage.client.close_session() @@ -147,8 +177,13 @@ app = FastAPI(title="АРМ КА", version=VERSION, root_path=settings.ROOT_PATH @app.get("/healthcheck", status_code=200) -async def healthcheck(): - return {"uptime": UP_TIME, "version": VERSION} +async def healthcheck(request: Request): + """Живость приложения. ``nats``: ``off`` — NATS выключен, ``ok`` — подключён, ``down`` — включён, но нет связи.""" + nats_status = "off" + if settings.NATS_ENABLED: + nc = getattr(request.app.state, "nats", None) + nats_status = "ok" if nc is not None and nc.is_connected else "down" + return {"uptime": UP_TIME, "version": VERSION, "nats": nats_status} app.include_router(router) diff --git a/api/tests/test_app.py b/api/tests/test_app.py new file mode 100644 index 0000000..601ddff --- /dev/null +++ b/api/tests/test_app.py @@ -0,0 +1,95 @@ +"""Быстрые тесты каркаса приложения: аутентификация NATS, /healthcheck, префикс API (без БД и NATS).""" + +import importlib +import os +from http import HTTPStatus +from types import SimpleNamespace + +import httpx +import nkeys +import pytest + +from src.api.router import router +from src.core.config import NATSSettings + +# src/__init__.py реэкспортирует объект app под именем модуля, поэтому модуль берём через importlib +app_module = importlib.import_module("src.app") + + +def _nats_settings(seed: str = "", jwt: str = "") -> NATSSettings: + return NATSSettings(NATS_URL="nats://localhost:4222", NATS_SEED=seed, NATS_JWT=jwt) + + +def _valid_seed() -> str: + return nkeys.encode_seed(os.urandom(32), nkeys.PREFIX_BYTE_USER).decode("utf-8") + + +def test_build_nats_auth_empty_credentials_gives_no_kwargs(): + assert app_module._build_nats_auth(_nats_settings()) == {} + + +@pytest.mark.parametrize( + ("seed", "jwt"), + [("SU-only-seed", ""), ("", "only-jwt")], +) +def test_build_nats_auth_partial_credentials_is_config_error(seed: str, jwt: str): + with pytest.raises(RuntimeError, match="NATS_SEED"): + app_module._build_nats_auth(_nats_settings(seed, jwt)) + + +def test_build_nats_auth_valid_credentials_give_callbacks(): + seed = _valid_seed() + auth = app_module._build_nats_auth(_nats_settings(seed, "header.payload.signature")) + + assert set(auth) == {"user_jwt_cb", "signature_cb"} + assert auth["user_jwt_cb"]() == b"header.payload.signature" + signature = auth["signature_cb"](b"nonce") + assert isinstance(signature, bytes) + assert signature + + +@pytest.fixture +async def client(): + # Без lifespan: app.state.nats не установлен — healthcheck обязан это пережить + transport = httpx.ASGITransport(app=app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +async def test_healthcheck_nats_off_when_disabled(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(app_module.settings, "NATS_ENABLED", False) + + response = await client.get("/healthcheck") + + assert response.status_code == HTTPStatus.OK + body = response.json() + assert body["nats"] == "off" + assert {"uptime", "version"} <= set(body) + + +async def test_healthcheck_nats_down_when_enabled_without_connection( + client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True) + + response = await client.get("/healthcheck") + + assert response.status_code == HTTPStatus.OK + assert response.json()["nats"] == "down" + + +@pytest.mark.parametrize(("connected", "expected"), [(True, "ok"), (False, "down")]) +async def test_healthcheck_nats_follows_connection_state( + client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, connected: bool, expected: str +): + monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True) + monkeypatch.setattr(app_module.app.state, "nats", SimpleNamespace(is_connected=connected), raising=False) + + response = await client.get("/healthcheck") + + assert response.status_code == HTTPStatus.OK + assert response.json()["nats"] == expected + + +def test_router_prefix(): + assert router.prefix == "/api/v1" -- 2.47.2 From 12ebb515c2409287866b1d7b3b2b3cef39e453f6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 18:37:05 +0400 Subject: [PATCH 16/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 65223ca..43c644a 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -33,7 +33,7 @@ ### Task 4: [1.4] `src/app.py` -- [ ] `src/app.py`: NATS — пустые `NATS_SEED/NATS_JWT` → подключение без nkeys (перенести `nkeys.from_seed` из модуля в функцию), подключение при `NATS_ENABLED=true` независимо от `DEBUG`, `/healthcheck` отдаёт `nats` (`ok`/`down`/`off`); `api/router.py` — `APIRouter(prefix="/api/v1")`; проверка: `python run_server.py` стартует против compose без учётных данных, `/healthcheck` 200 с `nats = ok` +- [x] `src/app.py`: NATS — пустые `NATS_SEED/NATS_JWT` → подключение без nkeys (перенести `nkeys.from_seed` из модуля в функцию), подключение при `NATS_ENABLED=true` независимо от `DEBUG`, `/healthcheck` отдаёт `nats` (`ok`/`down`/`off`); `api/router.py` — `APIRouter(prefix="/api/v1")`; проверка: `python run_server.py` стартует против compose без учётных данных, `/healthcheck` 200 с `nats = ok` ### Task 5: [2.1] `src/models/enums.py` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 4153558..dafeb08 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -20,7 +20,7 @@ строку `Dockerfile` из `.gitignore`; `web/package.json`: `raisa-ui`, `raisa-fastapi-protected-web` → `optionalDependencies`; проверка: `docker build -t arm-core .` успешен без Nexus; `npm install` в `web/` проходит, `npm run build` собирает -- [ ] 1.4 `src/app.py`: NATS — пустые `NATS_SEED/NATS_JWT` → подключение без nkeys (перенести `nkeys.from_seed` из +- [x] 1.4 `src/app.py`: NATS — пустые `NATS_SEED/NATS_JWT` → подключение без nkeys (перенести `nkeys.from_seed` из модуля в функцию), подключение при `NATS_ENABLED=true` независимо от `DEBUG`, `/healthcheck` отдаёт `nats` (`ok`/`down`/`off`); `api/router.py` — `APIRouter(prefix="/api/v1")`; проверка: `python run_server.py` стартует против compose без учётных данных, `/healthcheck` 200 с `nats = ok` -- 2.47.2 From 1c56d39b092c7094b2b9826f606888d4db656b45 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 18:42:54 +0400 Subject: [PATCH 17/97] =?UTF-8?q?test:=20=D1=80=D0=B5=D0=B3=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D1=81=D0=B8=D0=BE=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8B=20lifespan=20=E2=80=94=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D1=82=20=D0=BF=D1=80=D0=B8=20=D0=BD=D0=B5=D0=B4=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=83=D0=BF=D0=BD=D1=8B=D1=85=20S3/NATS=20=D0=B8?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D1=83=D1=80=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/tests/test_app.py | 64 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/api/tests/test_app.py b/api/tests/test_app.py index 601ddff..6dc057b 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -93,3 +93,67 @@ async def test_healthcheck_nats_follows_connection_state( def test_router_prefix(): assert router.prefix == "/api/v1" + + +class _FakeNats: + def __init__(self) -> None: + self.closed = False + + async def close(self) -> None: + self.closed = True + + +class _FakeS3Client: + def __init__(self) -> None: + self.session_closed = False + + async def close_session(self) -> None: + self.session_closed = True + + +@pytest.fixture +def _no_smb(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(app_module.smbclient, "register_session", lambda *args, **kwargs: None) + monkeypatch.setattr(app_module.smbclient, "reset_connection_cache", lambda: None) + + +@pytest.mark.usefixtures("_no_smb") +async def test_lifespan_starts_when_s3_and_nats_unavailable(monkeypatch: pytest.MonkeyPatch): + async def unavailable() -> None: + return None + + monkeypatch.setattr(app_module, "_connect_s3", unavailable) + monkeypatch.setattr(app_module, "_connect_nats", unavailable) + reached_yield = False + + async with app_module.lifespan(app_module.app): + reached_yield = True + assert app_module.app.state.storage is None + assert app_module.app.state.nats is None + + assert reached_yield + + +@pytest.mark.usefixtures("_no_smb") +async def test_lifespan_closes_created_resources(monkeypatch: pytest.MonkeyPatch): + fake_nats = _FakeNats() + s3_client = _FakeS3Client() + storage = SimpleNamespace(client=s3_client) + + async def connect_s3() -> SimpleNamespace: + return storage + + async def connect_nats() -> _FakeNats: + return fake_nats + + monkeypatch.setattr(app_module, "_connect_s3", connect_s3) + monkeypatch.setattr(app_module, "_connect_nats", connect_nats) + + async with app_module.lifespan(app_module.app): + assert app_module.app.state.storage is storage + assert app_module.app.state.nats is fake_nats + assert not fake_nats.closed + assert not s3_client.session_closed + + assert fake_nats.closed + assert s3_client.session_closed -- 2.47.2 From 8e6223a2735205efd6ca743ee6a9e8642f115b49 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 18:47:23 +0400 Subject: [PATCH 18/97] =?UTF-8?q?feat:=20models/enums.py=20=E2=80=94=2027?= =?UTF-8?q?=20enum'=D0=BE=D0=B2=20DBML=20v5.13,=20=D1=84=D0=B0=D0=B1=D1=80?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20pg=5Fenum,=20DBML=5FVERSION?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тест сверяет имена и упорядоченные значения с блоками Enum {} DBML через pydbml. Co-Authored-By: Claude Fable 5.1 --- api/pytest.ini | 3 + api/src/models/__init__.py | 5 +- api/src/models/enums.py | 338 +++++++++++++++++++++++++++++++++++++ api/tests/dbml_helpers.py | 24 +++ api/tests/test_enums.py | 57 +++++++ 5 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 api/src/models/enums.py create mode 100644 api/tests/dbml_helpers.py create mode 100644 api/tests/test_enums.py diff --git a/api/pytest.ini b/api/pytest.ini index 7ae5279..e6f0417 100644 --- a/api/pytest.ini +++ b/api/pytest.ini @@ -19,6 +19,9 @@ env = APP_ENV=test ENV_FILE=.env.test norecursedirs = .git __pycache__ .venv env build dist +# pydbml (dev-зависимость) вызывает устаревшие API pyparsing — шум библиотеки, не нашего кода +filterwarnings = + ignore::DeprecationWarning:pydbml.* markers = integration: тесты, которым нужны PostgreSQL/NATS (docker compose); запускаются отдельно от юнит-тестов fixtures: тесты на реальных данных из ../fixtures (внутренний доступ) diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index 86139b4..faac666 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -2,4 +2,7 @@ from .base import Base, UUIDPrimaryMixin -__all__ = ["Base", "UUIDPrimaryMixin"] +#: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm). +DBML_VERSION = "5.13" + +__all__ = ["DBML_VERSION", "Base", "UUIDPrimaryMixin"] diff --git a/api/src/models/enums.py b/api/src/models/enums.py new file mode 100644 index 0000000..67cc317 --- /dev/null +++ b/api/src/models/enums.py @@ -0,0 +1,338 @@ +"""Enum'ы модели данных DBML v5.13 (27 штук) и фабрика `pg_enum` для PostgreSQL-колонок. + +Источник истины — docs/model/data-model-v5.13.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке +DBML (включая кириллические `"ЮЛ"`/`"ИП"` и значения в разном регистре); `notes` DBML значениями не являются. +Имя члена совпадает со значением (все значения DBML — валидные python-идентификаторы, кириллица допустима). +Классы — `enum.StrEnum` (подкласс `str`) — удобно для pydantic/JSON; `values_callable` в `pg_enum` в БД пишет `.value`. +""" + +import enum + +from sqlalchemy import Enum as SAEnum + +SCHEMA = "dkb" + + +class EntityType(enum.StrEnum): + """DBML `entity_type`.""" + + ЮЛ = "ЮЛ" # noqa: PLC2401 — значение DBML буквально + ИП = "ИП" # noqa: PLC2401 — значение DBML буквально + + +class RelatedGroupKind(enum.StrEnum): + """DBML `related_group_kind`.""" + + gsk = "gsk" + gsz = "gsz" + + +class AccountKind(enum.StrEnum): + """DBML `account_kind`.""" + + A = "A" + P = "P" + AP = "AP" + + +class AccountRowType(enum.StrEnum): + """DBML `account_row_type`.""" + + account = "account" + subaccount = "subaccount" + analytic = "analytic" + + +class BusinessSegment(enum.StrEnum): + """DBML `business_segment`.""" + + DKB = "DKB" + DMSB = "DMSB" + DM = "DM" + + +class DocumentType(enum.StrEnum): + """DBML `document_type`.""" + + BALANCE = "BALANCE" + INCOME = "INCOME" + OSV = "OSV" + DISCLOSURE = "DISCLOSURE" + OTHER = "OTHER" + + +class FileType(enum.StrEnum): + """DBML `file_type`.""" + + XLS = "XLS" + XLSX = "XLSX" + XLSB = "XLSB" + PDF = "PDF" + PNG = "PNG" + JPG = "JPG" + + +class RecognitionPipeline(enum.StrEnum): + """DBML `recognition_pipeline`.""" + + tabular = "tabular" + image = "image" + + +class PeriodQuarter(enum.StrEnum): + """DBML `period_quarter`.""" + + Q1 = "Q1" + Q2 = "Q2" + Q3 = "Q3" + Q4 = "Q4" + + +class FileStatus(enum.StrEnum): + """DBML `file_status`.""" + + UPLOADED = "UPLOADED" + PROCESSING = "PROCESSING" + PROCESSED = "PROCESSED" + ERROR = "ERROR" + SUPERSEDED = "SUPERSEDED" + + +class DocumentStatus(enum.StrEnum): + """DBML `document_status`.""" + + RECOGNIZED = "RECOGNIZED" + NEEDS_CHECK = "NEEDS_CHECK" + CONFIRMED = "CONFIRMED" + ERROR = "ERROR" + + +class S3BucketRole(enum.StrEnum): + """DBML `s3_bucket_role`.""" + + uploads = "uploads" + results = "results" + exports = "exports" + archive = "archive" + + +class RecognitionStatus(enum.StrEnum): + """DBML `recognition_status`.""" + + queued = "queued" + published = "published" + processing = "processing" + done = "done" + needs_check = "needs_check" + error = "error" + timeout = "timeout" + dlq = "dlq" + + +class StatementType(enum.StrEnum): + """DBML `statement_type`.""" + + balance = "balance" + income = "income" + + +class OsvGroupType(enum.StrEnum): + """DBML `osv_group_type`.""" + + ACCOUNT = "ACCOUNT" + DEPARTMENT = "DEPARTMENT" + COUNTERPARTY = "COUNTERPARTY" + CONTRACT = "CONTRACT" + OTHER = "OTHER" + + +class OsvDataType(enum.StrEnum): + """DBML `osv_data_type`.""" + + BU = "BU" + CURRENCY = "CURRENCY" + QUANTITY = "QUANTITY" + SUM = "SUM" + + +class ConsolidationScenario(enum.StrEnum): + """DBML `consolidation_scenario`.""" + + CREATE = "CREATE" + CONFIRM = "CONFIRM" + + +class ConsolidationApproach(enum.StrEnum): + """DBML `consolidation_approach`.""" + + BASIC = "BASIC" + EXTENDED = "EXTENDED" + + +class ConsolidationStatus(enum.StrEnum): + """DBML `consolidation_status`.""" + + INITIATED = "INITIATED" + DOCUMENTS_PROCESSING = "DOCUMENTS_PROCESSING" + DATA_CORRECTION = "DATA_CORRECTION" + CONSOLIDATION_RF = "CONSOLIDATION_RF" + ADJUSTMENT = "ADJUSTMENT" + VERIFICATION_RF = "VERIFICATION_RF" + CONSOLIDATION_GO = "CONSOLIDATION_GO" + VERIFICATION_GO = "VERIFICATION_GO" + VERIFIED = "VERIFIED" + CANCELLED = "CANCELLED" + REJECTED = "REJECTED" + ARCHIVED = "ARCHIVED" + + +class CorrectionStatus(enum.StrEnum): + """DBML `correction_status`.""" + + DRAFT = "DRAFT" + APPLIED = "APPLIED" + REVOKED = "REVOKED" + + +class DocumentCheckResult(enum.StrEnum): + """DBML `document_check_result`.""" + + MATCH = "MATCH" + MISMATCH = "MISMATCH" + UNKNOWN = "UNKNOWN" + + +class LeHistoryEvent(enum.StrEnum): + """DBML `le_history_event`.""" + + CREATE = "CREATE" + UPDATE = "UPDATE" + MOVE_TO_GROUP = "MOVE_TO_GROUP" + CLOSE = "CLOSE" + REMOVE_FROM_GROUP = "REMOVE_FROM_GROUP" + + +class ExSnapshotStatus(enum.StrEnum): + """DBML `ex_snapshot_status`.""" + + ACTUAL = "ACTUAL" + ARCHIVE = "ARCHIVE" + + +class PermissionObject(enum.StrEnum): + """DBML `permission_object`.""" + + DIRECTORY = "DIRECTORY" + DOCUMENT = "DOCUMENT" + CONSOLIDATION = "CONSOLIDATION" + KFO = "KFO" + EX = "EX" + EXPORT = "EXPORT" + USERS = "USERS" + AUDIT = "AUDIT" + + +class PermissionAction(enum.StrEnum): + """DBML `permission_action`.""" + + VIEW = "VIEW" + EDIT = "EDIT" + APPROVE = "APPROVE" + VERIFY = "VERIFY" + DELETE = "DELETE" + EXPORT = "EXPORT" + ADMIN = "ADMIN" + + +class AuditSection(enum.StrEnum): + """DBML `audit_section`.""" + + DIRECTORIES = "DIRECTORIES" + CONSOLIDATION = "CONSOLIDATION" + PRIMARY_DOCUMENTS = "PRIMARY_DOCUMENTS" + RECOGNITION = "RECOGNITION" + KFO = "KFO" + EXPORT = "EXPORT" + ACCESS = "ACCESS" + SESSION = "SESSION" + USERS = "USERS" + + +class AuditAction(enum.StrEnum): + """DBML `audit_action`.""" + + LOGIN = "LOGIN" + LOGIN_FAILED = "LOGIN_FAILED" + LOGOUT = "LOGOUT" + CONSOLIDATION_CREATE = "CONSOLIDATION_CREATE" + CONSOLIDATION_STATUS_CHANGE = "CONSOLIDATION_STATUS_CHANGE" + CONSOLIDATION_APPROVE = "CONSOLIDATION_APPROVE" + CONSOLIDATION_VERIFY = "CONSOLIDATION_VERIFY" + GROUP_CHANGE = "GROUP_CHANGE" + MEMBERSHIP_CHANGE = "MEMBERSHIP_CHANGE" + ASSIGNMENT_CHANGE = "ASSIGNMENT_CHANGE" + FILE_UPLOAD = "FILE_UPLOAD" + FILE_DELETE = "FILE_DELETE" + FILE_REPLACE = "FILE_REPLACE" + DOCUMENT_CONFIRM = "DOCUMENT_CONFIRM" + CONSOLIDATION_LOCK = "CONSOLIDATION_LOCK" + CONSOLIDATION_LOCK_TAKEOVER = "CONSOLIDATION_LOCK_TAKEOVER" + DISCLOSURE_EDIT = "DISCLOSURE_EDIT" + RECOGNIZED_DATA_EDIT = "RECOGNIZED_DATA_EDIT" + OSV_COUNTERPARTY_MAP = "OSV_COUNTERPARTY_MAP" + OSV_TYPE_OVERRIDE = "OSV_TYPE_OVERRIDE" + CORRECTION_CREATE = "CORRECTION_CREATE" + CORRECTION_EDIT = "CORRECTION_EDIT" + CORRECTION_REVOKE = "CORRECTION_REVOKE" + GOODWILL_EDIT = "GOODWILL_EDIT" + EXPORT = "EXPORT" + DIRECTORY_IMPORT = "DIRECTORY_IMPORT" + USER_ROLE_CHANGE = "USER_ROLE_CHANGE" + ROLE_PERMISSION_CHANGE = "ROLE_PERMISSION_CHANGE" + + +#: Реестр: имя enum в DBML (= имя типа в PostgreSQL) → класс. +ENUMS: dict[str, type[enum.Enum]] = { + "entity_type": EntityType, + "related_group_kind": RelatedGroupKind, + "account_kind": AccountKind, + "account_row_type": AccountRowType, + "business_segment": BusinessSegment, + "document_type": DocumentType, + "file_type": FileType, + "recognition_pipeline": RecognitionPipeline, + "period_quarter": PeriodQuarter, + "file_status": FileStatus, + "document_status": DocumentStatus, + "s3_bucket_role": S3BucketRole, + "recognition_status": RecognitionStatus, + "statement_type": StatementType, + "osv_group_type": OsvGroupType, + "osv_data_type": OsvDataType, + "consolidation_scenario": ConsolidationScenario, + "consolidation_approach": ConsolidationApproach, + "consolidation_status": ConsolidationStatus, + "correction_status": CorrectionStatus, + "document_check_result": DocumentCheckResult, + "le_history_event": LeHistoryEvent, + "ex_snapshot_status": ExSnapshotStatus, + "permission_object": PermissionObject, + "permission_action": PermissionAction, + "audit_section": AuditSection, + "audit_action": AuditAction, +} + + +def pg_enum(name: str) -> SAEnum: + """Тип колонки для enum DBML `name`: нативный PG-enum `dkb.`, значения — `.value` членов. + + Создание типа — поведение SQLAlchemy по умолчанию (`create_type=True`, `checkfirst`): `Base.metadata.create_all` + создаёт типы сам; миграция 0001 создаёт их явно. + """ + return SAEnum( + ENUMS[name], + name=name, + schema=SCHEMA, + native_enum=True, + values_callable=lambda e: [m.value for m in e], + ) diff --git a/api/tests/dbml_helpers.py b/api/tests/dbml_helpers.py new file mode 100644 index 0000000..92216a8 --- /dev/null +++ b/api/tests/dbml_helpers.py @@ -0,0 +1,24 @@ +"""Поиск и разбор DBML-модели для тестов (переиспользуется тестами чётности ORM и DBML).""" + +import os +from pathlib import Path + +import pytest +from pydbml import PyDBML + +DBML_FILE_NAME = "data-model-v5.13.dbml" + + +def dbml_path() -> Path: + """Путь к DBML: каталог docs из `ARM_KA_DOCS`, иначе соседний `../../docs` относительно `arm-core/api`.""" + docs = os.environ.get("ARM_KA_DOCS") + docs_dir = Path(docs) if docs else Path(__file__).resolve().parents[3] / "docs" + return docs_dir / "model" / DBML_FILE_NAME + + +def load_dbml() -> PyDBML: + """Разобрать DBML через pydbml; без файла — пропуск теста с явной причиной.""" + path = dbml_path() + if not path.is_file(): + pytest.skip(f"docs недоступен: нет {path} (задайте ARM_KA_DOCS или положите docs рядом с arm-core)") + return PyDBML.parse_file(path) diff --git a/api/tests/test_enums.py b/api/tests/test_enums.py new file mode 100644 index 0000000..21811fa --- /dev/null +++ b/api/tests/test_enums.py @@ -0,0 +1,57 @@ +"""Enum'ы ORM против блоков `Enum {}` DBML v5.13: имена, значения и их порядок (без БД).""" + +import enum +import re + +import pytest +from sqlalchemy import Enum as SAEnum + +from src.models import DBML_VERSION +from src.models.enums import ENUMS, pg_enum +from tests.dbml_helpers import dbml_path, load_dbml + +EXPECTED_ENUM_COUNT = 27 + + +def test_dbml_version_format() -> None: + assert re.fullmatch(r"\d+\.\d+", DBML_VERSION) + assert DBML_VERSION == "5.13" + assert f"v{DBML_VERSION}" in dbml_path().name + + +def test_enums_count_and_names_match_dbml() -> None: + dbml_enums = {e.name for e in load_dbml().enums} + assert len(dbml_enums) == EXPECTED_ENUM_COUNT + assert set(ENUMS) == dbml_enums + + +def test_enum_values_match_dbml_in_order() -> None: + for dbml_enum in load_dbml().enums: + expected = [item.name for item in dbml_enum.items] + actual = [m.value for m in ENUMS[dbml_enum.name]] + assert actual == expected, f"enum {dbml_enum.name}: ожидалось {expected}, фактически {actual}" + + +def test_enum_classes_are_str_enums_with_member_name_equal_value() -> None: + for name, cls in ENUMS.items(): + assert issubclass(cls, enum.Enum) and issubclass(cls, str), name + assert [m.name for m in cls] == [m.value for m in cls], name + + +def test_cyrillic_values_kept_verbatim() -> None: + assert [m.value for m in ENUMS["entity_type"]] == ["ЮЛ", "ИП"] + + +def test_pg_enum_factory_gives_dbml_named_type_in_dkb_schema() -> None: + for name, cls in ENUMS.items(): + sa_enum = pg_enum(name) + assert isinstance(sa_enum, SAEnum) + assert sa_enum.name == name + assert sa_enum.schema == "dkb" + assert sa_enum.native_enum is True + assert sa_enum.enums == [m.value for m in cls], name + + +def test_pg_enum_unknown_name_fails() -> None: + with pytest.raises(KeyError): + pg_enum("no_such_enum") -- 2.47.2 From a272d6873af684a41f87d1da01dd68163bc86302 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 18:47:29 +0400 Subject: [PATCH 19/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 43c644a..ff6d7f0 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -37,7 +37,7 @@ ### Task 5: [2.1] `src/models/enums.py` -- [ ] `src/models/enums.py`: 27 enum'ов DBML как `enum.Enum` + фабрика `pg_enum`; `DBML_VERSION = "5.13"` в `models/__init__.py`; проверка: тест перечисляет имена и значения против списка из DBML (парсинг `Enum {}` блоков через pydbml) +- [x] `src/models/enums.py`: 27 enum'ов DBML как `enum.Enum` + фабрика `pg_enum`; `DBML_VERSION = "5.13"` в `models/__init__.py`; проверка: тест перечисляет имена и значения против списка из DBML (парсинг `Enum {}` блоков через pydbml) ### Task 6: [2.2] Части 01–03 diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index dafeb08..5d31696 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -27,7 +27,7 @@ ## 2. Модель данных (spec `data-model`) -- [ ] 2.1 `src/models/enums.py`: 27 enum'ов DBML как `enum.Enum` + фабрика `pg_enum`; `DBML_VERSION = "5.13"` в +- [x] 2.1 `src/models/enums.py`: 27 enum'ов DBML как `enum.Enum` + фабрика `pg_enum`; `DBML_VERSION = "5.13"` в `models/__init__.py`; проверка: тест перечисляет имена и значения против списка из DBML (парсинг `Enum {}` блоков через pydbml) - [ ] 2.2 Части 01–03: `directories.py` (`regional_office`, `company_group(+_history)`, `legal_entity(+_history)`, `legal_entity_alias`, `related_group`, `related_group_member` с partial unique), `access.py` (`app_user`, `role`, -- 2.47.2 From fb2b9ef9fc170e4b29f142c8f76232510e3f4265 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 19:05:04 +0400 Subject: [PATCH 20/97] =?UTF-8?q?feat:=20ORM-=D0=BC=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=20=D1=87=D0=B0=D1=81=D1=82=D0=B5=D0=B9=2001?= =?UTF-8?q?=E2=80=9303=20DBML=20v5.13=20(=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B8,=20=D0=B4=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=83=D0=BF,=20=D1=83=D1=87=D1=91=D1=82),=20naming=5Fcon?= =?UTF-8?q?vention=20=D0=B2=20MetaData?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 16 таблиц: directories, access, accounting; схема dkb, детерминированные имена ограничений. Co-Authored-By: Claude Fable 5.1 --- api/src/models/__init__.py | 8 +- api/src/models/access.py | 75 +++++++++++++++ api/src/models/accounting.py | 64 +++++++++++++ api/src/models/base.py | 25 ++++- api/src/models/directories.py | 169 ++++++++++++++++++++++++++++++++++ api/src/models/enums.py | 2 +- 6 files changed, 338 insertions(+), 5 deletions(-) create mode 100644 api/src/models/access.py create mode 100644 api/src/models/accounting.py create mode 100644 api/src/models/directories.py diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index faac666..8071b68 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -1,8 +1,12 @@ -"""ORM-модели. Источник истины — docs/model/data-model-v5.13.dbml; заполняется в Срезе 0.""" +"""ORM-модели. Источник истины — docs/model/data-model-v5.13.dbml; заполняется в Срезе 0. +Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic). +""" + +from . import access, accounting, directories from .base import Base, UUIDPrimaryMixin #: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm). DBML_VERSION = "5.13" -__all__ = ["DBML_VERSION", "Base", "UUIDPrimaryMixin"] +__all__ = ["DBML_VERSION", "Base", "UUIDPrimaryMixin", "access", "accounting", "directories"] diff --git a/api/src/models/access.py b/api/src/models/access.py new file mode 100644 index 0000000..cf8f275 --- /dev/null +++ b/api/src/models/access.py @@ -0,0 +1,75 @@ +"""Часть 02 DBML v5.13 «Пользователи и доступ»: пользователи, роли, права как данные (`effective_permissions`).""" + +import datetime +import uuid + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base, UUIDPrimaryMixin +from .enums import BusinessSegment, PermissionAction, PermissionObject, pg_enum + + +class AppUser(UUIDPrimaryMixin, Base): + """Пользователь; аутентификация внешняя (JWT РАИСА, email → `app_user`).""" + + __tablename__ = "app_user" + + external_id: Mapped[str | None] = mapped_column(String, unique=True) + email: Mapped[str | None] = mapped_column(String, unique=True) + full_name: Mapped[str] = mapped_column(String, nullable=False) + employee_no: Mapped[str | None] = mapped_column(String) + regional_office_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("regional_office.id"), nullable=False + ) + business_segment: Mapped[BusinessSegment | None] = mapped_column(pg_enum("business_segment")) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + created_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + + +class Role(UUIDPrimaryMixin, Base): + """Роль; полномочия — через `role_permission`, код роли в бизнес-логике не проверяется.""" + + __tablename__ = "role" + + code: Mapped[str] = mapped_column(String, nullable=False, unique=True) + name: Mapped[str] = mapped_column(String, nullable=False) + description: Mapped[str | None] = mapped_column(Text) + is_system: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + + +class Permission(UUIDPrimaryMixin, Base): + """Справочник прав «объект × действие» (seed, привязан к коду).""" + + __tablename__ = "permission" + __table_args__ = (Index("uq_permission_object_type_action", "object_type", "action", unique=True),) + + code: Mapped[str] = mapped_column(String, nullable=False, unique=True) + object_type: Mapped[PermissionObject] = mapped_column(pg_enum("permission_object"), nullable=False) + action: Mapped[PermissionAction] = mapped_column(pg_enum("permission_action"), nullable=False) + name: Mapped[str] = mapped_column(String, nullable=False) + description: Mapped[str | None] = mapped_column(Text) + + +class RolePermission(Base): + """Матрица доступов как данные; составной PK `(role_id, permission_id)`, суррогатного `id` нет.""" + + __tablename__ = "role_permission" + + role_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("role.id"), primary_key=True) + permission_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("permission.id"), primary_key=True) + granted_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + granted_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + + +class UserRole(UUIDPrimaryMixin, Base): + """Назначение ролей пользователю (M:N, current-state); история — `audit_event(USER_ROLE_CHANGE)`.""" + + __tablename__ = "user_role" + __table_args__ = (Index("uq_user_role_user_id_role_id", "user_id", "role_id", unique=True),) + + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id"), nullable=False) + role_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("role.id"), nullable=False) diff --git a/api/src/models/accounting.py b/api/src/models/accounting.py new file mode 100644 index 0000000..def7fd6 --- /dev/null +++ b/api/src/models/accounting.py @@ -0,0 +1,64 @@ +"""Часть 03 DBML v5.13 (учёт): план счетов и правила элиминации ВГО (Прил. 3/4 ТЗ как данные).""" + +import datetime +import uuid + +from sqlalchemy import Boolean, Date, ForeignKey, Index, Integer, SmallInteger, String, Text, text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base, UUIDPrimaryMixin +from .enums import AccountKind, AccountRowType, StatementType, pg_enum + + +class ChartOfAccount(UUIDPrimaryMixin, Base): + """План счетов; `code` — нормализованный (60.01, 76.07.1).""" + + __tablename__ = "chart_of_account" + + code: Mapped[str] = mapped_column(String, nullable=False, unique=True) + parent_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("chart_of_account.id")) + name: Mapped[str] = mapped_column(String, nullable=False) + kind: Mapped[AccountKind | None] = mapped_column(pg_enum("account_kind")) + row_type: Mapped[AccountRowType] = mapped_column(pg_enum("account_row_type"), nullable=False) + is_off_balance: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + + +class EliminationRule(UUIDPrimaryMixin, Base): + """Правило элиминации (Прил. 3 — ББ, Прил. 4 — ОФР), версионируется; состав счетов — `elimination_rule_account`.""" + + __tablename__ = "elimination_rule" + __table_args__ = ( + Index( + "uq_elimination_rule_statement_type_rule_no_version_no", + "statement_type", + "rule_no", + "version_no", + unique=True, + ), + ) + + statement_type: Mapped[StatementType] = mapped_column(pg_enum("statement_type"), nullable=False) + rule_no: Mapped[int] = mapped_column(SmallInteger, nullable=False) + article_1_line: Mapped[str] = mapped_column(String, nullable=False) + article_2_line: Mapped[str] = mapped_column(String, nullable=False) + side_1: Mapped[str] = mapped_column(String, nullable=False) + measure: Mapped[str] = mapped_column(String, nullable=False) + elimination_mode: Mapped[str] = mapped_column(String, nullable=False) + vat_handling: Mapped[str | None] = mapped_column(String) + excise_adjust: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + comment: Mapped[str | None] = mapped_column(Text) + version_no: Mapped[int] = mapped_column(Integer, nullable=False) + valid_from: Mapped[datetime.date] = mapped_column(Date, nullable=False) + valid_to: Mapped[datetime.date | None] = mapped_column(Date) + + +class EliminationRuleAccount(UUIDPrimaryMixin, Base): + """Счета и субсчета сторон правила; `is_excluded` — исключение внутри маски («76 кроме 76.7.1»).""" + + __tablename__ = "elimination_rule_account" + + rule_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("elimination_rule.id"), nullable=False) + company_no: Mapped[int] = mapped_column(SmallInteger, nullable=False) + account_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("chart_of_account.id"), nullable=False) + is_excluded: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) diff --git a/api/src/models/base.py b/api/src/models/base.py index 325b5bb..16839bc 100644 --- a/api/src/models/base.py +++ b/api/src/models/base.py @@ -1,14 +1,35 @@ +"""Базовый класс ORM: схема `dkb`, детерминированные имена ограничений и первичный ключ uuid.""" + import uuid from sqlalchemy import MetaData from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, mapped_column +#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.13). +SCHEMA = "dkb" + +#: Стандартная схема имён SQLAlchemy. Нужна, чтобы ORM, миграция 0001 и `alembic autogenerate` давали одни и те же +#: имена ограничений и `downgrade` был детерминирован. Для составных/partial-индексов, где конвенция даёт +#: неоднозначное или слишком длинное имя (лимит PostgreSQL — 63 символа), имена задаются явно в `__table_args__`. +NAMING_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + class Base(DeclarativeBase): - metadata = MetaData(schema="dkb") + metadata = MetaData(schema=SCHEMA, naming_convention=NAMING_CONVENTION) @declarative_mixin class UUIDPrimaryMixin: - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + """`id uuid [pk]` из DBML: значение генерирует приложение (uuid4), серверного default в БД нет. + + `sort_order=-1` — `id` первой колонкой, как в DBML (иначе колонки миксина идут после колонок класса). + """ + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, sort_order=-1) diff --git a/api/src/models/directories.py b/api/src/models/directories.py new file mode 100644 index 0000000..7b0f0a8 --- /dev/null +++ b/api/src/models/directories.py @@ -0,0 +1,169 @@ +"""Часть 01 DBML v5.13 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2).""" + +import datetime +import uuid + +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, String, text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base, UUIDPrimaryMixin +from .enums import EntityType, LeHistoryEvent, RelatedGroupKind, pg_enum + + +class RegionalOffice(UUIDPrimaryMixin, Base): + """Филиал: ГО и региональные филиалы (DECISION-3: ГО — обычная запись с `is_head_office`).""" + + __tablename__ = "regional_office" + + name: Mapped[str] = mapped_column(String, nullable=False, unique=True) + is_head_office: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + + +class CompanyGroup(UUIDPrimaryMixin, Base): + """ГК — текущее состояние (SCD4); история — `company_group_history`.""" + + __tablename__ = "company_group" + + code: Mapped[str] = mapped_column(String, nullable=False, unique=True) + crm_id: Mapped[str | None] = mapped_column(String, unique=True) + name: Mapped[str] = mapped_column(String, nullable=False) + base_regional_office_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("regional_office.id") + ) + is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + valid_from: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + created_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + + +class CompanyGroupHistory(UUIDPrimaryMixin, Base): + """Прошлые снимки ГК (SCD4).""" + + __tablename__ = "company_group_history" + __table_args__ = ( + Index("uq_company_group_history_company_group_id_valid_from", "company_group_id", "valid_from", unique=True), + ) + + company_group_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("company_group.id"), nullable=False + ) + crm_id: Mapped[str | None] = mapped_column(String) + name: Mapped[str | None] = mapped_column(String) + base_regional_office_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) + valid_from: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + valid_to: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + changed_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + + +class LegalEntity(UUIDPrimaryMixin, Base): + """Организация (ЮЛ/ИП) — текущее состояние (SCD4); `company_group_id IS NULL` = монозаёмщик.""" + + __tablename__ = "legal_entity" + + inn: Mapped[str] = mapped_column(String, nullable=False, unique=True) + crm_id: Mapped[str | None] = mapped_column(String, unique=True) + entity_type: Mapped[EntityType | None] = mapped_column(pg_enum("entity_type")) + name: Mapped[str] = mapped_column(String, nullable=False) + company_group_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("company_group.id")) + regional_office_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("regional_office.id")) + close_date: Mapped[datetime.date | None] = mapped_column(Date) + valid_from: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + created_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + + +class LegalEntityHistory(UUIDPrimaryMixin, Base): + """Прошлые снимки ЮЛ (SCD4) и события для архива ГК.""" + + __tablename__ = "legal_entity_history" + __table_args__ = ( + Index("uq_legal_entity_history_legal_entity_id_valid_from", "legal_entity_id", "valid_from", unique=True), + ) + + legal_entity_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("legal_entity.id"), nullable=False + ) + inn: Mapped[str | None] = mapped_column(String) + crm_id: Mapped[str | None] = mapped_column(String) + entity_type: Mapped[EntityType | None] = mapped_column(pg_enum("entity_type")) + name: Mapped[str | None] = mapped_column(String) + company_group_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) + regional_office_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) + close_date: Mapped[datetime.date | None] = mapped_column(Date) + event_type: Mapped[LeHistoryEvent | None] = mapped_column(pg_enum("le_history_event")) + effective_date: Mapped[datetime.date | None] = mapped_column(Date) + valid_from: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + valid_to: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + changed_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + + +class LegalEntityAlias(UUIDPrimaryMixin, Base): + """Словарь соответствий наименований контрагентов (ТЗ 6.5).""" + + __tablename__ = "legal_entity_alias" + __table_args__ = ( + Index( + "uq_legal_entity_alias_legal_entity_id_normalized_alias", "legal_entity_id", "normalized_alias", unique=True + ), + Index("ix_legal_entity_alias_normalized_alias", "normalized_alias"), + ) + + legal_entity_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("legal_entity.id"), nullable=False + ) + alias: Mapped[str] = mapped_column(String, nullable=False) + normalized_alias: Mapped[str] = mapped_column(String, nullable=False) + created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + created_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + + +class RelatedGroup(UUIDPrimaryMixin, Base): + """ГСК/ГСЗ — текущее состояние; иерархия групп не поддерживается (ТЗ 6.1).""" + + __tablename__ = "related_group" + + code: Mapped[str] = mapped_column(String, nullable=False, unique=True) + company_group_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("company_group.id"), nullable=False + ) + kind: Mapped[RelatedGroupKind] = mapped_column(pg_enum("related_group_kind"), nullable=False) + name: Mapped[str] = mapped_column(String, nullable=False) + created_date: Mapped[datetime.date | None] = mapped_column(Date) + + +class RelatedGroupMember(UUIDPrimaryMixin, Base): + """Состав ГСК/ГСЗ — SCD2; исключение = закрытие версии (`is_current=false`).""" + + __tablename__ = "related_group_member" + __table_args__ = ( + # (related_group_id, legal_entity_id): partial UNIQUE WHERE is_current + Index( + "uq_related_group_member_group_entity_current", + "related_group_id", + "legal_entity_id", + unique=True, + postgresql_where=text("is_current"), + ), + # legal_entity_id: partial UNIQUE WHERE is_current — ЮЛ только в одной ГСК или ГСЗ (ТЗ 6.1, ANL-030) + Index( + "uq_related_group_member_entity_current", + "legal_entity_id", + unique=True, + postgresql_where=text("is_current"), + ), + ) + + related_group_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("related_group.id"), nullable=False + ) + legal_entity_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("legal_entity.id"), nullable=False + ) + valid_from: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False) + valid_to: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + is_current: Mapped[bool] = mapped_column(Boolean, nullable=False) + created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) diff --git a/api/src/models/enums.py b/api/src/models/enums.py index 67cc317..2b621c8 100644 --- a/api/src/models/enums.py +++ b/api/src/models/enums.py @@ -10,7 +10,7 @@ import enum from sqlalchemy import Enum as SAEnum -SCHEMA = "dkb" +from .base import SCHEMA class EntityType(enum.StrEnum): -- 2.47.2 From 56c98a5d05928fd98c72979d39fa3d0ff11968cf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 19:05:04 +0400 Subject: [PATCH 21/97] =?UTF-8?q?test:=20=D1=87=D1=91=D1=82=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20ORM=20=D1=87=D0=B0=D1=81=D1=82=D0=B5=D0=B9?= =?UTF-8?q?=2001=E2=80=9303=20=D1=81=20DBML,=20create=5Fall=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=91=D0=94,=20=D1=84=D0=B8=D0=BA=D1=81=D1=82=D1=83=D1=80?= =?UTF-8?q?=D0=B0=20scratch=5Fdb=5Furl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/tests/conftest.py | 3 + api/tests/pg_helpers.py | 54 +++++++ api/tests/test_models_part_01_03.py | 210 ++++++++++++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 api/tests/pg_helpers.py create mode 100644 api/tests/test_models_part_01_03.py diff --git a/api/tests/conftest.py b/api/tests/conftest.py index e69de29..e5eeb18 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -0,0 +1,3 @@ +"""Общие фикстуры тестов.""" + +pytest_plugins = ["tests.pg_helpers"] diff --git a/api/tests/pg_helpers.py b/api/tests/pg_helpers.py new file mode 100644 index 0000000..a924716 --- /dev/null +++ b/api/tests/pg_helpers.py @@ -0,0 +1,54 @@ +"""Помощники интеграционных тестов с PostgreSQL: временная БД внутри того же сервера, что и `OPENBAO__DATABASE`. + +Фикстура `scratch_db_url` создаёт пустую БД `armcore_scratch_`, отдаёт её SQLAlchemy-URL (драйвер asyncpg) и +в конце удаляет. Рабочую БД (`armka`) тесты не трогают. Подключается плагином в `conftest.py`. +""" + +import uuid +from collections.abc import AsyncIterator + +import asyncpg +import pytest_asyncio +from sqlalchemy.engine import URL, make_url + +from src.core.config import settings + + +def base_url() -> URL: + """SQLAlchemy-URL рабочей БД из настроек (`.env.test`).""" + return make_url(settings.OPENBAO.DATABASE.DATABASE_URL) + + +def _asyncpg_dsn(url: URL) -> str: + return url.set(drivername="postgresql").render_as_string(hide_password=False) + + +async def create_scratch_db(name: str) -> URL: + """Создать пустую БД `name` на сервере рабочей БД; вернуть SQLAlchemy-URL на неё.""" + url = base_url() + conn = await asyncpg.connect(_asyncpg_dsn(url)) + try: + await conn.execute(f'CREATE DATABASE "{name}"') + finally: + await conn.close() + return url.set(database=name) + + +async def drop_scratch_db(name: str) -> None: + """Удалить БД `name` (закрывая оставшиеся подключения).""" + conn = await asyncpg.connect(_asyncpg_dsn(base_url())) + try: + await conn.execute(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)') + finally: + await conn.close() + + +@pytest_asyncio.fixture +async def scratch_db_url() -> AsyncIterator[str]: + """URL пустой временной БД (asyncpg); БД удаляется после теста.""" + name = f"armcore_scratch_{uuid.uuid4().hex[:12]}" + url = await create_scratch_db(name) + try: + yield url.render_as_string(hide_password=False) + finally: + await drop_scratch_db(name) diff --git a/api/tests/test_models_part_01_03.py b/api/tests/test_models_part_01_03.py new file mode 100644 index 0000000..00e8f75 --- /dev/null +++ b/api/tests/test_models_part_01_03.py @@ -0,0 +1,210 @@ +"""ORM частей 01–03 DBML v5.13 (справочники, доступ, учёт): чётность с DBML без БД и create_all на PostgreSQL.""" + +import datetime +import uuid + +import pytest +from pydbml.classes import Table as DbmlTable +from sqlalchemy import Table, inspect, text +from sqlalchemy.dialects import postgresql +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.schema import CreateIndex, CreateTable + +from src.models import Base +from tests.dbml_helpers import load_dbml + +PART_01_03_TABLES = { + # directories + "regional_office", + "company_group", + "company_group_history", + "legal_entity", + "legal_entity_history", + "legal_entity_alias", + "related_group", + "related_group_member", + # access + "app_user", + "role", + "permission", + "role_permission", + "user_role", + # accounting + "chart_of_account", + "elimination_rule", + "elimination_rule_account", +} +PG_IDENTIFIER_LIMIT = 63 +EXPECTED_TABLE_COUNT = 16 + + +def orm_tables() -> dict[str, Table]: + return {t.name: t for t in Base.metadata.sorted_tables} + + +def dbml_tables() -> dict[str, DbmlTable]: + return {t.name: t for t in load_dbml().tables if t.name in PART_01_03_TABLES} + + +def test_part_01_03_tables_registered_in_metadata() -> None: + assert set(orm_tables()) == PART_01_03_TABLES + assert len(Base.metadata.sorted_tables) == EXPECTED_TABLE_COUNT # на этом этапе (задачи 7–8 добавят остальные) + assert all(t.schema == "dkb" for t in Base.metadata.sorted_tables) + + +def test_every_table_and_index_compiles_for_postgresql() -> None: + dialect = postgresql.dialect() + for table in Base.metadata.sorted_tables: + assert str(CreateTable(table).compile(dialect=dialect)).strip() + for index in table.indexes: + assert str(CreateIndex(index).compile(dialect=dialect)).strip() + + +def test_foreign_keys_resolve_to_registered_tables() -> None: + for table in Base.metadata.sorted_tables: + for fk in table.foreign_keys: + assert fk.column.table.key in Base.metadata.tables, f"{table.name}.{fk.parent.name}" + assert fk.column.table.schema == "dkb" + + +def test_constraint_names_are_explicit_and_fit_postgres_limit() -> None: + """Все имена ограничений заданы (конвенцией/явно), без хеш-усечения, и не длиннее 63 символов.""" + for table in Base.metadata.sorted_tables: + names = [c.name for c in table.constraints] + [i.name for i in table.indexes] + for name in names: + assert name, f"{table.name}: безымянное ограничение" + assert len(str(name)) <= PG_IDENTIFIER_LIMIT, f"{table.name}: {name} длиннее {PG_IDENTIFIER_LIMIT}" + + +def test_columns_match_dbml() -> None: + """Имена колонок, nullability, PK и FK-цели — как в DBML.""" + orm, dbml = orm_tables(), dbml_tables() + refs = [r for r in load_dbml().refs if r.table1.name in PART_01_03_TABLES] + expected_fks = {(r.table1.name, r.col1[0].name, r.table2.name, r.col2[0].name) for r in refs} + actual_fks = { + (t.name, fk.parent.name, fk.column.table.name, fk.column.name) for t in orm.values() for fk in t.foreign_keys + } + assert actual_fks == expected_fks + for name, dbml_table in dbml.items(): + orm_table = orm[name] + dbml_cols = {c.name: c for c in dbml_table.columns} + assert set(orm_table.columns.keys()) == set(dbml_cols), name + composite_pk = {c.name for i in dbml_table.indexes if i.pk for c in i.subjects} + for col_name, dbml_col in dbml_cols.items(): + orm_col = orm_table.columns[col_name] + is_pk = dbml_col.pk or col_name in composite_pk + assert orm_col.primary_key == is_pk, f"{name}.{col_name}: pk" + assert orm_col.nullable == (not (dbml_col.not_null or is_pk)), f"{name}.{col_name}: nullable" + assert bool(orm_col.unique) == bool(dbml_col.unique), f"{name}.{col_name}: unique" + if dbml_col.default is None: + assert orm_col.server_default is None, f"{name}.{col_name}: лишний server_default" + else: + assert orm_col.server_default is not None, f"{name}.{col_name}: нет server_default" + assert str(orm_col.server_default.arg).lower() == str(dbml_col.default).lower() + + +def test_column_order_follows_dbml() -> None: + """Порядок колонок как в DBML (в частности `id` первой) — миграция 0001 читается рядом с DBML.""" + orm = orm_tables() + for name, dbml_table in dbml_tables().items(): + assert [c.name for c in orm[name].columns] == [c.name for c in dbml_table.columns], name + + +def test_indexes_match_dbml() -> None: + """Состав индексов (кроме PK): колонки, уникальность, partial-условие.""" + orm = orm_tables() + for name, dbml_table in dbml_tables().items(): + expected = sorted( + (tuple(c.name for c in i.subjects), i.unique or "partial UNIQUE" in str(i.note)) + for i in dbml_table.indexes + if not i.pk + ) + actual = sorted((tuple(c.name for c in i.columns), bool(i.unique)) for i in orm[name].indexes) + assert actual == expected, name + partial = {i.name: str(i.dialect_options["postgresql"]["where"]) for i in orm["related_group_member"].indexes} + assert partial == { + "uq_related_group_member_group_entity_current": "is_current", + "uq_related_group_member_entity_current": "is_current", + } + + +@pytest.mark.integration +async def test_create_all_on_scratch_db(scratch_db_url: str) -> None: + """`create_all` на пустой БД: 16 таблиц в схеме dkb, enum-типы, partial unique работают.""" + engine = create_async_engine(scratch_db_url) + try: + async with engine.begin() as conn: + await conn.execute(text("CREATE SCHEMA IF NOT EXISTS dkb")) + await conn.run_sync(Base.metadata.create_all) + + async with engine.connect() as conn: + tables = await conn.run_sync(lambda c: set(inspect(c).get_table_names(schema="dkb"))) + assert tables == PART_01_03_TABLES + enums = { + row[0] + for row in await conn.execute( + text( + "SELECT t.typname FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace " + "WHERE n.nspname = 'dkb' AND t.typtype = 'e'" + ) + ) + } + assert enums == { + "entity_type", "related_group_kind", "account_kind", "account_row_type", "business_segment", + "statement_type", "le_history_event", "permission_object", "permission_action", + } # fmt: skip + indexdefs = { + row[0]: row[1] + for row in await conn.execute( + text( + "SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'dkb' " + "AND tablename = 'related_group_member'" + ) + ) + } + for name in ("uq_related_group_member_group_entity_current", "uq_related_group_member_entity_current"): + assert "UNIQUE" in indexdefs[name] + assert "WHERE is_current" in indexdefs[name] + pk = await conn.run_sync(lambda c: inspect(c).get_pk_constraint("role_permission", schema="dkb")) + assert pk["name"] == "pk_role_permission" + assert pk["constrained_columns"] == ["role_id", "permission_id"] + + await _check_related_group_member_uniqueness(engine) + finally: + await engine.dispose() + + +async def _check_related_group_member_uniqueness(engine) -> None: # type: ignore[no-untyped-def] + """ЮЛ — только в одной ГСК/ГСЗ среди текущих; закрытая версия не мешает (ТЗ 6.1, ANL-030).""" + now = datetime.datetime.now(datetime.UTC) + ids = {k: uuid.uuid4() for k in ("ro", "cg", "le", "g1", "g2", "m1", "m2", "m3")} + async with engine.begin() as conn: + await conn.execute(text("INSERT INTO dkb.regional_office (id, name) VALUES (:ro, 'ГО')"), ids) + await conn.execute( + text("INSERT INTO dkb.company_group (id, code, name, valid_from) VALUES (:cg, 'G1', 'ГК', :now)"), + {**ids, "now": now}, + ) + await conn.execute( + text("INSERT INTO dkb.legal_entity (id, inn, name, valid_from) VALUES (:le, '1', 'ЮЛ', :now)"), + {**ids, "now": now}, + ) + for g, code in (("g1", "GSK-1"), ("g2", "GSK-2")): + await conn.execute( + text( + "INSERT INTO dkb.related_group (id, code, company_group_id, kind, name) " + "VALUES (:g, :code, :cg, 'gsk', 'ГСК')" + ), + {"g": ids[g], "code": code, "cg": ids["cg"]}, + ) + insert = text( + "INSERT INTO dkb.related_group_member (id, related_group_id, legal_entity_id, valid_from, is_current) " + "VALUES (:id, :g, :le, :now, :cur)" + ) + async with engine.begin() as conn: + await conn.execute(insert, {"id": ids["m1"], "g": ids["g1"], "le": ids["le"], "now": now, "cur": True}) + # закрытая версия в другой группе — допустима + await conn.execute(insert, {"id": ids["m2"], "g": ids["g2"], "le": ids["le"], "now": now, "cur": False}) + with pytest.raises(IntegrityError): + async with engine.begin() as conn: # второе текущее членство того же ЮЛ — нарушение + await conn.execute(insert, {"id": ids["m3"], "g": ids["g2"], "le": ids["le"], "now": now, "cur": True}) -- 2.47.2 From 9936b0a59fd27be992198a99e2858dbc6172e520 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 19:05:09 +0400 Subject: [PATCH 22/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index ff6d7f0..eef4e54 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -41,7 +41,7 @@ ### Task 6: [2.2] Части 01–03 -- [ ] Части 01–03: `directories.py` (`regional_office`, `company_group(+_history)`, `legal_entity(+_history)`, `legal_entity_alias`, `related_group`, `related_group_member` с partial unique), `access.py` (`app_user`, `role`, `permission`, `role_permission`, `user_role`), `accounting.py` (`chart_of_account`, `elimination_rule`, `elimination_rule_account`, `required_document_rule`); проверка: `Base.metadata.create_all` на тестовой БД без ошибок +- [x] Части 01–03: `directories.py` (`regional_office`, `company_group(+_history)`, `legal_entity(+_history)`, `legal_entity_alias`, `related_group`, `related_group_member` с partial unique), `access.py` (`app_user`, `role`, `permission`, `role_permission`, `user_role`), `accounting.py` (`chart_of_account`, `elimination_rule`, `elimination_rule_account`, `required_document_rule`); проверка: `Base.metadata.create_all` на тестовой БД без ошибок ### Task 7: [2.3] Части 04–06 diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 5d31696..5668f24 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -29,7 +29,7 @@ - [x] 2.1 `src/models/enums.py`: 27 enum'ов DBML как `enum.Enum` + фабрика `pg_enum`; `DBML_VERSION = "5.13"` в `models/__init__.py`; проверка: тест перечисляет имена и значения против списка из DBML (парсинг `Enum {}` блоков через pydbml) -- [ ] 2.2 Части 01–03: `directories.py` (`regional_office`, `company_group(+_history)`, `legal_entity(+_history)`, +- [x] 2.2 Части 01–03: `directories.py` (`regional_office`, `company_group(+_history)`, `legal_entity(+_history)`, `legal_entity_alias`, `related_group`, `related_group_member` с partial unique), `access.py` (`app_user`, `role`, `permission`, `role_permission`, `user_role`), `accounting.py` (`chart_of_account`, `elimination_rule`, `elimination_rule_account`, `required_document_rule`); проверка: `Base.metadata.create_all` на тестовой БД без ошибок -- 2.47.2 From 8aa1b80c330d3a7e2d2ab387a57c2813f98bcd99 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 19:05:56 +0400 Subject: [PATCH 23/97] =?UTF-8?q?test:=20=D1=82=D0=B8=D0=BF=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D1=85=D0=B5=D0=BB=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20par?= =?UTF-8?q?tial=20unique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/tests/test_models_part_01_03.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/tests/test_models_part_01_03.py b/api/tests/test_models_part_01_03.py index 00e8f75..db205b3 100644 --- a/api/tests/test_models_part_01_03.py +++ b/api/tests/test_models_part_01_03.py @@ -8,7 +8,7 @@ from pydbml.classes import Table as DbmlTable from sqlalchemy import Table, inspect, text from sqlalchemy.dialects import postgresql from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from sqlalchemy.schema import CreateIndex, CreateTable from src.models import Base @@ -175,7 +175,7 @@ async def test_create_all_on_scratch_db(scratch_db_url: str) -> None: await engine.dispose() -async def _check_related_group_member_uniqueness(engine) -> None: # type: ignore[no-untyped-def] +async def _check_related_group_member_uniqueness(engine: AsyncEngine) -> None: """ЮЛ — только в одной ГСК/ГСЗ среди текущих; закрытая версия не мешает (ТЗ 6.1, ANL-030).""" now = datetime.datetime.now(datetime.UTC) ids = {k: uuid.uuid4() for k in ("ro", "cg", "le", "g1", "g2", "m1", "m2", "m3")} -- 2.47.2 From 24fbe748400ced37ebe6dd019cc6c435ae2c1f0f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 19:12:53 +0400 Subject: [PATCH 24/97] =?UTF-8?q?test:=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20=D1=81=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B8=20ORM=20=D1=81=20DBML=20(dbml=5Fparity):=20=D1=82?= =?UTF-8?q?=D0=B8=D0=BF=D1=8B=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BE=D0=BA?= =?UTF-8?q?,=20default,=20partial=20WHERE;=20=D1=81=D0=B0=D0=BC=D0=BE?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D0=B0=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/tests/dbml_parity.py | 249 ++++++++++++++++++++++++++++ api/tests/test_dbml_parity.py | 187 +++++++++++++++++++++ api/tests/test_models_part_01_03.py | 60 +------ 3 files changed, 440 insertions(+), 56 deletions(-) create mode 100644 api/tests/dbml_parity.py create mode 100644 api/tests/test_dbml_parity.py diff --git a/api/tests/dbml_parity.py b/api/tests/dbml_parity.py new file mode 100644 index 0000000..144a8e4 --- /dev/null +++ b/api/tests/dbml_parity.py @@ -0,0 +1,249 @@ +"""Сверка ORM-метаданных с DBML v5.13 для заданного набора таблиц (без БД); переиспользуется тестами частей 01–09. + +Публичное API: +- `find_problems(metadata, tables)` — список расхождений (пустой = ORM совпадает с DBML) по колонкам (имя, порядок, + тип, nullable, PK, unique, server_default), FK и индексам (состав, уникальность, partial-условие WHERE); +- `type_problem(orm_type, dbml_type)` — расхождение типа одной колонки (или `None`); +- `canonical_default_dbml` / `canonical_default_orm` — приведение default к сопоставимому виду. + +Нетривиальные места. DBML-default — python-литерал pydbml (bool/int/float/str) или `Expression` (обратные кавычки, +напр. `now()`); ORM server_default — `text(...)` или SQL-функция (`func.now()`). Обе стороны приводятся к строке: +bool -> `true`/`false`, число -> как есть, строка -> в одинарных кавычках, выражение -> текст в нижнем регистре. +""" + +import re +from collections.abc import Callable, Collection + +from pydbml import PyDBML +from pydbml.classes import Column as DbmlColumn +from pydbml.classes import Enum as DbmlEnum +from pydbml.classes import Expression +from pydbml.classes import Table as DbmlTable +from sqlalchemy import ( + BigInteger, + Boolean, + Date, + DateTime, + Integer, + MetaData, + Numeric, + SmallInteger, + String, + Table, + Text, +) +from sqlalchemy import Enum as SAEnum +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.sql.elements import TextClause +from sqlalchemy.sql.functions import FunctionElement + +from tests.dbml_helpers import load_dbml + +SCHEMA = "dkb" +_NUMERIC_RE = re.compile(r"numeric\((\d+),\s*(\d+)\)") +_PARTIAL_RE = re.compile(r"partial\s+UNIQUE\s+WHERE\s+(.+?)(?:\s+—.*)?$", re.IGNORECASE | re.DOTALL) +_SYNONYMS = {"current_timestamp": "now()", "current_timestamp()": "now()"} + + +# --- типы ------------------------------------------------------------------------------------------------------- + + +def _is_exact(orm_type: object, cls: type) -> bool: + """Точное совпадение класса (SmallInteger — подкласс Integer, Text — подкласс String, и т.д.).""" + return type(orm_type) is cls + + +_SIMPLE_TYPES: dict[str, Callable[[object], bool]] = { + "uuid": lambda t: isinstance(t, UUID) and t.as_uuid is True, + "varchar": lambda t: _is_exact(t, String) and t.length is None, # type: ignore[attr-defined] + "text": lambda t: _is_exact(t, Text), + "smallint": lambda t: _is_exact(t, SmallInteger), + "int": lambda t: _is_exact(t, Integer), + "bigint": lambda t: _is_exact(t, BigInteger), + "boolean": lambda t: _is_exact(t, Boolean), + "date": lambda t: _is_exact(t, Date), + "timestamptz": lambda t: _is_exact(t, DateTime) and t.timezone is True, # type: ignore[attr-defined] + "jsonb": lambda t: isinstance(t, JSONB), +} + + +def type_problem(orm_type: object, dbml_type: object) -> str | None: + """Расхождение SQL-типа ORM-колонки с типом DBML; `dbml_type` — строка типа или объект enum pydbml.""" + if isinstance(dbml_type, DbmlEnum): + return _enum_problem(orm_type, dbml_type) + name = str(dbml_type).strip().lower() + numeric = _NUMERIC_RE.fullmatch(name) + if numeric: + precision, scale = int(numeric.group(1)), int(numeric.group(2)) + if _is_exact(orm_type, Numeric) and (orm_type.precision, orm_type.scale) == (precision, scale): # type: ignore[attr-defined] + return None + return f"ожидался Numeric({precision}, {scale}), фактически {_describe(orm_type)}" + check = _SIMPLE_TYPES.get(name) + if check is None: + return f"тип DBML `{dbml_type}` не поддержан сверкой (добавьте в dbml_parity._SIMPLE_TYPES)" + if check(orm_type): + return None + return f"ожидался тип для DBML `{name}`, фактически {_describe(orm_type)}" + + +def _enum_problem(orm_type: object, dbml_enum: DbmlEnum) -> str | None: + if not isinstance(orm_type, SAEnum): + return f"ожидался enum {dbml_enum.name}, фактически {_describe(orm_type)}" + expected_values = [item.name for item in dbml_enum.items] + if orm_type.name != dbml_enum.name: + return f"имя enum: ожидалось {dbml_enum.name}, фактически {orm_type.name}" + if orm_type.schema != SCHEMA: + return f"схема enum {dbml_enum.name}: ожидалась {SCHEMA}, фактически {orm_type.schema}" + if list(orm_type.enums) != expected_values: + return f"значения enum {dbml_enum.name}: ожидалось {expected_values}, фактически {list(orm_type.enums)}" + return None + + +def _describe(orm_type: object) -> str: + extra = "" + if isinstance(orm_type, Numeric): + extra = f"({orm_type.precision}, {orm_type.scale})" + elif isinstance(orm_type, DateTime): + extra = f"(timezone={orm_type.timezone})" + elif isinstance(orm_type, String): + extra = f"(length={orm_type.length})" + elif isinstance(orm_type, SAEnum): + extra = f"({orm_type.name})" + return f"{type(orm_type).__name__}{extra}" + + +# --- default ---------------------------------------------------------------------------------------------------- + + +def _canon_expr(text: str) -> str: + text = " ".join(text.split()) + if text.startswith("'"): + return text # строковый литерал — регистр значим + text = text.lower() + return _SYNONYMS.get(text, text) + + +def canonical_default_dbml(default: object) -> str: + """Канонический вид DBML-default: bool/число/строка-литерал/`Expression`.""" + if isinstance(default, Expression): + return _canon_expr(str(default.text)) + if isinstance(default, bool): + return "true" if default else "false" + if isinstance(default, int | float): + return str(default) + return "'" + str(default).replace("'", "''") + "'" + + +def canonical_default_orm(server_default: object) -> str: + """Канонический вид ORM `server_default` (`text(...)`, `func.now()`, строка).""" + arg = getattr(server_default, "arg", server_default) + if isinstance(arg, TextClause): + return _canon_expr(arg.text) + if isinstance(arg, FunctionElement): + return _canon_expr(f"{arg.name}()") + return _canon_expr(str(arg)) + + +# --- колонки, FK, индексы ------------------------------------------------------------------------------------- + + +def _column_problems(orm_table: Table, dbml_table: DbmlTable) -> list[str]: + name = dbml_table.name + problems: list[str] = [] + dbml_cols = {c.name: c for c in dbml_table.columns} + orm_names = [c.name for c in orm_table.columns] + if orm_names != list(dbml_cols): + problems.append(f"{name}: колонки/порядок: ожидалось {list(dbml_cols)}, фактически {orm_names}") + composite_pk = {c.name for i in dbml_table.indexes if i.pk for c in i.subjects} + for col_name, dbml_col in dbml_cols.items(): + if col_name not in orm_table.columns: + continue + problems.extend(_one_column_problems(f"{name}.{col_name}", orm_table.columns[col_name], dbml_col, composite_pk)) + return problems + + +def _one_column_problems(label: str, orm_col, dbml_col: DbmlColumn, composite_pk: set[str]) -> list[str]: # type: ignore[no-untyped-def] + problems: list[str] = [] + is_pk = bool(dbml_col.pk) or dbml_col.name in composite_pk + if orm_col.primary_key != is_pk: + problems.append(f"{label}: primary_key ожидался {is_pk}") + if orm_col.nullable != (not (dbml_col.not_null or is_pk)): + problems.append(f"{label}: nullable фактически {orm_col.nullable}") + if bool(orm_col.unique) != bool(dbml_col.unique): + problems.append(f"{label}: unique ожидался {bool(dbml_col.unique)}") + type_diff = type_problem(orm_col.type, dbml_col.type) + if type_diff: + problems.append(f"{label}: тип: {type_diff}") + if dbml_col.default is None: + if orm_col.server_default is not None: + problems.append(f"{label}: лишний server_default {canonical_default_orm(orm_col.server_default)}") + elif orm_col.server_default is None: + problems.append(f"{label}: нет server_default (ожидался {canonical_default_dbml(dbml_col.default)})") + else: + expected, actual = canonical_default_dbml(dbml_col.default), canonical_default_orm(orm_col.server_default) + if expected != actual: + problems.append(f"{label}: server_default ожидался {expected}, фактически {actual}") + return problems + + +def _fk_problems(orm_tables: dict[str, Table], dbml: PyDBML, names: Collection[str]) -> list[str]: + expected: set[tuple[str, str, str, str]] = set() + for ref in dbml.refs: + child, child_col, parent, parent_col = ref.table1, ref.col1[0], ref.table2, ref.col2[0] + if ref.type == "<": + child, child_col, parent, parent_col = parent, parent_col, child, child_col + if child.name in names: + expected.add((child.name, child_col.name, parent.name, parent_col.name)) + actual = { + (t.name, fk.parent.name, fk.column.table.name, fk.column.name) + for t in orm_tables.values() + for fk in t.foreign_keys + if t.name in names + } + problems = [f"FK отсутствует в ORM: {fk}" for fk in sorted(expected - actual)] + return problems + [f"FK лишний в ORM: {fk}" for fk in sorted(actual - expected)] + + +def _norm_where(where: object) -> str | None: + if where is None: + return None + text = where.text if isinstance(where, TextClause) else str(where) + return " ".join(text.split()).lower() + + +def _index_problems(orm_table: Table, dbml_table: DbmlTable) -> list[str]: + expected: list[tuple[tuple[str, ...], bool, str | None]] = [] + for i in dbml_table.indexes: + if i.pk: + continue + partial = _PARTIAL_RE.search(str(i.note)) if i.note else None + where = _norm_where(partial.group(1)) if partial else None + expected.append((tuple(c.name for c in i.subjects), bool(i.unique) or partial is not None, where)) + actual = [ + (tuple(c.name for c in i.columns), bool(i.unique), _norm_where(i.dialect_options["postgresql"]["where"])) + for i in orm_table.indexes + ] + if sorted(actual, key=repr) == sorted(expected, key=repr): + return [] + return [f"{dbml_table.name}: индексы (колонки, unique, WHERE): ожидалось {sorted(expected, key=repr)}, " + f"фактически {sorted(actual, key=repr)}"] # fmt: skip + + +# --- точка входа ------------------------------------------------------------------------------------------------ + + +def find_problems(metadata: MetaData, tables: Collection[str], dbml: PyDBML | None = None) -> list[str]: + """Все расхождения ORM (`metadata`) с DBML для таблиц `tables`; пустой список — совпадение.""" + dbml = dbml or load_dbml() + dbml_tables = {t.name: t for t in dbml.tables} + orm_tables = {t.name: t for t in metadata.tables.values()} + problems: list[str] = [] + for name in sorted(tables): + if name not in dbml_tables: + problems.append(f"{name}: нет в DBML") + elif name not in orm_tables: + problems.append(f"{name}: нет в ORM-метаданных") + else: + problems.extend(_column_problems(orm_tables[name], dbml_tables[name])) + problems.extend(_index_problems(orm_tables[name], dbml_tables[name])) + return problems + _fk_problems(orm_tables, dbml, tables) diff --git a/api/tests/test_dbml_parity.py b/api/tests/test_dbml_parity.py new file mode 100644 index 0000000..1f7fff3 --- /dev/null +++ b/api/tests/test_dbml_parity.py @@ -0,0 +1,187 @@ +"""Самопроверка сверки ORM с DBML (`dbml_parity`): компаратор обязан находить заведомо неверные расхождения.""" + +import pytest +from pydbml.classes import Expression +from sqlalchemy import ( + BigInteger, + Boolean, + Date, + DateTime, + DefaultClause, + Index, + Integer, + MetaData, + Numeric, + SmallInteger, + String, + Text, + func, + text, +) +from sqlalchemy.dialects.postgresql import JSON, JSONB, UUID + +from src.models import Base +from src.models.enums import pg_enum +from tests.dbml_helpers import load_dbml +from tests.dbml_parity import canonical_default_dbml, canonical_default_orm, find_problems, type_problem + +TABLES = {"regional_office", "permission", "related_group_member", "app_user", "elimination_rule", "role_permission"} + + +def clone_metadata() -> MetaData: + """Копия реальных метаданных ORM, которую можно портить, не затрагивая `Base.metadata`.""" + clone = MetaData(schema=Base.metadata.schema, naming_convention=Base.metadata.naming_convention) + for table in Base.metadata.sorted_tables: + table.to_metadata(clone) + return clone + + +def test_clone_is_clean_baseline() -> None: + assert find_problems(clone_metadata(), TABLES) == [] + + +@pytest.mark.parametrize( + ("table", "column", "wrong_type", "needle"), + [ + ("regional_office", "name", Text(), "name: тип"), # String -> Text + ("regional_office", "name", String(50), "name: тип"), # varchar с длиной + ("app_user", "created_at", DateTime(), "created_at: тип"), # без timezone + ("elimination_rule", "rule_no", Integer(), "rule_no: тип"), # smallint -> int + ("elimination_rule", "valid_from", DateTime(timezone=True), "valid_from: тип"), # date -> timestamptz + ("permission", "action", pg_enum("permission_object"), "action: тип"), # не тот enum + ("app_user", "regional_office_id", String(), "regional_office_id: тип"), # uuid -> varchar + ], +) +def test_wrong_column_type_is_detected(table: str, column: str, wrong_type: object, needle: str) -> None: + metadata = clone_metadata() + metadata.tables[f"dkb.{table}"].columns[column].type = wrong_type # type: ignore[assignment] + problems = find_problems(metadata, TABLES) + assert any(needle in p and p.startswith(f"{table}.") for p in problems), problems + + +def test_enum_with_wrong_schema_or_values_is_detected() -> None: + wrong_schema = pg_enum("permission_action") + wrong_schema.schema = "public" + assert type_problem(wrong_schema, _dbml_enum("permission_action")) is not None + wrong_values = pg_enum("permission_action") + wrong_values.enums = list(reversed(wrong_values.enums)) + assert type_problem(wrong_values, _dbml_enum("permission_action")) is not None + assert type_problem(pg_enum("permission_action"), _dbml_enum("permission_action")) is None + + +def _dbml_enum(name: str) -> object: + return next(e for e in load_dbml().enums if e.name == name) + + +@pytest.mark.parametrize( + ("orm_type", "dbml_type", "ok"), + [ + (Numeric(18, 2), "numeric(18,2)", True), + (Numeric(18, 3), "numeric(18,2)", False), + (Numeric(9, 4), "numeric(7,4)", False), + (Integer(), "numeric(18,2)", False), + (JSONB(), "jsonb", True), + (JSON(), "jsonb", False), + (BigInteger(), "bigint", True), + (Integer(), "bigint", False), + (SmallInteger(), "smallint", True), + (Boolean(), "boolean", True), + (Date(), "date", True), + (UUID(as_uuid=True), "uuid", True), + (UUID(as_uuid=False), "uuid", False), + (Text(), "text", True), + (String(), "text", False), + (String(), "varchar", True), + (DateTime(timezone=True), "timestamptz", True), + ], +) +def test_type_mapper(orm_type: object, dbml_type: str, ok: bool) -> None: + assert (type_problem(orm_type, dbml_type) is None) is ok + + +def test_unknown_dbml_type_is_reported_not_ignored() -> None: + assert "не поддержан" in (type_problem(String(), "geometry") or "") + + +def test_missing_wrong_or_extra_server_default_is_detected() -> None: + metadata = clone_metadata() + cols = metadata.tables["dkb.regional_office"].columns + cols["is_active"].server_default = None + cols["is_head_office"].server_default = _default("true") # в DBML false + cols["name"].server_default = _default("'x'") # в DBML default нет + problems = find_problems(metadata, TABLES) + assert any("is_active: нет server_default" in p for p in problems), problems + assert any("is_head_office: server_default ожидался false, фактически true" in p for p in problems), problems + assert any("name: лишний server_default" in p for p in problems), problems + + +def _default(sql: str) -> object: + return DefaultClause(text(sql)) + + +def test_nullable_pk_unique_and_order_mismatches_are_detected() -> None: + metadata = clone_metadata() + ro = metadata.tables["dkb.regional_office"] + ro.columns["name"].nullable = True + ro.columns["name"].unique = None + problems = find_problems(metadata, {"regional_office"}) + assert any("name: nullable" in p for p in problems), problems + assert any("name: unique" in p for p in problems), problems + perm = metadata.tables["dkb.permission"] + perm._columns.remove(perm.columns["name"]) # noqa: SLF001 — намеренная порча копии + assert any("колонки/порядок" in p for p in find_problems(metadata, {"permission"})) + + +def test_missing_extra_and_wrong_where_indexes_are_detected() -> None: + metadata = clone_metadata() + member = metadata.tables["dkb.related_group_member"] + index = next(i for i in member.indexes if i.name == "uq_related_group_member_entity_current") + index.dialect_options["postgresql"]["where"] = text("is_current AND valid_to IS NULL") + assert any("индексы" in p for p in find_problems(metadata, {"related_group_member"})) + index.dialect_options["postgresql"]["where"] = None # partial превращён в обычный unique + assert any("индексы" in p for p in find_problems(metadata, {"related_group_member"})) + index.dialect_options["postgresql"]["where"] = text("is_current") + assert find_problems(metadata, {"related_group_member"}) == [] + # лишний partial там, где DBML его не объявляет + ro = metadata.tables["dkb.regional_office"] + Index("ix_regional_office_extra", ro.columns["name"], postgresql_where=text("is_active")) + assert any("индексы" in p for p in find_problems(metadata, {"regional_office"})) + + +def test_missing_and_extra_foreign_keys_are_detected() -> None: + metadata = clone_metadata() + app_user = metadata.tables["dkb.app_user"] + fk = next(iter(app_user.foreign_keys)) + app_user.foreign_keys.remove(fk) + app_user.constraints.discard(fk.constraint) # type: ignore[arg-type] + assert any("FK отсутствует" in p for p in find_problems(metadata, {"app_user"})) + + +def test_unknown_table_is_reported() -> None: + assert find_problems(Base.metadata, {"no_such_table"}) == ["no_such_table: нет в DBML"] + assert find_problems(MetaData(schema="dkb"), {"regional_office"}) == ["regional_office: нет в ORM-метаданных"] + + +@pytest.mark.parametrize( + ("dbml_default", "orm_default"), + [ + (False, "false"), + (True, "TRUE"), + (1, "1"), + ("RUB", "'RUB'"), + ("it's", "'it''s'"), + ], +) +def test_default_canonical_forms_agree(dbml_default: object, orm_default: str) -> None: + assert canonical_default_dbml(dbml_default) == canonical_default_orm(DefaultClause(text(orm_default))) + + +def test_now_default_forms_agree() -> None: + expected = canonical_default_dbml(Expression("now()")) + assert expected == canonical_default_orm(DefaultClause(func.now())) == "now()" + assert ( + expected + == canonical_default_orm(DefaultClause(text("now()"))) + == canonical_default_orm(DefaultClause(text("CURRENT_TIMESTAMP"))) + ) + assert expected != canonical_default_dbml(False) diff --git a/api/tests/test_models_part_01_03.py b/api/tests/test_models_part_01_03.py index db205b3..c1723f5 100644 --- a/api/tests/test_models_part_01_03.py +++ b/api/tests/test_models_part_01_03.py @@ -4,7 +4,6 @@ import datetime import uuid import pytest -from pydbml.classes import Table as DbmlTable from sqlalchemy import Table, inspect, text from sqlalchemy.dialects import postgresql from sqlalchemy.exc import IntegrityError @@ -12,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from sqlalchemy.schema import CreateIndex, CreateTable from src.models import Base -from tests.dbml_helpers import load_dbml +from tests.dbml_parity import find_problems PART_01_03_TABLES = { # directories @@ -43,10 +42,6 @@ def orm_tables() -> dict[str, Table]: return {t.name: t for t in Base.metadata.sorted_tables} -def dbml_tables() -> dict[str, DbmlTable]: - return {t.name: t for t in load_dbml().tables if t.name in PART_01_03_TABLES} - - def test_part_01_03_tables_registered_in_metadata() -> None: assert set(orm_tables()) == PART_01_03_TABLES assert len(Base.metadata.sorted_tables) == EXPECTED_TABLE_COUNT # на этом этапе (задачи 7–8 добавят остальные) @@ -77,56 +72,9 @@ def test_constraint_names_are_explicit_and_fit_postgres_limit() -> None: assert len(str(name)) <= PG_IDENTIFIER_LIMIT, f"{table.name}: {name} длиннее {PG_IDENTIFIER_LIMIT}" -def test_columns_match_dbml() -> None: - """Имена колонок, nullability, PK и FK-цели — как в DBML.""" - orm, dbml = orm_tables(), dbml_tables() - refs = [r for r in load_dbml().refs if r.table1.name in PART_01_03_TABLES] - expected_fks = {(r.table1.name, r.col1[0].name, r.table2.name, r.col2[0].name) for r in refs} - actual_fks = { - (t.name, fk.parent.name, fk.column.table.name, fk.column.name) for t in orm.values() for fk in t.foreign_keys - } - assert actual_fks == expected_fks - for name, dbml_table in dbml.items(): - orm_table = orm[name] - dbml_cols = {c.name: c for c in dbml_table.columns} - assert set(orm_table.columns.keys()) == set(dbml_cols), name - composite_pk = {c.name for i in dbml_table.indexes if i.pk for c in i.subjects} - for col_name, dbml_col in dbml_cols.items(): - orm_col = orm_table.columns[col_name] - is_pk = dbml_col.pk or col_name in composite_pk - assert orm_col.primary_key == is_pk, f"{name}.{col_name}: pk" - assert orm_col.nullable == (not (dbml_col.not_null or is_pk)), f"{name}.{col_name}: nullable" - assert bool(orm_col.unique) == bool(dbml_col.unique), f"{name}.{col_name}: unique" - if dbml_col.default is None: - assert orm_col.server_default is None, f"{name}.{col_name}: лишний server_default" - else: - assert orm_col.server_default is not None, f"{name}.{col_name}: нет server_default" - assert str(orm_col.server_default.arg).lower() == str(dbml_col.default).lower() - - -def test_column_order_follows_dbml() -> None: - """Порядок колонок как в DBML (в частности `id` первой) — миграция 0001 читается рядом с DBML.""" - orm = orm_tables() - for name, dbml_table in dbml_tables().items(): - assert [c.name for c in orm[name].columns] == [c.name for c in dbml_table.columns], name - - -def test_indexes_match_dbml() -> None: - """Состав индексов (кроме PK): колонки, уникальность, partial-условие.""" - orm = orm_tables() - for name, dbml_table in dbml_tables().items(): - expected = sorted( - (tuple(c.name for c in i.subjects), i.unique or "partial UNIQUE" in str(i.note)) - for i in dbml_table.indexes - if not i.pk - ) - actual = sorted((tuple(c.name for c in i.columns), bool(i.unique)) for i in orm[name].indexes) - assert actual == expected, name - partial = {i.name: str(i.dialect_options["postgresql"]["where"]) for i in orm["related_group_member"].indexes} - assert partial == { - "uq_related_group_member_group_entity_current": "is_current", - "uq_related_group_member_entity_current": "is_current", - } +def test_orm_matches_dbml() -> None: + """Колонки (имя, порядок, тип, nullable, PK, unique, default), FK и индексы (вкл. partial WHERE) — как в DBML.""" + assert find_problems(Base.metadata, PART_01_03_TABLES) == [] @pytest.mark.integration -- 2.47.2 From 4e363bd00f7bfe0dfa7cb68d082a238fd748e4d2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 19:22:29 +0400 Subject: [PATCH 25/97] =?UTF-8?q?feat:=20ORM-=D0=BC=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=20=D1=87=D0=B0=D1=81=D1=82=D0=B5=D0=B9=2004?= =?UTF-8?q?=E2=80=9306=20DBML=20v5.13=20(=D1=84=D0=B0=D0=B9=D0=BB=D1=8B,?= =?UTF-8?q?=20=D0=9E=D0=A1=D0=92,=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit files.py: s3_object, stored_file, document (NULLS NOT DISTINCT), recognition_job; FK-цикл stored_file <-> recognition_job разорван через use_alter с явным именем. osv.py: osv_entry. statements.py: financial_statement_meta/column/line/value. Тест частей 01–03 не привязан к полному составу metadata. Co-Authored-By: Claude Fable 5.1 --- api/src/models/__init__.py | 14 ++- api/src/models/files.py | 157 ++++++++++++++++++++++++++++ api/src/models/osv.py | 59 +++++++++++ api/src/models/statements.py | 119 +++++++++++++++++++++ api/tests/test_models_part_01_03.py | 14 ++- 5 files changed, 353 insertions(+), 10 deletions(-) create mode 100644 api/src/models/files.py create mode 100644 api/src/models/osv.py create mode 100644 api/src/models/statements.py diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index 8071b68..dc6ac3d 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -3,10 +3,20 @@ Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic). """ -from . import access, accounting, directories +from . import access, accounting, directories, files, osv, statements from .base import Base, UUIDPrimaryMixin #: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm). DBML_VERSION = "5.13" -__all__ = ["DBML_VERSION", "Base", "UUIDPrimaryMixin", "access", "accounting", "directories"] +__all__ = [ + "DBML_VERSION", + "Base", + "UUIDPrimaryMixin", + "access", + "accounting", + "directories", + "files", + "osv", + "statements", +] diff --git a/api/src/models/files.py b/api/src/models/files.py new file mode 100644 index 0000000..43798b1 --- /dev/null +++ b/api/src/models/files.py @@ -0,0 +1,157 @@ +"""Часть 04 DBML v5.13 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания. + +Цикл FK `stored_file.current_recognition_job_id` -> `recognition_job` и `recognition_job.file_id` -> `stored_file` +разорван через `use_alter=True` на nullable-стороне (`stored_file.current_recognition_job_id`): `create_all` создаёт +таблицы без этого FK и добавляет его отдельным `ALTER TABLE`; имя FK задано явно (то же имя — в миграции 0001). +""" + +import datetime +import uuid +from typing import Any + +from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, SmallInteger, String, Text, text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base, UUIDPrimaryMixin +from .enums import ( + DocumentCheckResult, + DocumentStatus, + DocumentType, + FileStatus, + FileType, + PeriodQuarter, + RecognitionPipeline, + RecognitionStatus, + S3BucketRole, + pg_enum, +) + + +class S3Object(UUIDPrimaryMixin, Base): + """Реестр объектов S3: единая точка ключей для lifecycle/ретеншна (ТЗ 8.4) и дедупа.""" + + __tablename__ = "s3_object" + __table_args__ = (Index("uq_s3_object_bucket_key", "bucket", "key", unique=True),) + + bucket_role: Mapped[S3BucketRole] = mapped_column(pg_enum("s3_bucket_role"), nullable=False) + bucket: Mapped[str] = mapped_column(String, nullable=False) + key: Mapped[str] = mapped_column(String, nullable=False) + content_type: Mapped[str | None] = mapped_column(String) + size_bytes: Mapped[int | None] = mapped_column(BigInteger) + sha256: Mapped[str | None] = mapped_column(String) + version_id: Mapped[str | None] = mapped_column(String) + created_by: Mapped[str | None] = mapped_column(String) + created_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False) + expires_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + archived_to: Mapped[str | None] = mapped_column(String) + + +class StoredFile(UUIDPrimaryMixin, Base): + """Физический файл одной загрузки; что внутри — `document` (1:N). Первичка не редактируется (ТЗ 8.2).""" + + __tablename__ = "stored_file" + __table_args__ = (Index("ix_stored_file_legal_entity_id_status", "legal_entity_id", "status"),) + + legal_entity_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("legal_entity.id"), nullable=False + ) + file_type: Mapped[FileType] = mapped_column(pg_enum("file_type"), nullable=False) + original_name: Mapped[str | None] = mapped_column(String) + s3_object_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("s3_object.id"), nullable=False) + content_hash: Mapped[str | None] = mapped_column(String) + size_bytes: Mapped[int | None] = mapped_column(BigInteger) + status: Mapped[FileStatus] = mapped_column(pg_enum("file_status"), nullable=False) + version_no: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1")) + superseded_by_file_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("stored_file.id")) + current_recognition_job_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey( + "recognition_job.id", + use_alter=True, + name="fk_stored_file_current_recognition_job_id_recognition_job", + ), + ) + uploaded_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + uploaded_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + + +class Document(UUIDPrimaryMixin, Base): + """Логический документ внутри файла (ББ / ОФР / ОСВ по счёту / расшифровка); создаётся ядром при маппинге.""" + + __tablename__ = "document" + __table_args__ = ( + # один ББ / один ОФР / одна ОСВ по счёту на файл: NULL в osv_account_code считается значением (PG15+) + Index( + "uq_document_file_type_account", + "stored_file_id", + "document_type", + "osv_account_code", + unique=True, + postgresql_nulls_not_distinct=True, + ), + # чек-лист required_document_rule + Index( + "ix_document_legal_entity_period_status", + "legal_entity_id", + "document_type", + "osv_account_code", + "period_year", + "period_quarter", + "status", + ), + ) + + stored_file_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("stored_file.id"), nullable=False) + legal_entity_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("legal_entity.id"), nullable=False + ) + document_type: Mapped[DocumentType] = mapped_column(pg_enum("document_type"), nullable=False) + osv_account_code: Mapped[str | None] = mapped_column(String) + period_year: Mapped[int | None] = mapped_column(SmallInteger) + period_quarter: Mapped[PeriodQuarter | None] = mapped_column(pg_enum("period_quarter")) + is_cumulative: Mapped[bool | None] = mapped_column(Boolean) + reporting_date: Mapped[datetime.date | None] = mapped_column(Date) + locator: Mapped[str | None] = mapped_column(String) + status: Mapped[DocumentStatus] = mapped_column(pg_enum("document_status"), nullable=False) + period_check: Mapped[DocumentCheckResult | None] = mapped_column(pg_enum("document_check_result")) + counterparty_check: Mapped[DocumentCheckResult | None] = mapped_column(pg_enum("document_check_result")) + confirmed_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + confirmed_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + + +class RecognitionJob(UUIDPrimaryMixin, Base): + """Единственная точка обмена с NATS: запись = outbox (queued -> published), результат — inbox (дедуп).""" + + __tablename__ = "recognition_job" + __table_args__ = (Index("uq_recognition_job_file_id_attempt", "file_id", "attempt", unique=True),) + + file_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("stored_file.id"), nullable=False) + pipeline: Mapped[RecognitionPipeline] = mapped_column(pg_enum("recognition_pipeline"), nullable=False) + request_subject: Mapped[str] = mapped_column(String, nullable=False) + request_msg_id: Mapped[str | None] = mapped_column(String, unique=True) + result_msg_id: Mapped[str | None] = mapped_column(String, unique=True) + schema_version: Mapped[str] = mapped_column(String, nullable=False) + service_name: Mapped[str | None] = mapped_column(String) + service_version: Mapped[str | None] = mapped_column(String) + status: Mapped[RecognitionStatus] = mapped_column(pg_enum("recognition_status"), nullable=False) + attempt: Mapped[int] = mapped_column(SmallInteger, nullable=False, server_default=text("1")) + max_attempts: Mapped[int] = mapped_column(SmallInteger, nullable=False, server_default=text("1")) + requested_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + published_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + started_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + finished_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + reply_deadline: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + external_task_id: Mapped[str | None] = mapped_column(String) + error_code: Mapped[str | None] = mapped_column(String) + error_message: Mapped[str | None] = mapped_column(Text) + result_s3_object_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("s3_object.id")) + result_schema_version: Mapped[str | None] = mapped_column(String) + group_schema: Mapped[Any | None] = mapped_column(JSONB) + metrics: Mapped[Any | None] = mapped_column(JSONB) + fields_total: Mapped[int | None] = mapped_column(Integer) + fields_low_confidence: Mapped[int | None] = mapped_column(Integer) + fields_unrecognized: Mapped[int | None] = mapped_column(Integer) diff --git a/api/src/models/osv.py b/api/src/models/osv.py new file mode 100644 index 0000000..697afdc --- /dev/null +++ b/api/src/models/osv.py @@ -0,0 +1,59 @@ +"""Часть 05 DBML v5.13 «ОСВ»: строка оборотно-сальдовой ведомости (ТЗ 6.2, 6.5). + +Первичка не мутируется: сырые значения хранятся как пришли, правка аналитика — рядом +(`is_modified`, `original_values`, `modified_by/at` + audit_event(RECOGNIZED_DATA_EDIT)). +""" + +import datetime +import decimal +import uuid +from typing import Any + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, Numeric, SmallInteger, String, text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base, UUIDPrimaryMixin +from .enums import OsvDataType, OsvGroupType, pg_enum + + +class OsvEntry(UUIDPrimaryMixin, Base): + """Строка ОСВ; суммы — рубли, `numeric(18,2)`.""" + + __tablename__ = "osv_entry" + __table_args__ = ( + Index("uq_osv_entry_document_id_row_number", "document_id", "row_number", unique=True), + Index("ix_osv_entry_owner_account_counterparty", "owner_legal_entity_id", "account_id", "counterparty_id"), + ) + + recognition_job_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("recognition_job.id"), nullable=False + ) + document_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("document.id"), nullable=False) + owner_legal_entity_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("legal_entity.id"), nullable=False + ) + parent_entry_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("osv_entry.id")) + group_type: Mapped[OsvGroupType | None] = mapped_column(pg_enum("osv_group_type")) + group_name: Mapped[str | None] = mapped_column(String) + row_number: Mapped[int | None] = mapped_column(Integer) + level: Mapped[int | None] = mapped_column(SmallInteger) + row_value: Mapped[str | None] = mapped_column(String) + data_type: Mapped[OsvDataType | None] = mapped_column(pg_enum("osv_data_type")) + account_code: Mapped[str | None] = mapped_column(String) + account_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("chart_of_account.id")) + account_root: Mapped[str | None] = mapped_column(String) + counterparty_name: Mapped[str | None] = mapped_column(String) + counterparty_inn: Mapped[str | None] = mapped_column(String) + counterparty_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("legal_entity.id")) + opening_debit: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + opening_credit: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + turnover_debit: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + turnover_credit: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + closing_debit: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + closing_credit: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + is_modified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + original_values: Mapped[Any | None] = mapped_column(JSONB) + modified_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + modified_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + is_excisable: Mapped[bool | None] = mapped_column(Boolean) diff --git a/api/src/models/statements.py b/api/src/models/statements.py new file mode 100644 index 0000000..877396c --- /dev/null +++ b/api/src/models/statements.py @@ -0,0 +1,119 @@ +"""Часть 06 DBML v5.13 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н). + +Первичка не мутируется: `*_original_value` / `original_amount` / `is_modified` — рядом с распознанным значением. +Логические проверки (ТЗ 6.3) и «требует проверки» не хранятся — считаются на лету. +""" + +import datetime +import decimal +import uuid + +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Numeric, SmallInteger, String, Text, text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base, UUIDPrimaryMixin +from .enums import StatementType, pg_enum + + +class FinancialStatementMeta(UUIDPrimaryMixin, Base): + """Шапка распознанной формы ББ/ОФР; 1:1 с `document`.""" + + __tablename__ = "financial_statement_meta" + + document_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("document.id"), nullable=False, unique=True + ) + recognition_job_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("recognition_job.id"), nullable=False + ) + statement_type: Mapped[StatementType] = mapped_column(pg_enum("statement_type"), nullable=False) + inn: Mapped[str | None] = mapped_column(String) + inn_original_value: Mapped[str | None] = mapped_column(String) + inn_confidence: Mapped[decimal.Decimal | None] = mapped_column(Numeric(5, 2)) + inn_verified: Mapped[bool | None] = mapped_column(Boolean) + inn_modified: Mapped[bool | None] = mapped_column(Boolean) + organization_name: Mapped[str | None] = mapped_column(String) + report_date: Mapped[datetime.date | None] = mapped_column(Date) + report_date_original_value: Mapped[str | None] = mapped_column(String) + report_date_verified: Mapped[bool | None] = mapped_column(Boolean) + report_date_modified: Mapped[bool | None] = mapped_column(Boolean) + units: Mapped[str | None] = mapped_column(String) + units_original_value: Mapped[str | None] = mapped_column(String) + units_verified: Mapped[bool | None] = mapped_column(Boolean) + units_modified: Mapped[bool | None] = mapped_column(Boolean) + currency: Mapped[str | None] = mapped_column(String, server_default=text("'RUB'")) + okpo: Mapped[str | None] = mapped_column(String) + okopf: Mapped[str | None] = mapped_column(String) + okfs: Mapped[str | None] = mapped_column(String) + okud: Mapped[str | None] = mapped_column(String) + okei: Mapped[str | None] = mapped_column(String) + legal_form: Mapped[str | None] = mapped_column(String) + ownership_form: Mapped[str | None] = mapped_column(String) + address: Mapped[str | None] = mapped_column(String) + subject_to_audit: Mapped[bool | None] = mapped_column(Boolean) + reviewed_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + reviewed_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + checks_accepted_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + checks_accepted_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + checks_accepted_comment: Mapped[str | None] = mapped_column(Text) + + +class FinancialStatementColumn(UUIDPrimaryMixin, Base): + """Колонка-период формы (2–3 сравнительные даты); `period_date` нужна межформенным проверкам и гудвиллу.""" + + __tablename__ = "financial_statement_column" + __table_args__ = ( + Index("uq_financial_statement_column_meta_id_column_index", "meta_id", "column_index", unique=True), + ) + + meta_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("financial_statement_meta.id"), nullable=False + ) + column_index: Mapped[int] = mapped_column(SmallInteger, nullable=False) + label: Mapped[str | None] = mapped_column(String) + period_date: Mapped[datetime.date | None] = mapped_column(Date) + is_current: Mapped[bool | None] = mapped_column(Boolean) + + +class FinancialStatementLine(UUIDPrimaryMixin, Base): + """Строка формы с кодом; порядок, разделы и итоговые строки — шаблон формы на фронте.""" + + __tablename__ = "financial_statement_line" + __table_args__ = (Index("uq_financial_statement_line_meta_id_line_code", "meta_id", "line_code", unique=True),) + + meta_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("financial_statement_meta.id"), nullable=False + ) + line_code: Mapped[str | None] = mapped_column(String) + line_code_original_value: Mapped[str | None] = mapped_column(String) + line_code_confidence: Mapped[decimal.Decimal | None] = mapped_column(Numeric(5, 2)) + line_code_modified: Mapped[bool | None] = mapped_column(Boolean) + line_name: Mapped[str | None] = mapped_column(String) + line_name_original_value: Mapped[str | None] = mapped_column(String) + line_name_confidence: Mapped[decimal.Decimal | None] = mapped_column(Numeric(5, 2)) + line_name_modified: Mapped[bool | None] = mapped_column(Boolean) + + +class FinancialStatementValue(UUIDPrimaryMixin, Base): + """Распознанное значение (строка x колонка): сумма, уверенность, признак ручной правки (Прил. 2 ТЗ).""" + + __tablename__ = "financial_statement_value" + __table_args__ = (Index("uq_financial_statement_value_line_id_column_id", "line_id", "column_id", unique=True),) + + line_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("financial_statement_line.id"), nullable=False + ) + column_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("financial_statement_column.id", name="fk_financial_statement_value_column_id_fs_column"), + nullable=False, + ) + amount: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + string_value: Mapped[str | None] = mapped_column(String) + confidence: Mapped[decimal.Decimal | None] = mapped_column(Numeric(5, 2)) + is_recognized: Mapped[bool | None] = mapped_column(Boolean) + is_modified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + original_amount: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + modified_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + modified_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/api/tests/test_models_part_01_03.py b/api/tests/test_models_part_01_03.py index c1723f5..040e897 100644 --- a/api/tests/test_models_part_01_03.py +++ b/api/tests/test_models_part_01_03.py @@ -35,7 +35,6 @@ PART_01_03_TABLES = { "elimination_rule_account", } PG_IDENTIFIER_LIMIT = 63 -EXPECTED_TABLE_COUNT = 16 def orm_tables() -> dict[str, Table]: @@ -43,8 +42,7 @@ def orm_tables() -> dict[str, Table]: def test_part_01_03_tables_registered_in_metadata() -> None: - assert set(orm_tables()) == PART_01_03_TABLES - assert len(Base.metadata.sorted_tables) == EXPECTED_TABLE_COUNT # на этом этапе (задачи 7–8 добавят остальные) + assert PART_01_03_TABLES <= set(orm_tables()) # общий состав metadata — в тестах последней по номеру части assert all(t.schema == "dkb" for t in Base.metadata.sorted_tables) @@ -64,7 +62,7 @@ def test_foreign_keys_resolve_to_registered_tables() -> None: def test_constraint_names_are_explicit_and_fit_postgres_limit() -> None: - """Все имена ограничений заданы (конвенцией/явно), без хеш-усечения, и не длиннее 63 символов.""" + """Все имена ограничений ВСЕХ таблиц metadata (любых частей) заданы, без хеш-усечения, и не длиннее 63 символов.""" for table in Base.metadata.sorted_tables: names = [c.name for c in table.constraints] + [i.name for i in table.indexes] for name in names: @@ -79,7 +77,7 @@ def test_orm_matches_dbml() -> None: @pytest.mark.integration async def test_create_all_on_scratch_db(scratch_db_url: str) -> None: - """`create_all` на пустой БД: 16 таблиц в схеме dkb, enum-типы, partial unique работают.""" + """`create_all` на пустой БД: таблицы частей 01–03 в схеме dkb, их enum-типы, partial unique работают.""" engine = create_async_engine(scratch_db_url) try: async with engine.begin() as conn: @@ -88,7 +86,7 @@ async def test_create_all_on_scratch_db(scratch_db_url: str) -> None: async with engine.connect() as conn: tables = await conn.run_sync(lambda c: set(inspect(c).get_table_names(schema="dkb"))) - assert tables == PART_01_03_TABLES + assert PART_01_03_TABLES <= tables enums = { row[0] for row in await conn.execute( @@ -98,10 +96,10 @@ async def test_create_all_on_scratch_db(scratch_db_url: str) -> None: ) ) } - assert enums == { + assert { "entity_type", "related_group_kind", "account_kind", "account_row_type", "business_segment", "statement_type", "le_history_event", "permission_object", "permission_action", - } # fmt: skip + } <= enums # fmt: skip indexdefs = { row[0]: row[1] for row in await conn.execute( -- 2.47.2 From 856ee2129ff975a4312b9fbf604113d564b65181 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 19:22:29 +0400 Subject: [PATCH 26/97] =?UTF-8?q?test:=20=D1=87=D1=91=D1=82=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20=D1=87=D0=B0=D1=81=D1=82=D0=B5=D0=B9=2004?= =?UTF-8?q?=E2=80=9306=20=D1=81=20DBML,=20NULLS=20NOT=20DISTINCT=20=D0=B2?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=B0=D1=80=D0=B0=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B5,=20create=5Fall=2025=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=86=20=D0=BD=D0=B0=20PG16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/tests/dbml_parity.py | 28 +++- api/tests/test_dbml_parity.py | 17 +++ api/tests/test_models_part_04_06.py | 228 ++++++++++++++++++++++++++++ 3 files changed, 266 insertions(+), 7 deletions(-) create mode 100644 api/tests/test_models_part_04_06.py diff --git a/api/tests/dbml_parity.py b/api/tests/dbml_parity.py index 144a8e4..02fa7e1 100644 --- a/api/tests/dbml_parity.py +++ b/api/tests/dbml_parity.py @@ -2,7 +2,8 @@ Публичное API: - `find_problems(metadata, tables)` — список расхождений (пустой = ORM совпадает с DBML) по колонкам (имя, порядок, - тип, nullable, PK, unique, server_default), FK и индексам (состав, уникальность, partial-условие WHERE); + тип, nullable, PK, unique, server_default), FK и индексам (состав, уникальность, partial-условие WHERE, + `NULLS NOT DISTINCT` по note DBML); - `type_problem(orm_type, dbml_type)` — расхождение типа одной колонки (или `None`); - `canonical_default_dbml` / `canonical_default_orm` — приведение default к сопоставимому виду. @@ -42,6 +43,7 @@ from tests.dbml_helpers import load_dbml SCHEMA = "dkb" _NUMERIC_RE = re.compile(r"numeric\((\d+),\s*(\d+)\)") _PARTIAL_RE = re.compile(r"partial\s+UNIQUE\s+WHERE\s+(.+?)(?:\s+—.*)?$", re.IGNORECASE | re.DOTALL) +_NULLS_NOT_DISTINCT_RE = re.compile(r"NULLS\s+NOT\s+DISTINCT", re.IGNORECASE) _SYNONYMS = {"current_timestamp": "now()", "current_timestamp()": "now()"} @@ -212,21 +214,33 @@ def _norm_where(where: object) -> str | None: def _index_problems(orm_table: Table, dbml_table: DbmlTable) -> list[str]: - expected: list[tuple[tuple[str, ...], bool, str | None]] = [] + expected: list[tuple[tuple[str, ...], bool, str | None, bool]] = [] for i in dbml_table.indexes: if i.pk: continue - partial = _PARTIAL_RE.search(str(i.note)) if i.note else None + note = str(i.note) if i.note else "" + partial = _PARTIAL_RE.search(note) where = _norm_where(partial.group(1)) if partial else None - expected.append((tuple(c.name for c in i.subjects), bool(i.unique) or partial is not None, where)) + # note с `NULLS NOT DISTINCT` => уникальный индекс обязан иметь postgresql_nulls_not_distinct=True + nulls_not_distinct = _NULLS_NOT_DISTINCT_RE.search(note) is not None + expected.append( + (tuple(c.name for c in i.subjects), bool(i.unique) or partial is not None, where, nulls_not_distinct) + ) actual = [ - (tuple(c.name for c in i.columns), bool(i.unique), _norm_where(i.dialect_options["postgresql"]["where"])) + ( + tuple(c.name for c in i.columns), + bool(i.unique), + _norm_where(i.dialect_options["postgresql"]["where"]), + bool(i.dialect_options["postgresql"]["nulls_not_distinct"]), + ) for i in orm_table.indexes ] if sorted(actual, key=repr) == sorted(expected, key=repr): return [] - return [f"{dbml_table.name}: индексы (колонки, unique, WHERE): ожидалось {sorted(expected, key=repr)}, " - f"фактически {sorted(actual, key=repr)}"] # fmt: skip + return [ + f"{dbml_table.name}: индексы (колонки, unique, WHERE, NULLS NOT DISTINCT): " + f"ожидалось {sorted(expected, key=repr)}, фактически {sorted(actual, key=repr)}" + ] # --- точка входа ------------------------------------------------------------------------------------------------ diff --git a/api/tests/test_dbml_parity.py b/api/tests/test_dbml_parity.py index 1f7fff3..d610a7e 100644 --- a/api/tests/test_dbml_parity.py +++ b/api/tests/test_dbml_parity.py @@ -148,6 +148,23 @@ def test_missing_extra_and_wrong_where_indexes_are_detected() -> None: assert any("индексы" in p for p in find_problems(metadata, {"regional_office"})) +def test_nulls_not_distinct_flag_is_compared_both_ways() -> None: + """DBML-note `NULLS NOT DISTINCT` требует флаг в ORM-индексе; лишний флаг без note — тоже расхождение.""" + metadata = clone_metadata() + document = metadata.tables["dkb.document"] + index = next(i for i in document.indexes if i.unique) + assert find_problems(metadata, {"document"}) == [] + index.dialect_options["postgresql"]["nulls_not_distinct"] = None # индекс без флага + problems = find_problems(metadata, {"document"}) + assert any("NULLS NOT DISTINCT" in p for p in problems), problems + index.dialect_options["postgresql"]["nulls_not_distinct"] = True + assert find_problems(metadata, {"document"}) == [] + # лишний флаг на индексе, у которого в DBML нет такого note + other = next(i for i in metadata.tables["dkb.s3_object"].indexes if i.unique) + other.dialect_options["postgresql"]["nulls_not_distinct"] = True + assert any("NULLS NOT DISTINCT" in p for p in find_problems(metadata, {"s3_object"})) + + def test_missing_and_extra_foreign_keys_are_detected() -> None: metadata = clone_metadata() app_user = metadata.tables["dkb.app_user"] diff --git a/api/tests/test_models_part_04_06.py b/api/tests/test_models_part_04_06.py new file mode 100644 index 0000000..e1f8440 --- /dev/null +++ b/api/tests/test_models_part_04_06.py @@ -0,0 +1,228 @@ +"""ORM частей 04–06 DBML v5.13 (файлы, ОСВ, отчётность): чётность с DBML без БД и create_all на PostgreSQL.""" + +import datetime +import decimal +import uuid +from dataclasses import dataclass + +import pytest +from sqlalchemy import Table, inspect, select, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine + +from src.models import Base +from src.models.directories import LegalEntity +from src.models.enums import ( + DocumentStatus, + DocumentType, + FileStatus, + FileType, + RecognitionPipeline, + RecognitionStatus, + S3BucketRole, +) +from src.models.files import Document, RecognitionJob, S3Object, StoredFile +from src.models.osv import OsvEntry +from tests.dbml_parity import find_problems +from tests.test_models_part_01_03 import PART_01_03_TABLES, PG_IDENTIFIER_LIMIT + +PART_04_06_TABLES = { + # files + "s3_object", + "stored_file", + "document", + "recognition_job", + # osv + "osv_entry", + # statements + "financial_statement_meta", + "financial_statement_column", + "financial_statement_line", + "financial_statement_value", +} +EXPECTED_TABLE_COUNT = 25 # на этом этапе: 16 (части 01–03) + 9 (части 04–06); задача 8 добавит остальные + + +def orm_tables() -> dict[str, Table]: + return {t.name: t for t in Base.metadata.sorted_tables} + + +def test_part_04_06_tables_registered_in_metadata() -> None: + assert set(orm_tables()) == PART_01_03_TABLES | PART_04_06_TABLES + assert len(Base.metadata.sorted_tables) == EXPECTED_TABLE_COUNT + assert all(t.schema == "dkb" for t in Base.metadata.sorted_tables) + + +def test_orm_matches_dbml() -> None: + """Колонки (имя, порядок, тип, nullable, PK, unique, default), FK и индексы (вкл. NND) — как в DBML.""" + assert find_problems(Base.metadata, PART_04_06_TABLES) == [] + + +def test_new_tables_constraint_names_fit_postgres_limit() -> None: + """Имена всех ограничений и индексов новых таблиц заданы и не длиннее 63 символов (FK-цикл — тоже).""" + for name in PART_04_06_TABLES: + table = orm_tables()[name] + for constraint in [*table.constraints, *table.indexes]: + assert constraint.name, f"{name}: безымянное ограничение" + assert len(str(constraint.name)) <= PG_IDENTIFIER_LIMIT, f"{name}: {constraint.name}" + + +def test_fk_cycle_is_broken_by_use_alter_without_warning() -> None: + """`stored_file.current_recognition_job_id` — отложенный FK (use_alter), цикл не мешает `sorted_tables`.""" + stored_file = orm_tables()["stored_file"] + fk = next(f for f in stored_file.foreign_keys if f.parent.name == "current_recognition_job_id") + assert fk.constraint is not None + assert fk.constraint.use_alter is True + assert fk.constraint.name == "fk_stored_file_current_recognition_job_id_recognition_job" + # обратная сторона цикла — обычный FK + job_fk = next(f for f in orm_tables()["recognition_job"].foreign_keys if f.parent.name == "file_id") + assert job_fk.constraint is not None + assert job_fk.constraint.use_alter is False + names = [t.name for t in Base.metadata.sorted_tables] # pytest -W error: SAWarning о цикле стал бы ошибкой + assert names.index("stored_file") < names.index("recognition_job") + + +def test_document_unique_index_is_nulls_not_distinct() -> None: + index = next(i for i in orm_tables()["document"].indexes if i.unique) + assert index.dialect_options["postgresql"]["nulls_not_distinct"] is True + assert [c.name for c in index.columns] == ["stored_file_id", "document_type", "osv_account_code"] + + +@pytest.mark.integration +async def test_create_all_and_behavior_on_scratch_db(scratch_db_url: str) -> None: + """`create_all` частей 01–06 (25 таблиц); NULLS NOT DISTINCT и Decimal работают на реальной PostgreSQL 16.""" + engine = create_async_engine(scratch_db_url) + try: + async with engine.begin() as conn: + await conn.execute(text("CREATE SCHEMA IF NOT EXISTS dkb")) + await conn.run_sync(Base.metadata.create_all) + + async with engine.connect() as conn: + tables = await conn.run_sync(lambda c: set(inspect(c).get_table_names(schema="dkb"))) + assert tables == PART_01_03_TABLES | PART_04_06_TABLES + assert len(tables) == EXPECTED_TABLE_COUNT + fk_names = { + row[0] + for row in await conn.execute( + text("SELECT conname FROM pg_constraint WHERE contype = 'f' AND connamespace = 'dkb'::regnamespace") + ) + } + assert "fk_stored_file_current_recognition_job_id_recognition_job" in fk_names + assert "fk_financial_statement_value_column_id_fs_column" in fk_names + indexdef = ( + await conn.execute( + text( + "SELECT indexdef FROM pg_indexes " + "WHERE schemaname = 'dkb' AND indexname = 'uq_document_file_type_account'" + ) + ) + ).scalar_one() + assert "NULLS NOT DISTINCT" in indexdef + + async with AsyncSession(engine, expire_on_commit=False) as session: + chain = await _make_file_with_job(session) + await session.commit() + await _check_nulls_not_distinct(session, chain) + await _check_decimal_roundtrip(session, chain) + finally: + await engine.dispose() + + +@dataclass(frozen=True) +class Chain: + """Идентификаторы минимальной цепочки FK (простые uuid — объекты ORM истекают после rollback).""" + + entity_id: uuid.UUID + file_id: uuid.UUID + job_id: uuid.UUID + + +async def _make_file_with_job(session: AsyncSession) -> Chain: + """Минимальная цепочка FK: ЮЛ, s3_object, stored_file, recognition_job и замыкание цикла FK.""" + now = datetime.datetime.now(datetime.UTC) + entity = LegalEntity(inn="7700000001", name="ООО Тест", valid_from=now) + s3 = S3Object(bucket_role=S3BucketRole.uploads, bucket="uploads", key=f"{uuid.uuid4()}/osv.xlsx", created_at=now) + session.add_all([entity, s3]) + await session.flush() + file = StoredFile( + legal_entity_id=entity.id, file_type=FileType.XLSX, s3_object_id=s3.id, status=FileStatus.UPLOADED + ) + session.add(file) + await session.flush() + job = RecognitionJob( + file_id=file.id, + pipeline=RecognitionPipeline.tabular, + request_subject="recognition.tabular.request", + schema_version="1", + status=RecognitionStatus.queued, + ) + session.add(job) + await session.flush() + file.current_recognition_job_id = job.id # замыкание цикла через отложенный FK + await session.flush() + await session.refresh(job) + await session.refresh(file) + assert (job.attempt, job.max_attempts) == (1, 1) # server_default + assert file.version_no == 1 # server_default + assert file.current_recognition_job_id == job.id + return Chain(entity_id=entity.id, file_id=file.id, job_id=job.id) + + +def _document(chain: Chain, document_type: DocumentType, account: str | None) -> Document: + return Document( + stored_file_id=chain.file_id, + legal_entity_id=chain.entity_id, + document_type=document_type, + osv_account_code=account, + status=DocumentStatus.RECOGNIZED, + ) + + +async def _check_nulls_not_distinct(session: AsyncSession, chain: Chain) -> None: + """Два документа с одним (stored_file_id, document_type) и osv_account_code = NULL -> IntegrityError.""" + session.add(_document(chain, DocumentType.BALANCE, None)) + await session.flush() + session.add(_document(chain, DocumentType.BALANCE, None)) + with pytest.raises(IntegrityError): + await session.flush() + await session.rollback() + # разные счета ОСВ в одном файле и ББ/ОФР рядом — допустимы + session.add_all( + [_document(chain, DocumentType.OSV, "58"), _document(chain, DocumentType.OSV, "60")] + + [_document(chain, DocumentType.INCOME, None)] + ) + await session.flush() + await session.rollback() + + +async def _check_decimal_roundtrip(session: AsyncSession, chain: Chain) -> None: + """Сумма numeric(18,2) читается обратно как `Decimal` без потери точности (деньги — не float).""" + document = _document(chain, DocumentType.OSV, "62") + session.add(document) + await session.flush() + amount = decimal.Decimal("106311116.46") + entry = OsvEntry( + recognition_job_id=chain.job_id, + document_id=document.id, + owner_legal_entity_id=chain.entity_id, + row_number=1, + closing_debit=amount, + ) + session.add(entry) + await session.flush() + entry_id = entry.id + session.add( + OsvEntry( + recognition_job_id=chain.job_id, + document_id=document.id, + owner_legal_entity_id=chain.entity_id, + parent_entry_id=entry_id, # самоссылка + row_number=2, + ) + ) + await session.commit() + stored = (await session.execute(select(OsvEntry.closing_debit).where(OsvEntry.id == entry_id))).scalar_one() + assert isinstance(stored, decimal.Decimal) + assert stored == amount + is_modified = (await session.execute(select(OsvEntry.is_modified).where(OsvEntry.id == entry_id))).scalar_one() + assert is_modified is False # server_default false -- 2.47.2 From 842bb584ee12c01d229ef4da87d97d18bd868fd1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 19:22:33 +0400 Subject: [PATCH 27/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=207?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index eef4e54..63fb029 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -45,7 +45,7 @@ ### Task 7: [2.3] Части 04–06 -- [ ] Части 04–06: `files.py` (`s3_object`, `stored_file`, `document` с `NULLS NOT DISTINCT`, `recognition_job`; FK-цикл через `use_alter`), `osv.py` (`osv_entry`), `statements.py` (`financial_statement_meta/column/line/value`); проверка: как 2.2 +- [x] Части 04–06: `files.py` (`s3_object`, `stored_file`, `document` с `NULLS NOT DISTINCT`, `recognition_job`; FK-цикл через `use_alter`), `osv.py` (`osv_entry`), `statements.py` (`financial_statement_meta/column/line/value`); проверка: как 2.2 ### Task 8: [2.4] Части 07–09 diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 5668f24..50389cb 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -33,7 +33,7 @@ `legal_entity_alias`, `related_group`, `related_group_member` с partial unique), `access.py` (`app_user`, `role`, `permission`, `role_permission`, `user_role`), `accounting.py` (`chart_of_account`, `elimination_rule`, `elimination_rule_account`, `required_document_rule`); проверка: `Base.metadata.create_all` на тестовой БД без ошибок -- [ ] 2.3 Части 04–06: `files.py` (`s3_object`, `stored_file`, `document` с `NULLS NOT DISTINCT`, `recognition_job`; +- [x] 2.3 Части 04–06: `files.py` (`s3_object`, `stored_file`, `document` с `NULLS NOT DISTINCT`, `recognition_job`; FK-цикл через `use_alter`), `osv.py` (`osv_entry`), `statements.py` (`financial_statement_meta/column/line/value`); проверка: как 2.2 - [ ] 2.4 Части 07–09: `consolidation.py` (`consolidation`, `_version`, `_status_history`, `_lock` с partial unique, -- 2.47.2 From 48525731ff136437fd3161efea76a9a2792c150c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 19:32:48 +0400 Subject: [PATCH 28/97] =?UTF-8?q?feat:=20ORM-=D0=BC=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=20=D1=87=D0=B0=D1=81=D1=82=D0=B5=D0=B9=2007?= =?UTF-8?q?=E2=80=9309=20DBML=20v5.13=20(=D0=BA=D0=BE=D0=BD=D1=81=D0=BE?= =?UTF-8?q?=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D1=8F,=20=D0=9A=D0=A4?= =?UTF-8?q?=D0=9E,=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82/=D0=95=D0=A5,=20requir?= =?UTF-8?q?ed=5Fdocument=5Frule)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/src/models/__init__.py | 5 +- api/src/models/audit_ex.py | 156 +++++++++++++++++++++++++ api/src/models/consolidation.py | 158 +++++++++++++++++++++++++ api/src/models/kfo.py | 197 ++++++++++++++++++++++++++++++++ 4 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 api/src/models/audit_ex.py create mode 100644 api/src/models/consolidation.py create mode 100644 api/src/models/kfo.py diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index dc6ac3d..fcbbb72 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -3,7 +3,7 @@ Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic). """ -from . import access, accounting, directories, files, osv, statements +from . import access, accounting, audit_ex, consolidation, directories, files, kfo, osv, statements from .base import Base, UUIDPrimaryMixin #: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm). @@ -15,8 +15,11 @@ __all__ = [ "UUIDPrimaryMixin", "access", "accounting", + "audit_ex", + "consolidation", "directories", "files", + "kfo", "osv", "statements", ] diff --git a/api/src/models/audit_ex.py b/api/src/models/audit_ex.py new file mode 100644 index 0000000..04af415 --- /dev/null +++ b/api/src/models/audit_ex.py @@ -0,0 +1,156 @@ +"""Часть 09 DBML v5.13 «Комментарии, аудит, ЕХФД, прочее»: комментарии, журнал аудита, срезы ЕХ, чек-лист документов. + +`audit_event` — append-only, высокообъёмная (before/after — jsonb). Срезы ЕХ самодостаточны (v5.12): FK на +консолидацию и её результаты отсутствуют (их удаляют по сроку, срез хранится «вечно»): `ex_snapshot.source_version_id` +— uuid без FK. `required_document_rule` (секция DBML «ПРОЧЕЕ») лежит здесь по рулингу R-4. +""" + +import datetime +import uuid +from typing import Any + +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Integer, SmallInteger, String, Text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base, UUIDPrimaryMixin +from .enums import ( + AuditAction, + AuditSection, + ConsolidationApproach, + DocumentType, + ExSnapshotStatus, + PeriodQuarter, + pg_enum, +) + + +class Comment(UUIDPrimaryMixin, Base): + """Комментарий к объекту (документ, версия, строка КФО и т.п.; ТЗ 6.3/6.4); связь с объектом — полиморфная.""" + + __tablename__ = "comment" + __table_args__ = (Index("ix_comment_object_type_object_id", "object_type", "object_id"),) + + object_type: Mapped[str] = mapped_column(String, nullable=False) + object_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + text: Mapped[str] = mapped_column(Text, nullable=False) + author_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id"), nullable=False) + created_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class AuditEvent(UUIDPrimaryMixin, Base): + """Журнал изменений (ТЗ 8.2). Append-only; `occurred_at` проставляет приложение (default в DBML нет).""" + + __tablename__ = "audit_event" + __table_args__ = ( + Index("ix_audit_event_version_occurred_at", "consolidation_version_id", "occurred_at"), + Index("ix_audit_event_object_type_object_id", "object_type", "object_id"), + Index("ix_audit_event_user_occurred_at", "user_id", "occurred_at"), + ) + + occurred_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False) + user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + action: Mapped[AuditAction] = mapped_column(pg_enum("audit_action"), nullable=False) + section: Mapped[AuditSection] = mapped_column(pg_enum("audit_section"), nullable=False) + object_type: Mapped[str] = mapped_column(String, nullable=False) + object_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) + consolidation_version_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("consolidation_version.id") + ) + description: Mapped[str | None] = mapped_column(Text) + before_value: Mapped[Any | None] = mapped_column(JSONB) + after_value: Mapped[Any | None] = mapped_column(JSONB) + comment: Mapped[str | None] = mapped_column(Text) + result: Mapped[str | None] = mapped_column(String) + changed_field: Mapped[str | None] = mapped_column(String) + ip_address: Mapped[str | None] = mapped_column(String) + user_agent: Mapped[str | None] = mapped_column(String) + + +class ExSnapshot(UUIDPrimaryMixin, Base): + """Срез ЕХ: одна ГСК на одну отчётную дату (ЕХ 6.2). Неизменяем и самодостаточен — без FK на консолидацию.""" + + __tablename__ = "ex_snapshot" + __table_args__ = ( + Index( + "uq_ex_snapshot_group_year_quarter_version", + "related_group_id", + "year", + "quarter", + "version_no", + unique=True, + ), + Index("ix_ex_snapshot_group_year_quarter_status", "related_group_id", "year", "quarter", "status"), + ) + + related_group_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("related_group.id"), nullable=False + ) + company_group_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("company_group.id"), nullable=False + ) + reporting_date: Mapped[datetime.date] = mapped_column(Date, nullable=False) + year: Mapped[int] = mapped_column(SmallInteger, nullable=False) + quarter: Mapped[PeriodQuarter] = mapped_column(pg_enum("period_quarter"), nullable=False) + version_no: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[ExSnapshotStatus] = mapped_column(pg_enum("ex_snapshot_status"), nullable=False) + # id consolidation_version-источника БЕЗ FK (v5.12): источник удаляется по сроку, срез — нет + source_version_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, unique=True) + source_version_no: Mapped[int] = mapped_column(Integer, nullable=False) + superseded_by_snapshot_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("ex_snapshot.id") + ) + verified_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False) + written_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False) + written_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + archived_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + data_lake_ref: Mapped[str | None] = mapped_column(String) + + +class ExSnapshotMember(Base): + """Периметр среза на дату (ЕХ 6.3); `inn`/`name` денормализованы; составной PK (ex_snapshot_id, legal_entity_id).""" + + __tablename__ = "ex_snapshot_member" + + ex_snapshot_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("ex_snapshot.id"), primary_key=True + ) + legal_entity_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("legal_entity.id"), primary_key=True + ) + inn: Mapped[str] = mapped_column(String, nullable=False) + name: Mapped[str] = mapped_column(String, nullable=False) + + +class ExSnapshotItem(UUIDPrimaryMixin, Base): + """Состав среза: `primary_document` -> `document` (исходники хранятся весь срок), всё остальное -> `payload`.""" + + __tablename__ = "ex_snapshot_item" + __table_args__ = (Index("ix_ex_snapshot_item_snapshot_item_type", "ex_snapshot_id", "item_type"),) + + ex_snapshot_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ex_snapshot.id"), nullable=False) + item_type: Mapped[str] = mapped_column(String, nullable=False) + legal_entity_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("legal_entity.id")) + document_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("document.id")) + payload: Mapped[Any | None] = mapped_column(JSONB) + payload_schema_version: Mapped[str | None] = mapped_column(String) + + +class RequiredDocumentRule(UUIDPrimaryMixin, Base): + """Состав обязательных документов по подходу — основа чек-листа (ТЗ 5.1/6.2, сноска 12).""" + + __tablename__ = "required_document_rule" + __table_args__ = ( + Index( + "uq_required_document_rule_approach_type_account", + "approach", + "document_type", + "account_id", + unique=True, + ), + ) + + approach: Mapped[ConsolidationApproach] = mapped_column(pg_enum("consolidation_approach"), nullable=False) + document_type: Mapped[DocumentType] = mapped_column(pg_enum("document_type"), nullable=False) + account_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("chart_of_account.id")) + is_mandatory: Mapped[bool] = mapped_column(Boolean, nullable=False) diff --git a/api/src/models/consolidation.py b/api/src/models/consolidation.py new file mode 100644 index 0000000..53135a6 --- /dev/null +++ b/api/src/models/consolidation.py @@ -0,0 +1,158 @@ +"""Часть 07 DBML v5.13 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы. + +Производные консолидации (сводная ОСВ, пары ВГО, шахматка) НЕ хранятся (DECISION-5): считаются запросом из +`osv_entry` × `consolidation_member` × `elimination_rule`. Версия после VERIFIED — read-only (ТЗ 6.10). +Длинные имена FK на `consolidation_version` заданы явно (по конвенции R-9 > 63 символов PostgreSQL). +""" + +import datetime +import uuid + +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Integer, SmallInteger, String, Text, text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base, UUIDPrimaryMixin +from .enums import ( + ConsolidationApproach, + ConsolidationScenario, + ConsolidationStatus, + PeriodQuarter, + pg_enum, +) + + +class Consolidation(UUIDPrimaryMixin, Base): + """Логическая консолидация ГК за период (ТЗ 6.10); версии различаются периметром `consolidation_member`.""" + + __tablename__ = "consolidation" + __table_args__ = (Index("uq_consolidation_group_year_quarter", "company_group_id", "year", "quarter", unique=True),) + + company_group_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("company_group.id"), nullable=False + ) + related_group_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("related_group.id")) + year: Mapped[int] = mapped_column(SmallInteger, nullable=False) + quarter: Mapped[PeriodQuarter] = mapped_column(pg_enum("period_quarter"), nullable=False) + reporting_date: Mapped[datetime.date] = mapped_column(Date, nullable=False) + created_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + + +class ConsolidationVersion(UUIDPrimaryMixin, Base): + """Версия консолидации: сценарий, подход, статус, маршрут согласования, решение УОБ (ТЗ 5.1, 6.10).""" + + __tablename__ = "consolidation_version" + __table_args__ = ( + Index("uq_consolidation_version_consolidation_id_version_no", "consolidation_id", "version_no", unique=True), + ) + + consolidation_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("consolidation.id"), nullable=False + ) + version_no: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[ConsolidationStatus] = mapped_column(pg_enum("consolidation_status"), nullable=False) + scenario: Mapped[ConsolidationScenario] = mapped_column(pg_enum("consolidation_scenario"), nullable=False) + approach: Mapped[ConsolidationApproach] = mapped_column( + pg_enum("consolidation_approach"), nullable=False, server_default=text("'BASIC'") + ) + goodwill_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + initiator_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id"), nullable=False) + initiator_office_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("regional_office.id"), nullable=False + ) + approved_in_rf_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + approved_in_rf_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + verified_in_go_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + verified_in_go_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + uob_decision_no: Mapped[str | None] = mapped_column(String) + uob_decision_date: Mapped[datetime.date | None] = mapped_column(Date) + methodology_deviations: Mapped[str | None] = mapped_column(Text) + elimination_rules_version: Mapped[int | None] = mapped_column(Integer) + is_in_work: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + previous_version_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("consolidation_version.id", name="fk_consolidation_version_previous_version_id"), + ) + cancelled_reason: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + updated_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + + +class ConsolidationStatusHistory(UUIDPrimaryMixin, Base): + """Маршрут согласования: журнал смен статуса версии (ТЗ 6.10, 8.2).""" + + __tablename__ = "consolidation_status_history" + + version_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("consolidation_version.id", name="fk_consolidation_status_history_version_id"), + nullable=False, + ) + from_status: Mapped[ConsolidationStatus | None] = mapped_column(pg_enum("consolidation_status")) + to_status: Mapped[ConsolidationStatus] = mapped_column(pg_enum("consolidation_status"), nullable=False) + changed_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id"), nullable=False) + changed_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False) + comment: Mapped[str | None] = mapped_column(Text) + + +class ConsolidationLock(UUIDPrimaryMixin, Base): + """Взятие версии в работу: ответственный сейчас = активная блокировка (`released_at IS NULL`).""" + + __tablename__ = "consolidation_lock" + __table_args__ = ( + # не более одной активной блокировки на версию + Index( + "uq_consolidation_lock_version_active", + "consolidation_version_id", + unique=True, + postgresql_where=text("released_at IS NULL"), + ), + ) + + consolidation_version_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("consolidation_version.id", name="fk_consolidation_lock_consolidation_version_id"), + nullable=False, + ) + locked_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id"), nullable=False) + locked_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False) + released_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + taken_over_from: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + takeover_reason: Mapped[str | None] = mapped_column(Text) + + +class ConsolidationMember(UUIDPrimaryMixin, Base): + """Зафиксированный периметр версии: изменение `related_group_member` существующие версии не меняет.""" + + __tablename__ = "consolidation_member" + __table_args__ = ( + Index("uq_consolidation_member_version_entity", "consolidation_version_id", "legal_entity_id", unique=True), + ) + + consolidation_version_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("consolidation_version.id", name="fk_consolidation_member_consolidation_version_id"), + nullable=False, + ) + legal_entity_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("legal_entity.id"), nullable=False + ) + is_head_entity: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + + +class ConsolidationDocument(UUIDPrimaryMixin, Base): + """Явная привязка логических документов к версии (Прил. 2 ТЗ); чек-лист = required_document_rule × периметр.""" + + __tablename__ = "consolidation_document" + __table_args__ = ( + Index("uq_consolidation_document_version_document", "consolidation_version_id", "document_id", unique=True), + ) + + consolidation_version_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("consolidation_version.id", name="fk_consolidation_document_consolidation_version_id"), + nullable=False, + ) + document_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("document.id"), nullable=False) diff --git a/api/src/models/kfo.py b/api/src/models/kfo.py new file mode 100644 index 0000000..dc0df0f --- /dev/null +++ b/api/src/models/kfo.py @@ -0,0 +1,197 @@ +"""Часть 08 DBML v5.13 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента. + +Корректировки и гудвилл — ручные решения аналитика, хранятся поверх первички (overlay). Результат КФО +замораживается при VERIFIED. Деньги — `numeric(18,2)`, проценты — `numeric(7,4)` / `numeric(9,4)`. +""" + +import datetime +import decimal +import uuid + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base, UUIDPrimaryMixin +from .enums import CorrectionStatus, StatementType, pg_enum + + +class Correction(UUIDPrimaryMixin, Base): + """Корректировка ВГО: итог = свод первички − Σ применённых корректировок; пара — по (rule_id, компании).""" + + __tablename__ = "correction" + __table_args__ = ( + Index("ix_correction_consolidation_version_id", "consolidation_version_id"), + Index( + "ix_correction_version_rule_entities", + "consolidation_version_id", + "rule_id", + "legal_entity_id", + "counterparty_id", + ), + ) + + consolidation_version_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("consolidation_version.id"), nullable=False + ) + statement_type: Mapped[StatementType] = mapped_column(pg_enum("statement_type"), nullable=False) + rule_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("elimination_rule.id")) + parent_correction_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("correction.id")) + legal_entity_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("legal_entity.id")) + counterparty_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("legal_entity.id")) + debit_line_code: Mapped[str | None] = mapped_column(String) + debit_account_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("chart_of_account.id")) + debit_adjustment: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + credit_line_code: Mapped[str | None] = mapped_column(String) + credit_account_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("chart_of_account.id")) + credit_adjustment: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + deviation_amount: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + deviation_percent: Mapped[decimal.Decimal | None] = mapped_column(Numeric(9, 4)) + reason_code: Mapped[str | None] = mapped_column(String) + comment: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[CorrectionStatus] = mapped_column( + pg_enum("correction_status"), nullable=False, server_default=text("'DRAFT'") + ) + version_no: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1")) + created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + created_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + updated_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + + +class Goodwill(UUIDPrimaryMixin, Base): + """Гудвилл по паре «головная организация — дочерняя» (ТЗ 6.7); доля участия вводится вручную, %.""" + + __tablename__ = "goodwill" + __table_args__ = ( + Index( + "uq_goodwill_version_head_subsidiary", + "consolidation_version_id", + "head_legal_entity_id", + "subsidiary_legal_entity_id", + unique=True, + ), + ) + + consolidation_version_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("consolidation_version.id"), nullable=False + ) + head_legal_entity_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("legal_entity.id"), nullable=False + ) + subsidiary_legal_entity_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("legal_entity.id"), nullable=False + ) + ownership_share: Mapped[decimal.Decimal | None] = mapped_column(Numeric(7, 4)) + financial_investment_amount: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + charter_capital_amount: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + additional_capital_amount: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + participation_amount: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + goodwill_amount: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + include_in_kfo: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + comment: Mapped[str | None] = mapped_column(Text) + created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + created_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + updated_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + + +class KfoResult(UUIDPrimaryMixin, Base): + """Шапка результата КФО версии + контрольные пороги (ТЗ 6.8); `is_frozen = true` после VERIFIED.""" + + __tablename__ = "kfo_result" + + consolidation_version_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("consolidation_version.id"), nullable=False, unique=True + ) + computed_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False) + is_frozen: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + net_assets: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + balance_total: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + revenue: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + sales_profit: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + total_deviation_bb: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + total_deviation_ofr: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + threshold_bb_exceeded: Mapped[bool | None] = mapped_column(Boolean) + threshold_ofr_exceeded: Mapped[bool | None] = mapped_column(Boolean) + threshold_justification: Mapped[str | None] = mapped_column(Text) + goodwill_total: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + has_deviations: Mapped[bool | None] = mapped_column(Boolean) + + +class KfoResultLine(UUIDPrimaryMixin, Base): + """Строка КФО (ФСБУ 4/2023): сумма по компаниям -> авто-элиминация -> корректировки -> итог.""" + + __tablename__ = "kfo_result_line" + __table_args__ = ( + Index( + "uq_kfo_result_line_result_type_code", + "kfo_result_id", + "statement_type", + "line_code", + unique=True, + ), + ) + + kfo_result_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("kfo_result.id"), nullable=False) + statement_type: Mapped[StatementType] = mapped_column(pg_enum("statement_type"), nullable=False) + line_code: Mapped[str] = mapped_column(String, nullable=False) + line_name: Mapped[str | None] = mapped_column(String) + sum_amount: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + eliminated_auto: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + eliminated_manual: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + goodwill_adjust: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + consolidated_amount: Mapped[decimal.Decimal] = mapped_column(Numeric(18, 2), nullable=False) + vgo_amount: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + source_kind: Mapped[str | None] = mapped_column(String) + deviation_amount: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + comment: Mapped[str | None] = mapped_column(Text) + ordinal: Mapped[int | None] = mapped_column(Integer) + + +class KfoResultLineCompany(Base): + """Разрез статьи КФО по компаниям (ЕХ 6.6); составной PK (kfo_result_line_id, legal_entity_id).""" + + __tablename__ = "kfo_result_line_company" + + kfo_result_line_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("kfo_result_line.id"), primary_key=True + ) + legal_entity_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("legal_entity.id"), primary_key=True + ) + amount: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + share_percent: Mapped[decimal.Decimal | None] = mapped_column(Numeric(7, 4)) + + +class ClientKfoLine(UUIDPrimaryMixin, Base): + """Сравнение с КФО клиента: ручной ввод сумм клиента и расхождение относительно наименьшего (Методика, сн. 14).""" + + __tablename__ = "client_kfo_line" + __table_args__ = ( + Index( + "uq_client_kfo_line_version_type_code", + "consolidation_version_id", + "statement_type", + "line_code", + unique=True, + ), + ) + + consolidation_version_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("consolidation_version.id", name="fk_client_kfo_line_consolidation_version_id"), + nullable=False, + ) + statement_type: Mapped[StatementType] = mapped_column(pg_enum("statement_type"), nullable=False) + line_code: Mapped[str] = mapped_column(String, nullable=False) + client_amount: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + client_vgo_amount: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + bank_amount: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + bank_vgo_amount: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + deviation_amount: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + deviation_percent: Mapped[decimal.Decimal | None] = mapped_column(Numeric(9, 4)) + deviation_reason: Mapped[str | None] = mapped_column(Text) + comment: Mapped[str | None] = mapped_column(Text) + entered_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + entered_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) -- 2.47.2 From ef55485dfcae1c89ac38a2c1fccd739e4ec81294 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 19:32:48 +0400 Subject: [PATCH 29/97] =?UTF-8?q?test:=20=D1=87=D1=91=D1=82=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20=D1=87=D0=B0=D1=81=D1=82=D0=B5=D0=B9=2007?= =?UTF-8?q?=E2=80=9309=20=D1=81=20DBML,=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=2043=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=20?= =?UTF-8?q?=D0=B8=2027=20enum,=20partial=20unique=20consolidation=5Flock?= =?UTF-8?q?=20=D0=BD=D0=B0=20PG16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/tests/test_models_part_04_06.py | 14 +- api/tests/test_models_part_07_09.py | 241 ++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 api/tests/test_models_part_07_09.py diff --git a/api/tests/test_models_part_04_06.py b/api/tests/test_models_part_04_06.py index e1f8440..9126293 100644 --- a/api/tests/test_models_part_04_06.py +++ b/api/tests/test_models_part_04_06.py @@ -3,6 +3,7 @@ import datetime import decimal import uuid +import warnings from dataclasses import dataclass import pytest @@ -40,7 +41,6 @@ PART_04_06_TABLES = { "financial_statement_line", "financial_statement_value", } -EXPECTED_TABLE_COUNT = 25 # на этом этапе: 16 (части 01–03) + 9 (части 04–06); задача 8 добавит остальные def orm_tables() -> dict[str, Table]: @@ -48,8 +48,7 @@ def orm_tables() -> dict[str, Table]: def test_part_04_06_tables_registered_in_metadata() -> None: - assert set(orm_tables()) == PART_01_03_TABLES | PART_04_06_TABLES - assert len(Base.metadata.sorted_tables) == EXPECTED_TABLE_COUNT + assert PART_01_03_TABLES | PART_04_06_TABLES <= set(orm_tables()) # полный состав и счёт — в тесте части 07–09 assert all(t.schema == "dkb" for t in Base.metadata.sorted_tables) @@ -78,7 +77,9 @@ def test_fk_cycle_is_broken_by_use_alter_without_warning() -> None: job_fk = next(f for f in orm_tables()["recognition_job"].foreign_keys if f.parent.name == "file_id") assert job_fk.constraint is not None assert job_fk.constraint.use_alter is False - names = [t.name for t in Base.metadata.sorted_tables] # pytest -W error: SAWarning о цикле стал бы ошибкой + with warnings.catch_warnings(): + warnings.simplefilter("error") # SAWarning о неразрешённом цикле зависимостей стал бы ошибкой + names = [t.name for t in Base.metadata.sorted_tables] assert names.index("stored_file") < names.index("recognition_job") @@ -90,7 +91,7 @@ def test_document_unique_index_is_nulls_not_distinct() -> None: @pytest.mark.integration async def test_create_all_and_behavior_on_scratch_db(scratch_db_url: str) -> None: - """`create_all` частей 01–06 (25 таблиц); NULLS NOT DISTINCT и Decimal работают на реальной PostgreSQL 16.""" + """`create_all`; таблицы частей 01–06 созданы; NULLS NOT DISTINCT и Decimal работают на реальной PostgreSQL 16.""" engine = create_async_engine(scratch_db_url) try: async with engine.begin() as conn: @@ -99,8 +100,7 @@ async def test_create_all_and_behavior_on_scratch_db(scratch_db_url: str) -> Non async with engine.connect() as conn: tables = await conn.run_sync(lambda c: set(inspect(c).get_table_names(schema="dkb"))) - assert tables == PART_01_03_TABLES | PART_04_06_TABLES - assert len(tables) == EXPECTED_TABLE_COUNT + assert PART_01_03_TABLES | PART_04_06_TABLES <= tables fk_names = { row[0] for row in await conn.execute( diff --git a/api/tests/test_models_part_07_09.py b/api/tests/test_models_part_07_09.py new file mode 100644 index 0000000..97cccaa --- /dev/null +++ b/api/tests/test_models_part_07_09.py @@ -0,0 +1,241 @@ +"""ORM частей 07–09 DBML v5.13 (консолидация, КФО, аудит/ЕХ/прочее) и ИТОГОВАЯ полнота всех 43 таблиц.""" + +import datetime + +import pytest +from sqlalchemy import inspect, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine + +from src.models import Base +from src.models.access import AppUser +from src.models.consolidation import Consolidation, ConsolidationLock, ConsolidationVersion +from src.models.directories import CompanyGroup, RegionalOffice +from src.models.enums import ( + ENUMS, + ConsolidationApproach, + ConsolidationScenario, + ConsolidationStatus, + PeriodQuarter, +) +from tests.dbml_helpers import load_dbml +from tests.dbml_parity import find_problems +from tests.test_models_part_01_03 import PART_01_03_TABLES, PG_IDENTIFIER_LIMIT +from tests.test_models_part_04_06 import PART_04_06_TABLES + +PART_07_09_TABLES = { + # consolidation + "consolidation", + "consolidation_version", + "consolidation_status_history", + "consolidation_lock", + "consolidation_member", + "consolidation_document", + # kfo + "correction", + "goodwill", + "kfo_result", + "kfo_result_line", + "kfo_result_line_company", + "client_kfo_line", + # audit_ex (+ required_document_rule по рулингу R-4) + "comment", + "audit_event", + "ex_snapshot", + "ex_snapshot_member", + "ex_snapshot_item", + "required_document_rule", +} +ALL_TABLES = PART_01_03_TABLES | PART_04_06_TABLES | PART_07_09_TABLES +EXPECTED_TABLE_COUNT = 43 +EXPECTED_ENUM_COUNT = 27 + + +def test_part_07_09_tables_are_new() -> None: + assert PART_07_09_TABLES.isdisjoint(PART_01_03_TABLES | PART_04_06_TABLES) + + +def test_orm_matches_dbml_part_07_09() -> None: + """Колонки (имя, порядок, тип, nullable, PK, unique, default), FK и индексы (вкл. partial WHERE) — как в DBML.""" + assert find_problems(Base.metadata, PART_07_09_TABLES) == [] + + +def test_all_constraint_names_fit_postgres_limit() -> None: + """Имена всех ограничений и индексов всех таблиц metadata заданы и не длиннее 63 символов.""" + for table in Base.metadata.sorted_tables: + for constraint in [*table.constraints, *table.indexes]: + assert constraint.name, f"{table.name}: безымянное ограничение" + assert len(str(constraint.name)) <= PG_IDENTIFIER_LIMIT, f"{table.name}: {constraint.name}" + + +def test_consolidation_lock_index_is_partial_unique_on_version() -> None: + table = Base.metadata.tables["dkb.consolidation_lock"] + index = next(i for i in table.indexes if i.name == "uq_consolidation_lock_version_active") + assert index.unique is True + assert [c.name for c in index.columns] == ["consolidation_version_id"] + assert str(index.dialect_options["postgresql"]["where"]) == "released_at IS NULL" + + +def test_composite_primary_keys() -> None: + tables = Base.metadata.tables + assert [c.name for c in tables["dkb.kfo_result_line_company"].primary_key] == [ + "kfo_result_line_id", + "legal_entity_id", + ] + assert [c.name for c in tables["dkb.ex_snapshot_member"].primary_key] == ["ex_snapshot_id", "legal_entity_id"] + + +def test_ex_snapshot_has_no_fk_to_consolidation() -> None: + """v5.12: срез ЕХ самодостаточен — ни одна таблица ex_snapshot* не ссылается на консолидацию/КФО.""" + consolidation_side = { + "consolidation", + "consolidation_version", + "consolidation_status_history", + "consolidation_lock", + "consolidation_member", + "consolidation_document", + "correction", + "goodwill", + "kfo_result", + "kfo_result_line", + "kfo_result_line_company", + "client_kfo_line", + } + for name in ("ex_snapshot", "ex_snapshot_member", "ex_snapshot_item"): + for fk in Base.metadata.tables[f"dkb.{name}"].foreign_keys: + assert fk.column.table.name not in consolidation_side, f"{name}.{fk.parent.name}" + assert not Base.metadata.tables["dkb.ex_snapshot"].c.source_version_id.foreign_keys + + +# ---------------------------------------------------------------- итоговая полнота моделей (43 таблицы) + + +def test_all_43_tables_registered_and_match_dbml_names() -> None: + """Состав metadata == таблицы DBML v5.13 (имена буквально), всего 43, все в схеме dkb.""" + tables = list(Base.metadata.sorted_tables) + assert len(tables) == EXPECTED_TABLE_COUNT + assert all(t.schema == "dkb" for t in tables) + dbml_names = {t.name for t in load_dbml().tables} + assert len(dbml_names) == EXPECTED_TABLE_COUNT + assert {t.name for t in tables} == dbml_names + assert ALL_TABLES == dbml_names # разбиение по частям покрывает модель без пропусков и дублей + + +def test_all_27_enums_defined() -> None: + assert len(ENUMS) == EXPECTED_ENUM_COUNT + assert set(ENUMS) == {e.name for e in load_dbml().enums} + + +def test_orm_matches_dbml_for_all_43_tables() -> None: + """Сквозная сверка всех 43 таблиц одним вызовом компаратора.""" + assert find_problems(Base.metadata, ALL_TABLES) == [] + + +# ---------------------------------------------------------------- интеграция: PostgreSQL 16 + + +@pytest.mark.integration +async def test_create_all_43_tables_on_scratch_db(scratch_db_url: str) -> None: + """`create_all` всех 43 таблиц; 27 enum-типов в dkb; partial unique `consolidation_lock` работает поведением.""" + engine = create_async_engine(scratch_db_url) + try: + async with engine.begin() as conn: + await conn.execute(text("CREATE SCHEMA IF NOT EXISTS dkb")) + await conn.run_sync(Base.metadata.create_all) + + async with engine.connect() as conn: + tables = await conn.run_sync(lambda c: set(inspect(c).get_table_names(schema="dkb"))) + assert len(tables) == EXPECTED_TABLE_COUNT + assert tables == ALL_TABLES + enums = { + row[0] + for row in await conn.execute( + text( + "SELECT t.typname FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace " + "WHERE n.nspname = 'dkb' AND t.typtype = 'e'" + ) + ) + } + assert len(enums) == EXPECTED_ENUM_COUNT + assert enums == set(ENUMS) + indexdef = ( + await conn.execute( + text( + "SELECT indexdef FROM pg_indexes " + "WHERE schemaname = 'dkb' AND indexname = 'uq_consolidation_lock_version_active'" + ) + ) + ).scalar_one() + assert "UNIQUE" in indexdef + assert "WHERE (released_at IS NULL)" in indexdef + # у ex_snapshot.source_version_id нет FK; составные PK созданы + fk_columns = { + row[0] + for row in await conn.execute( + text( + "SELECT a.attname FROM pg_constraint c " + "JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey) " + "WHERE c.contype = 'f' AND c.conrelid = 'dkb.ex_snapshot'::regclass" + ) + ) + } + assert "source_version_id" not in fk_columns + pk = await conn.run_sync(lambda c: inspect(c).get_pk_constraint("ex_snapshot_member", schema="dkb")) + assert pk["constrained_columns"] == ["ex_snapshot_id", "legal_entity_id"] + + async with AsyncSession(engine, expire_on_commit=False) as session: + await _check_active_lock_is_unique(session) + finally: + await engine.dispose() + + +async def _check_active_lock_is_unique(session: AsyncSession) -> None: + """Два активных lock на одну версию -> IntegrityError; после `released_at` новый lock допустим.""" + now = datetime.datetime.now(datetime.UTC) + office = RegionalOffice(name="ГО") + session.add(office) + await session.flush() + user = AppUser(full_name="Аналитик", regional_office_id=office.id) + group = CompanyGroup(code="G1", name="ГК", valid_from=now) + session.add_all([user, group]) + await session.flush() + consolidation = Consolidation( + company_group_id=group.id, year=2025, quarter=PeriodQuarter.Q1, reporting_date=datetime.date(2025, 4, 1) + ) + session.add(consolidation) + await session.flush() + version = ConsolidationVersion( + consolidation_id=consolidation.id, + version_no=1, + status=ConsolidationStatus.INITIATED, + scenario=ConsolidationScenario.CREATE, + initiator_id=user.id, + initiator_office_id=office.id, + ) + session.add(version) + await session.flush() + await session.refresh(version) + assert version.approach == ConsolidationApproach.BASIC # server_default 'BASIC' + assert version.goodwill_enabled is False + assert version.is_in_work is True + version_id, user_id = version.id, user.id + await session.commit() + + def lock() -> ConsolidationLock: + return ConsolidationLock(consolidation_version_id=version_id, locked_by=user_id, locked_at=now) + + first = lock() + session.add(first) + await session.flush() + first_id = first.id + session.add(lock()) + with pytest.raises(IntegrityError): + await session.flush() # второй активный lock на ту же версию + await session.rollback() + + await session.execute( + text("UPDATE dkb.consolidation_lock SET released_at = :now WHERE id = :id"), {"now": now, "id": first_id} + ) + session.add(lock()) # после снятия первой блокировки — можно + await session.flush() + await session.rollback() -- 2.47.2 From f30494e7cf8482ee5e2caf279e563c42cc8a73e1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 19:33:11 +0400 Subject: [PATCH 30/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=208?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 63fb029..c5ea30d 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -49,7 +49,7 @@ ### Task 8: [2.4] Части 07–09 -- [ ] Части 07–09: `consolidation.py` (`consolidation`, `_version`, `_status_history`, `_lock` с partial unique, `_member`, `_document`), `kfo.py` (`correction`, `goodwill`, `kfo_result`, `kfo_result_line`, `kfo_result_line_company`, `client_kfo_line`), `audit_ex.py` (`comment`, `audit_event`, `ex_snapshot`, `ex_snapshot_member`, `ex_snapshot_item`); проверка: как 2.2, всего 43 таблицы в `Base.metadata.tables` +- [x] Части 07–09: `consolidation.py` (`consolidation`, `_version`, `_status_history`, `_lock` с partial unique, `_member`, `_document`), `kfo.py` (`correction`, `goodwill`, `kfo_result`, `kfo_result_line`, `kfo_result_line_company`, `client_kfo_line`), `audit_ex.py` (`comment`, `audit_event`, `ex_snapshot`, `ex_snapshot_member`, `ex_snapshot_item`); проверка: как 2.2, всего 43 таблицы в `Base.metadata.tables` ### Task 9: [2.5] `scripts/check_dbml_vs_orm.py` (pydbml diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 50389cb..439ba23 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -36,7 +36,7 @@ - [x] 2.3 Части 04–06: `files.py` (`s3_object`, `stored_file`, `document` с `NULLS NOT DISTINCT`, `recognition_job`; FK-цикл через `use_alter`), `osv.py` (`osv_entry`), `statements.py` (`financial_statement_meta/column/line/value`); проверка: как 2.2 -- [ ] 2.4 Части 07–09: `consolidation.py` (`consolidation`, `_version`, `_status_history`, `_lock` с partial unique, +- [x] 2.4 Части 07–09: `consolidation.py` (`consolidation`, `_version`, `_status_history`, `_lock` с partial unique, `_member`, `_document`), `kfo.py` (`correction`, `goodwill`, `kfo_result`, `kfo_result_line`, `kfo_result_line_company`, `client_kfo_line`), `audit_ex.py` (`comment`, `audit_event`, `ex_snapshot`, `ex_snapshot_member`, `ex_snapshot_item`); проверка: как 2.2, всего 43 таблицы в `Base.metadata.tables` -- 2.47.2 From 218ce88a3176cbccaf42f82dd1f4c76ef097f381 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 19:40:11 +0400 Subject: [PATCH 31/97] =?UTF-8?q?test:=20=D0=BF=D0=BE=D0=B2=D0=B5=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D1=87=D0=B5=D1=81=D0=BA=D0=B0=D1=8F=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20partial=20unique=20conso?= =?UTF-8?q?lidation=5Flock=20=E2=80=94=20=D0=BF=D0=B5=D1=80=D0=B2=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=84=D0=B8=D0=BA=D1=81=D0=B8=D1=80=D1=83=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F,=20=D1=81=D0=BD=D1=8F=D1=82=D0=B8=D0=B5=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?rowcount,=20=D0=B8=D0=BC=D1=8F=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D0=B0=20=D0=B2=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/tests/test_models_part_07_09.py | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/api/tests/test_models_part_07_09.py b/api/tests/test_models_part_07_09.py index 97cccaa..c5b7b83 100644 --- a/api/tests/test_models_part_07_09.py +++ b/api/tests/test_models_part_07_09.py @@ -226,16 +226,34 @@ async def _check_active_lock_is_unique(session: AsyncSession) -> None: first = lock() session.add(first) - await session.flush() + await session.commit() # первая активная блокировка зафиксирована: rollback ниже её не заденет first_id = first.id + session.add(lock()) - with pytest.raises(IntegrityError): + with pytest.raises(IntegrityError) as exc_info: await session.flush() # второй активный lock на ту же версию + assert "uq_consolidation_lock_version_active" in str(exc_info.value) await session.rollback() - await session.execute( + released = await session.execute( text("UPDATE dkb.consolidation_lock SET released_at = :now WHERE id = :id"), {"now": now, "id": first_id} ) - session.add(lock()) # после снятия первой блокировки — можно - await session.flush() + assert released.rowcount == 1 # type: ignore[attr-defined] # первая блокировка существовала и снята + session.add(lock()) # после снятия первой блокировки слот свободен — именно из-за `WHERE released_at IS NULL` + await session.commit() + + # снятая и активная блокировки одной версии сосуществуют; активной по-прежнему ровно одна + session.add(lock()) + with pytest.raises(IntegrityError): + await session.flush() await session.rollback() + counts = ( + await session.execute( + text( + "SELECT count(*) FILTER (WHERE released_at IS NULL), count(*) FILTER (WHERE released_at IS NOT NULL) " + "FROM dkb.consolidation_lock WHERE consolidation_version_id = :v" + ), + {"v": version_id}, + ) + ).one() + assert tuple(counts) == (1, 1) -- 2.47.2 From e064830702d1048df82af5addf29eae73f80b1da Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 19:56:58 +0400 Subject: [PATCH 32/97] =?UTF-8?q?feat:=20scripts/check=5Fdbml=5Fvs=5Form.p?= =?UTF-8?q?y=20=E2=80=94=20CLI=20=D1=81=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20OR?= =?UTF-8?q?M=20=D1=81=20DBML=20(=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F,=20?= =?UTF-8?q?=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=8B,=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=BD=D0=BA=D0=B8,=20=D1=82=D0=B8=D0=BF=D1=8B,=20F?= =?UTF-8?q?K,=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D1=8B,=20enum'=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ядро сверки перенесено из tests/dbml_parity.py в scripts/dbml_parity.py (единственная реализация, R-14); tests/dbml_parity.py и dbml_helpers.py — тонкие обёртки с пропуском теста без docs. Единый формат строк: `<объект>: ожидалось X / фактически Y`, `<объект>: отсутствует в ORM|DBML`; тип и nullability — одной строкой в нотации DBML. Версия из шапки DBML сверяется с DBML_VERSION до сравнения таблиц. Коды: 0/1/2. Без рабочего .env скрипт берёт api/.env.example. Makefile: dbml-check (в check-full), mypy проверяет scripts. Co-Authored-By: Claude Fable 5.1 --- Makefile | 12 +- api/scripts/__init__.py | 4 + api/scripts/check_dbml_vs_orm.py | 96 +++++++ api/scripts/dbml_parity.py | 394 ++++++++++++++++++++++++++++ api/tests/dbml_helpers.py | 13 +- api/tests/dbml_parity.py | 261 +----------------- api/tests/test_check_dbml_script.py | 162 ++++++++++++ api/tests/test_dbml_parity.py | 82 ++++-- 8 files changed, 735 insertions(+), 289 deletions(-) create mode 100644 api/scripts/__init__.py create mode 100644 api/scripts/check_dbml_vs_orm.py create mode 100644 api/scripts/dbml_parity.py create mode 100644 api/tests/test_check_dbml_script.py diff --git a/Makefile b/Makefile index bb2840d..34f3984 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,10 @@ VENV := api/.venv BIN := $(CURDIR)/$(VENV)/bin COMPOSE_FILE ?= ../docs/dev/docker-compose.yml +# DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/ +DBML ?= ../../docs/model/data-model-v5.13.dbml -.PHONY: venv lint test test-integration check check-full up migrate seed seed-dev +.PHONY: venv lint test test-integration dbml-check check check-full up migrate seed seed-dev # Python 3.11 (версия платформы); внутренние пакеты raisa_* вне банка недоступны — пропускаем их venv: @@ -14,7 +16,7 @@ venv: lint: cd api && $(BIN)/ruff check . cd api && $(BIN)/ruff format --check . - cd api && $(BIN)/mypy src + cd api && $(BIN)/mypy src scripts test: cd api && $(BIN)/pytest -m "not integration" @@ -22,10 +24,14 @@ test: test-integration: cd api && $(BIN)/pytest -m integration +# Сверка ORM с DBML (spec data-model): пустой вывод и код 0 — совпадение +dbml-check: + cd api && $(BIN)/python scripts/check_dbml_vs_orm.py $(DBML) + check: lint test # TODO (задача 25): добавить фронт (lint/test/build web/), проверку миграций с нуля и откат, актуальность OpenAPI -check-full: check test-integration +check-full: check dbml-check test-integration up: docker compose -f $(COMPOSE_FILE) up -d diff --git a/api/scripts/__init__.py b/api/scripts/__init__.py new file mode 100644 index 0000000..dad5dd8 --- /dev/null +++ b/api/scripts/__init__.py @@ -0,0 +1,4 @@ +"""Служебные скрипты arm-core (запуск из `api/`). + +Сверка ORM с DBML — `check_dbml_vs_orm.py` (ядро — `dbml_parity.py`); seed — `python -m scripts.seed`. +""" diff --git a/api/scripts/check_dbml_vs_orm.py b/api/scripts/check_dbml_vs_orm.py new file mode 100644 index 0000000..c8d3837 --- /dev/null +++ b/api/scripts/check_dbml_vs_orm.py @@ -0,0 +1,96 @@ +"""Сверка ORM-моделей arm-core с DBML-моделью данных (spec data-model, design D-3). + +Запуск из `api/`: + + python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.13.dbml + +Без аргумента DBML ищется в `$ARM_KA_DOCS/model/` или в соседнем `docs/` рядом с `arm-core`. + +Вывод: пусто и код 0 — ORM совпадает с DBML; иначе по строке на расхождение и код 1, например: + + osv_entry.extra_note: отсутствует в DBML + osv_entry.opening_debit: ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL + +Сначала сверяется версия из шапки DBML с `src.models.DBML_VERSION`: при расхождении — одна строка о версии, код 1, +таблицы не сравниваются. Код 2 — ошибка использования: нет файла, нет версии в шапке, DBML не разбирается +(сообщение — в stderr). + +Окружение. Импорт `src.models` тянет `src/__init__` → приложение → `Settings`, которому нужны ключи `OPENBAO__*`. +Рабочий `.env` скрипту не нужен: если `ENV_FILE` не задан и в текущем каталоге нет `.env`, берётся `api/.env.example` +(все обязательные ключи там есть; соединений при импорте не открывается). +""" + +import enum +import os +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path + +_API_DIR = Path(__file__).resolve().parents[1] + +# Запуск файлом (`python scripts/check_dbml_vs_orm.py`): в sys.path попадает только `api/scripts`, а нужен `api/`. +if str(_API_DIR) not in sys.path: + sys.path.insert(0, str(_API_DIR)) +if "ENV_FILE" not in os.environ and not Path(".env").is_file(): + os.environ["ENV_FILE"] = str(_API_DIR / ".env.example") + +from sqlalchemy import MetaData # noqa: E402 + +from scripts.dbml_parity import default_dbml_path, find_all_problems, parse_dbml, read_dbml_version # noqa: E402 +from src.models import DBML_VERSION, Base # noqa: E402 +from src.models.enums import ENUMS # noqa: E402 + +EXIT_OK, EXIT_PROBLEMS, EXIT_USAGE = 0, 1, 2 +USAGE = "использование: python scripts/check_dbml_vs_orm.py [путь к .dbml]" + + +def run_check( + dbml_path: Path, + *, + metadata: MetaData | None = None, + enums: Mapping[str, type[enum.Enum]] | None = None, + orm_version: str = DBML_VERSION, +) -> tuple[int, list[str]]: + """Сверить ORM с DBML-файлом; вернуть (код возврата, строки отчёта). + + По умолчанию сверяются реальные `Base.metadata` и `ENUMS`; тесты подставляют испорченные копии. + При коде 2 единственная строка — сообщение об ошибке использования. + """ + if not dbml_path.is_file(): + return EXIT_USAGE, [f"файл DBML не найден: {dbml_path}"] + # версия — до разбора и сравнения таблиц: новая модель без осознанного обновления ORM не сверяется вовсе + dbml_version = read_dbml_version(dbml_path) + if dbml_version is None: + return EXIT_USAGE, [ + f"в первой строке {dbml_path} не найдена версия DBML (ожидалось `... v<мажор>.<минор> ...`)" + ] + if dbml_version != orm_version: + return EXIT_PROBLEMS, [f"версия DBML {dbml_version} не совпадает с версией ORM {orm_version}: обновите модели"] + try: + dbml = parse_dbml(dbml_path) + except Exception as error: # любая ошибка pydbml = непригодный файл + return EXIT_USAGE, [f"не удалось разобрать DBML {dbml_path}: {error}"] + problems = find_all_problems( + Base.metadata if metadata is None else metadata, ENUMS if enums is None else enums, dbml + ) + return (EXIT_PROBLEMS if problems else EXIT_OK), problems + + +def main(argv: Sequence[str] | None = None) -> int: + """CLI: расхождения — в stdout, ошибки использования — в stderr.""" + args = list(sys.argv[1:] if argv is None else argv) + if args[:1] in (["-h"], ["--help"]): + print(__doc__) + return EXIT_OK + if len(args) > 1: + print(USAGE, file=sys.stderr) + return EXIT_USAGE + code, lines = run_check(Path(args[0]) if args else default_dbml_path()) + stream = sys.stderr if code == EXIT_USAGE else sys.stdout + for line in lines: + print(line, file=stream) + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/api/scripts/dbml_parity.py b/api/scripts/dbml_parity.py new file mode 100644 index 0000000..f55d400 --- /dev/null +++ b/api/scripts/dbml_parity.py @@ -0,0 +1,394 @@ +"""Сверка ORM-метаданных с DBML (design D-3) — единственная реализация: её вызывают CLI `check_dbml_vs_orm.py` и тесты. + +Модуль не импортирует `src` (на вход приходят `MetaData` и реестр enum'ов) и не зависит от pytest. + +Что сравнивается: +- множество таблиц (`таблица: отсутствует в ORM|DBML`) и схема таблиц ORM (`dkb`); +- колонки: состав (`таблица.колонка: отсутствует в ORM|DBML`), порядок общих колонок, тип + nullability одной строкой + в нотации DBML (`ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL`), PK, unique, default; +- FK по колонке-ссылке (`таблица.колонка: FK: ожидалось родитель.колонка / фактически …`); +- индексы по набору колонок: уникальность, partial-условие (note `partial UNIQUE WHERE `), `NULLS NOT DISTINCT`; + составной `[pk]` в Indexes DBML — это первичный ключ, а не индекс; +- enum'ы: множество имён и упорядоченный список значений. + +Формат каждой строки — `<объект>: ожидалось X / фактически Y` или `<объект>: отсутствует в ORM|DBML` (функции +`mismatch` / `missing`), чтобы тесты и человек читали один и тот же текст. + +Нетривиальные места. DBML-default — python-литерал pydbml (bool/int/float/str) или `Expression` (обратные кавычки, +напр. `now()`); ORM server_default — `text(...)` или SQL-функция (`func.now()`). Обе стороны приводятся к строке: +bool -> `true`/`false`, число -> как есть, строка -> в одинарных кавычках, выражение -> текст в нижнем регистре. +""" + +import enum +import os +import re +from collections.abc import Callable, Collection, Mapping +from pathlib import Path +from typing import Any + +from pydbml import PyDBML +from pydbml.classes import Column as DbmlColumn +from pydbml.classes import Enum as DbmlEnum +from pydbml.classes import Expression +from pydbml.classes import Table as DbmlTable +from sqlalchemy import ( + JSON, + BigInteger, + Boolean, + Column, + Date, + DateTime, + Integer, + MetaData, + Numeric, + SmallInteger, + String, + Table, + Text, +) +from sqlalchemy import Enum as SAEnum +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.sql.elements import TextClause +from sqlalchemy.sql.functions import FunctionElement + +SCHEMA = "dkb" +DBML_FILE_NAME = "data-model-v5.13.dbml" +ORM = "ORM" +DBML = "DBML" +ABSENT = "нет" + +_NUMERIC_RE = re.compile(r"numeric\((\d+),\s*(\d+)\)") +_VERSION_RE = re.compile(r"v(\d+\.\d+)") +_PARTIAL_RE = re.compile(r"partial\s+UNIQUE\s+WHERE\s+(.+?)(?:\s+—.*)?$", re.IGNORECASE | re.DOTALL) +_NULLS_NOT_DISTINCT_RE = re.compile(r"NULLS\s+NOT\s+DISTINCT", re.IGNORECASE) +_SYNONYMS = {"current_timestamp": "now()", "current_timestamp()": "now()"} +#: Типы DBML, которые сверка умеет сопоставлять (плюс `numeric(p,s)` и enum'ы); прочие — явное расхождение. +SUPPORTED_DBML_TYPES = frozenset( + {"uuid", "varchar", "text", "smallint", "int", "bigint", "boolean", "date", "timestamptz", "jsonb"} +) + + +# --- формат строк отчёта --------------------------------------------------------------------------------------- + + +def mismatch(obj: str, expected: object, actual: object) -> str: + """Строка расхождения значения: `<объект>: ожидалось X / фактически Y`.""" + return f"{obj}: ожидалось {expected} / фактически {actual}" + + +def missing(obj: str, where: str) -> str: + """Строка отсутствия объекта: `<объект>: отсутствует в ORM|DBML`.""" + return f"{obj}: отсутствует в {where}" + + +# --- поиск и чтение DBML --------------------------------------------------------------------------------------- + + +def default_dbml_path() -> Path: + """Путь к DBML по умолчанию: каталог docs из `ARM_KA_DOCS`, иначе соседний `docs` рядом с `arm-core`.""" + docs = os.environ.get("ARM_KA_DOCS") + docs_dir = Path(docs) if docs else Path(__file__).resolve().parents[3] / "docs" + return docs_dir / "model" / DBML_FILE_NAME + + +def read_dbml_version(path: Path) -> str | None: + """Версия из первой строки шапки DBML (`// ДКБ · Модель данных v5.13: ...`) или `None`, если её там нет.""" + with path.open(encoding="utf-8") as file: + first_line = file.readline() + found = _VERSION_RE.search(first_line) + return found.group(1) if found else None + + +def parse_dbml(path: Path) -> PyDBML: + """Разобрать DBML-файл через pydbml.""" + return PyDBML.parse_file(path) + + +# --- типы ------------------------------------------------------------------------------------------------------ + + +#: Типы без параметров: точный класс SQLAlchemy -> имя в DBML. +_SIMPLE_TYPE_NAMES: dict[type, str] = { + Text: "text", + SmallInteger: "smallint", + Integer: "int", + BigInteger: "bigint", + Boolean: "boolean", + Date: "date", + JSONB: "jsonb", + JSON: "json", +} +#: Типы с параметрами: точный класс -> отрисовка с параметрами (длина, точность, часовой пояс). +_PARAMETRIZED_TYPES: dict[type, Callable[[Any], str]] = { + Numeric: lambda t: f"numeric({t.precision},{t.scale})", + String: lambda t: "varchar" if t.length is None else f"varchar({t.length})", + DateTime: lambda t: "timestamptz" if t.timezone else "timestamp", +} + + +def render_orm_type(orm_type: object) -> str: + """SQL-тип ORM-колонки в нотации DBML (`numeric(18,2)`, `varchar`, `timestamptz`, имя enum, ...). + + Классы сравниваются точно: `SmallInteger` — подкласс `Integer`, `Text` — подкласс `String`, `Float` — подкласс + `Numeric`; подкласс не должен «проходить» за базовый тип. + """ + if isinstance(orm_type, SAEnum): + return str(orm_type.name) if orm_type.schema == SCHEMA else f"{orm_type.schema}.{orm_type.name}" + if isinstance(orm_type, UUID): + return "uuid" if orm_type.as_uuid else "uuid(as_uuid=False)" + cls = type(orm_type) + if cls in _PARAMETRIZED_TYPES: + return _PARAMETRIZED_TYPES[cls](orm_type) + return _SIMPLE_TYPE_NAMES.get(cls, cls.__name__.lower()) + + +def render_dbml_type(dbml_type: object) -> str | None: + """Тип DBML в канонической нотации; `None` — тип не поддержан сверкой.""" + if isinstance(dbml_type, DbmlEnum): + return str(dbml_type.name) + name = str(dbml_type).strip().lower() + numeric = _NUMERIC_RE.fullmatch(name) + if numeric: + return f"numeric({numeric.group(1)},{numeric.group(2)})" + return name if name in SUPPORTED_DBML_TYPES else None + + +def type_pair(orm_type: object, dbml_type: object) -> tuple[str, str] | None: + """(ожидаемый, фактический) тип в нотации DBML; совпадение — равенство строк. `None` — тип DBML не поддержан. + + Для enum с верным именем и схемой сравниваются ещё и значения в порядке объявления: при расхождении к имени + приписываются списки значений, чтобы строка отчёта показывала разницу. + """ + expected = render_dbml_type(dbml_type) + if expected is None: + return None + actual = render_orm_type(orm_type) + if isinstance(dbml_type, DbmlEnum) and isinstance(orm_type, SAEnum) and actual == expected: + expected_values = [item.name for item in dbml_type.items] + if list(orm_type.enums) != expected_values: + return f"{expected}{expected_values}", f"{actual}{list(orm_type.enums)}" + return expected, actual + + +def type_problem(orm_type: object, dbml_type: object) -> str | None: + """Расхождение только типа (`ожидалось X / фактически Y`), сообщение о неподдержанном типе DBML или `None`.""" + pair = type_pair(orm_type, dbml_type) + if pair is None: + return f"тип DBML `{dbml_type}` не поддержан сверкой (добавьте в dbml_parity.SUPPORTED_DBML_TYPES)" + expected, actual = pair + return None if expected == actual else f"ожидалось {expected} / фактически {actual}" + + +# --- default --------------------------------------------------------------------------------------------------- + + +def _canon_expr(text: str) -> str: + text = " ".join(text.split()) + if text.startswith("'"): + return text # строковый литерал — регистр значим + text = text.lower() + return _SYNONYMS.get(text, text) + + +def canonical_default_dbml(default: object) -> str: + """Канонический вид DBML-default: bool/число/строка-литерал/`Expression`.""" + if isinstance(default, Expression): + return _canon_expr(str(default.text)) + if isinstance(default, bool): + return "true" if default else "false" + if isinstance(default, int | float): + return str(default) + return "'" + str(default).replace("'", "''") + "'" + + +def canonical_default_orm(server_default: object) -> str: + """Канонический вид ORM `server_default` (`text(...)`, `func.now()`, строка).""" + arg = getattr(server_default, "arg", server_default) + if isinstance(arg, TextClause): + return _canon_expr(arg.text) + if isinstance(arg, FunctionElement): + return _canon_expr(f"{arg.name}()") + return _canon_expr(str(arg)) + + +# --- колонки --------------------------------------------------------------------------------------------------- + + +def _nullability(nullable: bool) -> str: + return "NULL" if nullable else "NOT NULL" + + +def _yes_no(flag: bool) -> str: + return "да" if flag else "нет" + + +def _column_problems(orm_table: Table, dbml_table: DbmlTable) -> list[str]: + """Состав, порядок и свойства колонок одной таблицы.""" + name = dbml_table.name + dbml_names = [c.name for c in dbml_table.columns] + orm_names = [c.name for c in orm_table.columns] + problems = [missing(f"{name}.{col}", ORM) for col in dbml_names if col not in orm_names] + problems += [missing(f"{name}.{col}", DBML) for col in orm_names if col not in dbml_names] + # порядок сравнивается только по общим колонкам: пропавшая/лишняя колонка уже названа выше + common_dbml = [col for col in dbml_names if col in orm_names] + common_orm = [col for col in orm_names if col in dbml_names] + if common_dbml != common_orm: + problems.append(mismatch(f"{name}: порядок колонок", common_dbml, common_orm)) + composite_pk = {c.name for i in dbml_table.indexes if i.pk for c in i.subjects} + for dbml_col in dbml_table.columns: + if dbml_col.name in orm_table.columns: + label = f"{name}.{dbml_col.name}" + problems += _one_column_problems(label, orm_table.columns[dbml_col.name], dbml_col, composite_pk) + return problems + + +def _one_column_problems(label: str, orm_col: Column[Any], dbml_col: DbmlColumn, composite_pk: set[str]) -> list[str]: + problems: list[str] = [] + is_pk = bool(dbml_col.pk) or dbml_col.name in composite_pk + # тип и nullability — одной строкой, как в спеке: «ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL» + pair = type_pair(orm_col.type, dbml_col.type) + if pair is None: + problems.append(f"{label}: тип DBML `{dbml_col.type}` не поддержан сверкой") + else: + expected_null = _nullability(not (dbml_col.not_null or is_pk)) + actual_null = _nullability(bool(orm_col.nullable)) + expected, actual = f"{pair[0]} {expected_null}", f"{pair[1]} {actual_null}" + if expected != actual: + problems.append(mismatch(label, expected, actual)) + if orm_col.primary_key != is_pk: + problems.append(mismatch(f"{label}: первичный ключ", _yes_no(is_pk), _yes_no(orm_col.primary_key))) + if bool(orm_col.unique) != bool(dbml_col.unique): + problems.append(mismatch(f"{label}: unique", _yes_no(bool(dbml_col.unique)), _yes_no(bool(orm_col.unique)))) + expected_default = ABSENT if dbml_col.default is None else canonical_default_dbml(dbml_col.default) + actual_default = ABSENT if orm_col.server_default is None else canonical_default_orm(orm_col.server_default) + if expected_default != actual_default: + problems.append(mismatch(f"{label}: default", expected_default, actual_default)) + return problems + + +# --- FK -------------------------------------------------------------------------------------------------------- + + +def _fk_problems(metadata: MetaData, dbml: PyDBML, names: Collection[str]) -> list[str]: + """FK по колонке-ссылке: множество целей `родитель.колонка` в DBML против ORM.""" + expected: dict[tuple[str, str], set[str]] = {} + for ref in dbml.refs: + # `>`: table1 ссылается на table2 (many-to-one); `<` — наоборот; `-` (one-to-one) читаем как `>` + child, child_cols, parent, parent_cols = ref.table1, ref.col1, ref.table2, ref.col2 + if ref.type == "<": + child, child_cols, parent, parent_cols = parent, parent_cols, child, child_cols + if child.name in names: + for child_col, parent_col in zip(child_cols, parent_cols, strict=True): + expected.setdefault((child.name, child_col.name), set()).add(f"{parent.name}.{parent_col.name}") + actual: dict[tuple[str, str], set[str]] = {} + for table in metadata.tables.values(): + if table.name in names: + for fk in table.foreign_keys: + target = f"{fk.column.table.name}.{fk.column.name}" + actual.setdefault((table.name, fk.parent.name), set()).add(target) + problems = [] + for key in sorted(expected.keys() | actual.keys()): + want, have = expected.get(key, set()), actual.get(key, set()) + if want != have: + problems.append(mismatch(f"{key[0]}.{key[1]}: FK", _targets(want), _targets(have))) + return problems + + +def _targets(targets: set[str]) -> str: + return ", ".join(sorted(targets)) or ABSENT + + +# --- индексы --------------------------------------------------------------------------------------------------- + + +def _norm_where(where: object) -> str | None: + if where is None: + return None + text = where.text if isinstance(where, TextClause) else str(where) + return " ".join(text.split()).lower() + + +def _index_props(unique: bool, where: str | None, nulls_not_distinct: bool) -> str: + """Свойства индекса текстом: `unique WHERE is_current NULLS NOT DISTINCT` / `не unique`.""" + parts = ["unique" if unique else "не unique"] + if where: + parts.append(f"WHERE {where}") + if nulls_not_distinct: + parts.append("NULLS NOT DISTINCT") + return " ".join(parts) + + +def _index_problems(orm_table: Table, dbml_table: DbmlTable) -> list[str]: + """Индексы, сгруппированные по набору колонок: состав и свойства (unique, WHERE, NULLS NOT DISTINCT).""" + expected: dict[tuple[str, ...], list[str]] = {} + for index in dbml_table.indexes: + if index.pk: + continue # составной [pk] — первичный ключ, сверяется в колонках + note = str(index.note) if index.note else "" + partial = _PARTIAL_RE.search(note) + props = _index_props( + bool(index.unique) or partial is not None, + _norm_where(partial.group(1)) if partial else None, + # note с `NULLS NOT DISTINCT` => уникальный индекс обязан иметь postgresql_nulls_not_distinct=True + _NULLS_NOT_DISTINCT_RE.search(note) is not None, + ) + expected.setdefault(tuple(c.name for c in index.subjects), []).append(props) + actual: dict[tuple[str, ...], list[str]] = {} + for orm_index in orm_table.indexes: + options = orm_index.dialect_options["postgresql"] + props = _index_props(bool(orm_index.unique), _norm_where(options["where"]), bool(options["nulls_not_distinct"])) + actual.setdefault(tuple(c.name for c in orm_index.columns), []).append(props) + problems = [] + for columns in sorted(expected.keys() | actual.keys()): + label = f"{dbml_table.name}: индекс ({', '.join(columns)})" + want, have = sorted(expected.get(columns, [])), sorted(actual.get(columns, [])) + if not have: + problems.append(missing(label, ORM)) + elif not want: + problems.append(missing(label, DBML)) + elif want != have: + problems.append(mismatch(label, "; ".join(want), "; ".join(have))) + return problems + + +# --- точки входа ----------------------------------------------------------------------------------------------- + + +def find_problems(metadata: MetaData, tables: Collection[str], dbml: PyDBML) -> list[str]: + """Расхождения ORM (`metadata`) с DBML для таблиц `tables`; пустой список — совпадение.""" + dbml_tables = {t.name: t for t in dbml.tables} + orm_tables = {t.name: t for t in metadata.tables.values()} + problems: list[str] = [] + for name in sorted(tables): + if name not in dbml_tables: + problems.append(missing(name, DBML)) + elif name not in orm_tables: + problems.append(missing(name, ORM)) + else: + if orm_tables[name].schema != SCHEMA: + problems.append(mismatch(f"{name}: схема", SCHEMA, orm_tables[name].schema)) + problems += _column_problems(orm_tables[name], dbml_tables[name]) + problems += _index_problems(orm_tables[name], dbml_tables[name]) + return problems + _fk_problems(metadata, dbml, {n for n in tables if n in dbml_tables}) + + +def enum_problems(enums: Mapping[str, type[enum.Enum]], dbml: PyDBML) -> list[str]: + """Enum'ы ORM-реестра (имя DBML → класс) против блоков `Enum {}` DBML: множество имён и значения по порядку.""" + dbml_enums = {e.name: [item.name for item in e.items] for e in dbml.enums} + problems: list[str] = [] + for name in sorted(dbml_enums.keys() | enums.keys()): + if name not in enums: + problems.append(missing(f"enum {name}", ORM)) + elif name not in dbml_enums: + problems.append(missing(f"enum {name}", DBML)) + else: + actual = [member.value for member in enums[name]] + if actual != dbml_enums[name]: + problems.append(mismatch(f"enum {name}", dbml_enums[name], actual)) + return problems + + +def find_all_problems(metadata: MetaData, enums: Mapping[str, type[enum.Enum]], dbml: PyDBML) -> list[str]: + """Полная сверка: все таблицы обеих сторон (количество — следствие сравнения множеств) и все enum'ы.""" + names = {t.name for t in dbml.tables} | {t.name for t in metadata.tables.values()} + return find_problems(metadata, names, dbml) + enum_problems(enums, dbml) diff --git a/api/tests/dbml_helpers.py b/api/tests/dbml_helpers.py index 92216a8..f984333 100644 --- a/api/tests/dbml_helpers.py +++ b/api/tests/dbml_helpers.py @@ -1,19 +1,18 @@ -"""Поиск и разбор DBML-модели для тестов (переиспользуется тестами чётности ORM и DBML).""" +"""Поиск и разбор DBML-модели для тестов: обёртка над `scripts.dbml_parity` с пропуском теста, если docs недоступен.""" -import os from pathlib import Path import pytest from pydbml import PyDBML -DBML_FILE_NAME = "data-model-v5.13.dbml" +from scripts.dbml_parity import DBML_FILE_NAME, default_dbml_path, parse_dbml + +__all__ = ["DBML_FILE_NAME", "dbml_path", "load_dbml"] def dbml_path() -> Path: """Путь к DBML: каталог docs из `ARM_KA_DOCS`, иначе соседний `../../docs` относительно `arm-core/api`.""" - docs = os.environ.get("ARM_KA_DOCS") - docs_dir = Path(docs) if docs else Path(__file__).resolve().parents[3] / "docs" - return docs_dir / "model" / DBML_FILE_NAME + return default_dbml_path() def load_dbml() -> PyDBML: @@ -21,4 +20,4 @@ def load_dbml() -> PyDBML: path = dbml_path() if not path.is_file(): pytest.skip(f"docs недоступен: нет {path} (задайте ARM_KA_DOCS или положите docs рядом с arm-core)") - return PyDBML.parse_file(path) + return parse_dbml(path) diff --git a/api/tests/dbml_parity.py b/api/tests/dbml_parity.py index 02fa7e1..eb19329 100644 --- a/api/tests/dbml_parity.py +++ b/api/tests/dbml_parity.py @@ -1,263 +1,20 @@ -"""Сверка ORM-метаданных с DBML v5.13 для заданного набора таблиц (без БД); переиспользуется тестами частей 01–09. +"""Тонкая обёртка тестов над `scripts.dbml_parity` (единственная реализация сверки ORM с DBML, рулинг R-14). -Публичное API: -- `find_problems(metadata, tables)` — список расхождений (пустой = ORM совпадает с DBML) по колонкам (имя, порядок, - тип, nullable, PK, unique, server_default), FK и индексам (состав, уникальность, partial-условие WHERE, - `NULLS NOT DISTINCT` по note DBML); -- `type_problem(orm_type, dbml_type)` — расхождение типа одной колонки (или `None`); -- `canonical_default_dbml` / `canonical_default_orm` — приведение default к сопоставимому виду. - -Нетривиальные места. DBML-default — python-литерал pydbml (bool/int/float/str) или `Expression` (обратные кавычки, -напр. `now()`); ORM server_default — `text(...)` или SQL-функция (`func.now()`). Обе стороны приводятся к строке: -bool -> `true`/`false`, число -> как есть, строка -> в одинарных кавычках, выражение -> текст в нижнем регистре. +Отличие одно: `find_problems` без явного `dbml` берёт DBML через `load_dbml()` (пропуск теста, если docs недоступен). """ -import re -from collections.abc import Callable, Collection +from collections.abc import Collection from pydbml import PyDBML -from pydbml.classes import Column as DbmlColumn -from pydbml.classes import Enum as DbmlEnum -from pydbml.classes import Expression -from pydbml.classes import Table as DbmlTable -from sqlalchemy import ( - BigInteger, - Boolean, - Date, - DateTime, - Integer, - MetaData, - Numeric, - SmallInteger, - String, - Table, - Text, -) -from sqlalchemy import Enum as SAEnum -from sqlalchemy.dialects.postgresql import JSONB, UUID -from sqlalchemy.sql.elements import TextClause -from sqlalchemy.sql.functions import FunctionElement +from sqlalchemy import MetaData +from scripts import dbml_parity +from scripts.dbml_parity import canonical_default_dbml, canonical_default_orm, type_problem from tests.dbml_helpers import load_dbml -SCHEMA = "dkb" -_NUMERIC_RE = re.compile(r"numeric\((\d+),\s*(\d+)\)") -_PARTIAL_RE = re.compile(r"partial\s+UNIQUE\s+WHERE\s+(.+?)(?:\s+—.*)?$", re.IGNORECASE | re.DOTALL) -_NULLS_NOT_DISTINCT_RE = re.compile(r"NULLS\s+NOT\s+DISTINCT", re.IGNORECASE) -_SYNONYMS = {"current_timestamp": "now()", "current_timestamp()": "now()"} - - -# --- типы ------------------------------------------------------------------------------------------------------- - - -def _is_exact(orm_type: object, cls: type) -> bool: - """Точное совпадение класса (SmallInteger — подкласс Integer, Text — подкласс String, и т.д.).""" - return type(orm_type) is cls - - -_SIMPLE_TYPES: dict[str, Callable[[object], bool]] = { - "uuid": lambda t: isinstance(t, UUID) and t.as_uuid is True, - "varchar": lambda t: _is_exact(t, String) and t.length is None, # type: ignore[attr-defined] - "text": lambda t: _is_exact(t, Text), - "smallint": lambda t: _is_exact(t, SmallInteger), - "int": lambda t: _is_exact(t, Integer), - "bigint": lambda t: _is_exact(t, BigInteger), - "boolean": lambda t: _is_exact(t, Boolean), - "date": lambda t: _is_exact(t, Date), - "timestamptz": lambda t: _is_exact(t, DateTime) and t.timezone is True, # type: ignore[attr-defined] - "jsonb": lambda t: isinstance(t, JSONB), -} - - -def type_problem(orm_type: object, dbml_type: object) -> str | None: - """Расхождение SQL-типа ORM-колонки с типом DBML; `dbml_type` — строка типа или объект enum pydbml.""" - if isinstance(dbml_type, DbmlEnum): - return _enum_problem(orm_type, dbml_type) - name = str(dbml_type).strip().lower() - numeric = _NUMERIC_RE.fullmatch(name) - if numeric: - precision, scale = int(numeric.group(1)), int(numeric.group(2)) - if _is_exact(orm_type, Numeric) and (orm_type.precision, orm_type.scale) == (precision, scale): # type: ignore[attr-defined] - return None - return f"ожидался Numeric({precision}, {scale}), фактически {_describe(orm_type)}" - check = _SIMPLE_TYPES.get(name) - if check is None: - return f"тип DBML `{dbml_type}` не поддержан сверкой (добавьте в dbml_parity._SIMPLE_TYPES)" - if check(orm_type): - return None - return f"ожидался тип для DBML `{name}`, фактически {_describe(orm_type)}" - - -def _enum_problem(orm_type: object, dbml_enum: DbmlEnum) -> str | None: - if not isinstance(orm_type, SAEnum): - return f"ожидался enum {dbml_enum.name}, фактически {_describe(orm_type)}" - expected_values = [item.name for item in dbml_enum.items] - if orm_type.name != dbml_enum.name: - return f"имя enum: ожидалось {dbml_enum.name}, фактически {orm_type.name}" - if orm_type.schema != SCHEMA: - return f"схема enum {dbml_enum.name}: ожидалась {SCHEMA}, фактически {orm_type.schema}" - if list(orm_type.enums) != expected_values: - return f"значения enum {dbml_enum.name}: ожидалось {expected_values}, фактически {list(orm_type.enums)}" - return None - - -def _describe(orm_type: object) -> str: - extra = "" - if isinstance(orm_type, Numeric): - extra = f"({orm_type.precision}, {orm_type.scale})" - elif isinstance(orm_type, DateTime): - extra = f"(timezone={orm_type.timezone})" - elif isinstance(orm_type, String): - extra = f"(length={orm_type.length})" - elif isinstance(orm_type, SAEnum): - extra = f"({orm_type.name})" - return f"{type(orm_type).__name__}{extra}" - - -# --- default ---------------------------------------------------------------------------------------------------- - - -def _canon_expr(text: str) -> str: - text = " ".join(text.split()) - if text.startswith("'"): - return text # строковый литерал — регистр значим - text = text.lower() - return _SYNONYMS.get(text, text) - - -def canonical_default_dbml(default: object) -> str: - """Канонический вид DBML-default: bool/число/строка-литерал/`Expression`.""" - if isinstance(default, Expression): - return _canon_expr(str(default.text)) - if isinstance(default, bool): - return "true" if default else "false" - if isinstance(default, int | float): - return str(default) - return "'" + str(default).replace("'", "''") + "'" - - -def canonical_default_orm(server_default: object) -> str: - """Канонический вид ORM `server_default` (`text(...)`, `func.now()`, строка).""" - arg = getattr(server_default, "arg", server_default) - if isinstance(arg, TextClause): - return _canon_expr(arg.text) - if isinstance(arg, FunctionElement): - return _canon_expr(f"{arg.name}()") - return _canon_expr(str(arg)) - - -# --- колонки, FK, индексы ------------------------------------------------------------------------------------- - - -def _column_problems(orm_table: Table, dbml_table: DbmlTable) -> list[str]: - name = dbml_table.name - problems: list[str] = [] - dbml_cols = {c.name: c for c in dbml_table.columns} - orm_names = [c.name for c in orm_table.columns] - if orm_names != list(dbml_cols): - problems.append(f"{name}: колонки/порядок: ожидалось {list(dbml_cols)}, фактически {orm_names}") - composite_pk = {c.name for i in dbml_table.indexes if i.pk for c in i.subjects} - for col_name, dbml_col in dbml_cols.items(): - if col_name not in orm_table.columns: - continue - problems.extend(_one_column_problems(f"{name}.{col_name}", orm_table.columns[col_name], dbml_col, composite_pk)) - return problems - - -def _one_column_problems(label: str, orm_col, dbml_col: DbmlColumn, composite_pk: set[str]) -> list[str]: # type: ignore[no-untyped-def] - problems: list[str] = [] - is_pk = bool(dbml_col.pk) or dbml_col.name in composite_pk - if orm_col.primary_key != is_pk: - problems.append(f"{label}: primary_key ожидался {is_pk}") - if orm_col.nullable != (not (dbml_col.not_null or is_pk)): - problems.append(f"{label}: nullable фактически {orm_col.nullable}") - if bool(orm_col.unique) != bool(dbml_col.unique): - problems.append(f"{label}: unique ожидался {bool(dbml_col.unique)}") - type_diff = type_problem(orm_col.type, dbml_col.type) - if type_diff: - problems.append(f"{label}: тип: {type_diff}") - if dbml_col.default is None: - if orm_col.server_default is not None: - problems.append(f"{label}: лишний server_default {canonical_default_orm(orm_col.server_default)}") - elif orm_col.server_default is None: - problems.append(f"{label}: нет server_default (ожидался {canonical_default_dbml(dbml_col.default)})") - else: - expected, actual = canonical_default_dbml(dbml_col.default), canonical_default_orm(orm_col.server_default) - if expected != actual: - problems.append(f"{label}: server_default ожидался {expected}, фактически {actual}") - return problems - - -def _fk_problems(orm_tables: dict[str, Table], dbml: PyDBML, names: Collection[str]) -> list[str]: - expected: set[tuple[str, str, str, str]] = set() - for ref in dbml.refs: - child, child_col, parent, parent_col = ref.table1, ref.col1[0], ref.table2, ref.col2[0] - if ref.type == "<": - child, child_col, parent, parent_col = parent, parent_col, child, child_col - if child.name in names: - expected.add((child.name, child_col.name, parent.name, parent_col.name)) - actual = { - (t.name, fk.parent.name, fk.column.table.name, fk.column.name) - for t in orm_tables.values() - for fk in t.foreign_keys - if t.name in names - } - problems = [f"FK отсутствует в ORM: {fk}" for fk in sorted(expected - actual)] - return problems + [f"FK лишний в ORM: {fk}" for fk in sorted(actual - expected)] - - -def _norm_where(where: object) -> str | None: - if where is None: - return None - text = where.text if isinstance(where, TextClause) else str(where) - return " ".join(text.split()).lower() - - -def _index_problems(orm_table: Table, dbml_table: DbmlTable) -> list[str]: - expected: list[tuple[tuple[str, ...], bool, str | None, bool]] = [] - for i in dbml_table.indexes: - if i.pk: - continue - note = str(i.note) if i.note else "" - partial = _PARTIAL_RE.search(note) - where = _norm_where(partial.group(1)) if partial else None - # note с `NULLS NOT DISTINCT` => уникальный индекс обязан иметь postgresql_nulls_not_distinct=True - nulls_not_distinct = _NULLS_NOT_DISTINCT_RE.search(note) is not None - expected.append( - (tuple(c.name for c in i.subjects), bool(i.unique) or partial is not None, where, nulls_not_distinct) - ) - actual = [ - ( - tuple(c.name for c in i.columns), - bool(i.unique), - _norm_where(i.dialect_options["postgresql"]["where"]), - bool(i.dialect_options["postgresql"]["nulls_not_distinct"]), - ) - for i in orm_table.indexes - ] - if sorted(actual, key=repr) == sorted(expected, key=repr): - return [] - return [ - f"{dbml_table.name}: индексы (колонки, unique, WHERE, NULLS NOT DISTINCT): " - f"ожидалось {sorted(expected, key=repr)}, фактически {sorted(actual, key=repr)}" - ] - - -# --- точка входа ------------------------------------------------------------------------------------------------ +__all__ = ["canonical_default_dbml", "canonical_default_orm", "find_problems", "type_problem"] def find_problems(metadata: MetaData, tables: Collection[str], dbml: PyDBML | None = None) -> list[str]: - """Все расхождения ORM (`metadata`) с DBML для таблиц `tables`; пустой список — совпадение.""" - dbml = dbml or load_dbml() - dbml_tables = {t.name: t for t in dbml.tables} - orm_tables = {t.name: t for t in metadata.tables.values()} - problems: list[str] = [] - for name in sorted(tables): - if name not in dbml_tables: - problems.append(f"{name}: нет в DBML") - elif name not in orm_tables: - problems.append(f"{name}: нет в ORM-метаданных") - else: - problems.extend(_column_problems(orm_tables[name], dbml_tables[name])) - problems.extend(_index_problems(orm_tables[name], dbml_tables[name])) - return problems + _fk_problems(orm_tables, dbml, tables) + """Расхождения ORM с DBML для таблиц `tables` (см. `scripts.dbml_parity.find_problems`).""" + return dbml_parity.find_problems(metadata, tables, dbml or load_dbml()) diff --git a/api/tests/test_check_dbml_script.py b/api/tests/test_check_dbml_script.py new file mode 100644 index 0000000..3b45f17 --- /dev/null +++ b/api/tests/test_check_dbml_script.py @@ -0,0 +1,162 @@ +"""CLI сверки ORM с DBML (`scripts/check_dbml_vs_orm.py`): сценарии спеки data-model «Сверка ORM с DBML», +«Лишняя колонка в ORM», «Расхождение типа или nullability», «Новая версия DBML без обновления ORM». + +Мутации ORM делаются на копии метаданных (`run_check(..., metadata=...)`), реальный запуск — через subprocess. +""" + +import enum +import os +import subprocess +import sys +from pathlib import Path + +import pytest +from sqlalchemy import Column, MetaData, Numeric, String, Table + +from scripts.check_dbml_vs_orm import EXIT_USAGE, run_check +from src.models import Base +from src.models.enums import ENUMS +from tests.dbml_helpers import dbml_path + +API_DIR = Path(__file__).resolve().parents[1] +SCRIPT = "scripts/check_dbml_vs_orm.py" + + +@pytest.fixture +def dbml_file() -> Path: + path = dbml_path() + if not path.is_file(): + pytest.skip(f"docs недоступен: нет {path} (задайте ARM_KA_DOCS или положите docs рядом с arm-core)") + return path + + +def clone_metadata() -> MetaData: + """Копия реальных метаданных ORM, которую можно портить, не затрагивая `Base.metadata`.""" + clone = MetaData(schema=Base.metadata.schema, naming_convention=Base.metadata.naming_convention) + for table in Base.metadata.sorted_tables: + table.to_metadata(clone) + return clone + + +def _run_cli(*args: str) -> subprocess.CompletedProcess[str]: + """Запуск скрипта как файла из `api/` без ENV_FILE: скрипт обязан обойтись без рабочего `.env`.""" + env = {k: v for k, v in os.environ.items() if k not in {"ENV_FILE", "APP_ENV"}} + return subprocess.run( + [sys.executable, SCRIPT, *args], cwd=API_DIR, env=env, capture_output=True, text=True, check=False + ) + + +# --- реальный запуск ------------------------------------------------------------------------------------------- + + +def test_cli_on_real_models_prints_nothing_and_exits_0(dbml_file: Path) -> None: + result = _run_cli(str(dbml_file)) + assert (result.returncode, result.stdout) == (0, ""), result.stdout + result.stderr + + +def test_cli_missing_file_exits_2_with_message(tmp_path: Path) -> None: + result = _run_cli(str(tmp_path / "no-such.dbml")) + assert result.returncode == EXIT_USAGE + assert result.stdout == "" + assert "файл DBML не найден" in result.stderr + + +def test_run_check_on_real_models_is_clean(dbml_file: Path) -> None: + assert run_check(dbml_file) == (0, []) + + +# --- сценарий «Лишняя колонка в ORM» --------------------------------------------------------------------------- + + +def test_extra_orm_column_is_reported_missing_in_dbml(dbml_file: Path) -> None: + metadata = clone_metadata() + metadata.tables["dkb.osv_entry"].append_column(Column("extra_note", String())) + assert run_check(dbml_file, metadata=metadata) == (1, ["osv_entry.extra_note: отсутствует в DBML"]) + + +def test_removed_orm_column_is_reported_missing_in_orm(dbml_file: Path) -> None: + metadata = clone_metadata() + osv_entry = metadata.tables["dkb.osv_entry"] + osv_entry._columns.remove(osv_entry.columns["opening_credit"]) # noqa: SLF001 — намеренная порча копии + assert run_check(dbml_file, metadata=metadata) == (1, ["osv_entry.opening_credit: отсутствует в ORM"]) + + +# --- сценарий «Расхождение типа или nullability» -------------------------------------------------------------- + + +def test_type_and_nullability_mismatch_is_one_line_in_dbml_notation(dbml_file: Path) -> None: + metadata = clone_metadata() + column = metadata.tables["dkb.osv_entry"].columns["opening_debit"] + column.type = Numeric(18, 4) + column.nullable = False + assert run_check(dbml_file, metadata=metadata) == ( + 1, + ["osv_entry.opening_debit: ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL"], + ) + + +def test_nullability_only_mismatch(dbml_file: Path) -> None: + metadata = clone_metadata() + metadata.tables["dkb.osv_entry"].columns["opening_debit"].nullable = False + code, lines = run_check(dbml_file, metadata=metadata) + assert (code, lines) == ( + 1, + ["osv_entry.opening_debit: ожидалось numeric(18,2) NULL / фактически numeric(18,2) NOT NULL"], + ) + + +# --- таблицы и enum'ы: сравнение множеств ---------------------------------------------------------------------- + + +def test_missing_and_extra_tables_are_reported(dbml_file: Path) -> None: + metadata = clone_metadata() + metadata.remove(metadata.tables["dkb.consolidation_lock"]) + code, lines = run_check(dbml_file, metadata=metadata) + assert code == 1 + assert "consolidation_lock: отсутствует в ORM" in lines + metadata = clone_metadata() + Table("orphan_table", metadata, Column("id", String(), primary_key=True)) + assert run_check(dbml_file, metadata=metadata) == (1, ["orphan_table: отсутствует в DBML"]) + + +def test_enum_values_and_sets_are_compared(dbml_file: Path) -> None: + enums: dict[str, type[enum.Enum]] = dict(ENUMS) + enums["entity_type"] = enum.Enum("EntityType", {"ИП": "ИП", "ЮЛ": "ЮЛ"}) # порядок значений нарушен + del enums["audit_section"] + enums["no_such_enum"] = enum.Enum("NoSuch", {"A": "A"}) + code, lines = run_check(dbml_file, enums=enums) + assert code == 1 + assert sorted(lines) == sorted( + [ + "enum entity_type: ожидалось ['ЮЛ', 'ИП'] / фактически ['ИП', 'ЮЛ']", + "enum audit_section: отсутствует в ORM", + "enum no_such_enum: отсутствует в DBML", + ] + ) + + +# --- сценарий «Новая версия DBML без обновления ORM» ----------------------------------------------------------- + + +def test_newer_dbml_version_fails_before_comparing_tables(tmp_path: Path, dbml_file: Path) -> None: + lines = dbml_file.read_text(encoding="utf-8").splitlines() + newer = tmp_path / "data-model-v5.14.dbml" + # шапка 5.14 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора + newer.write_text(lines[0].replace("v5.13", "v5.14") + "\nTable broken {{{\n", encoding="utf-8") + assert run_check(newer) == (1, ["версия DBML 5.14 не совпадает с версией ORM 5.13: обновите модели"]) + + +def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None: + newer = tmp_path / "data-model-v5.14.dbml" + newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.13", "v5.14", 1), encoding="utf-8") + result = _run_cli(str(newer)) + assert result.returncode == 1 + assert result.stdout == "версия DBML 5.14 не совпадает с версией ORM 5.13: обновите модели\n" + + +def test_header_without_version_is_usage_error(tmp_path: Path) -> None: + bad = tmp_path / "model.dbml" + bad.write_text("// модель без версии\nTable t {\n id uuid [pk]\n}\n", encoding="utf-8") + code, lines = run_check(bad) + assert code == EXIT_USAGE + assert len(lines) == 1 and "версия" in lines[0] diff --git a/api/tests/test_dbml_parity.py b/api/tests/test_dbml_parity.py index d610a7e..fc4f33a 100644 --- a/api/tests/test_dbml_parity.py +++ b/api/tests/test_dbml_parity.py @@ -1,4 +1,5 @@ -"""Самопроверка сверки ORM с DBML (`dbml_parity`): компаратор обязан находить заведомо неверные расхождения.""" +"""Самопроверка сверки ORM с DBML (`scripts.dbml_parity`): компаратор обязан находить заведомо неверные расхождения +и описывать их строками единого формата `<объект>: ожидалось X / фактически Y` / `<объект>: отсутствует в ORM|DBML`.""" import pytest from pydbml.classes import Expression @@ -41,22 +42,26 @@ def test_clone_is_clean_baseline() -> None: @pytest.mark.parametrize( - ("table", "column", "wrong_type", "needle"), + ("table", "column", "wrong_type", "expected"), [ - ("regional_office", "name", Text(), "name: тип"), # String -> Text - ("regional_office", "name", String(50), "name: тип"), # varchar с длиной - ("app_user", "created_at", DateTime(), "created_at: тип"), # без timezone - ("elimination_rule", "rule_no", Integer(), "rule_no: тип"), # smallint -> int - ("elimination_rule", "valid_from", DateTime(timezone=True), "valid_from: тип"), # date -> timestamptz - ("permission", "action", pg_enum("permission_object"), "action: тип"), # не тот enum - ("app_user", "regional_office_id", String(), "regional_office_id: тип"), # uuid -> varchar + ("regional_office", "name", Text(), "varchar NOT NULL / фактически text NOT NULL"), # String -> Text + ("regional_office", "name", String(50), "varchar NOT NULL / фактически varchar(50) NOT NULL"), # с длиной + ("app_user", "created_at", DateTime(), "timestamptz NULL / фактически timestamp NULL"), # без timezone + ("elimination_rule", "rule_no", Integer(), "smallint NOT NULL / фактически int NOT NULL"), + ("elimination_rule", "valid_from", DateTime(timezone=True), "date NOT NULL / фактически timestamptz NOT NULL"), + ( # не тот enum + "permission", + "action", + pg_enum("permission_object"), + "permission_action NOT NULL / фактически permission_object NOT NULL", + ), + ("app_user", "regional_office_id", String(), "uuid NOT NULL / фактически varchar NOT NULL"), # uuid -> varchar ], ) -def test_wrong_column_type_is_detected(table: str, column: str, wrong_type: object, needle: str) -> None: +def test_wrong_column_type_is_detected(table: str, column: str, wrong_type: object, expected: str) -> None: metadata = clone_metadata() metadata.tables[f"dkb.{table}"].columns[column].type = wrong_type # type: ignore[assignment] - problems = find_problems(metadata, TABLES) - assert any(needle in p and p.startswith(f"{table}.") for p in problems), problems + assert find_problems(metadata, TABLES) == [f"{table}.{column}: ожидалось {expected}"] def test_enum_with_wrong_schema_or_values_is_detected() -> None: @@ -109,10 +114,11 @@ def test_missing_wrong_or_extra_server_default_is_detected() -> None: cols["is_active"].server_default = None cols["is_head_office"].server_default = _default("true") # в DBML false cols["name"].server_default = _default("'x'") # в DBML default нет - problems = find_problems(metadata, TABLES) - assert any("is_active: нет server_default" in p for p in problems), problems - assert any("is_head_office: server_default ожидался false, фактически true" in p for p in problems), problems - assert any("name: лишний server_default" in p for p in problems), problems + assert sorted(find_problems(metadata, TABLES)) == [ + "regional_office.is_active: default: ожидалось true / фактически нет", + "regional_office.is_head_office: default: ожидалось false / фактически true", + "regional_office.name: default: ожидалось нет / фактически 'x'", + ] def _default(sql: str) -> object: @@ -124,12 +130,23 @@ def test_nullable_pk_unique_and_order_mismatches_are_detected() -> None: ro = metadata.tables["dkb.regional_office"] ro.columns["name"].nullable = True ro.columns["name"].unique = None - problems = find_problems(metadata, {"regional_office"}) - assert any("name: nullable" in p for p in problems), problems - assert any("name: unique" in p for p in problems), problems + assert find_problems(metadata, {"regional_office"}) == [ + "regional_office.name: ожидалось varchar NOT NULL / фактически varchar NULL", + "regional_office.name: unique: ожидалось да / фактически нет", + ] + ro.columns["id"].primary_key = False + assert "regional_office.id: первичный ключ: ожидалось да / фактически нет" in find_problems( + metadata, {"regional_office"} + ) perm = metadata.tables["dkb.permission"] - perm._columns.remove(perm.columns["name"]) # noqa: SLF001 — намеренная порча копии - assert any("колонки/порядок" in p for p in find_problems(metadata, {"permission"})) + name_col = perm.columns["name"] + perm._columns.remove(name_col) # noqa: SLF001 — намеренная порча копии + assert find_problems(metadata, {"permission"}) == ["permission.name: отсутствует в ORM"] + perm.append_column(name_col) # вернулась в конец: состав совпал, порядок — нет + [problem] = find_problems(metadata, {"permission"}) + assert problem.startswith( + "permission: порядок колонок: ожидалось ['id', 'code', 'object_type', 'action', 'name'" + ), problem def test_missing_extra_and_wrong_where_indexes_are_detected() -> None: @@ -137,15 +154,24 @@ def test_missing_extra_and_wrong_where_indexes_are_detected() -> None: member = metadata.tables["dkb.related_group_member"] index = next(i for i in member.indexes if i.name == "uq_related_group_member_entity_current") index.dialect_options["postgresql"]["where"] = text("is_current AND valid_to IS NULL") - assert any("индексы" in p for p in find_problems(metadata, {"related_group_member"})) + label = "related_group_member: индекс (legal_entity_id)" + assert find_problems(metadata, {"related_group_member"}) == [ + f"{label}: ожидалось unique WHERE is_current / фактически unique WHERE is_current and valid_to is null" + ] index.dialect_options["postgresql"]["where"] = None # partial превращён в обычный unique - assert any("индексы" in p for p in find_problems(metadata, {"related_group_member"})) + assert find_problems(metadata, {"related_group_member"}) == [ + f"{label}: ожидалось unique WHERE is_current / фактически unique" + ] index.dialect_options["postgresql"]["where"] = text("is_current") assert find_problems(metadata, {"related_group_member"}) == [] # лишний partial там, где DBML его не объявляет ro = metadata.tables["dkb.regional_office"] Index("ix_regional_office_extra", ro.columns["name"], postgresql_where=text("is_active")) - assert any("индексы" in p for p in find_problems(metadata, {"regional_office"})) + assert find_problems(metadata, {"regional_office"}) == ["regional_office: индекс (name): отсутствует в DBML"] + # пропавший индекс + index.dialect_options["postgresql"]["where"] = text("is_current") + member.indexes.discard(index) + assert find_problems(metadata, {"related_group_member"}) == [f"{label}: отсутствует в ORM"] def test_nulls_not_distinct_flag_is_compared_both_ways() -> None: @@ -171,12 +197,14 @@ def test_missing_and_extra_foreign_keys_are_detected() -> None: fk = next(iter(app_user.foreign_keys)) app_user.foreign_keys.remove(fk) app_user.constraints.discard(fk.constraint) # type: ignore[arg-type] - assert any("FK отсутствует" in p for p in find_problems(metadata, {"app_user"})) + assert find_problems(metadata, {"app_user"}) == [ + f"app_user.{fk.parent.name}: FK: ожидалось {fk.column.table.name}.{fk.column.name} / фактически нет" + ] def test_unknown_table_is_reported() -> None: - assert find_problems(Base.metadata, {"no_such_table"}) == ["no_such_table: нет в DBML"] - assert find_problems(MetaData(schema="dkb"), {"regional_office"}) == ["regional_office: нет в ORM-метаданных"] + assert find_problems(Base.metadata, {"no_such_table"}) == ["no_such_table: отсутствует в DBML"] + assert find_problems(MetaData(schema="dkb"), {"regional_office"}) == ["regional_office: отсутствует в ORM"] @pytest.mark.parametrize( -- 2.47.2 From dd551faf178adf070b3cc12cab259e69821bb8dc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 19:57:07 +0400 Subject: [PATCH 33/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=209?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index c5ea30d..85c56a0 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -53,7 +53,7 @@ ### Task 9: [2.5] `scripts/check_dbml_vs_orm.py` (pydbml -- [ ] `scripts/check_dbml_vs_orm.py` (pydbml; сравнение таблиц, колонок, типов, nullability, PK/UNIQUE/FK, индексов с notes-шаблонами, enum'ов, версии) + `tests/test_dbml_parity.py`; проверка: 0 расхождений на 2.1–2.4; намеренно добавленная колонка даёт код 1 и понятное сообщение +- [x] `scripts/check_dbml_vs_orm.py` (pydbml; сравнение таблиц, колонок, типов, nullability, PK/UNIQUE/FK, индексов с notes-шаблонами, enum'ов, версии) + `tests/test_dbml_parity.py`; проверка: 0 расхождений на 2.1–2.4; намеренно добавленная колонка даёт код 1 и понятное сообщение ### Task 10: [2.6] `alembic/env.py` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 439ba23..c882cdf 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -40,7 +40,7 @@ `_member`, `_document`), `kfo.py` (`correction`, `goodwill`, `kfo_result`, `kfo_result_line`, `kfo_result_line_company`, `client_kfo_line`), `audit_ex.py` (`comment`, `audit_event`, `ex_snapshot`, `ex_snapshot_member`, `ex_snapshot_item`); проверка: как 2.2, всего 43 таблицы в `Base.metadata.tables` -- [ ] 2.5 `scripts/check_dbml_vs_orm.py` (pydbml; сравнение таблиц, колонок, типов, nullability, PK/UNIQUE/FK, индексов с +- [x] 2.5 `scripts/check_dbml_vs_orm.py` (pydbml; сравнение таблиц, колонок, типов, nullability, PK/UNIQUE/FK, индексов с notes-шаблонами, enum'ов, версии) + `tests/test_dbml_parity.py`; проверка: 0 расхождений на 2.1–2.4; намеренно добавленная колонка даёт код 1 и понятное сообщение - [ ] 2.6 `alembic/env.py`: `CREATE SCHEMA IF NOT EXISTS dkb` перед миграциями; ревизия `0001_dbml_v5_13.py` (черновик -- 2.47.2 From 7ea1e67f4e3e160b0559e66ce0ddd318ed66bdf8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 20:11:25 +0400 Subject: [PATCH 34/97] =?UTF-8?q?fix:=20=D1=81=D0=B2=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=20DBML=20=E2=80=94=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D0=B5=20UNIQUE=20=D0=B8=20CHECK,=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D1=8F=20FK,=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=B8=D0=B7=D0=B2=D0=B5=D1=81=D1=82=D0=BD=D1=8B=D0=B5=20=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D1=81=D1=81=D1=8B=20=D1=82=D0=B8=D0=BF=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-1: табличный UniqueConstraint ORM сверяется как уникальный индекс по колонкам (флаг колонки unique=True — по-прежнему в свойствах колонки); любое CHECK в ORM — «отсутствует в DBML». I-2: у совпавших целей FK сравниваются ON DELETE / ON UPDATE (нет в DBML = NO ACTION), DEFERRABLE и INITIALLY. I-3: неизвестный класс типа отрисовывается repr с параметрами (VARCHAR(length=50), Uuid(as_uuid=False)) и никогда не совпадает с именем типа DBML. Co-Authored-By: Claude Fable 5.1 --- api/scripts/dbml_parity.py | 82 +++++++++++++++++++++++++++++------ api/tests/test_dbml_parity.py | 53 ++++++++++++++++++++++ 2 files changed, 122 insertions(+), 13 deletions(-) diff --git a/api/scripts/dbml_parity.py b/api/scripts/dbml_parity.py index f55d400..ac3feb5 100644 --- a/api/scripts/dbml_parity.py +++ b/api/scripts/dbml_parity.py @@ -6,9 +6,11 @@ - множество таблиц (`таблица: отсутствует в ORM|DBML`) и схема таблиц ORM (`dkb`); - колонки: состав (`таблица.колонка: отсутствует в ORM|DBML`), порядок общих колонок, тип + nullability одной строкой в нотации DBML (`ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL`), PK, unique, default; -- FK по колонке-ссылке (`таблица.колонка: FK: ожидалось родитель.колонка / фактически …`); +- FK по колонке-ссылке (`таблица.колонка: FK: ожидалось родитель.колонка / фактически …`), у совпавших целей — + ON DELETE / ON UPDATE (нет в DBML = `NO ACTION`), DEFERRABLE и INITIALLY; - индексы по набору колонок: уникальность, partial-условие (note `partial UNIQUE WHERE `), `NULLS NOT DISTINCT`; - составной `[pk]` в Indexes DBML — это первичный ключ, а не индекс; + табличный `UniqueConstraint` ORM считается уникальным индексом; составной `[pk]` в Indexes DBML — это первичный ключ, + а не индекс; CHECK-ограничений в DBML нет — любое CHECK в ORM лишнее; - enum'ы: множество имён и упорядоченный список значений. Формат каждой строки — `<объект>: ожидалось X / фактически Y` или `<объект>: отсутствует в ORM|DBML` (функции @@ -35,6 +37,7 @@ from sqlalchemy import ( JSON, BigInteger, Boolean, + CheckConstraint, Column, Date, DateTime, @@ -45,6 +48,7 @@ from sqlalchemy import ( String, Table, Text, + UniqueConstraint, ) from sqlalchemy import Enum as SAEnum from sqlalchemy.dialects.postgresql import JSONB, UUID @@ -56,6 +60,7 @@ DBML_FILE_NAME = "data-model-v5.13.dbml" ORM = "ORM" DBML = "DBML" ABSENT = "нет" +NO_ACTION = "NO ACTION" _NUMERIC_RE = re.compile(r"numeric\((\d+),\s*(\d+)\)") _VERSION_RE = re.compile(r"v(\d+\.\d+)") @@ -130,7 +135,8 @@ def render_orm_type(orm_type: object) -> str: """SQL-тип ORM-колонки в нотации DBML (`numeric(18,2)`, `varchar`, `timestamptz`, имя enum, ...). Классы сравниваются точно: `SmallInteger` — подкласс `Integer`, `Text` — подкласс `String`, `Float` — подкласс - `Numeric`; подкласс не должен «проходить» за базовый тип. + `Numeric`; подкласс не должен «проходить» за базовый тип. Неизвестный класс отрисовывается как `repr` и всегда + расходится с DBML. """ if isinstance(orm_type, SAEnum): return str(orm_type.name) if orm_type.schema == SCHEMA else f"{orm_type.schema}.{orm_type.name}" @@ -139,7 +145,9 @@ def render_orm_type(orm_type: object) -> str: cls = type(orm_type) if cls in _PARAMETRIZED_TYPES: return _PARAMETRIZED_TYPES[cls](orm_type) - return _SIMPLE_TYPE_NAMES.get(cls, cls.__name__.lower()) + # неизвестный класс (в т.ч. `VARCHAR(50)`, `Uuid(as_uuid=False)`, подклассы) — repr с параметрами: он начинается + # с имени класса в CamelCase и потому никогда не совпадёт с именем типа DBML; длина/флаги не теряются молча + return _SIMPLE_TYPE_NAMES.get(cls) or repr(orm_type) def render_dbml_type(dbml_type: object) -> str | None: @@ -269,28 +277,59 @@ def _one_column_problems(label: str, orm_col: Column[Any], dbml_col: DbmlColumn, # --- FK -------------------------------------------------------------------------------------------------------- +def _fk_action(action: object) -> str: + """Действие FK в каноническом виде; отсутствие действия в DBML и ORM — это `NO ACTION` PostgreSQL.""" + return " ".join(str(action).split()).upper() if action else NO_ACTION + + +def _fk_initially(initially: object) -> str: + return str(initially).upper() if initially else ABSENT + + def _fk_problems(metadata: MetaData, dbml: PyDBML, names: Collection[str]) -> list[str]: - """FK по колонке-ссылке: множество целей `родитель.колонка` в DBML против ORM.""" - expected: dict[tuple[str, str], set[str]] = {} + """FK по колонке-ссылке: множество целей `родитель.колонка`, затем у совпавших целей — ON DELETE/ON UPDATE и + DEFERRABLE/INITIALLY (в DBML их нет, значит в ORM их быть не должно).""" + expected: dict[tuple[str, str], dict[str, dict[str, str]]] = {} for ref in dbml.refs: # `>`: table1 ссылается на table2 (many-to-one); `<` — наоборот; `-` (one-to-one) читаем как `>` child, child_cols, parent, parent_cols = ref.table1, ref.col1, ref.table2, ref.col2 if ref.type == "<": child, child_cols, parent, parent_cols = parent, parent_cols, child, child_cols if child.name in names: + props = { + "ON DELETE": _fk_action(ref.on_delete), + "ON UPDATE": _fk_action(ref.on_update), + "DEFERRABLE": _yes_no(False), + "INITIALLY": ABSENT, + } for child_col, parent_col in zip(child_cols, parent_cols, strict=True): - expected.setdefault((child.name, child_col.name), set()).add(f"{parent.name}.{parent_col.name}") - actual: dict[tuple[str, str], set[str]] = {} + targets = expected.setdefault((child.name, child_col.name), {}) + targets[f"{parent.name}.{parent_col.name}"] = props + actual: dict[tuple[str, str], dict[str, dict[str, str]]] = {} for table in metadata.tables.values(): if table.name in names: for fk in table.foreign_keys: - target = f"{fk.column.table.name}.{fk.column.name}" - actual.setdefault((table.name, fk.parent.name), set()).add(target) + # DDL строится по ForeignKeyConstraint; ForeignKey(ondelete=...) копирует туда же — читаем оба + constraint = fk.constraint + props = { + "ON DELETE": _fk_action(getattr(constraint, "ondelete", None) or fk.ondelete), + "ON UPDATE": _fk_action(getattr(constraint, "onupdate", None) or fk.onupdate), + "DEFERRABLE": _yes_no(bool(getattr(constraint, "deferrable", None) or fk.deferrable)), + "INITIALLY": _fk_initially(getattr(constraint, "initially", None) or fk.initially), + } + targets = actual.setdefault((table.name, fk.parent.name), {}) + targets[f"{fk.column.table.name}.{fk.column.name}"] = props problems = [] for key in sorted(expected.keys() | actual.keys()): - want, have = expected.get(key, set()), actual.get(key, set()) - if want != have: - problems.append(mismatch(f"{key[0]}.{key[1]}: FK", _targets(want), _targets(have))) + want, have = expected.get(key, {}), actual.get(key, {}) + label = f"{key[0]}.{key[1]}: FK" + if want.keys() != have.keys(): + problems.append(mismatch(label, _targets(set(want)), _targets(set(have)))) + continue + for target in sorted(want): + for prop, value in want[target].items(): + if have[target][prop] != value: + problems.append(mismatch(f"{label} {prop}", value, have[target][prop])) return problems @@ -338,6 +377,13 @@ def _index_problems(orm_table: Table, dbml_table: DbmlTable) -> list[str]: options = orm_index.dialect_options["postgresql"] props = _index_props(bool(orm_index.unique), _norm_where(options["where"]), bool(options["nulls_not_distinct"])) actual.setdefault(tuple(c.name for c in orm_index.columns), []).append(props) + for constraint in orm_table.constraints: + # табличный UniqueConstraint сверяется как уникальный индекс по тем же колонкам; ограничение, созданное + # флагом колонки `unique=True` (`_column_flag`), уже сверено в свойствах колонки + if isinstance(constraint, UniqueConstraint) and not getattr(constraint, "_column_flag", False): + options = constraint.dialect_options["postgresql"] + props = _index_props(True, None, bool(options["nulls_not_distinct"])) + actual.setdefault(tuple(c.name for c in constraint.columns), []).append(props) problems = [] for columns in sorted(expected.keys() | actual.keys()): label = f"{dbml_table.name}: индекс ({', '.join(columns)})" @@ -351,6 +397,15 @@ def _index_problems(orm_table: Table, dbml_table: DbmlTable) -> list[str]: return problems +def _check_problems(orm_table: Table) -> list[str]: + """CHECK-ограничения: в DBML их нет (типы и enum'ы — нативные), любое CHECK в ORM — лишнее.""" + return [ + missing(f"{orm_table.name}: CHECK {constraint.sqltext}", DBML) + for constraint in orm_table.constraints + if isinstance(constraint, CheckConstraint) + ] + + # --- точки входа ----------------------------------------------------------------------------------------------- @@ -369,6 +424,7 @@ def find_problems(metadata: MetaData, tables: Collection[str], dbml: PyDBML) -> problems.append(mismatch(f"{name}: схема", SCHEMA, orm_tables[name].schema)) problems += _column_problems(orm_tables[name], dbml_tables[name]) problems += _index_problems(orm_tables[name], dbml_tables[name]) + problems += _check_problems(orm_tables[name]) return problems + _fk_problems(metadata, dbml, {n for n in tables if n in dbml_tables}) diff --git a/api/tests/test_dbml_parity.py b/api/tests/test_dbml_parity.py index fc4f33a..39a40b8 100644 --- a/api/tests/test_dbml_parity.py +++ b/api/tests/test_dbml_parity.py @@ -4,8 +4,10 @@ import pytest from pydbml.classes import Expression from sqlalchemy import ( + VARCHAR, BigInteger, Boolean, + CheckConstraint, Date, DateTime, DefaultClause, @@ -16,10 +18,13 @@ from sqlalchemy import ( SmallInteger, String, Text, + UniqueConstraint, + Uuid, func, text, ) from sqlalchemy.dialects.postgresql import JSON, JSONB, UUID +from sqlalchemy.dialects.postgresql import VARCHAR as PG_VARCHAR from src.models import Base from src.models.enums import pg_enum @@ -98,12 +103,21 @@ def _dbml_enum(name: str) -> object: (String(), "text", False), (String(), "varchar", True), (DateTime(timezone=True), "timestamptz", True), + # неизвестные/производные классы не должны терять параметры и «проходить» за тип DBML + (VARCHAR(50), "varchar", False), + (PG_VARCHAR(10), "varchar", False), + (Uuid(as_uuid=False), "uuid", False), ], ) def test_type_mapper(orm_type: object, dbml_type: str, ok: bool) -> None: assert (type_problem(orm_type, dbml_type) is None) is ok +def test_unknown_orm_type_class_keeps_parameters_in_report() -> None: + assert type_problem(VARCHAR(50), "varchar") == "ожидалось varchar / фактически VARCHAR(length=50)" + assert type_problem(Uuid(as_uuid=False), "uuid") == "ожидалось uuid / фактически Uuid(as_uuid=False)" + + def test_unknown_dbml_type_is_reported_not_ignored() -> None: assert "не поддержан" in (type_problem(String(), "geometry") or "") @@ -230,3 +244,42 @@ def test_now_default_forms_agree() -> None: == canonical_default_orm(DefaultClause(text("CURRENT_TIMESTAMP"))) ) assert expected != canonical_default_dbml(False) + + +def test_table_level_unique_constraint_is_compared_as_unique_index() -> None: + """Табличный UniqueConstraint ORM, которого нет в DBML, — расхождение (флаг колонки `unique=True` — не он).""" + metadata = clone_metadata() + metadata.tables["dkb.regional_office"].append_constraint(UniqueConstraint("name", "is_active")) + assert find_problems(metadata, {"regional_office"}) == [ + "regional_office: индекс (name, is_active): отсутствует в DBML" + ] + metadata.tables["dkb.permission"].append_constraint(UniqueConstraint("name")) + assert find_problems(metadata, {"permission"}) == ["permission: индекс (name): отсутствует в DBML"] + + +def test_check_constraint_is_reported_missing_in_dbml() -> None: + metadata = clone_metadata() + metadata.tables["dkb.elimination_rule"].append_constraint(CheckConstraint("rule_no > 0", name="rule_no_positive")) + assert find_problems(metadata, {"elimination_rule"}) == ["elimination_rule: CHECK rule_no > 0: отсутствует в DBML"] + + +def test_fk_actions_deferrable_and_initially_are_compared() -> None: + """В DBML у ссылок нет действий (= NO ACTION) и DEFERRABLE: лишний CASCADE в ORM обязан всплыть.""" + label = "app_user.regional_office_id: FK" + metadata = clone_metadata() + fk = next(f for f in metadata.tables["dkb.app_user"].foreign_keys if f.parent.name == "regional_office_id") + fk.ondelete = "CASCADE" # только на ForeignKey, ограничение не тронуто + assert find_problems(metadata, {"app_user"}) == [f"{label} ON DELETE: ожидалось NO ACTION / фактически CASCADE"] + fk.ondelete = None + constraint = fk.constraint + assert constraint is not None + constraint.onupdate = "set null" + constraint.deferrable = True + constraint.initially = "deferred" + assert find_problems(metadata, {"app_user"}) == [ + f"{label} ON UPDATE: ожидалось NO ACTION / фактически SET NULL", + f"{label} DEFERRABLE: ожидалось нет / фактически да", + f"{label} INITIALLY: ожидалось нет / фактически DEFERRED", + ] + constraint.onupdate, constraint.deferrable, constraint.initially = "NO ACTION", None, None + assert find_problems(metadata, {"app_user"}) == [] # явный NO ACTION равен отсутствию действия -- 2.47.2 From 3b110b6417c3715c536f8a8a7b4f19faef1f6cc8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 20:27:15 +0400 Subject: [PATCH 35/97] =?UTF-8?q?feat:=20alembic=20env.py=20=E2=80=94=20CR?= =?UTF-8?q?EATE=20SCHEMA=20dkb=20=D0=B4=D0=BE=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D1=8B=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B9,=20UR?= =?UTF-8?q?L=20=D0=B8=D0=B7=20-x=20dburl,=20compare=5Fserver=5Fdefault?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Схема dkb создаётся и фиксируется до context.configure (онлайн) и печатается первой в офлайн-SQL. URL: -x dburl > sqlalchemy.url, заданный вызывающим кодом (не заглушка driver://) > настройки. Async-движок из URL напрямую (без configparser-интерполяции %). Co-Authored-By: Claude Fable 5.1 --- api/alembic/env.py | 117 +++++++++++++++++----------------- api/alembic/versions/.gitkeep | 0 2 files changed, 59 insertions(+), 58 deletions(-) delete mode 100644 api/alembic/versions/.gitkeep diff --git a/api/alembic/env.py b/api/alembic/env.py index 2a949b4..f2baf16 100644 --- a/api/alembic/env.py +++ b/api/alembic/env.py @@ -1,94 +1,95 @@ +"""Окружение Alembic: схема `dkb`, async-движок, URL БД из настроек или из вызывающего кода. + +URL БД (по убыванию приоритета): +1. `alembic -x dburl= ...` — для тестов и разовых запусков против другой БД (временной, scratch); +2. `sqlalchemy.url` в Config, если вызывающий код задал его сам (`Config.set_main_option`), — всё, кроме + шаблонной заглушки `driver://...` из `alembic.ini`; +3. `settings.OPENBAO.DATABASE.DATABASE_URL` (`.env` / `ENV_FILE`) — обычный `alembic upgrade head` / `make migrate`. + +Схема `dkb` создаётся (`CREATE SCHEMA IF NOT EXISTS`) до таблицы версий `dkb.alembic_version` и до миграций. +""" + import asyncio from logging.config import fileConfig -from sqlalchemy import engine_from_config, pool -from sqlalchemy.ext.asyncio import AsyncEngine +from sqlalchemy import Connection, pool, text +from sqlalchemy.ext.asyncio import create_async_engine from alembic import context from src.core.config import settings -from src.models import Base # noqa: F401 — модели регистрируются в src/models/__init__.py +from src.models import Base # модели регистрируются в src/models/__init__.py + +SCHEMA = "dkb" +#: Значение `sqlalchemy.url` в шаблонном `alembic.ini` — признак «URL не задан вызывающим кодом». +INI_PLACEHOLDER_PREFIX = "driver://" -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. config = context.config -# Interpret the config file for Python logging. -# This line sets up loggers basically. if config.config_file_name is not None: fileConfig(config.config_file_name) -config.set_main_option("sqlalchemy.url", settings.OPENBAO.DATABASE.DATABASE_URL) -# add your model's MetaData object here -# for 'autogenerate' support -# from myapp import mymodel -# target_metadata = mymodel.Base.metadata target_metadata = Base.metadata -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. + +def database_url() -> str: + """URL БД: `-x dburl=...` > `sqlalchemy.url`, заданный вызывающим кодом > настройки приложения.""" + x_url = context.get_x_argument(as_dictionary=True).get("dburl") + if x_url: + return x_url + config_url = config.get_main_option("sqlalchemy.url") + if config_url and not config_url.startswith(INI_PLACEHOLDER_PREFIX): + return config_url + return settings.OPENBAO.DATABASE.DATABASE_URL -def include_name(name, type_, parent_names): +def include_name(name: str | None, type_: str, parent_names: object) -> bool: + """Autogenerate видит только схему `dkb` (схема по умолчанию `public` и прочие — не наши).""" if type_ == "schema": - return name == "dkb" + return name == SCHEMA return True -def do_run_migrations(connection) -> None: - context.configure( - connection=connection, - target_metadata=target_metadata, - compare_type=True, - version_table_schema="dkb", - include_schemas=True, - include_name=include_name, - ) - - with context.begin_transaction(): - context.run_migrations() +#: Опции, общие для онлайн- и офлайн-режима. `compare_server_default` — чтобы autogenerate ловил расхождение +#: server_default между ORM и БД (иначе изменение default'а проходит незамеченным). +CONFIGURE_OPTS = { + "target_metadata": target_metadata, + "compare_type": True, + "compare_server_default": True, + "version_table_schema": SCHEMA, + "include_schemas": True, + "include_name": include_name, +} def run_migrations_offline() -> None: - """Run migrations in 'offline' mode. - - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. - - """ - url = config.get_main_option("sqlalchemy.url") + """Офлайн-режим (`--sql`): DDL печатается в вывод; `CREATE SCHEMA` идёт первым, до таблицы версий.""" context.configure( - url=url, - target_metadata=target_metadata, + url=database_url(), literal_binds=True, dialect_opts={"paramstyle": "named"}, - version_table_schema="dkb", - include_schemas=True, - include_name=include_name, + **CONFIGURE_OPTS, ) + with context.begin_transaction(): + context.execute(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}") + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + # Схема нужна до того, как alembic создаст `dkb.alembic_version`; фиксируем её отдельной транзакцией — + # иначе autobegin оставил бы соединение во «внешней» транзакции и alembic не стал бы коммитить миграции. + connection.execute(text(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}")) + connection.commit() + + context.configure(connection=connection, **CONFIGURE_OPTS) + with context.begin_transaction(): context.run_migrations() async def run_migrations_online() -> None: - """Run migrations in 'online' mode. - - In this scenario we need to create an Engine - and associate a connection with the context. - - """ - connectable = AsyncEngine( - engine_from_config( - config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool, future=True - ) - ) + """Онлайн-режим: async-движок (asyncpg), миграции в синхронном адаптере соединения.""" + connectable = create_async_engine(database_url(), poolclass=pool.NullPool) async with connectable.connect() as connection: await connection.run_sync(do_run_migrations) diff --git a/api/alembic/versions/.gitkeep b/api/alembic/versions/.gitkeep deleted file mode 100644 index e69de29..0000000 -- 2.47.2 From ed5ed43fa62e72619b9733d92bafa2088cc0fed8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 20:27:23 +0400 Subject: [PATCH 36/97] =?UTF-8?q?feat:=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=200001=5Fdbml=5Fv5=5F13=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20DDL=20DBML=20v5.13=20=D1=81=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=B0=D1=82=D0=BE=D0=BC;=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8B=20=D1=86=D0=B8=D0=BA=D0=BB=D0=B0=20=D0=B8=20?= =?UTF-8?q?make=20migration-cycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Явный DDL без импорта src.models: схема dkb, 27 enum-типов (явный CREATE/DROP TYPE), 43 таблицы с индексами (partial unique, NULLS NOT DISTINCT), FK цикла stored_file -> recognition_job отдельным шагом; downgrade — FK цикла, таблицы в обратном порядке, типы. Тесты (integration, scratch-БД, alembic подпроцессом): upgrade -> downgrade -> upgrade, пустой compare_metadata и alembic check, каталог = create_all ORM, поведение partial unique/NULLS NOT DISTINCT/FK-цикла; офлайн-SQL — CREATE SCHEMA первым. Co-Authored-By: Claude Fable 5.1 --- Makefile | 7 +- api/alembic/versions/0001_dbml_v5_13.py | 1481 +++++++++++++++++++++++ api/pyproject.toml | 4 + api/tests/test_migrations.py | 284 +++++ 4 files changed, 1775 insertions(+), 1 deletion(-) create mode 100644 api/alembic/versions/0001_dbml_v5_13.py create mode 100644 api/tests/test_migrations.py diff --git a/Makefile b/Makefile index 34f3984..b0d16b5 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ COMPOSE_FILE ?= ../docs/dev/docker-compose.yml # DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/ DBML ?= ../../docs/model/data-model-v5.13.dbml -.PHONY: venv lint test test-integration dbml-check check check-full up migrate seed seed-dev +.PHONY: venv lint test test-integration dbml-check migration-cycle check check-full up migrate seed seed-dev # Python 3.11 (версия платформы); внутренние пакеты raisa_* вне банка недоступны — пропускаем их venv: @@ -28,6 +28,11 @@ test-integration: dbml-check: cd api && $(BIN)/python scripts/check_dbml_vs_orm.py $(DBML) +# Миграции на временной БД (spec data-model): upgrade head -> downgrade base -> upgrade head, пустой autogenerate, +# схема после миграции = create_all ORM. Подключается в check-full (задача 25) +migration-cycle: + cd api && $(BIN)/pytest -m integration tests/test_migrations.py + check: lint test # TODO (задача 25): добавить фронт (lint/test/build web/), проверку миграций с нуля и откат, актуальность OpenAPI diff --git a/api/alembic/versions/0001_dbml_v5_13.py b/api/alembic/versions/0001_dbml_v5_13.py new file mode 100644 index 0000000..5a68308 --- /dev/null +++ b/api/alembic/versions/0001_dbml_v5_13.py @@ -0,0 +1,1481 @@ +"""DBML v5.13: вся модель данных АРМ КА с нуля (схема dkb, 27 enum-типов, 43 таблицы, ограничения, индексы). + +Ревизия заморожена: явный DDL, модели `src.models` не импортируются. Имена ограничений и индексов совпадают с ORM +(naming_convention `src/models/base.py` + явные имена); `alembic check` после upgrade не находит расхождений. +Черновик — autogenerate, вручную: явное создание/удаление enum-типов, FK цикла stored_file <-> recognition_job +отдельным шагом, полный downgrade (design D-2). + +Revision ID: 0001_dbml_v5_13 +Revises: +Create Date: 2026-10-03 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "0001_dbml_v5_13" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +SCHEMA = "dkb" +FK_CYCLE = "fk_stored_file_current_recognition_job_id_recognition_job" + + +def _enum(name: str, *values: str) -> postgresql.ENUM: + """Нативный PG-enum `dkb.`; тип создаётся явно в upgrade(), а не неявно из create_table.""" + return postgresql.ENUM(*values, name=name, schema=SCHEMA, create_type=False) + + +#: 27 enum-типов DBML v5.13 в порядке DBML; значения — в порядке DBML (включая кириллицу `ЮЛ`/`ИП`). +ENUMS: dict[str, postgresql.ENUM] = { + enum_type.name: enum_type + for enum_type in ( + _enum("entity_type", "ЮЛ", "ИП"), + _enum("related_group_kind", "gsk", "gsz"), + _enum("account_kind", "A", "P", "AP"), + _enum("account_row_type", "account", "subaccount", "analytic"), + _enum("business_segment", "DKB", "DMSB", "DM"), + _enum("document_type", "BALANCE", "INCOME", "OSV", "DISCLOSURE", "OTHER"), + _enum("file_type", "XLS", "XLSX", "XLSB", "PDF", "PNG", "JPG"), + _enum("recognition_pipeline", "tabular", "image"), + _enum("period_quarter", "Q1", "Q2", "Q3", "Q4"), + _enum("file_status", "UPLOADED", "PROCESSING", "PROCESSED", "ERROR", "SUPERSEDED"), + _enum("document_status", "RECOGNIZED", "NEEDS_CHECK", "CONFIRMED", "ERROR"), + _enum("s3_bucket_role", "uploads", "results", "exports", "archive"), + _enum( + "recognition_status", "queued", "published", "processing", "done", "needs_check", "error", "timeout", "dlq" + ), + _enum("statement_type", "balance", "income"), + _enum("osv_group_type", "ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"), + _enum("osv_data_type", "BU", "CURRENCY", "QUANTITY", "SUM"), + _enum("consolidation_scenario", "CREATE", "CONFIRM"), + _enum("consolidation_approach", "BASIC", "EXTENDED"), + _enum( + "consolidation_status", + "INITIATED", + "DOCUMENTS_PROCESSING", + "DATA_CORRECTION", + "CONSOLIDATION_RF", + "ADJUSTMENT", + "VERIFICATION_RF", + "CONSOLIDATION_GO", + "VERIFICATION_GO", + "VERIFIED", + "CANCELLED", + "REJECTED", + "ARCHIVED", + ), + _enum("correction_status", "DRAFT", "APPLIED", "REVOKED"), + _enum("document_check_result", "MATCH", "MISMATCH", "UNKNOWN"), + _enum("le_history_event", "CREATE", "UPDATE", "MOVE_TO_GROUP", "CLOSE", "REMOVE_FROM_GROUP"), + _enum("ex_snapshot_status", "ACTUAL", "ARCHIVE"), + _enum("permission_object", "DIRECTORY", "DOCUMENT", "CONSOLIDATION", "KFO", "EX", "EXPORT", "USERS", "AUDIT"), + _enum("permission_action", "VIEW", "EDIT", "APPROVE", "VERIFY", "DELETE", "EXPORT", "ADMIN"), + _enum( + "audit_section", + "DIRECTORIES", + "CONSOLIDATION", + "PRIMARY_DOCUMENTS", + "RECOGNITION", + "KFO", + "EXPORT", + "ACCESS", + "SESSION", + "USERS", + ), + _enum( + "audit_action", + "LOGIN", + "LOGIN_FAILED", + "LOGOUT", + "CONSOLIDATION_CREATE", + "CONSOLIDATION_STATUS_CHANGE", + "CONSOLIDATION_APPROVE", + "CONSOLIDATION_VERIFY", + "GROUP_CHANGE", + "MEMBERSHIP_CHANGE", + "ASSIGNMENT_CHANGE", + "FILE_UPLOAD", + "FILE_DELETE", + "FILE_REPLACE", + "DOCUMENT_CONFIRM", + "CONSOLIDATION_LOCK", + "CONSOLIDATION_LOCK_TAKEOVER", + "DISCLOSURE_EDIT", + "RECOGNIZED_DATA_EDIT", + "OSV_COUNTERPARTY_MAP", + "OSV_TYPE_OVERRIDE", + "CORRECTION_CREATE", + "CORRECTION_EDIT", + "CORRECTION_REVOKE", + "GOODWILL_EDIT", + "EXPORT", + "DIRECTORY_IMPORT", + "USER_ROLE_CHANGE", + "ROLE_PERMISSION_CHANGE", + ), + ) +} + +#: 43 таблицы в порядке создания (зависимости FK); downgrade удаляет их в обратном порядке. +TABLES: tuple[str, ...] = ( + "chart_of_account", + "elimination_rule", + "permission", + "regional_office", + "role", + "s3_object", + "app_user", + "elimination_rule_account", + "required_document_rule", + "comment", + "company_group", + "role_permission", + "user_role", + "company_group_history", + "legal_entity", + "related_group", + "consolidation", + "ex_snapshot", + "legal_entity_alias", + "legal_entity_history", + "related_group_member", + "stored_file", + "consolidation_version", + "document", + "ex_snapshot_member", + "recognition_job", + "audit_event", + "client_kfo_line", + "consolidation_document", + "consolidation_lock", + "consolidation_member", + "consolidation_status_history", + "correction", + "ex_snapshot_item", + "financial_statement_meta", + "goodwill", + "kfo_result", + "osv_entry", + "financial_statement_column", + "financial_statement_line", + "kfo_result_line", + "financial_statement_value", + "kfo_result_line_company", +) + + +def upgrade() -> None: + """Схема dkb (идемпотентно) -> 27 enum-типов -> 43 таблицы с их индексами в порядке зависимостей FK. + + FK цикла `stored_file.current_recognition_job_id` добавляется сразу после создания `recognition_job`. + """ + op.execute(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}") + bind = op.get_bind() + for enum_type in ENUMS.values(): + enum_type.create(bind, checkfirst=False) + op.create_table( + "chart_of_account", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("code", sa.String(), nullable=False), + sa.Column("parent_id", sa.UUID(), nullable=True), + sa.Column("name", sa.String(), nullable=False), + sa.Column("kind", ENUMS["account_kind"], nullable=True), + sa.Column("row_type", ENUMS["account_row_type"], nullable=False), + sa.Column("is_off_balance", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.ForeignKeyConstraint( + ["parent_id"], ["dkb.chart_of_account.id"], name=op.f("fk_chart_of_account_parent_id_chart_of_account") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_chart_of_account")), + sa.UniqueConstraint("code", name=op.f("uq_chart_of_account_code")), + schema=SCHEMA, + ) + op.create_table( + "elimination_rule", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("statement_type", ENUMS["statement_type"], nullable=False), + sa.Column("rule_no", sa.SmallInteger(), nullable=False), + sa.Column("article_1_line", sa.String(), nullable=False), + sa.Column("article_2_line", sa.String(), nullable=False), + sa.Column("side_1", sa.String(), nullable=False), + sa.Column("measure", sa.String(), nullable=False), + sa.Column("elimination_mode", sa.String(), nullable=False), + sa.Column("vat_handling", sa.String(), nullable=True), + sa.Column("excise_adjust", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("comment", sa.Text(), nullable=True), + sa.Column("version_no", sa.Integer(), nullable=False), + sa.Column("valid_from", sa.Date(), nullable=False), + sa.Column("valid_to", sa.Date(), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_elimination_rule")), + schema=SCHEMA, + ) + op.create_index( + "uq_elimination_rule_statement_type_rule_no_version_no", + "elimination_rule", + ["statement_type", "rule_no", "version_no"], + unique=True, + schema=SCHEMA, + ) + op.create_table( + "permission", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("code", sa.String(), nullable=False), + sa.Column("object_type", ENUMS["permission_object"], nullable=False), + sa.Column("action", ENUMS["permission_action"], nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_permission")), + sa.UniqueConstraint("code", name=op.f("uq_permission_code")), + schema=SCHEMA, + ) + op.create_index( + "uq_permission_object_type_action", "permission", ["object_type", "action"], unique=True, schema=SCHEMA + ) + op.create_table( + "regional_office", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("is_head_office", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_regional_office")), + sa.UniqueConstraint("name", name=op.f("uq_regional_office_name")), + schema=SCHEMA, + ) + op.create_table( + "role", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("code", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("is_system", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_role")), + sa.UniqueConstraint("code", name=op.f("uq_role_code")), + schema=SCHEMA, + ) + op.create_table( + "s3_object", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("bucket_role", ENUMS["s3_bucket_role"], nullable=False), + sa.Column("bucket", sa.String(), nullable=False), + sa.Column("key", sa.String(), nullable=False), + sa.Column("content_type", sa.String(), nullable=True), + sa.Column("size_bytes", sa.BigInteger(), nullable=True), + sa.Column("sha256", sa.String(), nullable=True), + sa.Column("version_id", sa.String(), nullable=True), + sa.Column("created_by", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("archived_to", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_s3_object")), + schema=SCHEMA, + ) + op.create_index("uq_s3_object_bucket_key", "s3_object", ["bucket", "key"], unique=True, schema=SCHEMA) + op.create_table( + "app_user", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("external_id", sa.String(), nullable=True), + sa.Column("email", sa.String(), nullable=True), + sa.Column("full_name", sa.String(), nullable=False), + sa.Column("employee_no", sa.String(), nullable=True), + sa.Column("regional_office_id", sa.UUID(), nullable=False), + sa.Column("business_segment", ENUMS["business_segment"], nullable=True), + sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["regional_office_id"], + ["dkb.regional_office.id"], + name=op.f("fk_app_user_regional_office_id_regional_office"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_app_user")), + sa.UniqueConstraint("email", name=op.f("uq_app_user_email")), + sa.UniqueConstraint("external_id", name=op.f("uq_app_user_external_id")), + schema=SCHEMA, + ) + op.create_table( + "elimination_rule_account", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("rule_id", sa.UUID(), nullable=False), + sa.Column("company_no", sa.SmallInteger(), nullable=False), + sa.Column("account_id", sa.UUID(), nullable=False), + sa.Column("is_excluded", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.ForeignKeyConstraint( + ["account_id"], + ["dkb.chart_of_account.id"], + name=op.f("fk_elimination_rule_account_account_id_chart_of_account"), + ), + sa.ForeignKeyConstraint( + ["rule_id"], ["dkb.elimination_rule.id"], name=op.f("fk_elimination_rule_account_rule_id_elimination_rule") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_elimination_rule_account")), + schema=SCHEMA, + ) + op.create_table( + "required_document_rule", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("approach", ENUMS["consolidation_approach"], nullable=False), + sa.Column("document_type", ENUMS["document_type"], nullable=False), + sa.Column("account_id", sa.UUID(), nullable=True), + sa.Column("is_mandatory", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint( + ["account_id"], + ["dkb.chart_of_account.id"], + name=op.f("fk_required_document_rule_account_id_chart_of_account"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_required_document_rule")), + schema=SCHEMA, + ) + op.create_index( + "uq_required_document_rule_approach_type_account", + "required_document_rule", + ["approach", "document_type", "account_id"], + unique=True, + schema=SCHEMA, + ) + op.create_table( + "comment", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("object_type", sa.String(), nullable=False), + sa.Column("object_id", sa.UUID(), nullable=False), + sa.Column("text", sa.Text(), nullable=False), + sa.Column("author_id", sa.UUID(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["author_id"], ["dkb.app_user.id"], name=op.f("fk_comment_author_id_app_user")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_comment")), + schema=SCHEMA, + ) + op.create_index( + "ix_comment_object_type_object_id", "comment", ["object_type", "object_id"], unique=False, schema=SCHEMA + ) + op.create_table( + "company_group", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("code", sa.String(), nullable=False), + sa.Column("crm_id", sa.String(), nullable=True), + sa.Column("name", sa.String(), nullable=False), + sa.Column("base_regional_office_id", sa.UUID(), nullable=True), + sa.Column("is_archived", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_by", sa.UUID(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["base_regional_office_id"], + ["dkb.regional_office.id"], + name=op.f("fk_company_group_base_regional_office_id_regional_office"), + ), + sa.ForeignKeyConstraint(["updated_by"], ["dkb.app_user.id"], name=op.f("fk_company_group_updated_by_app_user")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_company_group")), + sa.UniqueConstraint("code", name=op.f("uq_company_group_code")), + sa.UniqueConstraint("crm_id", name=op.f("uq_company_group_crm_id")), + schema=SCHEMA, + ) + op.create_table( + "role_permission", + sa.Column("role_id", sa.UUID(), nullable=False), + sa.Column("permission_id", sa.UUID(), nullable=False), + sa.Column("granted_by", sa.UUID(), nullable=True), + sa.Column("granted_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["granted_by"], ["dkb.app_user.id"], name=op.f("fk_role_permission_granted_by_app_user") + ), + sa.ForeignKeyConstraint( + ["permission_id"], ["dkb.permission.id"], name=op.f("fk_role_permission_permission_id_permission") + ), + sa.ForeignKeyConstraint(["role_id"], ["dkb.role.id"], name=op.f("fk_role_permission_role_id_role")), + sa.PrimaryKeyConstraint("role_id", "permission_id", name=op.f("pk_role_permission")), + schema=SCHEMA, + ) + op.create_table( + "user_role", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("role_id", sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(["role_id"], ["dkb.role.id"], name=op.f("fk_user_role_role_id_role")), + sa.ForeignKeyConstraint(["user_id"], ["dkb.app_user.id"], name=op.f("fk_user_role_user_id_app_user")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_user_role")), + schema=SCHEMA, + ) + op.create_index("uq_user_role_user_id_role_id", "user_role", ["user_id", "role_id"], unique=True, schema=SCHEMA) + op.create_table( + "company_group_history", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("company_group_id", sa.UUID(), nullable=False), + sa.Column("crm_id", sa.String(), nullable=True), + sa.Column("name", sa.String(), nullable=True), + sa.Column("base_regional_office_id", sa.UUID(), nullable=True), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True), + sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True), + sa.Column("changed_by", sa.UUID(), nullable=True), + sa.ForeignKeyConstraint( + ["changed_by"], ["dkb.app_user.id"], name=op.f("fk_company_group_history_changed_by_app_user") + ), + sa.ForeignKeyConstraint( + ["company_group_id"], + ["dkb.company_group.id"], + name=op.f("fk_company_group_history_company_group_id_company_group"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_company_group_history")), + schema=SCHEMA, + ) + op.create_index( + "uq_company_group_history_company_group_id_valid_from", + "company_group_history", + ["company_group_id", "valid_from"], + unique=True, + schema=SCHEMA, + ) + op.create_table( + "legal_entity", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("inn", sa.String(), nullable=False), + sa.Column("crm_id", sa.String(), nullable=True), + sa.Column("entity_type", ENUMS["entity_type"], nullable=True), + sa.Column("name", sa.String(), nullable=False), + sa.Column("company_group_id", sa.UUID(), nullable=True), + sa.Column("regional_office_id", sa.UUID(), nullable=True), + sa.Column("close_date", sa.Date(), nullable=True), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_by", sa.UUID(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["company_group_id"], ["dkb.company_group.id"], name=op.f("fk_legal_entity_company_group_id_company_group") + ), + sa.ForeignKeyConstraint( + ["regional_office_id"], + ["dkb.regional_office.id"], + name=op.f("fk_legal_entity_regional_office_id_regional_office"), + ), + sa.ForeignKeyConstraint(["updated_by"], ["dkb.app_user.id"], name=op.f("fk_legal_entity_updated_by_app_user")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_legal_entity")), + sa.UniqueConstraint("crm_id", name=op.f("uq_legal_entity_crm_id")), + sa.UniqueConstraint("inn", name=op.f("uq_legal_entity_inn")), + schema=SCHEMA, + ) + op.create_table( + "related_group", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("code", sa.String(), nullable=False), + sa.Column("company_group_id", sa.UUID(), nullable=False), + sa.Column("kind", ENUMS["related_group_kind"], nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("created_date", sa.Date(), nullable=True), + sa.ForeignKeyConstraint( + ["company_group_id"], ["dkb.company_group.id"], name=op.f("fk_related_group_company_group_id_company_group") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_related_group")), + sa.UniqueConstraint("code", name=op.f("uq_related_group_code")), + schema=SCHEMA, + ) + op.create_table( + "consolidation", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("company_group_id", sa.UUID(), nullable=False), + sa.Column("related_group_id", sa.UUID(), nullable=True), + sa.Column("year", sa.SmallInteger(), nullable=False), + sa.Column("quarter", ENUMS["period_quarter"], nullable=False), + sa.Column("reporting_date", sa.Date(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["company_group_id"], ["dkb.company_group.id"], name=op.f("fk_consolidation_company_group_id_company_group") + ), + sa.ForeignKeyConstraint( + ["related_group_id"], ["dkb.related_group.id"], name=op.f("fk_consolidation_related_group_id_related_group") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_consolidation")), + schema=SCHEMA, + ) + op.create_index( + "uq_consolidation_group_year_quarter", + "consolidation", + ["company_group_id", "year", "quarter"], + unique=True, + schema=SCHEMA, + ) + op.create_table( + "ex_snapshot", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("related_group_id", sa.UUID(), nullable=False), + sa.Column("company_group_id", sa.UUID(), nullable=False), + sa.Column("reporting_date", sa.Date(), nullable=False), + sa.Column("year", sa.SmallInteger(), nullable=False), + sa.Column("quarter", ENUMS["period_quarter"], nullable=False), + sa.Column("version_no", sa.Integer(), nullable=False), + sa.Column("status", ENUMS["ex_snapshot_status"], nullable=False), + sa.Column("source_version_id", sa.UUID(), nullable=False), + sa.Column("source_version_no", sa.Integer(), nullable=False), + sa.Column("superseded_by_snapshot_id", sa.UUID(), nullable=True), + sa.Column("verified_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("written_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("written_by", sa.UUID(), nullable=True), + sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("data_lake_ref", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["company_group_id"], ["dkb.company_group.id"], name=op.f("fk_ex_snapshot_company_group_id_company_group") + ), + sa.ForeignKeyConstraint( + ["related_group_id"], ["dkb.related_group.id"], name=op.f("fk_ex_snapshot_related_group_id_related_group") + ), + sa.ForeignKeyConstraint( + ["superseded_by_snapshot_id"], + ["dkb.ex_snapshot.id"], + name=op.f("fk_ex_snapshot_superseded_by_snapshot_id_ex_snapshot"), + ), + sa.ForeignKeyConstraint(["written_by"], ["dkb.app_user.id"], name=op.f("fk_ex_snapshot_written_by_app_user")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_ex_snapshot")), + sa.UniqueConstraint("source_version_id", name=op.f("uq_ex_snapshot_source_version_id")), + schema=SCHEMA, + ) + op.create_index( + "ix_ex_snapshot_group_year_quarter_status", + "ex_snapshot", + ["related_group_id", "year", "quarter", "status"], + unique=False, + schema=SCHEMA, + ) + op.create_index( + "uq_ex_snapshot_group_year_quarter_version", + "ex_snapshot", + ["related_group_id", "year", "quarter", "version_no"], + unique=True, + schema=SCHEMA, + ) + op.create_table( + "legal_entity_alias", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("legal_entity_id", sa.UUID(), nullable=False), + sa.Column("alias", sa.String(), nullable=False), + sa.Column("normalized_alias", sa.String(), nullable=False), + sa.Column("created_by", sa.UUID(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["created_by"], ["dkb.app_user.id"], name=op.f("fk_legal_entity_alias_created_by_app_user") + ), + sa.ForeignKeyConstraint( + ["legal_entity_id"], + ["dkb.legal_entity.id"], + name=op.f("fk_legal_entity_alias_legal_entity_id_legal_entity"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_legal_entity_alias")), + schema=SCHEMA, + ) + op.create_index( + "ix_legal_entity_alias_normalized_alias", + "legal_entity_alias", + ["normalized_alias"], + unique=False, + schema=SCHEMA, + ) + op.create_index( + "uq_legal_entity_alias_legal_entity_id_normalized_alias", + "legal_entity_alias", + ["legal_entity_id", "normalized_alias"], + unique=True, + schema=SCHEMA, + ) + op.create_table( + "legal_entity_history", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("legal_entity_id", sa.UUID(), nullable=False), + sa.Column("inn", sa.String(), nullable=True), + sa.Column("crm_id", sa.String(), nullable=True), + sa.Column("entity_type", ENUMS["entity_type"], nullable=True), + sa.Column("name", sa.String(), nullable=True), + sa.Column("company_group_id", sa.UUID(), nullable=True), + sa.Column("regional_office_id", sa.UUID(), nullable=True), + sa.Column("close_date", sa.Date(), nullable=True), + sa.Column("event_type", ENUMS["le_history_event"], nullable=True), + sa.Column("effective_date", sa.Date(), nullable=True), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True), + sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True), + sa.Column("changed_by", sa.UUID(), nullable=True), + sa.ForeignKeyConstraint( + ["changed_by"], ["dkb.app_user.id"], name=op.f("fk_legal_entity_history_changed_by_app_user") + ), + sa.ForeignKeyConstraint( + ["legal_entity_id"], + ["dkb.legal_entity.id"], + name=op.f("fk_legal_entity_history_legal_entity_id_legal_entity"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_legal_entity_history")), + schema=SCHEMA, + ) + op.create_index( + "uq_legal_entity_history_legal_entity_id_valid_from", + "legal_entity_history", + ["legal_entity_id", "valid_from"], + unique=True, + schema=SCHEMA, + ) + op.create_table( + "related_group_member", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("related_group_id", sa.UUID(), nullable=False), + sa.Column("legal_entity_id", sa.UUID(), nullable=False), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False), + sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True), + sa.Column("is_current", sa.Boolean(), nullable=False), + sa.Column("created_by", sa.UUID(), nullable=True), + sa.ForeignKeyConstraint( + ["created_by"], ["dkb.app_user.id"], name=op.f("fk_related_group_member_created_by_app_user") + ), + sa.ForeignKeyConstraint( + ["legal_entity_id"], + ["dkb.legal_entity.id"], + name=op.f("fk_related_group_member_legal_entity_id_legal_entity"), + ), + sa.ForeignKeyConstraint( + ["related_group_id"], + ["dkb.related_group.id"], + name=op.f("fk_related_group_member_related_group_id_related_group"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_related_group_member")), + schema=SCHEMA, + ) + op.create_index( + "uq_related_group_member_entity_current", + "related_group_member", + ["legal_entity_id"], + unique=True, + schema=SCHEMA, + postgresql_where=sa.text("is_current"), + ) + op.create_index( + "uq_related_group_member_group_entity_current", + "related_group_member", + ["related_group_id", "legal_entity_id"], + unique=True, + schema=SCHEMA, + postgresql_where=sa.text("is_current"), + ) + op.create_table( + "stored_file", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("legal_entity_id", sa.UUID(), nullable=False), + sa.Column("file_type", ENUMS["file_type"], nullable=False), + sa.Column("original_name", sa.String(), nullable=True), + sa.Column("s3_object_id", sa.UUID(), nullable=False), + sa.Column("content_hash", sa.String(), nullable=True), + sa.Column("size_bytes", sa.BigInteger(), nullable=True), + sa.Column("status", ENUMS["file_status"], nullable=False), + sa.Column("version_no", sa.Integer(), server_default=sa.text("1"), nullable=False), + sa.Column("superseded_by_file_id", sa.UUID(), nullable=True), + sa.Column("current_recognition_job_id", sa.UUID(), nullable=True), + sa.Column("uploaded_by", sa.UUID(), nullable=True), + sa.Column("uploaded_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["legal_entity_id"], ["dkb.legal_entity.id"], name=op.f("fk_stored_file_legal_entity_id_legal_entity") + ), + sa.ForeignKeyConstraint( + ["s3_object_id"], ["dkb.s3_object.id"], name=op.f("fk_stored_file_s3_object_id_s3_object") + ), + sa.ForeignKeyConstraint( + ["superseded_by_file_id"], + ["dkb.stored_file.id"], + name=op.f("fk_stored_file_superseded_by_file_id_stored_file"), + ), + sa.ForeignKeyConstraint(["uploaded_by"], ["dkb.app_user.id"], name=op.f("fk_stored_file_uploaded_by_app_user")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_stored_file")), + schema=SCHEMA, + ) + op.create_index( + "ix_stored_file_legal_entity_id_status", + "stored_file", + ["legal_entity_id", "status"], + unique=False, + schema=SCHEMA, + ) + op.create_table( + "consolidation_version", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("consolidation_id", sa.UUID(), nullable=False), + sa.Column("version_no", sa.Integer(), nullable=False), + sa.Column("status", ENUMS["consolidation_status"], nullable=False), + sa.Column("scenario", ENUMS["consolidation_scenario"], nullable=False), + sa.Column("approach", ENUMS["consolidation_approach"], server_default=sa.text("'BASIC'"), nullable=False), + sa.Column("goodwill_enabled", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("initiator_id", sa.UUID(), nullable=False), + sa.Column("initiator_office_id", sa.UUID(), nullable=False), + sa.Column("approved_in_rf_by", sa.UUID(), nullable=True), + sa.Column("approved_in_rf_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("verified_in_go_by", sa.UUID(), nullable=True), + sa.Column("verified_in_go_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("uob_decision_no", sa.String(), nullable=True), + sa.Column("uob_decision_date", sa.Date(), nullable=True), + sa.Column("methodology_deviations", sa.Text(), nullable=True), + sa.Column("elimination_rules_version", sa.Integer(), nullable=True), + sa.Column("is_in_work", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.Column("previous_version_id", sa.UUID(), nullable=True), + sa.Column("cancelled_reason", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by", sa.UUID(), nullable=True), + sa.Column("updated_by", sa.UUID(), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["approved_in_rf_by"], ["dkb.app_user.id"], name=op.f("fk_consolidation_version_approved_in_rf_by_app_user") + ), + sa.ForeignKeyConstraint( + ["consolidation_id"], + ["dkb.consolidation.id"], + name=op.f("fk_consolidation_version_consolidation_id_consolidation"), + ), + sa.ForeignKeyConstraint( + ["created_by"], ["dkb.app_user.id"], name=op.f("fk_consolidation_version_created_by_app_user") + ), + sa.ForeignKeyConstraint( + ["initiator_id"], ["dkb.app_user.id"], name=op.f("fk_consolidation_version_initiator_id_app_user") + ), + sa.ForeignKeyConstraint( + ["initiator_office_id"], + ["dkb.regional_office.id"], + name=op.f("fk_consolidation_version_initiator_office_id_regional_office"), + ), + sa.ForeignKeyConstraint( + ["previous_version_id"], + ["dkb.consolidation_version.id"], + name="fk_consolidation_version_previous_version_id", + ), + sa.ForeignKeyConstraint( + ["updated_by"], ["dkb.app_user.id"], name=op.f("fk_consolidation_version_updated_by_app_user") + ), + sa.ForeignKeyConstraint( + ["verified_in_go_by"], ["dkb.app_user.id"], name=op.f("fk_consolidation_version_verified_in_go_by_app_user") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_consolidation_version")), + schema=SCHEMA, + ) + op.create_index( + "uq_consolidation_version_consolidation_id_version_no", + "consolidation_version", + ["consolidation_id", "version_no"], + unique=True, + schema=SCHEMA, + ) + op.create_table( + "document", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("stored_file_id", sa.UUID(), nullable=False), + sa.Column("legal_entity_id", sa.UUID(), nullable=False), + sa.Column("document_type", ENUMS["document_type"], nullable=False), + sa.Column("osv_account_code", sa.String(), nullable=True), + sa.Column("period_year", sa.SmallInteger(), nullable=True), + sa.Column("period_quarter", ENUMS["period_quarter"], nullable=True), + sa.Column("is_cumulative", sa.Boolean(), nullable=True), + sa.Column("reporting_date", sa.Date(), nullable=True), + sa.Column("locator", sa.String(), nullable=True), + sa.Column("status", ENUMS["document_status"], nullable=False), + sa.Column("period_check", ENUMS["document_check_result"], nullable=True), + sa.Column("counterparty_check", ENUMS["document_check_result"], nullable=True), + sa.Column("confirmed_by", sa.UUID(), nullable=True), + sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["confirmed_by"], ["dkb.app_user.id"], name=op.f("fk_document_confirmed_by_app_user")), + sa.ForeignKeyConstraint( + ["legal_entity_id"], ["dkb.legal_entity.id"], name=op.f("fk_document_legal_entity_id_legal_entity") + ), + sa.ForeignKeyConstraint( + ["stored_file_id"], ["dkb.stored_file.id"], name=op.f("fk_document_stored_file_id_stored_file") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_document")), + schema=SCHEMA, + ) + op.create_index( + "ix_document_legal_entity_period_status", + "document", + ["legal_entity_id", "document_type", "osv_account_code", "period_year", "period_quarter", "status"], + unique=False, + schema=SCHEMA, + ) + op.create_index( + "uq_document_file_type_account", + "document", + ["stored_file_id", "document_type", "osv_account_code"], + unique=True, + schema=SCHEMA, + postgresql_nulls_not_distinct=True, + ) + op.create_table( + "ex_snapshot_member", + sa.Column("ex_snapshot_id", sa.UUID(), nullable=False), + sa.Column("legal_entity_id", sa.UUID(), nullable=False), + sa.Column("inn", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["ex_snapshot_id"], ["dkb.ex_snapshot.id"], name=op.f("fk_ex_snapshot_member_ex_snapshot_id_ex_snapshot") + ), + sa.ForeignKeyConstraint( + ["legal_entity_id"], + ["dkb.legal_entity.id"], + name=op.f("fk_ex_snapshot_member_legal_entity_id_legal_entity"), + ), + sa.PrimaryKeyConstraint("ex_snapshot_id", "legal_entity_id", name=op.f("pk_ex_snapshot_member")), + schema=SCHEMA, + ) + op.create_table( + "recognition_job", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("file_id", sa.UUID(), nullable=False), + sa.Column("pipeline", ENUMS["recognition_pipeline"], nullable=False), + sa.Column("request_subject", sa.String(), nullable=False), + sa.Column("request_msg_id", sa.String(), nullable=True), + sa.Column("result_msg_id", sa.String(), nullable=True), + sa.Column("schema_version", sa.String(), nullable=False), + sa.Column("service_name", sa.String(), nullable=True), + sa.Column("service_version", sa.String(), nullable=True), + sa.Column("status", ENUMS["recognition_status"], nullable=False), + sa.Column("attempt", sa.SmallInteger(), server_default=sa.text("1"), nullable=False), + sa.Column("max_attempts", sa.SmallInteger(), server_default=sa.text("1"), nullable=False), + sa.Column("requested_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("reply_deadline", sa.DateTime(timezone=True), nullable=True), + sa.Column("external_task_id", sa.String(), nullable=True), + sa.Column("error_code", sa.String(), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("result_s3_object_id", sa.UUID(), nullable=True), + sa.Column("result_schema_version", sa.String(), nullable=True), + sa.Column("group_schema", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("metrics", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("fields_total", sa.Integer(), nullable=True), + sa.Column("fields_low_confidence", sa.Integer(), nullable=True), + sa.Column("fields_unrecognized", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["file_id"], ["dkb.stored_file.id"], name=op.f("fk_recognition_job_file_id_stored_file") + ), + sa.ForeignKeyConstraint( + ["result_s3_object_id"], ["dkb.s3_object.id"], name=op.f("fk_recognition_job_result_s3_object_id_s3_object") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_recognition_job")), + sa.UniqueConstraint("request_msg_id", name=op.f("uq_recognition_job_request_msg_id")), + sa.UniqueConstraint("result_msg_id", name=op.f("uq_recognition_job_result_msg_id")), + schema=SCHEMA, + ) + op.create_index( + "uq_recognition_job_file_id_attempt", "recognition_job", ["file_id", "attempt"], unique=True, schema=SCHEMA + ) + # цикл stored_file <-> recognition_job: FK на nullable-стороне добавляется после создания обеих таблиц + op.create_foreign_key( + FK_CYCLE, + "stored_file", + "recognition_job", + ["current_recognition_job_id"], + ["id"], + source_schema=SCHEMA, + referent_schema=SCHEMA, + ) + op.create_table( + "audit_event", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=True), + sa.Column("action", ENUMS["audit_action"], nullable=False), + sa.Column("section", ENUMS["audit_section"], nullable=False), + sa.Column("object_type", sa.String(), nullable=False), + sa.Column("object_id", sa.UUID(), nullable=True), + sa.Column("consolidation_version_id", sa.UUID(), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("before_value", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("after_value", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("comment", sa.Text(), nullable=True), + sa.Column("result", sa.String(), nullable=True), + sa.Column("changed_field", sa.String(), nullable=True), + sa.Column("ip_address", sa.String(), nullable=True), + sa.Column("user_agent", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["consolidation_version_id"], + ["dkb.consolidation_version.id"], + name=op.f("fk_audit_event_consolidation_version_id_consolidation_version"), + ), + sa.ForeignKeyConstraint(["user_id"], ["dkb.app_user.id"], name=op.f("fk_audit_event_user_id_app_user")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_audit_event")), + schema=SCHEMA, + ) + op.create_index( + "ix_audit_event_object_type_object_id", "audit_event", ["object_type", "object_id"], unique=False, schema=SCHEMA + ) + op.create_index( + "ix_audit_event_user_occurred_at", "audit_event", ["user_id", "occurred_at"], unique=False, schema=SCHEMA + ) + op.create_index( + "ix_audit_event_version_occurred_at", + "audit_event", + ["consolidation_version_id", "occurred_at"], + unique=False, + schema=SCHEMA, + ) + op.create_table( + "client_kfo_line", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("consolidation_version_id", sa.UUID(), nullable=False), + sa.Column("statement_type", ENUMS["statement_type"], nullable=False), + sa.Column("line_code", sa.String(), nullable=False), + sa.Column("client_amount", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("client_vgo_amount", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("bank_amount", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("bank_vgo_amount", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("deviation_amount", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("deviation_percent", sa.Numeric(precision=9, scale=4), nullable=True), + sa.Column("deviation_reason", sa.Text(), nullable=True), + sa.Column("comment", sa.Text(), nullable=True), + sa.Column("entered_by", sa.UUID(), nullable=True), + sa.Column("entered_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["consolidation_version_id"], + ["dkb.consolidation_version.id"], + name="fk_client_kfo_line_consolidation_version_id", + ), + sa.ForeignKeyConstraint( + ["entered_by"], ["dkb.app_user.id"], name=op.f("fk_client_kfo_line_entered_by_app_user") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_client_kfo_line")), + schema=SCHEMA, + ) + op.create_index( + "uq_client_kfo_line_version_type_code", + "client_kfo_line", + ["consolidation_version_id", "statement_type", "line_code"], + unique=True, + schema=SCHEMA, + ) + op.create_table( + "consolidation_document", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("consolidation_version_id", sa.UUID(), nullable=False), + sa.Column("document_id", sa.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["consolidation_version_id"], + ["dkb.consolidation_version.id"], + name="fk_consolidation_document_consolidation_version_id", + ), + sa.ForeignKeyConstraint( + ["document_id"], ["dkb.document.id"], name=op.f("fk_consolidation_document_document_id_document") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_consolidation_document")), + schema=SCHEMA, + ) + op.create_index( + "uq_consolidation_document_version_document", + "consolidation_document", + ["consolidation_version_id", "document_id"], + unique=True, + schema=SCHEMA, + ) + op.create_table( + "consolidation_lock", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("consolidation_version_id", sa.UUID(), nullable=False), + sa.Column("locked_by", sa.UUID(), nullable=False), + sa.Column("locked_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("released_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("taken_over_from", sa.UUID(), nullable=True), + sa.Column("takeover_reason", sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ["consolidation_version_id"], + ["dkb.consolidation_version.id"], + name="fk_consolidation_lock_consolidation_version_id", + ), + sa.ForeignKeyConstraint( + ["locked_by"], ["dkb.app_user.id"], name=op.f("fk_consolidation_lock_locked_by_app_user") + ), + sa.ForeignKeyConstraint( + ["taken_over_from"], ["dkb.app_user.id"], name=op.f("fk_consolidation_lock_taken_over_from_app_user") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_consolidation_lock")), + schema=SCHEMA, + ) + op.create_index( + "uq_consolidation_lock_version_active", + "consolidation_lock", + ["consolidation_version_id"], + unique=True, + schema=SCHEMA, + postgresql_where=sa.text("released_at IS NULL"), + ) + op.create_table( + "consolidation_member", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("consolidation_version_id", sa.UUID(), nullable=False), + sa.Column("legal_entity_id", sa.UUID(), nullable=False), + sa.Column("is_head_entity", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.ForeignKeyConstraint( + ["consolidation_version_id"], + ["dkb.consolidation_version.id"], + name="fk_consolidation_member_consolidation_version_id", + ), + sa.ForeignKeyConstraint( + ["legal_entity_id"], + ["dkb.legal_entity.id"], + name=op.f("fk_consolidation_member_legal_entity_id_legal_entity"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_consolidation_member")), + schema=SCHEMA, + ) + op.create_index( + "uq_consolidation_member_version_entity", + "consolidation_member", + ["consolidation_version_id", "legal_entity_id"], + unique=True, + schema=SCHEMA, + ) + op.create_table( + "consolidation_status_history", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("version_id", sa.UUID(), nullable=False), + sa.Column("from_status", ENUMS["consolidation_status"], nullable=True), + sa.Column("to_status", ENUMS["consolidation_status"], nullable=False), + sa.Column("changed_by", sa.UUID(), nullable=False), + sa.Column("changed_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("comment", sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ["changed_by"], ["dkb.app_user.id"], name=op.f("fk_consolidation_status_history_changed_by_app_user") + ), + sa.ForeignKeyConstraint( + ["version_id"], ["dkb.consolidation_version.id"], name="fk_consolidation_status_history_version_id" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_consolidation_status_history")), + schema=SCHEMA, + ) + op.create_table( + "correction", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("consolidation_version_id", sa.UUID(), nullable=False), + sa.Column("statement_type", ENUMS["statement_type"], nullable=False), + sa.Column("rule_id", sa.UUID(), nullable=True), + sa.Column("parent_correction_id", sa.UUID(), nullable=True), + sa.Column("legal_entity_id", sa.UUID(), nullable=True), + sa.Column("counterparty_id", sa.UUID(), nullable=True), + sa.Column("debit_line_code", sa.String(), nullable=True), + sa.Column("debit_account_id", sa.UUID(), nullable=True), + sa.Column("debit_adjustment", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("credit_line_code", sa.String(), nullable=True), + sa.Column("credit_account_id", sa.UUID(), nullable=True), + sa.Column("credit_adjustment", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("deviation_amount", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("deviation_percent", sa.Numeric(precision=9, scale=4), nullable=True), + sa.Column("reason_code", sa.String(), nullable=True), + sa.Column("comment", sa.Text(), nullable=False), + sa.Column("status", ENUMS["correction_status"], server_default=sa.text("'DRAFT'"), nullable=False), + sa.Column("version_no", sa.Integer(), server_default=sa.text("1"), nullable=False), + sa.Column("created_by", sa.UUID(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_by", sa.UUID(), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["consolidation_version_id"], + ["dkb.consolidation_version.id"], + name=op.f("fk_correction_consolidation_version_id_consolidation_version"), + ), + sa.ForeignKeyConstraint( + ["counterparty_id"], ["dkb.legal_entity.id"], name=op.f("fk_correction_counterparty_id_legal_entity") + ), + sa.ForeignKeyConstraint(["created_by"], ["dkb.app_user.id"], name=op.f("fk_correction_created_by_app_user")), + sa.ForeignKeyConstraint( + ["credit_account_id"], + ["dkb.chart_of_account.id"], + name=op.f("fk_correction_credit_account_id_chart_of_account"), + ), + sa.ForeignKeyConstraint( + ["debit_account_id"], + ["dkb.chart_of_account.id"], + name=op.f("fk_correction_debit_account_id_chart_of_account"), + ), + sa.ForeignKeyConstraint( + ["legal_entity_id"], ["dkb.legal_entity.id"], name=op.f("fk_correction_legal_entity_id_legal_entity") + ), + sa.ForeignKeyConstraint( + ["parent_correction_id"], ["dkb.correction.id"], name=op.f("fk_correction_parent_correction_id_correction") + ), + sa.ForeignKeyConstraint( + ["rule_id"], ["dkb.elimination_rule.id"], name=op.f("fk_correction_rule_id_elimination_rule") + ), + sa.ForeignKeyConstraint(["updated_by"], ["dkb.app_user.id"], name=op.f("fk_correction_updated_by_app_user")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_correction")), + schema=SCHEMA, + ) + op.create_index( + "ix_correction_consolidation_version_id", + "correction", + ["consolidation_version_id"], + unique=False, + schema=SCHEMA, + ) + op.create_index( + "ix_correction_version_rule_entities", + "correction", + ["consolidation_version_id", "rule_id", "legal_entity_id", "counterparty_id"], + unique=False, + schema=SCHEMA, + ) + op.create_table( + "ex_snapshot_item", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("ex_snapshot_id", sa.UUID(), nullable=False), + sa.Column("item_type", sa.String(), nullable=False), + sa.Column("legal_entity_id", sa.UUID(), nullable=True), + sa.Column("document_id", sa.UUID(), nullable=True), + sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("payload_schema_version", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["document_id"], ["dkb.document.id"], name=op.f("fk_ex_snapshot_item_document_id_document") + ), + sa.ForeignKeyConstraint( + ["ex_snapshot_id"], ["dkb.ex_snapshot.id"], name=op.f("fk_ex_snapshot_item_ex_snapshot_id_ex_snapshot") + ), + sa.ForeignKeyConstraint( + ["legal_entity_id"], ["dkb.legal_entity.id"], name=op.f("fk_ex_snapshot_item_legal_entity_id_legal_entity") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_ex_snapshot_item")), + schema=SCHEMA, + ) + op.create_index( + "ix_ex_snapshot_item_snapshot_item_type", + "ex_snapshot_item", + ["ex_snapshot_id", "item_type"], + unique=False, + schema=SCHEMA, + ) + op.create_table( + "financial_statement_meta", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("document_id", sa.UUID(), nullable=False), + sa.Column("recognition_job_id", sa.UUID(), nullable=False), + sa.Column("statement_type", ENUMS["statement_type"], nullable=False), + sa.Column("inn", sa.String(), nullable=True), + sa.Column("inn_original_value", sa.String(), nullable=True), + sa.Column("inn_confidence", sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column("inn_verified", sa.Boolean(), nullable=True), + sa.Column("inn_modified", sa.Boolean(), nullable=True), + sa.Column("organization_name", sa.String(), nullable=True), + sa.Column("report_date", sa.Date(), nullable=True), + sa.Column("report_date_original_value", sa.String(), nullable=True), + sa.Column("report_date_verified", sa.Boolean(), nullable=True), + sa.Column("report_date_modified", sa.Boolean(), nullable=True), + sa.Column("units", sa.String(), nullable=True), + sa.Column("units_original_value", sa.String(), nullable=True), + sa.Column("units_verified", sa.Boolean(), nullable=True), + sa.Column("units_modified", sa.Boolean(), nullable=True), + sa.Column("currency", sa.String(), server_default=sa.text("'RUB'"), nullable=True), + sa.Column("okpo", sa.String(), nullable=True), + sa.Column("okopf", sa.String(), nullable=True), + sa.Column("okfs", sa.String(), nullable=True), + sa.Column("okud", sa.String(), nullable=True), + sa.Column("okei", sa.String(), nullable=True), + sa.Column("legal_form", sa.String(), nullable=True), + sa.Column("ownership_form", sa.String(), nullable=True), + sa.Column("address", sa.String(), nullable=True), + sa.Column("subject_to_audit", sa.Boolean(), nullable=True), + sa.Column("reviewed_by", sa.UUID(), nullable=True), + sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("checks_accepted_by", sa.UUID(), nullable=True), + sa.Column("checks_accepted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("checks_accepted_comment", sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ["checks_accepted_by"], + ["dkb.app_user.id"], + name=op.f("fk_financial_statement_meta_checks_accepted_by_app_user"), + ), + sa.ForeignKeyConstraint( + ["document_id"], ["dkb.document.id"], name=op.f("fk_financial_statement_meta_document_id_document") + ), + sa.ForeignKeyConstraint( + ["recognition_job_id"], + ["dkb.recognition_job.id"], + name=op.f("fk_financial_statement_meta_recognition_job_id_recognition_job"), + ), + sa.ForeignKeyConstraint( + ["reviewed_by"], ["dkb.app_user.id"], name=op.f("fk_financial_statement_meta_reviewed_by_app_user") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_financial_statement_meta")), + sa.UniqueConstraint("document_id", name=op.f("uq_financial_statement_meta_document_id")), + schema=SCHEMA, + ) + op.create_table( + "goodwill", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("consolidation_version_id", sa.UUID(), nullable=False), + sa.Column("head_legal_entity_id", sa.UUID(), nullable=False), + sa.Column("subsidiary_legal_entity_id", sa.UUID(), nullable=False), + sa.Column("ownership_share", sa.Numeric(precision=7, scale=4), nullable=True), + sa.Column("financial_investment_amount", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("charter_capital_amount", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("additional_capital_amount", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("participation_amount", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("goodwill_amount", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("include_in_kfo", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("comment", sa.Text(), nullable=True), + sa.Column("created_by", sa.UUID(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_by", sa.UUID(), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["consolidation_version_id"], + ["dkb.consolidation_version.id"], + name=op.f("fk_goodwill_consolidation_version_id_consolidation_version"), + ), + sa.ForeignKeyConstraint(["created_by"], ["dkb.app_user.id"], name=op.f("fk_goodwill_created_by_app_user")), + sa.ForeignKeyConstraint( + ["head_legal_entity_id"], + ["dkb.legal_entity.id"], + name=op.f("fk_goodwill_head_legal_entity_id_legal_entity"), + ), + sa.ForeignKeyConstraint( + ["subsidiary_legal_entity_id"], + ["dkb.legal_entity.id"], + name=op.f("fk_goodwill_subsidiary_legal_entity_id_legal_entity"), + ), + sa.ForeignKeyConstraint(["updated_by"], ["dkb.app_user.id"], name=op.f("fk_goodwill_updated_by_app_user")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_goodwill")), + schema=SCHEMA, + ) + op.create_index( + "uq_goodwill_version_head_subsidiary", + "goodwill", + ["consolidation_version_id", "head_legal_entity_id", "subsidiary_legal_entity_id"], + unique=True, + schema=SCHEMA, + ) + op.create_table( + "kfo_result", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("consolidation_version_id", sa.UUID(), nullable=False), + sa.Column("computed_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("is_frozen", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("net_assets", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("balance_total", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("revenue", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("sales_profit", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("total_deviation_bb", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("total_deviation_ofr", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("threshold_bb_exceeded", sa.Boolean(), nullable=True), + sa.Column("threshold_ofr_exceeded", sa.Boolean(), nullable=True), + sa.Column("threshold_justification", sa.Text(), nullable=True), + sa.Column("goodwill_total", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("has_deviations", sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint( + ["consolidation_version_id"], + ["dkb.consolidation_version.id"], + name=op.f("fk_kfo_result_consolidation_version_id_consolidation_version"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_kfo_result")), + sa.UniqueConstraint("consolidation_version_id", name=op.f("uq_kfo_result_consolidation_version_id")), + schema=SCHEMA, + ) + op.create_table( + "osv_entry", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("recognition_job_id", sa.UUID(), nullable=False), + sa.Column("document_id", sa.UUID(), nullable=False), + sa.Column("owner_legal_entity_id", sa.UUID(), nullable=False), + sa.Column("parent_entry_id", sa.UUID(), nullable=True), + sa.Column("group_type", ENUMS["osv_group_type"], nullable=True), + sa.Column("group_name", sa.String(), nullable=True), + sa.Column("row_number", sa.Integer(), nullable=True), + sa.Column("level", sa.SmallInteger(), nullable=True), + sa.Column("row_value", sa.String(), nullable=True), + sa.Column("data_type", ENUMS["osv_data_type"], nullable=True), + sa.Column("account_code", sa.String(), nullable=True), + sa.Column("account_id", sa.UUID(), nullable=True), + sa.Column("account_root", sa.String(), nullable=True), + sa.Column("counterparty_name", sa.String(), nullable=True), + sa.Column("counterparty_inn", sa.String(), nullable=True), + sa.Column("counterparty_id", sa.UUID(), nullable=True), + sa.Column("opening_debit", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("opening_credit", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("turnover_debit", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("turnover_credit", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("closing_debit", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("closing_credit", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("is_modified", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("original_values", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("modified_by", sa.UUID(), nullable=True), + sa.Column("modified_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("is_excisable", sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint( + ["account_id"], ["dkb.chart_of_account.id"], name=op.f("fk_osv_entry_account_id_chart_of_account") + ), + sa.ForeignKeyConstraint( + ["counterparty_id"], ["dkb.legal_entity.id"], name=op.f("fk_osv_entry_counterparty_id_legal_entity") + ), + sa.ForeignKeyConstraint(["document_id"], ["dkb.document.id"], name=op.f("fk_osv_entry_document_id_document")), + sa.ForeignKeyConstraint(["modified_by"], ["dkb.app_user.id"], name=op.f("fk_osv_entry_modified_by_app_user")), + sa.ForeignKeyConstraint( + ["owner_legal_entity_id"], + ["dkb.legal_entity.id"], + name=op.f("fk_osv_entry_owner_legal_entity_id_legal_entity"), + ), + sa.ForeignKeyConstraint( + ["parent_entry_id"], ["dkb.osv_entry.id"], name=op.f("fk_osv_entry_parent_entry_id_osv_entry") + ), + sa.ForeignKeyConstraint( + ["recognition_job_id"], + ["dkb.recognition_job.id"], + name=op.f("fk_osv_entry_recognition_job_id_recognition_job"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_osv_entry")), + schema=SCHEMA, + ) + op.create_index( + "ix_osv_entry_owner_account_counterparty", + "osv_entry", + ["owner_legal_entity_id", "account_id", "counterparty_id"], + unique=False, + schema=SCHEMA, + ) + op.create_index( + "uq_osv_entry_document_id_row_number", "osv_entry", ["document_id", "row_number"], unique=True, schema=SCHEMA + ) + op.create_table( + "financial_statement_column", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("meta_id", sa.UUID(), nullable=False), + sa.Column("column_index", sa.SmallInteger(), nullable=False), + sa.Column("label", sa.String(), nullable=True), + sa.Column("period_date", sa.Date(), nullable=True), + sa.Column("is_current", sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint( + ["meta_id"], + ["dkb.financial_statement_meta.id"], + name=op.f("fk_financial_statement_column_meta_id_financial_statement_meta"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_financial_statement_column")), + schema=SCHEMA, + ) + op.create_index( + "uq_financial_statement_column_meta_id_column_index", + "financial_statement_column", + ["meta_id", "column_index"], + unique=True, + schema=SCHEMA, + ) + op.create_table( + "financial_statement_line", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("meta_id", sa.UUID(), nullable=False), + sa.Column("line_code", sa.String(), nullable=True), + sa.Column("line_code_original_value", sa.String(), nullable=True), + sa.Column("line_code_confidence", sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column("line_code_modified", sa.Boolean(), nullable=True), + sa.Column("line_name", sa.String(), nullable=True), + sa.Column("line_name_original_value", sa.String(), nullable=True), + sa.Column("line_name_confidence", sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column("line_name_modified", sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint( + ["meta_id"], + ["dkb.financial_statement_meta.id"], + name=op.f("fk_financial_statement_line_meta_id_financial_statement_meta"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_financial_statement_line")), + schema=SCHEMA, + ) + op.create_index( + "uq_financial_statement_line_meta_id_line_code", + "financial_statement_line", + ["meta_id", "line_code"], + unique=True, + schema=SCHEMA, + ) + op.create_table( + "kfo_result_line", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("kfo_result_id", sa.UUID(), nullable=False), + sa.Column("statement_type", ENUMS["statement_type"], nullable=False), + sa.Column("line_code", sa.String(), nullable=False), + sa.Column("line_name", sa.String(), nullable=True), + sa.Column("sum_amount", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("eliminated_auto", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("eliminated_manual", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("goodwill_adjust", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("consolidated_amount", sa.Numeric(precision=18, scale=2), nullable=False), + sa.Column("vgo_amount", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("source_kind", sa.String(), nullable=True), + sa.Column("deviation_amount", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("comment", sa.Text(), nullable=True), + sa.Column("ordinal", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["kfo_result_id"], ["dkb.kfo_result.id"], name=op.f("fk_kfo_result_line_kfo_result_id_kfo_result") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_kfo_result_line")), + schema=SCHEMA, + ) + op.create_index( + "uq_kfo_result_line_result_type_code", + "kfo_result_line", + ["kfo_result_id", "statement_type", "line_code"], + unique=True, + schema=SCHEMA, + ) + op.create_table( + "financial_statement_value", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("line_id", sa.UUID(), nullable=False), + sa.Column("column_id", sa.UUID(), nullable=False), + sa.Column("amount", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("string_value", sa.String(), nullable=True), + sa.Column("confidence", sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column("is_recognized", sa.Boolean(), nullable=True), + sa.Column("is_modified", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("original_amount", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("modified_by", sa.UUID(), nullable=True), + sa.Column("modified_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["column_id"], + ["dkb.financial_statement_column.id"], + name="fk_financial_statement_value_column_id_fs_column", + ), + sa.ForeignKeyConstraint( + ["line_id"], + ["dkb.financial_statement_line.id"], + name=op.f("fk_financial_statement_value_line_id_financial_statement_line"), + ), + sa.ForeignKeyConstraint( + ["modified_by"], ["dkb.app_user.id"], name=op.f("fk_financial_statement_value_modified_by_app_user") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_financial_statement_value")), + schema=SCHEMA, + ) + op.create_index( + "uq_financial_statement_value_line_id_column_id", + "financial_statement_value", + ["line_id", "column_id"], + unique=True, + schema=SCHEMA, + ) + op.create_table( + "kfo_result_line_company", + sa.Column("kfo_result_line_id", sa.UUID(), nullable=False), + sa.Column("legal_entity_id", sa.UUID(), nullable=False), + sa.Column("amount", sa.Numeric(precision=18, scale=2), nullable=True), + sa.Column("share_percent", sa.Numeric(precision=7, scale=4), nullable=True), + sa.ForeignKeyConstraint( + ["kfo_result_line_id"], + ["dkb.kfo_result_line.id"], + name=op.f("fk_kfo_result_line_company_kfo_result_line_id_kfo_result_line"), + ), + sa.ForeignKeyConstraint( + ["legal_entity_id"], + ["dkb.legal_entity.id"], + name=op.f("fk_kfo_result_line_company_legal_entity_id_legal_entity"), + ), + sa.PrimaryKeyConstraint("kfo_result_line_id", "legal_entity_id", name=op.f("pk_kfo_result_line_company")), + schema=SCHEMA, + ) + + +def downgrade() -> None: + """Полный откат: FK цикла -> таблицы в обратном порядке (с индексами) -> enum-типы. Схема dkb остаётся.""" + op.drop_constraint(FK_CYCLE, "stored_file", type_="foreignkey", schema=SCHEMA) + for table in reversed(TABLES): + op.drop_table(table, schema=SCHEMA) + bind = op.get_bind() + for enum_type in reversed(ENUMS.values()): + enum_type.drop(bind, checkfirst=False) diff --git a/api/pyproject.toml b/api/pyproject.toml index d9ed6ed..f7ede09 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -7,6 +7,10 @@ target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "PL"] +# Ревизии alembic — линейный DDL: одна функция upgrade() на десятки create_table/create_index +[tool.ruff.lint.per-file-ignores] +"alembic/versions/*" = ["PLR0915"] + [tool.ruff.lint.isort] known-first-party = ["src", "scripts"] diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py new file mode 100644 index 0000000..223304e --- /dev/null +++ b/api/tests/test_migrations.py @@ -0,0 +1,284 @@ +"""Миграция 0001 (DBML v5.13) на PostgreSQL 16: полный цикл, пустой autogenerate, схема = схеме ORM. + +Spec data-model, требование «Миграции применяются с нуля и откатываются» (сценарии «Полный цикл» и «Автогенерация +ничего не находит»), design D-2. Alembic запускается отдельным процессом (`python -m alembic -x dburl=...`), как из +CLI: `env.py` вызывает `asyncio.run(...)`, а внутри event loop pytest-asyncio это невозможно; заодно процесс не +перенастраивает логирование тестов через `fileConfig`. Каждая проверка идёт на своей временной БД (`scratch_db_url`). +""" + +import asyncio +import subprocess +import sys +import uuid +from pathlib import Path +from typing import Any + +import pytest +from alembic.autogenerate import compare_metadata +from alembic.runtime.migration import MigrationContext +from sqlalchemy import Connection, text +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine + +from src.models import Base +from src.models.enums import ENUMS +from tests.pg_helpers import create_scratch_db, drop_scratch_db +from tests.test_models_part_01_03 import _check_related_group_member_uniqueness +from tests.test_models_part_04_06 import _check_nulls_not_distinct, _make_file_with_job +from tests.test_models_part_07_09 import ( + ALL_TABLES, + EXPECTED_ENUM_COUNT, + EXPECTED_TABLE_COUNT, + _check_active_lock_is_unique, +) + +API_DIR = Path(__file__).resolve().parents[1] +HEAD = "0001_dbml_v5_13" +VERSION_TABLE = "alembic_version" +FK_CYCLE = "fk_stored_file_current_recognition_job_id_recognition_job" + + +def _run_alembic(url: str, *args: str) -> str: + """`alembic -x dburl= ` из каталога api/; вернуть stdout+stderr, упасть с выводом при ошибке.""" + result = subprocess.run( + [sys.executable, "-m", "alembic", "-x", f"dburl={url}", *args], + cwd=API_DIR, + capture_output=True, + text=True, + check=False, + ) + output = result.stdout + result.stderr + assert result.returncode == 0, f"alembic {' '.join(args)} завершился с кодом {result.returncode}:\n{output}" + return output + + +async def alembic(url: str, *args: str) -> str: + return await asyncio.to_thread(_run_alembic, url, *args) + + +async def _scalars(conn: AsyncConnection, sql: str) -> list[Any]: + return list((await conn.execute(text(sql))).scalars()) + + +async def tables_in_dkb(conn: AsyncConnection) -> set[str]: + return set( + await _scalars( + conn, + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'dkb'", + ) + ) + + +async def enums_in_dkb(conn: AsyncConnection) -> set[str]: + return set( + await _scalars( + conn, + "SELECT t.typname FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace " + "WHERE n.nspname = 'dkb' AND t.typtype = 'e'", + ) + ) + + +async def schema_snapshot(conn: AsyncConnection) -> dict[str, Any]: + """Каталог схемы dkb без служебной таблицы alembic: колонки, ограничения, индексы, enum'ы (с порядком значений).""" + columns = ( + await conn.execute( + text( + "SELECT table_name, ordinal_position, column_name, data_type, udt_schema, udt_name, is_nullable, " + "column_default, numeric_precision, numeric_scale FROM information_schema.columns " + "WHERE table_schema = 'dkb' AND table_name <> :vt ORDER BY table_name, ordinal_position" + ), + {"vt": VERSION_TABLE}, + ) + ).all() + constraints = ( + await conn.execute( + text( + "SELECT c.conname, c.conrelid::regclass::text, pg_get_constraintdef(c.oid) FROM pg_constraint c " + "WHERE c.connamespace = 'dkb'::regnamespace AND c.conrelid::regclass::text <> 'dkb.alembic_version' " + "AND c.contype <> 'n'" + ) + ) + ).all() + indexes = ( + await conn.execute( + text("SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'dkb' AND tablename <> :vt"), + {"vt": VERSION_TABLE}, + ) + ).all() + enums = ( + await conn.execute( + text( + "SELECT t.typname, array_agg(e.enumlabel ORDER BY e.enumsortorder) FROM pg_type t " + "JOIN pg_namespace n ON n.oid = t.typnamespace JOIN pg_enum e ON e.enumtypid = t.oid " + "WHERE n.nspname = 'dkb' GROUP BY t.typname" + ) + ) + ).all() + return { + "columns": [tuple(row) for row in columns], + "constraints": {row[0]: (row[1], row[2]) for row in constraints}, + "indexes": {row[0]: row[1] for row in indexes}, + "enums": {row[0]: list(row[1]) for row in enums}, + } + + +async def _assert_full_model(conn: AsyncConnection) -> None: + tables = await tables_in_dkb(conn) + assert tables - {VERSION_TABLE} == ALL_TABLES + assert len(tables - {VERSION_TABLE}) == EXPECTED_TABLE_COUNT + enums = await enums_in_dkb(conn) + assert enums == set(ENUMS) + assert len(enums) == EXPECTED_ENUM_COUNT + assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [HEAD] + + +async def _truncate_model_tables(engine: AsyncEngine) -> None: + async with engine.begin() as conn: + await conn.execute(text("TRUNCATE " + ", ".join(f"dkb.{table}" for table in sorted(ALL_TABLES)) + " CASCADE")) + + +@pytest.mark.integration +async def test_full_cycle_upgrade_downgrade_upgrade(scratch_db_url: str) -> None: + """Сценарий «Полный цикл»: upgrade head → downgrade base → upgrade head; после отката схема dkb пуста.""" + engine = create_async_engine(scratch_db_url) + try: + await alembic(scratch_db_url, "upgrade", "head") + async with engine.connect() as conn: + await _assert_full_model(conn) + + await alembic(scratch_db_url, "downgrade", "base") + async with engine.connect() as conn: + assert await tables_in_dkb(conn) == {VERSION_TABLE} # служебная таблица alembic остаётся, она пуста + assert await _scalars(conn, "SELECT count(*) FROM dkb.alembic_version") == [0] + assert await enums_in_dkb(conn) == set() + # в схеме не осталось других объектов (последовательностей, типов, индексов) кроме alembic_version + leftovers = await _scalars( + conn, + "SELECT relname FROM pg_class WHERE relnamespace = 'dkb'::regnamespace " + "AND relname NOT IN ('alembic_version', 'alembic_version_pkc')", + ) + assert leftovers == [] + types = await _scalars( + conn, + "SELECT typname FROM pg_type WHERE typnamespace = 'dkb'::regnamespace " + "AND typname NOT IN ('alembic_version', '_alembic_version')", + ) + assert types == [] + + await alembic(scratch_db_url, "upgrade", "head") + async with engine.connect() as conn: + await _assert_full_model(conn) + finally: + await engine.dispose() + + +@pytest.mark.integration +async def test_autogenerate_finds_nothing_after_upgrade(scratch_db_url: str) -> None: + """Сценарий «Автогенерация ничего не находит»: compare_metadata пуст, `alembic check` не видит операций.""" + await alembic(scratch_db_url, "upgrade", "head") + + def diff(sync_conn: Connection) -> list[Any]: + context = MigrationContext.configure( + sync_conn, + opts={ + # те же опции сравнения, что в alembic/env.py + "compare_type": True, + "compare_server_default": True, + "include_schemas": True, + "include_name": lambda name, type_, _parents: name == "dkb" if type_ == "schema" else True, + "version_table_schema": "dkb", + }, + ) + return compare_metadata(context, Base.metadata) + + engine = create_async_engine(scratch_db_url) + try: + async with engine.connect() as conn: + assert await conn.run_sync(diff) == [] + finally: + await engine.dispose() + + output = await alembic(scratch_db_url, "check") + assert "No new upgrade operations detected" in output + + +@pytest.mark.integration +async def test_migrated_schema_equals_orm_create_all(scratch_db_url: str) -> None: + """Каталог после миграции = каталог `create_all` ORM: колонки, default, ограничения, индексы, enum (имена и DDL).""" + await alembic(scratch_db_url, "upgrade", "head") + reference_name = f"armcore_scratch_{uuid.uuid4().hex[:12]}" + reference_url = await create_scratch_db(reference_name) + migrated = create_async_engine(scratch_db_url) + reference = create_async_engine(reference_url) + try: + async with reference.begin() as conn: + await conn.execute(text("CREATE SCHEMA IF NOT EXISTS dkb")) + await conn.run_sync(Base.metadata.create_all) + async with migrated.connect() as conn: + got = await schema_snapshot(conn) + async with reference.connect() as conn: + expected = await schema_snapshot(conn) + assert got["enums"] == expected["enums"] + assert got["columns"] == expected["columns"] + assert got["constraints"] == expected["constraints"] + assert got["indexes"] == expected["indexes"] + finally: + await migrated.dispose() + await reference.dispose() + await drop_scratch_db(reference_name) + + +@pytest.mark.integration +async def test_migrated_schema_behaviour(scratch_db_url: str) -> None: + """После миграции: FK-цикл, partial unique, NULLS NOT DISTINCT и server_default работают на реальной БД.""" + await alembic(scratch_db_url, "upgrade", "head") + engine = create_async_engine(scratch_db_url) + try: + async with engine.connect() as conn: + fk = ( + await conn.execute( + text( + "SELECT conrelid::regclass::text, pg_get_constraintdef(oid) FROM pg_constraint " + "WHERE conname = :name AND connamespace = 'dkb'::regnamespace" + ), + {"name": FK_CYCLE}, + ) + ).one() + assert fk[0] == "dkb.stored_file" + assert fk[1] == "FOREIGN KEY (current_recognition_job_id) REFERENCES dkb.recognition_job(id)" + rows = await conn.execute( + text( + "SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'dkb' AND indexname IN " + "('uq_document_file_type_account', 'uq_consolidation_lock_version_active', " + "'uq_related_group_member_entity_current', 'uq_related_group_member_group_entity_current')" + ) + ) + indexdefs = {row[0]: row[1] for row in rows} + assert "NULLS NOT DISTINCT" in indexdefs["uq_document_file_type_account"] + assert "WHERE (released_at IS NULL)" in indexdefs["uq_consolidation_lock_version_active"] + for name in ("uq_related_group_member_entity_current", "uq_related_group_member_group_entity_current"): + assert indexdefs[name].startswith("CREATE UNIQUE INDEX") + assert "WHERE is_current" in indexdefs[name] + + # поведенческие проверки из тестов ORM (create_all) — теперь на схеме из миграции; данные между ними + # очищаются: помощники создают одинаковые справочные записи (ГО, группа G1) + await _check_related_group_member_uniqueness(engine) + await _truncate_model_tables(engine) + async with AsyncSession(engine, expire_on_commit=False) as session: + chain = await _make_file_with_job(session) # замыкает FK-цикл, проверяет server_default + await session.commit() + await _check_nulls_not_distinct(session, chain) + await _truncate_model_tables(engine) + async with AsyncSession(engine, expire_on_commit=False) as session: + await _check_active_lock_is_unique(session) + finally: + await engine.dispose() + + +def test_offline_sql_creates_schema_before_version_table() -> None: + """Офлайн-режим (`upgrade head --sql`): CREATE SCHEMA dkb идёт до таблицы версий и до enum-типов.""" + output = _run_alembic("postgresql+asyncpg://user:pass@localhost/offline", "upgrade", "head", "--sql") + schema_at = output.index("CREATE SCHEMA IF NOT EXISTS dkb") + assert schema_at < output.index("CREATE TABLE dkb.alembic_version") + assert schema_at < output.index("CREATE TYPE dkb.entity_type AS ENUM ('ЮЛ', 'ИП')") + assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{HEAD}')" in output -- 2.47.2 From 3a057b259141a607c91cd8d71b871a4392e43092 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 20:27:29 +0400 Subject: [PATCH 37/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 85c56a0..3206ca2 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -57,7 +57,7 @@ ### Task 10: [2.6] `alembic/env.py` -- [ ] `alembic/env.py`: `CREATE SCHEMA IF NOT EXISTS dkb` перед миграциями; ревизия `0001_dbml_v5_13.py` (черновик autogenerate → ручная правка: enum'ы, partial-индексы, `NULLS NOT DISTINCT`, `use_alter`, полный `downgrade`); проверка: `alembic upgrade head && alembic downgrade base && alembic upgrade head` на compose-PostgreSQL 16; `alembic revision --autogenerate` после — пустая ревизия +- [x] `alembic/env.py`: `CREATE SCHEMA IF NOT EXISTS dkb` перед миграциями; ревизия `0001_dbml_v5_13.py` (черновик autogenerate → ручная правка: enum'ы, partial-индексы, `NULLS NOT DISTINCT`, `use_alter`, полный `downgrade`); проверка: `alembic upgrade head && alembic downgrade base && alembic upgrade head` на compose-PostgreSQL 16; `alembic revision --autogenerate` после — пустая ревизия ### Task 11: [2.7] `tests/conftest.py` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index c882cdf..38a3f68 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -43,7 +43,7 @@ - [x] 2.5 `scripts/check_dbml_vs_orm.py` (pydbml; сравнение таблиц, колонок, типов, nullability, PK/UNIQUE/FK, индексов с notes-шаблонами, enum'ов, версии) + `tests/test_dbml_parity.py`; проверка: 0 расхождений на 2.1–2.4; намеренно добавленная колонка даёт код 1 и понятное сообщение -- [ ] 2.6 `alembic/env.py`: `CREATE SCHEMA IF NOT EXISTS dkb` перед миграциями; ревизия `0001_dbml_v5_13.py` (черновик +- [x] 2.6 `alembic/env.py`: `CREATE SCHEMA IF NOT EXISTS dkb` перед миграциями; ревизия `0001_dbml_v5_13.py` (черновик autogenerate → ручная правка: enum'ы, partial-индексы, `NULLS NOT DISTINCT`, `use_alter`, полный `downgrade`); проверка: `alembic upgrade head && alembic downgrade base && alembic upgrade head` на compose-PostgreSQL 16; `alembic revision --autogenerate` после — пустая ревизия -- 2.47.2 From 78e4a953278ca0aae5c5abdf404dfb79cb74fab9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 20:36:13 +0400 Subject: [PATCH 38/97] =?UTF-8?q?fix:=20=D1=81=D1=85=D0=B5=D0=BC=D0=B0=20d?= =?UTF-8?q?kb=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D1=91=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=82=D1=81=D1=83=D1=82=D1=81=D1=82=D0=B2=D0=B8=D0=B8=20?= =?UTF-8?q?=E2=80=94=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D1=8E=D1=82=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=20CREATE=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B1=D0=B0=D0=B7=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostgreSQL проверяет право CREATE на базу раньше IF NOT EXISTS: роль миграций, которой платформа выдала готовую схему dkb без этого права, падала на CREATE SCHEMA IF NOT EXISTS. env.py (онлайн) проверяет pg_namespace и создаёт схему только при отсутствии; офлайн-SQL и upgrade() ревизии 0001 — DO-блок с той же проверкой. Тест: схема заранее во владении отдельной роли, CREATE на базу отозван у PUBLIC — upgrade/check/downgrade/upgrade проходят. Co-Authored-By: Claude Fable 5.1 --- api/alembic/env.py | 21 +++++++--- api/alembic/versions/0001_dbml_v5_13.py | 10 ++++- api/tests/test_migrations.py | 56 +++++++++++++++++++++++-- 3 files changed, 75 insertions(+), 12 deletions(-) diff --git a/api/alembic/env.py b/api/alembic/env.py index f2baf16..ebf310d 100644 --- a/api/alembic/env.py +++ b/api/alembic/env.py @@ -6,7 +6,8 @@ URL БД (по убыванию приоритета): шаблонной заглушки `driver://...` из `alembic.ini`; 3. `settings.OPENBAO.DATABASE.DATABASE_URL` (`.env` / `ENV_FILE`) — обычный `alembic upgrade head` / `make migrate`. -Схема `dkb` создаётся (`CREATE SCHEMA IF NOT EXISTS`) до таблицы версий `dkb.alembic_version` и до миграций. +Схема `dkb` создаётся до таблицы версий `dkb.alembic_version` и до миграций — только если её нет: PostgreSQL проверяет +право CREATE на базу раньше, чем `IF NOT EXISTS`, а платформа может выдать роли миграций готовую схему без этого права. """ import asyncio @@ -22,6 +23,11 @@ from src.models import Base # модели регистрируются в src/ SCHEMA = "dkb" #: Значение `sqlalchemy.url` в шаблонном `alembic.ini` — признак «URL не задан вызывающим кодом». INI_PLACEHOLDER_PREFIX = "driver://" +#: Офлайн-вариант условного создания схемы: условие проверяется при применении SQL-скрипта, а не при его генерации. +CREATE_SCHEMA_IF_MISSING_SQL = ( + f"DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = '{SCHEMA}') " + f"THEN CREATE SCHEMA {SCHEMA}; END IF; END $$" +) config = context.config @@ -62,7 +68,7 @@ CONFIGURE_OPTS = { def run_migrations_offline() -> None: - """Офлайн-режим (`--sql`): DDL печатается в вывод; `CREATE SCHEMA` идёт первым, до таблицы версий.""" + """Офлайн-режим (`--sql`): DDL печатается в вывод; условный `CREATE SCHEMA` идёт первым, до таблицы версий.""" context.configure( url=database_url(), literal_binds=True, @@ -71,14 +77,17 @@ def run_migrations_offline() -> None: ) with context.begin_transaction(): - context.execute(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}") + context.execute(CREATE_SCHEMA_IF_MISSING_SQL) context.run_migrations() def do_run_migrations(connection: Connection) -> None: - # Схема нужна до того, как alembic создаст `dkb.alembic_version`; фиксируем её отдельной транзакцией — - # иначе autobegin оставил бы соединение во «внешней» транзакции и alembic не стал бы коммитить миграции. - connection.execute(text(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}")) + # Схема нужна до того, как alembic создаст `dkb.alembic_version`. CREATE — только если схемы нет (см. docstring + # модуля). Транзакцию закрываем сразу: иначе autobegin оставил бы соединение во «внешней» транзакции и alembic + # не стал бы коммитить миграции. + exists = connection.execute(text("SELECT 1 FROM pg_namespace WHERE nspname = :name"), {"name": SCHEMA}).scalar() + if exists is None: + connection.execute(text(f"CREATE SCHEMA {SCHEMA}")) connection.commit() context.configure(connection=connection, **CONFIGURE_OPTS) diff --git a/api/alembic/versions/0001_dbml_v5_13.py b/api/alembic/versions/0001_dbml_v5_13.py index 5a68308..bec958c 100644 --- a/api/alembic/versions/0001_dbml_v5_13.py +++ b/api/alembic/versions/0001_dbml_v5_13.py @@ -24,6 +24,12 @@ depends_on: str | Sequence[str] | None = None SCHEMA = "dkb" FK_CYCLE = "fk_stored_file_current_recognition_job_id_recognition_job" +#: Схема создаётся только при отсутствии (и без env.py, и в офлайн-SQL): PostgreSQL проверяет право CREATE на базу +#: раньше, чем `IF NOT EXISTS`, а роли миграций платформа может выдать готовую схему без этого права. +CREATE_SCHEMA_IF_MISSING_SQL = ( + f"DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = '{SCHEMA}') " + f"THEN CREATE SCHEMA {SCHEMA}; END IF; END $$" +) def _enum(name: str, *values: str) -> postgresql.ENUM: @@ -171,11 +177,11 @@ TABLES: tuple[str, ...] = ( def upgrade() -> None: - """Схема dkb (идемпотентно) -> 27 enum-типов -> 43 таблицы с их индексами в порядке зависимостей FK. + """Схема dkb (если её нет) -> 27 enum-типов -> 43 таблицы с их индексами в порядке зависимостей FK. FK цикла `stored_file.current_recognition_job_id` добавляется сразу после создания `recognition_job`. """ - op.execute(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}") + op.execute(CREATE_SCHEMA_IF_MISSING_SQL) bind = op.get_bind() for enum_type in ENUMS.values(): enum_type.create(bind, checkfirst=False) diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 223304e..780717d 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -16,12 +16,12 @@ from typing import Any import pytest from alembic.autogenerate import compare_metadata from alembic.runtime.migration import MigrationContext -from sqlalchemy import Connection, text +from sqlalchemy import Connection, make_url, text from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine from src.models import Base from src.models.enums import ENUMS -from tests.pg_helpers import create_scratch_db, drop_scratch_db +from tests.pg_helpers import base_url, create_scratch_db, drop_scratch_db from tests.test_models_part_01_03 import _check_related_group_member_uniqueness from tests.test_models_part_04_06 import _check_nulls_not_distinct, _make_file_with_job from tests.test_models_part_07_09 import ( @@ -275,10 +275,58 @@ async def test_migrated_schema_behaviour(scratch_db_url: str) -> None: await engine.dispose() +@pytest.mark.integration +async def test_upgrade_into_preprovisioned_schema_without_database_create(scratch_db_url: str) -> None: + """Схему dkb выдала платформа во владение роли миграций, права CREATE на БД у роли нет: цикл миграций проходит. + + PostgreSQL проверяет право CREATE на БД раньше, чем IF NOT EXISTS, поэтому схема создаётся только при отсутствии. + """ + role = f"armcore_mig_{uuid.uuid4().hex[:12]}" # роли общие для кластера — имя уникальное, удаляется в finally + password = uuid.uuid4().hex + scratch_url = make_url(scratch_db_url) + role_url = scratch_url.set(username=role, password=password).render_as_string(hide_password=False) + server = create_async_engine(base_url(), isolation_level="AUTOCOMMIT") + scratch = create_async_engine(scratch_db_url, isolation_level="AUTOCOMMIT") + role_created = False + try: + async with server.connect() as conn: + await conn.execute(text(f"CREATE ROLE \"{role}\" LOGIN PASSWORD '{password}'")) + role_created = True + async with scratch.connect() as conn: + await conn.execute(text(f'REVOKE CREATE ON DATABASE "{scratch_url.database}" FROM PUBLIC')) + await conn.execute(text(f'CREATE SCHEMA dkb AUTHORIZATION "{role}"')) + can_create = await conn.scalar( + text("SELECT has_database_privilege(:role, :db, 'CREATE')"), + {"role": role, "db": scratch_url.database}, + ) + assert can_create is False + + await alembic(role_url, "upgrade", "head") + await alembic(role_url, "check") + await alembic(role_url, "downgrade", "base") + await alembic(role_url, "upgrade", "head") + async with scratch.connect() as conn: + await _assert_full_model(conn) + owner = await conn.scalar(text("SELECT nspowner::regrole::text FROM pg_namespace WHERE nspname = 'dkb'")) + assert owner == role + finally: + if role_created: + async with scratch.connect() as conn: + await conn.execute(text(f'DROP OWNED BY "{role}" CASCADE')) + async with server.connect() as conn: + await conn.execute(text(f'DROP ROLE "{role}"')) + await scratch.dispose() + await server.dispose() + + def test_offline_sql_creates_schema_before_version_table() -> None: - """Офлайн-режим (`upgrade head --sql`): CREATE SCHEMA dkb идёт до таблицы версий и до enum-типов.""" + """Офлайн-режим (`upgrade head --sql`): условный CREATE SCHEMA dkb идёт до таблицы версий и до enum-типов.""" output = _run_alembic("postgresql+asyncpg://user:pass@localhost/offline", "upgrade", "head", "--sql") - schema_at = output.index("CREATE SCHEMA IF NOT EXISTS dkb") + schema_at = output.index( + "DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'dkb') " + "THEN CREATE SCHEMA dkb; END IF; END $$" + ) + assert "CREATE SCHEMA IF NOT EXISTS" not in output # требует права CREATE на базу даже при существующей схеме assert schema_at < output.index("CREATE TABLE dkb.alembic_version") assert schema_at < output.index("CREATE TYPE dkb.entity_type AS ENUM ('ЮЛ', 'ИП')") assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{HEAD}')" in output -- 2.47.2 From c3cf3078669511c2e2d2a4a60a5ce97384457078 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 20:46:50 +0400 Subject: [PATCH 39/97] =?UTF-8?q?test:=20conftest=20=E2=80=94=20=D0=91?= =?UTF-8?q?=D0=94=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B8=20=D1=81=20=D0=BC?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=D0=BC=D0=B8,=20?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BD=D0=B7=D0=B0=D0=BA=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=81=20=D0=BE=D1=82=D0=BA=D0=B0=D1=82=D0=BE=D0=BC=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=82=D0=B5=D1=81=D1=82,=20=D0=BC=D0=B0=D1=80=D0=BA?= =?UTF-8?q?=D0=B5=D1=80=20fixtures,=20=D1=82=D0=B5=D1=81=D1=82=20=D1=87?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D0=B8=D1=8F=20Decimal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/tests/conftest.py | 88 ++++++++++++++++++++++++++++++- api/tests/factories.py | 69 ++++++++++++++++++++++++ api/tests/fixtures_helpers.py | 28 ++++++++++ api/tests/pg_helpers.py | 29 ++++++++++ api/tests/test_fixtures_marker.py | 36 +++++++++++++ api/tests/test_migrations.py | 28 ++-------- api/tests/test_money_types.py | 43 +++++++++++++++ 7 files changed, 294 insertions(+), 27 deletions(-) create mode 100644 api/tests/factories.py create mode 100644 api/tests/fixtures_helpers.py create mode 100644 api/tests/test_fixtures_marker.py create mode 100644 api/tests/test_money_types.py diff --git a/api/tests/conftest.py b/api/tests/conftest.py index e5eeb18..93f6c5c 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,3 +1,87 @@ -"""Общие фикстуры тестов.""" +"""Общие фикстуры тестов (spec data-model; design D-7, рулинг R-16). -pytest_plugins = ["tests.pg_helpers"] +БД-фикстуры — только для тестов с маркером `integration` и ленивые: пока тест их не запросил, PostgreSQL не +трогается (`make check` не требует БД). Тестовая БД сессии — отдельная свежая `armcore_pytest_` на сервере из +`.env.test`: мигрируется alembic один раз на сессию и удаляется в конце; рабочая БД `armka` не используется. +Изоляция тестов — внешняя транзакция с SAVEPOINT: `commit()` внутри теста ничего не фиксирует. +""" + +import uuid +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine + +from tests.fixtures_helpers import fixtures_skip_reason, resolve_fixtures_dir +from tests.pg_helpers import ( # scratch_db_url — реэкспорт фикстуры, чтобы pytest её нашёл + alembic_async, + create_scratch_db, + drop_scratch_db, + scratch_db_url, # noqa: F401 +) + +PG_UNAVAILABLE = "PostgreSQL недоступен (make up / .env.test): {error}" + + +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + """Тесты с маркером `fixtures` пропускаются с явной причиной, если каталог fixtures недоступен.""" + reason = fixtures_skip_reason() + if reason is None: + return + skip = pytest.mark.skip(reason=reason) + for item in items: + if "fixtures" in item.keywords: + item.add_marker(skip) + + +@pytest.fixture(scope="session") +def fixtures_dir() -> Path: + """Каталог реальных данных (`ARM_KA_FIXTURES` или соседний `fixtures`); без него тест пропускается.""" + reason = fixtures_skip_reason() + if reason is not None: + pytest.skip(reason) + return resolve_fixtures_dir() + + +@pytest_asyncio.fixture(scope="session") +async def migrated_db_url() -> AsyncIterator[str]: + """URL свежей БД `armcore_pytest_` с применённой миграцией `alembic upgrade head`; удаляется в конце сессии.""" + name = f"armcore_pytest_{uuid.uuid4().hex[:12]}" + try: + url = await create_scratch_db(name) + except (OSError, ConnectionError) as error: + pytest.fail(PG_UNAVAILABLE.format(error=error), pytrace=False) + except Exception as error: # asyncpg: PostgresError/InterfaceError и т.п. при недоступном сервере + pytest.fail(PG_UNAVAILABLE.format(error=f"{type(error).__name__}: {error}"), pytrace=False) + try: + rendered = url.render_as_string(hide_password=False) + await alembic_async(rendered, "upgrade", "head") + yield rendered + finally: + await drop_scratch_db(name) + + +@pytest_asyncio.fixture(scope="session") +async def db_engine(migrated_db_url: str) -> AsyncIterator[AsyncEngine]: + """Async-движок на мигрированную БД сессии; `search_path = dkb`.""" + engine = create_async_engine(migrated_db_url, connect_args={"server_settings": {"search_path": "dkb"}}) + try: + yield engine + finally: + await engine.dispose() + + +@pytest_asyncio.fixture +async def db_session(db_engine: AsyncEngine) -> AsyncIterator[AsyncSession]: + """Сессия в внешней транзакции с откатом: `commit()` в тесте освобождает лишь SAVEPOINT, данные не остаются.""" + connection: AsyncConnection = await db_engine.connect() + transaction = await connection.begin() + session = AsyncSession(bind=connection, join_transaction_mode="create_savepoint", expire_on_commit=False) + try: + yield session + finally: + await session.close() + await transaction.rollback() + await connection.close() diff --git a/api/tests/factories.py b/api/tests/factories.py new file mode 100644 index 0000000..38a6cc8 --- /dev/null +++ b/api/tests/factories.py @@ -0,0 +1,69 @@ +"""Фабрики тестовых данных для БД-тестов: минимальные цепочки FK поверх сессии (`flush`, без `commit`).""" + +import datetime +import decimal +import uuid +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.directories import LegalEntity +from src.models.enums import ( + DocumentStatus, + DocumentType, + FileStatus, + FileType, + RecognitionPipeline, + RecognitionStatus, + S3BucketRole, +) +from src.models.files import Document, RecognitionJob, S3Object, StoredFile +from src.models.osv import OsvEntry + + +async def make_osv_entry(session: AsyncSession, **overrides: Any) -> OsvEntry: + """Строка ОСВ с минимальной цепочкой FK (ЮЛ, s3_object, stored_file, recognition_job, document); `flush`. + + `overrides` перекрывают поля `OsvEntry` (например, `closing_debit=Decimal("1.00")`). Данные остаются в + транзакции сессии: фикстура `db_session` откатывает их после теста. + """ + now = datetime.datetime.now(datetime.UTC) + entity = LegalEntity(inn="7700000001", name="ООО Тест", valid_from=now) + s3 = S3Object(bucket_role=S3BucketRole.uploads, bucket="uploads", key=f"{uuid.uuid4()}/osv.xlsx", created_at=now) + session.add_all([entity, s3]) + await session.flush() + file = StoredFile( + legal_entity_id=entity.id, file_type=FileType.XLSX, s3_object_id=s3.id, status=FileStatus.UPLOADED + ) + session.add(file) + await session.flush() + job = RecognitionJob( + file_id=file.id, + pipeline=RecognitionPipeline.tabular, + request_subject="recognition.tabular.request", + schema_version="1", + status=RecognitionStatus.queued, + ) + session.add(job) + await session.flush() + document = Document( + stored_file_id=file.id, + legal_entity_id=entity.id, + document_type=DocumentType.OSV, + osv_account_code="62", + status=DocumentStatus.RECOGNIZED, + ) + session.add(document) + await session.flush() + fields: dict[str, Any] = { + "recognition_job_id": job.id, + "document_id": document.id, + "owner_legal_entity_id": entity.id, + "row_number": 1, + "closing_debit": decimal.Decimal("0.00"), + } + fields.update(overrides) + entry = OsvEntry(**fields) + session.add(entry) + await session.flush() + return entry diff --git a/api/tests/fixtures_helpers.py b/api/tests/fixtures_helpers.py new file mode 100644 index 0000000..242ece7 --- /dev/null +++ b/api/tests/fixtures_helpers.py @@ -0,0 +1,28 @@ +"""Поиск каталога реальных данных `fixtures` (соседний репозиторий workspace) для тестов с маркером `fixtures`.""" + +import os +from pathlib import Path + +FIXTURES_ENV = "ARM_KA_FIXTURES" + + +def resolve_fixtures_dir() -> Path: + """Каталог fixtures: `ARM_KA_FIXTURES`, иначе соседний `../../fixtures` относительно `arm-core/api`. + + Не проверяет существование: это делает вызывающий код (пропуск теста с причиной). + """ + explicit = os.environ.get(FIXTURES_ENV) + if explicit: + return Path(explicit) + return Path(__file__).resolve().parents[3] / "fixtures" + + +def fixtures_skip_reason() -> str | None: + """Причина пропуска тестов с маркером `fixtures` или `None`, если каталог доступен.""" + path = resolve_fixtures_dir() + if path.is_dir(): + return None + return ( + f"ARM_KA_FIXTURES недоступен: нет каталога {path} " + f"(задайте {FIXTURES_ENV} или положите fixtures рядом с arm-core)" + ) diff --git a/api/tests/pg_helpers.py b/api/tests/pg_helpers.py index a924716..8138471 100644 --- a/api/tests/pg_helpers.py +++ b/api/tests/pg_helpers.py @@ -4,8 +4,12 @@ в конце удаляет. Рабочую БД (`armka`) тесты не трогают. Подключается плагином в `conftest.py`. """ +import asyncio +import subprocess +import sys import uuid from collections.abc import AsyncIterator +from pathlib import Path import asyncpg import pytest_asyncio @@ -13,6 +17,8 @@ from sqlalchemy.engine import URL, make_url from src.core.config import settings +API_DIR = Path(__file__).resolve().parents[1] + def base_url() -> URL: """SQLAlchemy-URL рабочей БД из настроек (`.env.test`).""" @@ -23,6 +29,29 @@ def _asyncpg_dsn(url: URL) -> str: return url.set(drivername="postgresql").render_as_string(hide_password=False) +def run_alembic(url: str, *args: str) -> str: + """`alembic -x dburl= ` из каталога api/ отдельным процессом; вернуть stdout+stderr. + + `env.py` вызывает `asyncio.run(...)` — внутри event loop pytest-asyncio это невозможно. При ошибке — + `AssertionError` с выводом процесса (`ENV_FILE`/`APP_ENV` наследуются из окружения pytest-env). + """ + result = subprocess.run( + [sys.executable, "-m", "alembic", "-x", f"dburl={url}", *args], + cwd=API_DIR, + capture_output=True, + text=True, + check=False, + ) + output = result.stdout + result.stderr + assert result.returncode == 0, f"alembic {' '.join(args)} завершился с кодом {result.returncode}:\n{output}" + return output + + +async def alembic_async(url: str, *args: str) -> str: + """`run_alembic` в потоке, чтобы не блокировать event loop.""" + return await asyncio.to_thread(run_alembic, url, *args) + + async def create_scratch_db(name: str) -> URL: """Создать пустую БД `name` на сервере рабочей БД; вернуть SQLAlchemy-URL на неё.""" url = base_url() diff --git a/api/tests/test_fixtures_marker.py b/api/tests/test_fixtures_marker.py new file mode 100644 index 0000000..6206e97 --- /dev/null +++ b/api/tests/test_fixtures_marker.py @@ -0,0 +1,36 @@ +"""Маркер `fixtures`: путь к реальным данным и пропуск с причиной (spec quality-checks, «Фикстуры … опциональны»).""" + +from pathlib import Path + +import pytest + +from tests.fixtures_helpers import FIXTURES_ENV, fixtures_skip_reason, resolve_fixtures_dir + + +@pytest.mark.fixtures +def test_fixtures_dir_contains_readme(fixtures_dir: Path) -> None: + """При доступных fixtures каталог существует и содержит README.md; иначе тест пропускается с причиной.""" + assert fixtures_dir.is_dir() + assert (fixtures_dir / "README.md").is_file() + + +def test_resolve_prefers_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv(FIXTURES_ENV, str(tmp_path)) + assert resolve_fixtures_dir() == tmp_path + assert fixtures_skip_reason() is None + + +def test_resolve_falls_back_to_sibling_dir(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(FIXTURES_ENV, raising=False) + path = resolve_fixtures_dir() + assert path.name == "fixtures" + assert path.parent == Path(__file__).resolve().parents[3] + + +def test_skip_reason_names_variable_and_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + missing = tmp_path / "нет-такого" + monkeypatch.setenv(FIXTURES_ENV, str(missing)) + reason = fixtures_skip_reason() + assert reason is not None + assert "ARM_KA_FIXTURES недоступен" in reason + assert str(missing) in reason diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 780717d..d33289f 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -6,11 +6,7 @@ CLI: `env.py` вызывает `asyncio.run(...)`, а внутри event loop py перенастраивает логирование тестов через `fileConfig`. Каждая проверка идёт на своей временной БД (`scratch_db_url`). """ -import asyncio -import subprocess -import sys import uuid -from pathlib import Path from typing import Any import pytest @@ -21,7 +17,8 @@ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, c from src.models import Base from src.models.enums import ENUMS -from tests.pg_helpers import base_url, create_scratch_db, drop_scratch_db +from tests.pg_helpers import alembic_async as alembic +from tests.pg_helpers import base_url, create_scratch_db, drop_scratch_db, run_alembic from tests.test_models_part_01_03 import _check_related_group_member_uniqueness from tests.test_models_part_04_06 import _check_nulls_not_distinct, _make_file_with_job from tests.test_models_part_07_09 import ( @@ -31,30 +28,11 @@ from tests.test_models_part_07_09 import ( _check_active_lock_is_unique, ) -API_DIR = Path(__file__).resolve().parents[1] HEAD = "0001_dbml_v5_13" VERSION_TABLE = "alembic_version" FK_CYCLE = "fk_stored_file_current_recognition_job_id_recognition_job" -def _run_alembic(url: str, *args: str) -> str: - """`alembic -x dburl= ` из каталога api/; вернуть stdout+stderr, упасть с выводом при ошибке.""" - result = subprocess.run( - [sys.executable, "-m", "alembic", "-x", f"dburl={url}", *args], - cwd=API_DIR, - capture_output=True, - text=True, - check=False, - ) - output = result.stdout + result.stderr - assert result.returncode == 0, f"alembic {' '.join(args)} завершился с кодом {result.returncode}:\n{output}" - return output - - -async def alembic(url: str, *args: str) -> str: - return await asyncio.to_thread(_run_alembic, url, *args) - - async def _scalars(conn: AsyncConnection, sql: str) -> list[Any]: return list((await conn.execute(text(sql))).scalars()) @@ -321,7 +299,7 @@ async def test_upgrade_into_preprovisioned_schema_without_database_create(scratc def test_offline_sql_creates_schema_before_version_table() -> None: """Офлайн-режим (`upgrade head --sql`): условный CREATE SCHEMA dkb идёт до таблицы версий и до enum-типов.""" - output = _run_alembic("postgresql+asyncpg://user:pass@localhost/offline", "upgrade", "head", "--sql") + output = run_alembic("postgresql+asyncpg://user:pass@localhost/offline", "upgrade", "head", "--sql") schema_at = output.index( "DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'dkb') " "THEN CREATE SCHEMA dkb; END IF; END $$" diff --git a/api/tests/test_money_types.py b/api/tests/test_money_types.py new file mode 100644 index 0000000..16a1228 --- /dev/null +++ b/api/tests/test_money_types.py @@ -0,0 +1,43 @@ +"""Деньги — точные типы (spec data-model, «Деньги и проценты — точные типы») и изоляция БД-фикстур. + +Сценарий «Чтение денежной колонки»: `osv_entry.closing_debit = 106311116.46` читается как `Decimal("106311116.46")`. +Тесты идут на БД сессии (`migrated_db_url`, миграции alembic) в транзакции с откатом (`db_session`, design D-7, R-16). +""" + +import decimal + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.osv import OsvEntry +from tests.factories import make_osv_entry + +AMOUNT = decimal.Decimal("106311116.46") + +pytestmark = pytest.mark.integration + + +async def test_closing_debit_is_read_back_as_decimal(db_session: AsyncSession) -> None: + """Запись из БД, а не из identity map: тип `Decimal`, значение без потери точности.""" + entry = await make_osv_entry(db_session, closing_debit=AMOUNT) + entry_id = entry.id + db_session.expire_all() # забыть объект: значение читается только запросом + stored = (await db_session.execute(select(OsvEntry.closing_debit).where(OsvEntry.id == entry_id))).scalar_one() + assert type(stored) is decimal.Decimal + assert stored == AMOUNT + assert str(stored) == "106311116.46" + + +async def test_rollback_isolation_writes_and_commits(db_session: AsyncSession) -> None: + """Первая половина проверки изоляции: запись и `commit` внутри теста (SAVEPOINT, не настоящая фиксация).""" + await make_osv_entry(db_session, closing_debit=AMOUNT) + await db_session.commit() + count = (await db_session.execute(select(func.count()).select_from(OsvEntry))).scalar_one() + assert count == 1 + + +async def test_rollback_isolation_next_test_sees_nothing(db_session: AsyncSession) -> None: + """Вторая половина (порядок — по именам внутри модуля): данные предыдущего теста откатились.""" + count = (await db_session.execute(select(func.count()).select_from(OsvEntry))).scalar_one() + assert count == 0 -- 2.47.2 From b1f76d0a7d99eb0f0f7925e113d2f3c64a2f3faa Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 20:47:03 +0400 Subject: [PATCH 40/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2011?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 3206ca2..3cd8f4c 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -61,7 +61,7 @@ ### Task 11: [2.7] `tests/conftest.py` -- [ ] `tests/conftest.py`: engine из `.env.test`, миграции один раз на сессию, транзакция с откатом на тест, маркеры `integration`/`fixtures` (skip с причиной при отсутствии `ARM_KA_FIXTURES`); проверка: тест чтения `Decimal` из `osv_entry.closing_debit` проходит; `make check` зелёный +- [x] `tests/conftest.py`: engine из `.env.test`, миграции один раз на сессию, транзакция с откатом на тест, маркеры `integration`/`fixtures` (skip с причиной при отсутствии `ARM_KA_FIXTURES`); проверка: тест чтения `Decimal` из `osv_entry.closing_debit` проходит; `make check` зелёный ### Task 12: [3.1] `seed/core/roles.yaml`, `permissions.yaml` (16 кодов), `role_permissions.yaml` ( diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 38a3f68..5c38742 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -47,7 +47,7 @@ autogenerate → ручная правка: enum'ы, partial-индексы, `NULLS NOT DISTINCT`, `use_alter`, полный `downgrade`); проверка: `alembic upgrade head && alembic downgrade base && alembic upgrade head` на compose-PostgreSQL 16; `alembic revision --autogenerate` после — пустая ревизия -- [ ] 2.7 `tests/conftest.py`: engine из `.env.test`, миграции один раз на сессию, транзакция с откатом на тест, маркеры +- [x] 2.7 `tests/conftest.py`: engine из `.env.test`, миграции один раз на сессию, транзакция с откатом на тест, маркеры `integration`/`fixtures` (skip с причиной при отсутствии `ARM_KA_FIXTURES`); проверка: тест чтения `Decimal` из `osv_entry.closing_debit` проходит; `make check` зелёный -- 2.47.2 From c8c218e4ac7b2cc96815997c6827bad3d573fc8f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 20:54:30 +0400 Subject: [PATCH 41/97] =?UTF-8?q?test:=20=D1=81=D1=83=D0=B6=D0=B5=D0=BD=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=B2=D0=B0=D1=82=20=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=B1=D0=BE=D0=BA=20PostgreSQL=20=D0=B2=20conftest,=20?= =?UTF-8?q?=D1=81=D0=B0=D0=BC=D0=BE=D0=B4=D0=BE=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D1=8B=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= =?UTF-8?q?=20=D0=B8=D0=B7=D0=BE=D0=BB=D1=8F=D1=86=D0=B8=D0=B8=20=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B7=D0=B0=D0=BA=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/tests/conftest.py | 13 +++++++---- api/tests/factories.py | 4 +++- api/tests/test_money_types.py | 42 ++++++++++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 93f6c5c..3f5c8df 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -10,6 +10,7 @@ import uuid from collections.abc import AsyncIterator from pathlib import Path +import asyncpg import pytest import pytest_asyncio from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine @@ -32,7 +33,7 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item return skip = pytest.mark.skip(reason=reason) for item in items: - if "fixtures" in item.keywords: + if item.get_closest_marker("fixtures") is not None: item.add_marker(skip) @@ -51,10 +52,14 @@ async def migrated_db_url() -> AsyncIterator[str]: name = f"armcore_pytest_{uuid.uuid4().hex[:12]}" try: url = await create_scratch_db(name) - except (OSError, ConnectionError) as error: - pytest.fail(PG_UNAVAILABLE.format(error=error), pytrace=False) - except Exception as error: # asyncpg: PostgresError/InterfaceError и т.п. при недоступном сервере + except (OSError, asyncpg.InterfaceError) as error: # соединение не установлено: сервер не запущен, порт закрыт pytest.fail(PG_UNAVAILABLE.format(error=f"{type(error).__name__}: {error}"), pytrace=False) + except asyncpg.PostgresError as error: # сервер ответил отказом: учётные данные, роль, права на CREATE DATABASE + pytest.fail( + f"PostgreSQL отказал при создании тестовой БД ({type(error).__name__}: {error}); " + "проверьте учётные данные и право CREATE DATABASE в .env.test", + pytrace=False, + ) try: rendered = url.render_as_string(hide_password=False) await alembic_async(rendered, "upgrade", "head") diff --git a/api/tests/factories.py b/api/tests/factories.py index 38a6cc8..150a585 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -24,11 +24,13 @@ from src.models.osv import OsvEntry async def make_osv_entry(session: AsyncSession, **overrides: Any) -> OsvEntry: """Строка ОСВ с минимальной цепочкой FK (ЮЛ, s3_object, stored_file, recognition_job, document); `flush`. + ИНН ЮЛ случайный, поэтому фабрику можно вызывать несколько раз в одном тесте. + `overrides` перекрывают поля `OsvEntry` (например, `closing_debit=Decimal("1.00")`). Данные остаются в транзакции сессии: фикстура `db_session` откатывает их после теста. """ now = datetime.datetime.now(datetime.UTC) - entity = LegalEntity(inn="7700000001", name="ООО Тест", valid_from=now) + entity = LegalEntity(inn=str(uuid.uuid4().int)[:10], name="ООО Тест", valid_from=now) s3 = S3Object(bucket_role=S3BucketRole.uploads, bucket="uploads", key=f"{uuid.uuid4()}/osv.xlsx", created_at=now) session.add_all([entity, s3]) await session.flush() diff --git a/api/tests/test_money_types.py b/api/tests/test_money_types.py index 16a1228..732b0d1 100644 --- a/api/tests/test_money_types.py +++ b/api/tests/test_money_types.py @@ -8,12 +8,14 @@ import decimal import pytest from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from src.models.osv import OsvEntry from tests.factories import make_osv_entry AMOUNT = decimal.Decimal("106311116.46") +TWO_ENTRIES = 2 pytestmark = pytest.mark.integration @@ -41,3 +43,41 @@ async def test_rollback_isolation_next_test_sees_nothing(db_session: AsyncSessio """Вторая половина (порядок — по именам внутри модуля): данные предыдущего теста откатились.""" count = (await db_session.execute(select(func.count()).select_from(OsvEntry))).scalar_one() assert count == 0 + + +async def _count_via_separate_connection(engine: AsyncEngine) -> int: + """Число строк osv_entry глазами другого соединения: видит только зафиксированное по-настоящему.""" + async with engine.connect() as other: + return (await other.execute(select(func.count()).select_from(OsvEntry))).scalar_one() + + +async def test_commit_in_test_is_not_persisted(db_session: AsyncSession, db_engine: AsyncEngine) -> None: + """Самодостаточная проверка `create_savepoint`: `commit()` в тесте не фиксирует данные для других соединений.""" + await make_osv_entry(db_session, closing_debit=AMOUNT) + await db_session.commit() + assert await _count_via_separate_connection(db_engine) == 0 + # свои данные сессия видит, и после освобождающего SAVEPOINT коммита она остаётся рабочей + assert (await db_session.execute(select(func.count()).select_from(OsvEntry))).scalar_one() == 1 + await make_osv_entry(db_session, closing_debit=AMOUNT) + await db_session.commit() + assert await _count_via_separate_connection(db_engine) == 0 + + +async def test_rollback_after_integrity_error_keeps_session_usable(db_session: AsyncSession) -> None: + """Ошибка целостности + `rollback()` откатывают только текущий SAVEPOINT: внешняя транзакция жива, прежнее видно.""" + first = await make_osv_entry(db_session, closing_debit=AMOUNT) + await db_session.commit() # данные первой части лежат в внешней транзакции + duplicate = OsvEntry( + recognition_job_id=first.recognition_job_id, + document_id=first.document_id, + owner_legal_entity_id=first.owner_legal_entity_id, + row_number=first.row_number, # нарушает uq_osv_entry_document_id_row_number + ) + db_session.add(duplicate) + with pytest.raises(IntegrityError): + await db_session.flush() + await db_session.rollback() + count = (await db_session.execute(select(func.count()).select_from(OsvEntry))).scalar_one() + assert count == 1 # закоммиченное в SAVEPOINT осталось, дубликат откатился + await make_osv_entry(db_session, closing_debit=AMOUNT) # сессия по-прежнему рабочая + assert (await db_session.execute(select(func.count()).select_from(OsvEntry))).scalar_one() == TWO_ENTRIES -- 2.47.2 From 186ae802e69801f5a6fef1913eb43827cefa7031 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 21:07:07 +0400 Subject: [PATCH 42/97] =?UTF-8?q?feat:=20seed=20=D1=80=D0=BE=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9,=20=D0=BF=D1=80=D0=B0=D0=B2,=20=D0=BC=D0=B0=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D1=86=D1=8B=20=C2=A710=20=D0=B8=20=D0=93=D0=BE=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BE=D1=84=D0=B8=D1=81?= =?UTF-8?q?=D0=B0,=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D1=87=D0=B8?= =?UTF-8?q?=D0=BA=20scripts.seed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Двухфазный загрузчик: валидация yaml целиком до записи (SeedError), затем upsert по натуральному ключу без удалений; run_seed(session, *, dev, seed_dir) без commit, CLI коммитит. Co-Authored-By: Claude Fable 5.1 --- api/scripts/seed.py | 68 ++++++ api/scripts/seed_lib/__init__.py | 1 + api/scripts/seed_lib/apply.py | 117 ++++++++++ api/scripts/seed_lib/loaders.py | 204 +++++++++++++++++ api/scripts/seed_lib/model.py | 69 ++++++ api/seed/core/permissions.yaml | 67 ++++++ api/seed/core/regional_office.yaml | 6 + api/seed/core/role_permissions.yaml | 65 ++++++ api/seed/core/roles.yaml | 50 ++++ api/tests/test_seed_roles.py | 338 ++++++++++++++++++++++++++++ 10 files changed, 985 insertions(+) create mode 100644 api/scripts/seed.py create mode 100644 api/scripts/seed_lib/__init__.py create mode 100644 api/scripts/seed_lib/apply.py create mode 100644 api/scripts/seed_lib/loaders.py create mode 100644 api/scripts/seed_lib/model.py create mode 100644 api/seed/core/permissions.yaml create mode 100644 api/seed/core/regional_office.yaml create mode 100644 api/seed/core/role_permissions.yaml create mode 100644 api/seed/core/roles.yaml create mode 100644 api/tests/test_seed_roles.py diff --git a/api/scripts/seed.py b/api/scripts/seed.py new file mode 100644 index 0000000..94ca722 --- /dev/null +++ b/api/scripts/seed.py @@ -0,0 +1,68 @@ +"""Загрузчик справочников продукта: `python -m scripts.seed [--dev]` (запуск из `api/`). + +Spec reference-data-seed; design D-4 и решение #18 (R-5): `run_seed(session, *, dev)` не делает commit — коммитит CLI. +Источник данных — `api/seed/core/*.yaml|csv` (+ `api/seed/dev/` при `--dev`, задача 15). + +Загрузка двухфазная: +1. `load_seed` (`seed_lib/loaders.py`) читает ВСЕ файлы в память и проверяет их (обязательные поля, enum'ы из + `src.models.enums`, ссылки внутри наборов); любая ошибка — `SeedError`, база при этом не затрагивается. +2. `apply_seed` (`seed_lib/apply.py`) применяет набор к БД шагами в порядке зависимостей: upsert по натуральному + ключу, без удалений. + +Расширение (задачи 13–15): поле в `SeedData` (`seed_lib/model.py`), читатель в `load_seed`, шаг в `apply_seed`. +""" + +import argparse +import asyncio +import sys +from pathlib import Path + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from scripts.seed_lib.apply import apply_seed +from scripts.seed_lib.loaders import DEFAULT_SEED_DIR, load_seed +from scripts.seed_lib.model import SeedData, SeedError, SeedReport + +__all__ = ["DEFAULT_SEED_DIR", "SeedError", "SeedReport", "apply_seed", "load_seed", "main", "run_seed"] + + +async def run_seed(session: AsyncSession, *, dev: bool = False, seed_dir: Path | None = None) -> SeedReport: + """Загрузить и применить seed (решение #18 / R-5): валидация до записи, без commit. + + `seed_dir` — каталог с `core/` (и `dev/`), по умолчанию `api/seed`; подменяется в тестах. + """ + return await apply_seed(session, load_seed(seed_dir, dev=dev)) + + +async def _run_cli(data: SeedData) -> SeedReport: + # импорт здесь: настройки читаются из ENV_FILE только при реальном запуске, + # поэтому `--help` и ошибки валидации работают без env + from src.core.config import settings # noqa: PLC0415 + + engine = create_async_engine( + settings.OPENBAO.DATABASE.DATABASE_URL, connect_args={"server_settings": {"search_path": "dkb"}} + ) + try: + async with async_sessionmaker(engine, expire_on_commit=False)() as session, session.begin(): + return await apply_seed(session, data) + finally: + await engine.dispose() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="python -m scripts.seed", description="Загрузка справочников продукта.") + parser.add_argument("--dev", action="store_true", help="дополнительно загрузить dev-данные из seed/dev/") + parser.add_argument("--seed-dir", type=Path, default=DEFAULT_SEED_DIR, help="каталог seed (по умолчанию api/seed)") + args = parser.parse_args(argv) + try: + data = load_seed(args.seed_dir, dev=args.dev) + report = asyncio.run(_run_cli(data)) + except SeedError as error: + print(f"Ошибка seed: {error}", file=sys.stderr) + return 1 + print(report.format()) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/api/scripts/seed_lib/__init__.py b/api/scripts/seed_lib/__init__.py new file mode 100644 index 0000000..55e9bb0 --- /dev/null +++ b/api/scripts/seed_lib/__init__.py @@ -0,0 +1 @@ +"""Части загрузчика seed: модель набора, чтение/проверка файлов, применение к БД.""" diff --git a/api/scripts/seed_lib/apply.py b/api/scripts/seed_lib/apply.py new file mode 100644 index 0000000..ea3e748 --- /dev/null +++ b/api/scripts/seed_lib/apply.py @@ -0,0 +1,117 @@ +"""Фаза 2: применение проверенного набора к БД — upsert по натуральному ключу, без удалений и без commit.""" + +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import AsyncSession + +from scripts.seed_lib.model import SeedData, SeedReport, TableStat +from src.models.access import Permission, Role, RolePermission +from src.models.directories import RegionalOffice + + +async def _upsert( + session: AsyncSession, + model: Any, + key: str, + rows: list[dict[str, Any]], + stat: TableStat, +) -> None: + """Вставить новые и обновить изменившиеся строки по натуральному ключу `key`; удалений нет.""" + if not rows: + return + key_column = getattr(model, key) + value_names = [name for name in rows[0] if name != key] + existing: dict[Any, tuple[Any, ...]] = { + row[0]: tuple(row[1:]) + for row in (await session.execute(select(key_column, *(getattr(model, name) for name in value_names)))).all() + } + changed = [] + for row in rows: + current = existing.get(row[key]) + if current is None: + stat.inserted += 1 + changed.append(row) + elif current != tuple(row[name] for name in value_names): + stat.updated += 1 + changed.append(row) + else: + stat.unchanged += 1 + if changed: + statement = pg_insert(model) + statement = statement.on_conflict_do_update( + index_elements=[key_column], set_={name: statement.excluded[name] for name in value_names} + ) + await session.execute(statement, changed) + + +async def _apply_role_permissions(session: AsyncSession, data: SeedData, stat: TableStat) -> None: + role_ids = {code: id_ for id_, code in (await session.execute(select(Role.id, Role.code))).all()} + permission_ids = {code: id_ for id_, code in (await session.execute(select(Permission.id, Permission.code))).all()} + existing = set((await session.execute(select(RolePermission.role_id, RolePermission.permission_id))).all()) + new_rows = [] + for role_code, codes in data.role_permissions.items(): + for code in codes: + pair = (role_ids[role_code], permission_ids[code]) + if pair in existing: + stat.unchanged += 1 + else: + stat.inserted += 1 + new_rows.append( + {"role_id": pair[0], "permission_id": pair[1], "granted_by": None, "granted_at": func.now()} + ) + if new_rows: + # .values(): func.now() допустим только во встроенном VALUES, не в executemany + statement = ( + pg_insert(RolePermission) + .values(new_rows) + .on_conflict_do_nothing(index_elements=["role_id", "permission_id"]) + ) + await session.execute(statement) + + +async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport: + """Фаза 2: upsert набора в порядке зависимостей. Без commit — транзакцией управляет вызывающий.""" + report = SeedReport() + await _upsert( + session, + RegionalOffice, + "name", + [{"name": o.name, "is_head_office": o.is_head_office, "is_active": o.is_active} for o in data.regional_offices], + report.stat("regional_office"), + ) + await _upsert( + session, + Role, + "code", + [ + { + "code": r.code, + "name": r.name, + "description": r.description, + "is_system": r.is_system, + "is_active": r.is_active, + } + for r in data.roles + ], + report.stat("role"), + ) + await _upsert( + session, + Permission, + "code", + [ + { + "code": p.code, + "object_type": p.object_type, + "action": p.action, + "name": p.name, + "description": p.description, + } + for p in data.permissions + ], + report.stat("permission"), + ) + await _apply_role_permissions(session, data, report.stat("role_permission")) + return report diff --git a/api/scripts/seed_lib/loaders.py b/api/scripts/seed_lib/loaders.py new file mode 100644 index 0000000..7ed5be1 --- /dev/null +++ b/api/scripts/seed_lib/loaders.py @@ -0,0 +1,204 @@ +"""Фаза 1: чтение файлов `seed/` в память и проверка (обязательные поля, enum'ы, ссылки внутри наборов). + +База данных здесь не используется; любая ошибка — `SeedError` с файлом и записью. +""" + +from pathlib import Path +from typing import Any + +import yaml + +from scripts.seed_lib.model import PermissionSeed, RegionalOfficeSeed, RoleSeed, SeedData, SeedError +from src.models.enums import PermissionAction, PermissionObject + +DEFAULT_SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed" + + +def _where(path: Path, seed_dir: Path, index: int | None = None, key: str | None = None) -> str: + place = str(path.relative_to(seed_dir)) if path.is_relative_to(seed_dir) else str(path) + if index is not None: + place += f", запись №{index + 1}" + if key is not None: + place += f" ({key})" + return place + + +def _read_yaml(path: Path, seed_dir: Path) -> Any: + if not path.is_file(): + raise SeedError(f"{_where(path, seed_dir)}: файл не найден") + try: + return yaml.safe_load(path.read_text(encoding="utf-8")) + except (yaml.YAMLError, UnicodeDecodeError) as error: + raise SeedError(f"{_where(path, seed_dir)}: не удалось разобрать файл: {error}") from error + + +def _records(data: Any, allowed: set[str], required: set[str], path: Path, seed_dir: Path) -> list[dict[str, Any]]: + """Список записей-словарей: тип, обязательные поля, отсутствие лишних ключей.""" + if not isinstance(data, list): + raise SeedError(f"{_where(path, seed_dir)}: ожидается список записей") + for index, record in enumerate(data): + if not isinstance(record, dict): + raise SeedError(f"{_where(path, seed_dir, index)}: ожидается словарь полей") + for key in sorted(set(record) - allowed): + raise SeedError(f"{_where(path, seed_dir, index)}: неизвестное поле «{key}»") + for key in sorted(required - set(record)): + raise SeedError(f"{_where(path, seed_dir, index)}: нет обязательного поля «{key}»") + return data + + +def _str(record: dict[str, Any], key: str, where: str, *, optional: bool = False) -> str | None: + value = record.get(key) + if value is None and optional: + return None + if not isinstance(value, str) or not value.strip(): + raise SeedError(f"{where}: поле «{key}» должно быть непустой строкой") + return value.strip() + + +def _bool(record: dict[str, Any], key: str, where: str, *, default: bool) -> bool: + value = record.get(key, default) + if not isinstance(value, bool): + raise SeedError(f"{where}: поле «{key}» должно быть true или false, получено {value!r}") + return value + + +def _unique(values: list[str], what: str, path: Path, seed_dir: Path) -> None: + seen: set[str] = set() + for value in values: + if value in seen: + raise SeedError(f"{_where(path, seed_dir)}: {what} «{value}» встречается больше одного раза") + seen.add(value) + + +def _load_regional_offices(core: Path, seed_dir: Path) -> list[RegionalOfficeSeed]: + path = core / "regional_office.yaml" + records = _records(_read_yaml(path, seed_dir), {"name", "is_head_office", "is_active"}, {"name"}, path, seed_dir) + result = [] + for index, record in enumerate(records): + where = _where(path, seed_dir, index) + result.append( + RegionalOfficeSeed( + name=str(_str(record, "name", where)), + is_head_office=_bool(record, "is_head_office", where, default=False), + is_active=_bool(record, "is_active", where, default=True), + ) + ) + _unique([o.name for o in result], "филиал", path, seed_dir) + return result + + +def _load_roles(core: Path, seed_dir: Path) -> list[RoleSeed]: + path = core / "roles.yaml" + records = _records( + _read_yaml(path, seed_dir), + {"code", "name", "description", "is_system", "is_active"}, + {"code", "name"}, + path, + seed_dir, + ) + result = [] + for index, record in enumerate(records): + where = _where(path, seed_dir, index, str(record.get("code"))) + result.append( + RoleSeed( + code=str(_str(record, "code", where)), + name=str(_str(record, "name", where)), + description=_str(record, "description", where, optional=True), + is_system=_bool(record, "is_system", where, default=False), + is_active=_bool(record, "is_active", where, default=True), + ) + ) + _unique([r.code for r in result], "роль", path, seed_dir) + return result + + +def _expected_permission_code(object_type: PermissionObject, action: PermissionAction) -> str: + """Код права `_`; при совпадении объекта и действия — одно слово (`EXPORT`).""" + if object_type.value == action.value: + return action.value + return f"{object_type.value}_{action.value}" + + +def _load_permissions(core: Path, seed_dir: Path) -> list[PermissionSeed]: + path = core / "permissions.yaml" + records = _records( + _read_yaml(path, seed_dir), + {"code", "object", "action", "name", "description"}, + {"code", "object", "action", "name"}, + path, + seed_dir, + ) + result = [] + for index, record in enumerate(records): + where = _where(path, seed_dir, index, str(record.get("code"))) + try: + object_type = PermissionObject(record["object"]) + except ValueError: + raise SeedError(f"{where}: неизвестный объект права «{record['object']}»") from None + try: + action = PermissionAction(record["action"]) + except ValueError: + raise SeedError(f"{where}: неизвестное действие права «{record['action']}»") from None + code = str(_str(record, "code", where)) + if code != _expected_permission_code(object_type, action): + raise SeedError( + f"{where}: код права «{code}» не соответствует объекту и действию, " + f"ожидался «{_expected_permission_code(object_type, action)}»" + ) + result.append( + PermissionSeed( + code=code, + object_type=object_type, + action=action, + name=str(_str(record, "name", where)), + description=_str(record, "description", where, optional=True), + ) + ) + _unique([p.code for p in result], "право", path, seed_dir) + pairs = [f"{p.object_type.value}.{p.action.value}" for p in result] + _unique(pairs, "сочетание объект.действие", path, seed_dir) + return result + + +def _load_role_permissions( + core: Path, seed_dir: Path, role_codes: set[str], permission_codes: set[str] +) -> dict[str, list[str]]: + path = core / "role_permissions.yaml" + data = _read_yaml(path, seed_dir) + if not isinstance(data, dict): + raise SeedError(f"{_where(path, seed_dir)}: ожидается словарь «код роли -> список кодов прав»") + result: dict[str, list[str]] = {} + for role_code, codes in data.items(): + where = _where(path, seed_dir, key=str(role_code)) + if role_code not in role_codes: + raise SeedError(f"{where}: роль «{role_code}» не описана в roles.yaml") + if not isinstance(codes, list) or not all(isinstance(c, str) for c in codes): + raise SeedError(f"{where}: ожидается список кодов прав") + for code in codes: + if code not in permission_codes: + raise SeedError(f"{where}: право «{code}» не описано в permissions.yaml") + duplicates = sorted({c for c in codes if codes.count(c) > 1}) + if duplicates: + raise SeedError(f"{where}: право «{duplicates[0]}» указано больше одного раза") + result[role_code] = list(codes) + return result + + +def load_seed(seed_dir: Path | None = None, *, dev: bool = False) -> SeedData: + """Фаза 1: прочитать и проверить все файлы. Бросает `SeedError`; к БД не обращается.""" + seed_dir = seed_dir or DEFAULT_SEED_DIR + core = seed_dir / "core" + data = SeedData() + data.roles = _load_roles(core, seed_dir) + data.permissions = _load_permissions(core, seed_dir) + data.role_permissions = _load_role_permissions( + core, seed_dir, {r.code for r in data.roles}, {p.code for p in data.permissions} + ) + data.regional_offices = _load_regional_offices(core, seed_dir) + if dev: + _load_dev(data, seed_dir / "dev") + return data + + +def _load_dev(data: SeedData, dev_dir: Path) -> None: + """Заглушка расширения: dev-данные (филиалы, пользователи по ролям) появятся в задаче 15.""" diff --git a/api/scripts/seed_lib/model.py b/api/scripts/seed_lib/model.py new file mode 100644 index 0000000..34f28f0 --- /dev/null +++ b/api/scripts/seed_lib/model.py @@ -0,0 +1,69 @@ +"""Модель проверенного набора seed и отчёта о применении (без доступа к БД).""" + +from dataclasses import dataclass, field + +from src.models.enums import PermissionAction, PermissionObject + + +class SeedError(Exception): + """Ошибка файлов seed (отсутствие, формат, ссылочная целостность); сообщение — по-русски, с файлом и записью.""" + + +@dataclass(frozen=True) +class RegionalOfficeSeed: + name: str + is_head_office: bool + is_active: bool + + +@dataclass(frozen=True) +class RoleSeed: + code: str + name: str + description: str | None + is_system: bool + is_active: bool + + +@dataclass(frozen=True) +class PermissionSeed: + code: str + object_type: PermissionObject + action: PermissionAction + name: str + description: str | None + + +@dataclass +class SeedData: + """Проверенный набор seed; ничего не знает о БД.""" + + regional_offices: list[RegionalOfficeSeed] = field(default_factory=list) + roles: list[RoleSeed] = field(default_factory=list) + permissions: list[PermissionSeed] = field(default_factory=list) + #: код роли -> коды прав (порядок как в файле) + role_permissions: dict[str, list[str]] = field(default_factory=dict) + + +@dataclass +class TableStat: + inserted: int = 0 + updated: int = 0 + unchanged: int = 0 + + +@dataclass +class SeedReport: + """Итог применения: по таблицам — сколько строк вставлено / обновлено / не изменилось.""" + + tables: dict[str, TableStat] = field(default_factory=dict) + + def stat(self, table: str) -> TableStat: + return self.tables.setdefault(table, TableStat()) + + def format(self) -> str: + lines = ["Seed применён (вставлено / обновлено / без изменений):"] + lines += [ + f" {name}: {stat.inserted} / {stat.updated} / {stat.unchanged}" for name, stat in self.tables.items() + ] + return "\n".join(lines) diff --git a/api/seed/core/permissions.yaml b/api/seed/core/permissions.yaml new file mode 100644 index 0000000..34b518a --- /dev/null +++ b/api/seed/core/permissions.yaml @@ -0,0 +1,67 @@ +# Справочник прав «объект × действие» (docs/context/05-domain-rules.md §10; DBML permission_object / permission_action). +# Код права — _; при совпадении объекта и действия (EXPORT) — одно слово EXPORT. +# Натуральный ключ upsert — code. Администратор новые коды не создаёт. +- code: DIRECTORY_VIEW + object: DIRECTORY + action: VIEW + name: "Справочники: просмотр" +- code: DIRECTORY_EDIT + object: DIRECTORY + action: EDIT + name: "Справочники: редактирование" +- code: DIRECTORY_DELETE + object: DIRECTORY + action: DELETE + name: "Справочники: удаление" +- code: DIRECTORY_ADMIN + object: DIRECTORY + action: ADMIN + name: "Справочники: администрирование (импорт)" +- code: DOCUMENT_VIEW + object: DOCUMENT + action: VIEW + name: "Документы: просмотр" +- code: DOCUMENT_EDIT + object: DOCUMENT + action: EDIT + name: "Документы: загрузка, правка, подтверждение" +- code: CONSOLIDATION_VIEW + object: CONSOLIDATION + action: VIEW + name: "Консолидация: просмотр" +- code: CONSOLIDATION_EDIT + object: CONSOLIDATION + action: EDIT + name: "Консолидация: редактирование" +- code: KFO_VIEW + object: KFO + action: VIEW + name: "КФО: просмотр" +- code: KFO_EXPORT + object: KFO + action: EXPORT + name: "КФО: выгрузка" +- code: KFO_APPROVE + object: KFO + action: APPROVE + name: "КФО: утверждение в РФ" +- code: KFO_VERIFY + object: KFO + action: VERIFY + name: "КФО: верификация в ГО" +- code: EX_VIEW + object: EX + action: VIEW + name: "Единое хранилище: просмотр" +- code: EXPORT + object: EXPORT + action: EXPORT + name: "Выгрузка отчётности" +- code: USERS_ADMIN + object: USERS + action: ADMIN + name: "Пользователи и роли: администрирование" +- code: AUDIT_VIEW + object: AUDIT + action: VIEW + name: "Журнал действий: просмотр" diff --git a/api/seed/core/regional_office.yaml b/api/seed/core/regional_office.yaml new file mode 100644 index 0000000..ff442b3 --- /dev/null +++ b/api/seed/core/regional_office.yaml @@ -0,0 +1,6 @@ +# Филиалы продукта (spec reference-data-seed). DECISION-3 DBML: Головной офис — явная запись. +# Региональные филиалы — только dev-данные (seed/dev/), в прод-режиме загружается лишь ГО. +# Натуральный ключ upsert — name. +- name: Головной офис + is_head_office: true + is_active: true diff --git a/api/seed/core/role_permissions.yaml b/api/seed/core/role_permissions.yaml new file mode 100644 index 0000000..0684198 --- /dev/null +++ b/api/seed/core/role_permissions.yaml @@ -0,0 +1,65 @@ +# Матрица «роль → права»: docs/context/05-domain-rules.md §10 (ТЗ 8.1, Прил. 2 «Роль: перечень полномочий»). +# ASSUMPTION(OQ-16): в ТЗ матрица помечена «будет разработана» (сноска 29) — это ЧЕРНОВИК; администратор правит её в UI. +# Пояснения в скобках в таблице §10 («свой РФ», «состав ГСК», «INITIATED…CONSOLIDATION_RF» и т. п.) — условия +# policy-слоя, а не отдельные права, поэтому здесь не представлены. +# Ключ — role.code из roles.yaml, значения — permission.code из permissions.yaml. Upsert по (роль, право); +# granted_by = NULL (выдано seed'ом). Права, которых в файле нет, seed не отзывает. +ADMIN: + - DIRECTORY_VIEW + - DIRECTORY_EDIT + - DIRECTORY_DELETE + - DIRECTORY_ADMIN + - EX_VIEW + - USERS_ADMIN + - AUDIT_VIEW +CURATOR_GO: + - DIRECTORY_VIEW + - DIRECTORY_EDIT + - CONSOLIDATION_VIEW + - KFO_VIEW + - EX_VIEW +KA_RF: + - DIRECTORY_VIEW + - DIRECTORY_EDIT + - DOCUMENT_VIEW + - DOCUMENT_EDIT + - CONSOLIDATION_VIEW + - CONSOLIDATION_EDIT + - KFO_VIEW + - KFO_EXPORT + - EX_VIEW + - EXPORT +RKA: + - DIRECTORY_VIEW + - DIRECTORY_EDIT + - DOCUMENT_VIEW + - CONSOLIDATION_VIEW + - CONSOLIDATION_EDIT + - KFO_VIEW + - KFO_APPROVE + - EX_VIEW + - EXPORT +KA_GO: + - DIRECTORY_VIEW + - DIRECTORY_EDIT + - DOCUMENT_VIEW + - DOCUMENT_EDIT + - CONSOLIDATION_VIEW + - CONSOLIDATION_EDIT + - KFO_VIEW + - KFO_VERIFY + - KFO_EXPORT + - EX_VIEW + - EXPORT +EXPERT: + - DIRECTORY_VIEW + - DOCUMENT_VIEW + - CONSOLIDATION_VIEW + - KFO_VIEW + - EX_VIEW +CLIENT_MANAGER: + - DIRECTORY_VIEW + - DOCUMENT_VIEW + - DOCUMENT_EDIT + - CONSOLIDATION_VIEW + - KFO_VIEW diff --git a/api/seed/core/roles.yaml b/api/seed/core/roles.yaml new file mode 100644 index 0000000..35b413c --- /dev/null +++ b/api/seed/core/roles.yaml @@ -0,0 +1,50 @@ +# Системные роли ТЗ 8.1 (docs/context/05-domain-rules.md §10; ТЗ «Ролевая модель»). Натуральный ключ upsert — code. +# Название и описание можно править в файле: повторный запуск seed обновит строку role, а не создаст новую. +# Удаление ролей seed не выполняет. +- code: KA_RF + name: Кредитный аналитик РФ + description: >- + Инициация КФО, загрузка и правка форм ФО по компаниям ГСК, редактирование сводной ОСВ, отправка КФО на + верификацию РКА РФ, выгрузка КФО; в своей зоне РФ. + is_system: true + is_active: true +- code: KA_GO + name: Кредитный аналитик ГО + description: >- + Все функции КА РФ без ограничения по филиалу, самостоятельная инициация КФО на уровне ГО, проверки и сравнение + КФО, верификация (№ и дата УОБ) на этапе «Консолидация ГО». + is_system: true + is_active: true +- code: RKA + name: Руководитель кредитного аналитика РФ + description: >- + Проверка и подтверждение КФО на этапе «Верификация РФ», передача на «Консолидация ГО», возврат на доработку КА РФ, + корректировка КФО (ОСВ), просмотр КФО по закреплённым ГК. + is_system: true + is_active: true +- code: CLIENT_MANAGER + name: Клиентский менеджер + description: >- + Просмотр статуса формирования КФО по своим клиентам и загрузка ФО по компаниям закреплённой ГК. В процессе + формирования КФО не участвует. + is_system: true + is_active: true +- code: CURATOR_GO + name: Куратор КП ГО + description: >- + Создание ГК, перезакрепление ГК за пользователями РФ, сверка состава периметра. В процессе формирования КФО не + участвует. + is_system: true + is_active: true +- code: EXPERT + name: Эксперт + description: Просмотр данных ФО и КФО по всем ГК (только чтение). + is_system: true + is_active: true +- code: ADMIN + name: Администратор + description: >- + Управление пользователями, ролями и правами доступа, ведение справочников. Отчёты не формирует, не редактирует и + не утверждает. + is_system: true + is_active: true diff --git a/api/tests/test_seed_roles.py b/api/tests/test_seed_roles.py new file mode 100644 index 0000000..79deda4 --- /dev/null +++ b/api/tests/test_seed_roles.py @@ -0,0 +1,338 @@ +"""Seed ролей, прав, матрицы §10 и ГО (spec reference-data-seed, задача 12). + +Матрица §10 переписана в тесте заново и независимо от `seed/core/role_permissions.yaml`. +""" + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from scripts.seed import SeedError, SeedReport, load_seed, run_seed +from src.models.access import AppUser, Permission, Role, RolePermission +from src.models.directories import RegionalOffice + +API_DIR = Path(__file__).resolve().parent.parent +SEED_DIR = API_DIR / "seed" + +# docs/context/05-domain-rules.md §10: роль -> множество (объект, действие). Пояснения в скобках — policy, не права. +MATRIX_S10: dict[str, set[tuple[str, str]]] = { + "ADMIN": { + ("DIRECTORY", "VIEW"), + ("DIRECTORY", "EDIT"), + ("DIRECTORY", "DELETE"), + ("DIRECTORY", "ADMIN"), + ("EX", "VIEW"), + ("USERS", "ADMIN"), + ("AUDIT", "VIEW"), + }, + "CURATOR_GO": { + ("DIRECTORY", "VIEW"), + ("DIRECTORY", "EDIT"), + ("CONSOLIDATION", "VIEW"), + ("KFO", "VIEW"), + ("EX", "VIEW"), + }, + "KA_RF": { + ("DIRECTORY", "VIEW"), + ("DIRECTORY", "EDIT"), + ("DOCUMENT", "VIEW"), + ("DOCUMENT", "EDIT"), + ("CONSOLIDATION", "VIEW"), + ("CONSOLIDATION", "EDIT"), + ("KFO", "VIEW"), + ("KFO", "EXPORT"), + ("EX", "VIEW"), + ("EXPORT", "EXPORT"), + }, + "RKA": { + ("DIRECTORY", "VIEW"), + ("DIRECTORY", "EDIT"), + ("DOCUMENT", "VIEW"), + ("CONSOLIDATION", "VIEW"), + ("CONSOLIDATION", "EDIT"), + ("KFO", "VIEW"), + ("KFO", "APPROVE"), + ("EX", "VIEW"), + ("EXPORT", "EXPORT"), + }, + "KA_GO": { + ("DIRECTORY", "VIEW"), + ("DIRECTORY", "EDIT"), + ("DOCUMENT", "VIEW"), + ("DOCUMENT", "EDIT"), + ("CONSOLIDATION", "VIEW"), + ("CONSOLIDATION", "EDIT"), + ("KFO", "VIEW"), + ("KFO", "VERIFY"), + ("KFO", "EXPORT"), + ("EX", "VIEW"), + ("EXPORT", "EXPORT"), + }, + "EXPERT": { + ("DIRECTORY", "VIEW"), + ("DOCUMENT", "VIEW"), + ("CONSOLIDATION", "VIEW"), + ("KFO", "VIEW"), + ("EX", "VIEW"), + }, + "CLIENT_MANAGER": { + ("DIRECTORY", "VIEW"), + ("DOCUMENT", "VIEW"), + ("DOCUMENT", "EDIT"), + ("CONSOLIDATION", "VIEW"), + ("KFO", "VIEW"), + }, +} +ROLE_COUNT = 7 +PERMISSION_COUNT = 16 +GRANT_COUNT = sum(len(pairs) for pairs in MATRIX_S10.values()) # 52 +ROLE_CODES = {"KA_RF", "KA_GO", "RKA", "CLIENT_MANAGER", "CURATOR_GO", "EXPERT", "ADMIN"} + + +async def _count(session: AsyncSession, model: type) -> int: + return int((await session.execute(select(func.count()).select_from(model))).scalar_one()) + + +async def _counts(session: AsyncSession) -> dict[str, int]: + return { + "role": await _count(session, Role), + "permission": await _count(session, Permission), + "role_permission": await _count(session, RolePermission), + "regional_office": await _count(session, RegionalOffice), + } + + +def _copy_seed(tmp_path: Path) -> Path: + target = tmp_path / "seed" + shutil.copytree(SEED_DIR, target) + return target + + +@pytest.mark.integration +async def test_first_run_loads_roles_permissions_and_head_office(db_session: AsyncSession) -> None: + report = await run_seed(db_session) + + counts = await _counts(db_session) + assert counts["role"] == ROLE_COUNT + assert counts["permission"] == PERMISSION_COUNT + assert counts["role_permission"] == GRANT_COUNT + assert counts["regional_office"] == 1 + + roles = (await db_session.execute(select(Role))).scalars().all() + assert {r.code for r in roles} == ROLE_CODES + assert all(r.is_system and r.is_active and r.name for r in roles) + + head = (await db_session.execute(select(RegionalOffice))).scalar_one() + assert head.name == "Головной офис" + assert head.is_head_office is True + + assert isinstance(report, SeedReport) + assert report.tables["role"].inserted == ROLE_COUNT + assert report.tables["role_permission"].inserted == GRANT_COUNT + + +@pytest.mark.integration +async def test_second_run_does_not_change_row_counts(db_session: AsyncSession) -> None: + await run_seed(db_session) + first = await _counts(db_session) + + report = await run_seed(db_session) + + assert await _counts(db_session) == first + assert all(stat.inserted == 0 and stat.updated == 0 for stat in report.tables.values()) + + +@pytest.mark.integration +async def test_role_permission_matches_matrix_s10(db_session: AsyncSession) -> None: + await run_seed(db_session) + + rows = ( + await db_session.execute( + select(Role.code, Permission.object_type, Permission.action) + .join(RolePermission, RolePermission.role_id == Role.id) + .join(Permission, Permission.id == RolePermission.permission_id) + ) + ).all() + actual: dict[str, set[tuple[str, str]]] = {} + for code, obj, action in rows: + actual.setdefault(code, set()).add((obj.value, action.value)) + + assert set(actual) == set(MATRIX_S10) + for role, expected in MATRIX_S10.items(): + assert actual[role] == expected, role + + +@pytest.mark.integration +async def test_permission_codes_and_grant_metadata(db_session: AsyncSession) -> None: + await run_seed(db_session) + + perms = (await db_session.execute(select(Permission))).scalars().all() + for perm in perms: + expected = ( + perm.action.value + if perm.object_type.value == perm.action.value + else (f"{perm.object_type.value}_{perm.action.value}") + ) + assert perm.code == expected + assert "EXPORT" in {p.code for p in perms} + + grants = (await db_session.execute(select(RolePermission))).scalars().all() + assert all(g.granted_by is None and g.granted_at is not None for g in grants) + + +@pytest.mark.integration +async def test_role_name_change_updates_row_instead_of_inserting(db_session: AsyncSession, tmp_path: Path) -> None: + seed_dir = _copy_seed(tmp_path) + await run_seed(db_session, seed_dir=seed_dir) + + roles_path = seed_dir / "core" / "roles.yaml" + roles = yaml.safe_load(roles_path.read_text(encoding="utf-8")) + for role in roles: + if role["code"] == "EXPERT": + role["name"] = "Эксперт (переименован)" + roles_path.write_text(yaml.safe_dump(roles, allow_unicode=True), encoding="utf-8") + + report = await run_seed(db_session, seed_dir=seed_dir) + + assert await _count(db_session, Role) == ROLE_COUNT + expert = (await db_session.execute(select(Role).where(Role.code == "EXPERT"))).scalar_one() + assert expert.name == "Эксперт (переименован)" + assert report.tables["role"].updated == 1 + assert report.tables["role"].inserted == 0 + + +@pytest.mark.integration +async def test_unknown_permission_in_matrix_fails_before_any_write(db_session: AsyncSession, tmp_path: Path) -> None: + seed_dir = _copy_seed(tmp_path) + matrix_path = seed_dir / "core" / "role_permissions.yaml" + matrix = yaml.safe_load(matrix_path.read_text(encoding="utf-8")) + matrix["EXPERT"].append("NO_SUCH_PERMISSION") + matrix_path.write_text(yaml.safe_dump(matrix, allow_unicode=True), encoding="utf-8") + before = await _counts(db_session) + + with pytest.raises(SeedError, match="NO_SUCH_PERMISSION"): + await run_seed(db_session, seed_dir=seed_dir) + + assert ( + await _counts(db_session) == before == {"role": 0, "permission": 0, "role_permission": 0, "regional_office": 0} + ) + + +@pytest.mark.integration +async def test_unknown_role_in_matrix_fails_before_any_write(db_session: AsyncSession, tmp_path: Path) -> None: + seed_dir = _copy_seed(tmp_path) + matrix_path = seed_dir / "core" / "role_permissions.yaml" + matrix = yaml.safe_load(matrix_path.read_text(encoding="utf-8")) + matrix["GHOST_ROLE"] = ["EX_VIEW"] + matrix_path.write_text(yaml.safe_dump(matrix, allow_unicode=True), encoding="utf-8") + + with pytest.raises(SeedError, match="GHOST_ROLE"): + await run_seed(db_session, seed_dir=seed_dir) + + assert await _count(db_session, Role) == 0 + + +@pytest.mark.integration +async def test_without_dev_flag_no_users_are_loaded(db_session: AsyncSession) -> None: + await run_seed(db_session) + + assert await _count(db_session, AppUser) == 0 + assert await _count(db_session, RegionalOffice) == 1 + + +@pytest.mark.integration +async def test_dev_flag_is_accepted(db_session: AsyncSession) -> None: + """Каркас --dev: до задачи 15 dev-данных нет, но флаг не должен падать.""" + await run_seed(db_session, dev=True) + + assert await _count(db_session, Role) == ROLE_COUNT + + +# ---- валидатор без БД ---- + + +def _write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def test_loader_reads_repository_seed() -> None: + data = load_seed(SEED_DIR) + + assert len(data.roles) == ROLE_COUNT + assert len(data.permissions) == PERMISSION_COUNT + assert len(data.regional_offices) == 1 + assert sum(len(codes) for codes in data.role_permissions.values()) == GRANT_COUNT + + +@pytest.mark.parametrize( + ("file", "mutate", "expected"), + [ + ( + "roles.yaml", + lambda text: text.replace("code: KA_RF\n", "code: KA_RF\n unknown_field: 1\n", 1), + "unknown_field", + ), + ("roles.yaml", lambda text: text.replace("is_system: true", "is_system: yes-please", 1), "is_system"), + ("permissions.yaml", lambda text: text.replace("action: VIEW", "action: LOOK", 1), "LOOK"), + ("permissions.yaml", lambda text: text.replace("code: DIRECTORY_VIEW", "code: WRONG_CODE", 1), "WRONG_CODE"), + ("roles.yaml", lambda text: text.replace("code: ADMIN", "code: KA_RF", 1), "KA_RF"), + ("regional_office.yaml", lambda text: text.replace("- name: Головной офис\n ", "- ", 1), "name"), + ("roles.yaml", lambda text: "- [broken", "roles.yaml"), + ], +) +def test_validator_rejects_malformed_seed( + tmp_path: Path, + file: str, + mutate, + expected: str, # noqa: ANN001 +) -> None: + seed_dir = _copy_seed(tmp_path) + path = seed_dir / "core" / file + path.write_text(mutate(path.read_text(encoding="utf-8")), encoding="utf-8") + + with pytest.raises(SeedError, match=expected): + load_seed(seed_dir) + + +def test_validator_rejects_duplicate_permission_in_role(tmp_path: Path) -> None: + seed_dir = _copy_seed(tmp_path) + path = seed_dir / "core" / "role_permissions.yaml" + path.write_text(path.read_text(encoding="utf-8").replace("ADMIN:\n", "ADMIN:\n - EX_VIEW\n", 1), encoding="utf-8") + + with pytest.raises(SeedError, match="EX_VIEW"): + load_seed(seed_dir) + + +def test_missing_seed_file_is_seed_error(tmp_path: Path) -> None: + with pytest.raises(SeedError, match="roles.yaml"): + load_seed(tmp_path) + + +def test_cli_validation_error_exits_with_code_1_without_db(tmp_path: Path) -> None: + """CLI с битым каталогом seed: код 1 и сообщение в stderr; до БД дело не доходит.""" + result = subprocess.run( # noqa: S603 + [sys.executable, "-m", "scripts.seed", "--seed-dir", str(tmp_path)], + cwd=API_DIR, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 1 + assert "roles.yaml" in result.stderr + + +def test_cli_help() -> None: + result = subprocess.run( # noqa: S603 + [sys.executable, "-m", "scripts.seed", "--help"], cwd=API_DIR, capture_output=True, text=True, check=False + ) + + assert result.returncode == 0 + assert "--dev" in result.stdout -- 2.47.2 From 81af5e7dd776407667be45e3115f06a7196afe77 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 21:07:20 +0400 Subject: [PATCH 43/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2012?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 3cd8f4c..b7b0db6 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -65,7 +65,7 @@ ### Task 12: [3.1] `seed/core/roles.yaml`, `permissions.yaml` (16 кодов), `role_permissions.yaml` ( -- [ ] `seed/core/roles.yaml`, `permissions.yaml` (16 кодов), `role_permissions.yaml` (матрица §10, ASSUMPTION(OQ-16)), `regional_office.yaml` (ГО); `scripts/seed.py` — загрузчик yaml/csv с upsert по натуральному ключу, флаг `--dev`; проверка: двойной запуск — одинаковое число строк; тест сравнивает `role_permission` с таблицей §10 +- [x] `seed/core/roles.yaml`, `permissions.yaml` (16 кодов), `role_permissions.yaml` (матрица §10, ASSUMPTION(OQ-16)), `regional_office.yaml` (ГО); `scripts/seed.py` — загрузчик yaml/csv с upsert по натуральному ключу, флаг `--dev`; проверка: двойной запуск — одинаковое число строк; тест сравнивает `role_permission` с таблицей §10 ### Task 13: [3.2] `seed/core/chart_of_accounts.csv` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 5c38742..961893c 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -53,7 +53,7 @@ ## 3. Seed (spec `reference-data-seed`) -- [ ] 3.1 `seed/core/roles.yaml`, `permissions.yaml` (16 кодов), `role_permissions.yaml` (матрица §10, ASSUMPTION(OQ-16)), +- [x] 3.1 `seed/core/roles.yaml`, `permissions.yaml` (16 кодов), `role_permissions.yaml` (матрица §10, ASSUMPTION(OQ-16)), `regional_office.yaml` (ГО); `scripts/seed.py` — загрузчик yaml/csv с upsert по натуральному ключу, флаг `--dev`; проверка: двойной запуск — одинаковое число строк; тест сравнивает `role_permission` с таблицей §10 - [ ] 3.2 `seed/core/chart_of_accounts.csv`: План счетов 94н (01–99, 001–011, `kind`, `row_type`, `is_off_balance`) + -- 2.47.2 From caf009d34354b89a7bf9cf80e560330eff6efc84 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 21:17:18 +0400 Subject: [PATCH 44/97] =?UTF-8?q?feat:=20seed=20=D0=BF=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=81=D1=87=D0=B5=D1=82=D0=BE=D0=B2=2094=D0=BD=20?= =?UTF-8?q?=D1=81=20=D1=81=D1=83=D0=B1=D1=81=D1=87=D0=B5=D1=82=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=B8=20=D1=87?= =?UTF-8?q?=D0=B5=D0=BA-=D0=BB=D0=B8=D1=81=D1=82=D0=B0,=20=D1=88=D0=B0?= =?UTF-8?q?=D0=B3=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D1=87=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=20chart=5Fof=5Faccount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/scripts/seed.py | 9 + api/scripts/seed_lib/apply.py | 61 ++++++ api/scripts/seed_lib/loaders.py | 96 ++++++++- api/scripts/seed_lib/model.py | 21 +- api/seed/core/chart_of_accounts.csv | 123 +++++++++++ api/tests/test_seed_chart.py | 302 ++++++++++++++++++++++++++++ 6 files changed, 609 insertions(+), 3 deletions(-) create mode 100644 api/seed/core/chart_of_accounts.csv create mode 100644 api/tests/test_seed_chart.py diff --git a/api/scripts/seed.py b/api/scripts/seed.py index 94ca722..e2f4d1f 100644 --- a/api/scripts/seed.py +++ b/api/scripts/seed.py @@ -9,6 +9,15 @@ Spec reference-data-seed; design D-4 и решение #18 (R-5): `run_seed(sess 2. `apply_seed` (`seed_lib/apply.py`) применяет набор к БД шагами в порядке зависимостей: upsert по натуральному ключу, без удалений. +План счетов (`core/chart_of_accounts.csv`, design D-9) — ДОПУЩЕНИЕ OQ-47: полного плана 1С клиента нет, в файле +Приказ Минфина № 94н: 62 активных синтетических счёта (01–05, 07–11, 14–16, 19–21, 23, 25, 26, 28, 29, 40–46, +50–52, 55, 57–60, 62, 63, 66–71, 73, 75–77, 79–84, 86, 90, 91, 94, 96–99; номера 06, 12, 13, 17, 18, 22, 24, 27, +30–39, 47–49, 53, 54, 56, 61, 64, 65, 72, 74, 78, 85, 87–89, 92, 93, 95 в плане отсутствуют), забалансовые 001–011 +(`kind = A`, `is_off_balance = true`) и только те субсчета и подсубсчета типового 1С БП 3.0, на которые ссылаются §4/§6 +правил и чек-лист; `kind` субсчетов наследуется от счёта. +Остальное добавляет администратор (ARCH-004). Реестр кодов для проверки ссылок правил и чек-листа — +`scripts.seed_lib.model.account_codes(data)`. + Расширение (задачи 13–15): поле в `SeedData` (`seed_lib/model.py`), читатель в `load_seed`, шаг в `apply_seed`. """ diff --git a/api/scripts/seed_lib/apply.py b/api/scripts/seed_lib/apply.py index ea3e748..2d4f276 100644 --- a/api/scripts/seed_lib/apply.py +++ b/api/scripts/seed_lib/apply.py @@ -1,5 +1,6 @@ """Фаза 2: применение проверенного набора к БД — upsert по натуральному ключу, без удалений и без commit.""" +import uuid from typing import Any from sqlalchemy import func, select @@ -8,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from scripts.seed_lib.model import SeedData, SeedReport, TableStat from src.models.access import Permission, Role, RolePermission +from src.models.accounting import ChartOfAccount from src.models.directories import RegionalOffice @@ -71,6 +73,64 @@ async def _apply_role_permissions(session: AsyncSession, data: SeedData, stat: T await session.execute(statement) +async def _apply_chart_of_accounts(session: AsyncSession, data: SeedData, stat: TableStat) -> None: + """План счетов: upsert по `code` в порядке глубины (родитель раньше потомков), одним `run_seed` и одной транзакцией. + + Идентификаторы новых строк генерирует приложение заранее, поэтому `parent_id` известен при вставке и второй проход + не нужен; существующим строкам при расхождении с файлом обновляются поля и `parent_id`. Удалений нет. + """ + if not data.accounts: + return + stored = ( + await session.execute( + select( + ChartOfAccount.code, + ChartOfAccount.id, + ChartOfAccount.parent_id, + ChartOfAccount.name, + ChartOfAccount.kind, + ChartOfAccount.row_type, + ChartOfAccount.is_off_balance, + ) + ) + ).all() + ids = {row.code: row.id for row in stored} + existing = {row.code: (row.parent_id, row.name, row.kind, row.row_type, row.is_off_balance) for row in stored} + for code in data.accounts: + ids.setdefault(code, uuid.uuid4()) + rows = [] + for code, account in sorted(data.accounts.items(), key=lambda item: item[0].count(".")): + parent_id = ids[account.parent_code] if account.parent_code is not None else None + values = (parent_id, account.name, account.kind, account.row_type, account.is_off_balance) + if code not in existing: + stat.inserted += 1 + elif existing[code] != values: + stat.updated += 1 + else: + stat.unchanged += 1 + continue + rows.append( + { + "id": ids[code], + "code": code, + "parent_id": parent_id, + "name": account.name, + "kind": account.kind, + "row_type": account.row_type, + "is_off_balance": account.is_off_balance, + } + ) + if rows: + statement = pg_insert(ChartOfAccount) + statement = statement.on_conflict_do_update( + index_elements=[ChartOfAccount.code], + set_={ + name: statement.excluded[name] for name in ("parent_id", "name", "kind", "row_type", "is_off_balance") + }, + ) + await session.execute(statement, rows) + + async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport: """Фаза 2: upsert набора в порядке зависимостей. Без commit — транзакцией управляет вызывающий.""" report = SeedReport() @@ -114,4 +174,5 @@ async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport: report.stat("permission"), ) await _apply_role_permissions(session, data, report.stat("role_permission")) + await _apply_chart_of_accounts(session, data, report.stat("chart_of_account")) return report diff --git a/api/scripts/seed_lib/loaders.py b/api/scripts/seed_lib/loaders.py index 7ed5be1..c0636af 100644 --- a/api/scripts/seed_lib/loaders.py +++ b/api/scripts/seed_lib/loaders.py @@ -3,13 +3,15 @@ База данных здесь не используется; любая ошибка — `SeedError` с файлом и записью. """ +import csv +import re from pathlib import Path from typing import Any import yaml -from scripts.seed_lib.model import PermissionSeed, RegionalOfficeSeed, RoleSeed, SeedData, SeedError -from src.models.enums import PermissionAction, PermissionObject +from scripts.seed_lib.model import AccountSeed, PermissionSeed, RegionalOfficeSeed, RoleSeed, SeedData, SeedError +from src.models.enums import AccountKind, AccountRowType, PermissionAction, PermissionObject DEFAULT_SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed" @@ -184,6 +186,95 @@ def _load_role_permissions( return result +CHART_COLUMNS = ["code", "parent_code", "name", "kind", "row_type", "is_off_balance"] +#: нормализованный код: NN или NNN (забалансовый), NN.MM, NN.MM.K (K без ведущего нуля: 76.07.1, как в ТЗ; 60.1 — нет) +_ACCOUNT_CODE = re.compile(r"^\d{2,3}(\.\d{2}(\.[1-9]\d?)?)?$") +_ROW_TYPE_BY_DEPTH = (AccountRowType.account, AccountRowType.subaccount, AccountRowType.analytic) +_OFF_BALANCE_ROOTS = {f"{n:03d}" for n in range(1, 12)} +_OFF_BALANCE_CODE_LENGTH = 3 # забалансовые счета — трёхзначные (001–011), балансовые синтетические — двузначные + + +def _chart_row(row: dict[str | None, Any], where: str, code: str) -> AccountSeed: + """Проверка одной строки плана счетов, не требующая других строк.""" + where = f"{where} ({code})" + if not _ACCOUNT_CODE.match(code): + raise SeedError(f"{where}: код не нормализован, ожидается вид 60, 001, 60.01 или 76.07.1") + root = code.split(".", maxsplit=1)[0] + off_balance_root = len(root) == _OFF_BALANCE_CODE_LENGTH + if off_balance_root and root not in _OFF_BALANCE_ROOTS: + raise SeedError(f"{where}: трёхзначные коды допустимы только для 001–011") + if root == "00": + raise SeedError(f"{where}: счёта 00 не существует") + kind_text = str(row["kind"]).strip() + try: + kind = AccountKind(kind_text) if kind_text else None + except ValueError: + raise SeedError(f"{where}: неизвестный kind «{kind_text}» (допустимы A, P, AP)") from None + row_type_text = str(row["row_type"]).strip() + try: + row_type = AccountRowType(row_type_text) + except ValueError: + raise SeedError( + f"{where}: неизвестный row_type «{row_type_text}» (допустимы account, subaccount, analytic)" + ) from None + flag_text = str(row["is_off_balance"]).strip() + if flag_text not in ("true", "false"): + raise SeedError(f"{where}: is_off_balance должен быть true или false, получено «{flag_text}»") + is_off_balance = flag_text == "true" + expected_type = _ROW_TYPE_BY_DEPTH[code.count(".")] + if row_type is not expected_type: + raise SeedError( + f"{where}: row_type «{row_type.value}» не соответствует глубине кода (ожидался «{expected_type.value}»)" + ) + if off_balance_root and not is_off_balance: + raise SeedError(f"{where}: забалансовый счёт должен иметь is_off_balance=true") + if not off_balance_root and is_off_balance: + raise SeedError(f"{where}: is_off_balance=true допустим только для счетов 001–011") + if row_type is AccountRowType.account and kind is None: + raise SeedError(f"{where}: у синтетического счёта должен быть задан kind") + name = str(row["name"]).strip() + if not name: + raise SeedError(f"{where}: пустое название счёта") + parent_text = str(row["parent_code"]).strip() + return AccountSeed(code, parent_text or None, name, kind, row_type, is_off_balance) + + +def load_chart_of_accounts(core: Path, seed_dir: Path) -> dict[str, AccountSeed]: + """План счетов из `chart_of_accounts.csv` (UTF-8): проверка до записи, порядок файла сохраняется.""" + path = core / "chart_of_accounts.csv" + if not path.is_file(): + raise SeedError(f"{_where(path, seed_dir)}: файл не найден") + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError as error: + raise SeedError(f"{_where(path, seed_dir)}: не удалось прочитать файл как UTF-8: {error}") from error + reader = csv.DictReader(text.splitlines()) + if reader.fieldnames != CHART_COLUMNS: + raise SeedError(f"{_where(path, seed_dir)}: заголовок должен быть «{','.join(CHART_COLUMNS)}»") + accounts: dict[str, AccountSeed] = {} + lines: dict[str, int] = {} + for line_no, row in enumerate(reader, start=2): + where = f"{_where(path, seed_dir)}, строка {line_no}" + if None in row or any(value is None for value in row.values()): + raise SeedError(f"{where}: число полей не совпадает с заголовком") + code = str(row["code"]).strip() + account = _chart_row(row, where, code) + if code in accounts: + raise SeedError(f"{where} ({code}): код уже встречался в строке {lines[code]}") + accounts[code] = account + lines[code] = line_no + for code, account in accounts.items(): + where = f"{_where(path, seed_dir)}, строка {lines[code]} ({code})" + expected_parent = code.rsplit(".", 1)[0] if "." in code else None + if account.parent_code != expected_parent: + if expected_parent is None: + raise SeedError(f"{where}: у синтетического счёта parent_code должен быть пустым") + raise SeedError(f"{where}: родитель должен быть «{expected_parent}», указан «{account.parent_code or ''}»") + if account.parent_code is not None and account.parent_code not in accounts: + raise SeedError(f"{where}: родитель «{account.parent_code}» не найден в файле") + return accounts + + def load_seed(seed_dir: Path | None = None, *, dev: bool = False) -> SeedData: """Фаза 1: прочитать и проверить все файлы. Бросает `SeedError`; к БД не обращается.""" seed_dir = seed_dir or DEFAULT_SEED_DIR @@ -195,6 +286,7 @@ def load_seed(seed_dir: Path | None = None, *, dev: bool = False) -> SeedData: core, seed_dir, {r.code for r in data.roles}, {p.code for p in data.permissions} ) data.regional_offices = _load_regional_offices(core, seed_dir) + data.accounts = load_chart_of_accounts(core, seed_dir) if dev: _load_dev(data, seed_dir / "dev") return data diff --git a/api/scripts/seed_lib/model.py b/api/scripts/seed_lib/model.py index 34f28f0..9428bf8 100644 --- a/api/scripts/seed_lib/model.py +++ b/api/scripts/seed_lib/model.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field -from src.models.enums import PermissionAction, PermissionObject +from src.models.enums import AccountKind, AccountRowType, PermissionAction, PermissionObject class SeedError(Exception): @@ -34,6 +34,18 @@ class PermissionSeed: description: str | None +@dataclass(frozen=True) +class AccountSeed: + """Строка плана счетов; `code` нормализован (60.01, 76.07.1), `parent_code` — код родителя или None.""" + + code: str + parent_code: str | None + name: str + kind: AccountKind | None + row_type: AccountRowType + is_off_balance: bool + + @dataclass class SeedData: """Проверенный набор seed; ничего не знает о БД.""" @@ -43,6 +55,13 @@ class SeedData: permissions: list[PermissionSeed] = field(default_factory=list) #: код роли -> коды прав (порядок как в файле) role_permissions: dict[str, list[str]] = field(default_factory=dict) + #: код счёта -> счёт; порядок файла (родитель раньше потомков). Реестр кодов для загрузчика правил: `account_codes` + accounts: dict[str, AccountSeed] = field(default_factory=dict) + + +def account_codes(data: SeedData) -> frozenset[str]: + """Коды плана счетов набора: правила элиминации и чек-лист проверяют свои ссылки на счета по этому реестру.""" + return frozenset(data.accounts) @dataclass diff --git a/api/seed/core/chart_of_accounts.csv b/api/seed/core/chart_of_accounts.csv new file mode 100644 index 0000000..f41fdd2 --- /dev/null +++ b/api/seed/core/chart_of_accounts.csv @@ -0,0 +1,123 @@ +code,parent_code,name,kind,row_type,is_off_balance +01,,Основные средства,A,account,false +02,,Амортизация основных средств,P,account,false +03,,Доходные вложения в материальные ценности,A,account,false +04,,Нематериальные активы,A,account,false +05,,Амортизация нематериальных активов,P,account,false +07,,Оборудование к установке,A,account,false +08,,Вложения во внеоборотные активы,A,account,false +09,,Отложенные налоговые активы,A,account,false +10,,Материалы,A,account,false +11,,Животные на выращивании и откорме,A,account,false +14,,Резервы под снижение стоимости материальных ценностей,P,account,false +15,,Заготовление и приобретение материальных ценностей,A,account,false +16,,Отклонение в стоимости материальных ценностей,AP,account,false +19,,Налог на добавленную стоимость по приобретенным ценностям,A,account,false +20,,Основное производство,A,account,false +21,,Полуфабрикаты собственного производства,A,account,false +23,,Вспомогательные производства,A,account,false +25,,Общепроизводственные расходы,A,account,false +26,,Общехозяйственные расходы,A,account,false +28,,Брак в производстве,A,account,false +29,,Обслуживающие производства и хозяйства,A,account,false +40,,"Выпуск продукции (работ, услуг)",AP,account,false +41,,Товары,A,account,false +42,,Торговая наценка,P,account,false +43,,Готовая продукция,A,account,false +44,,Расходы на продажу,A,account,false +45,,Товары отгруженные,A,account,false +46,,Выполненные этапы по незавершенным работам,A,account,false +50,,Касса,A,account,false +51,,Расчетные счета,A,account,false +52,,Валютные счета,A,account,false +55,,Специальные счета в банках,A,account,false +57,,Переводы в пути,A,account,false +58,,Финансовые вложения,A,account,false +58.01,58,Паи и акции,A,subaccount,false +58.03,58,Предоставленные займы,A,subaccount,false +59,,Резервы под обесценение финансовых вложений,P,account,false +60,,Расчеты с поставщиками и подрядчиками,AP,account,false +60.01,60,Расчеты с поставщиками и подрядчиками,AP,subaccount,false +60.02,60,Расчеты по авансам выданным,AP,subaccount,false +60.03,60,Расчеты по векселям выданным,AP,subaccount,false +60.21,60,Расчеты с поставщиками и подрядчиками (в валюте),AP,subaccount,false +60.22,60,Расчеты по авансам выданным (в валюте),AP,subaccount,false +60.31,60,Расчеты с поставщиками и подрядчиками (в условных единицах),AP,subaccount,false +60.32,60,Расчеты по авансам выданным (в условных единицах),AP,subaccount,false +62,,Расчеты с покупателями и заказчиками,AP,account,false +62.01,62,Расчеты с покупателями и заказчиками,AP,subaccount,false +62.02,62,Расчеты по авансам полученным,AP,subaccount,false +62.03,62,Расчеты по векселям полученным,AP,subaccount,false +62.21,62,Расчеты с покупателями и заказчиками (в валюте),AP,subaccount,false +62.22,62,Расчеты по авансам полученным (в валюте),AP,subaccount,false +62.31,62,Расчеты с покупателями и заказчиками (в условных единицах),AP,subaccount,false +62.32,62,Расчеты по авансам полученным (в условных единицах),AP,subaccount,false +63,,Резервы по сомнительным долгам,P,account,false +66,,Расчеты по краткосрочным кредитам и займам,P,account,false +66.02,66,Проценты по краткосрочным кредитам,P,subaccount,false +66.03,66,Краткосрочные займы,P,subaccount,false +66.04,66,Проценты по краткосрочным займам,P,subaccount,false +66.06,66,Проценты по краткосрочным кредитам и займам (субсчет 06),P,subaccount,false +66.22,66,Проценты по краткосрочным кредитам и займам (субсчет 22),P,subaccount,false +66.24,66,Проценты по краткосрочным кредитам и займам (субсчет 24),P,subaccount,false +66.26,66,Проценты по краткосрочным кредитам и займам (субсчет 26),P,subaccount,false +67,,Расчеты по долгосрочным кредитам и займам,P,account,false +67.02,67,Проценты по долгосрочным кредитам,P,subaccount,false +67.03,67,Долгосрочные займы,P,subaccount,false +67.04,67,Проценты по долгосрочным займам,P,subaccount,false +67.06,67,Проценты по долгосрочным кредитам и займам (субсчет 06),P,subaccount,false +67.22,67,Проценты по долгосрочным кредитам и займам (субсчет 22),P,subaccount,false +67.24,67,Проценты по долгосрочным кредитам и займам (субсчет 24),P,subaccount,false +67.26,67,Проценты по долгосрочным кредитам и займам (субсчет 26),P,subaccount,false +68,,Расчеты по налогам и сборам,AP,account,false +69,,Расчеты по социальному страхованию и обеспечению,AP,account,false +70,,Расчеты с персоналом по оплате труда,P,account,false +71,,Расчеты с подотчетными лицами,AP,account,false +73,,Расчеты с персоналом по прочим операциям,AP,account,false +75,,Расчеты с учредителями,AP,account,false +76,,Расчеты с разными дебиторами и кредиторами,AP,account,false +76.03,76,Расчеты по причитающимся дивидендам и другим доходам,AP,subaccount,false +76.05,76,Расчеты с прочими поставщиками и подрядчиками,AP,subaccount,false +76.07,76,Расчеты по лизингу,AP,subaccount,false +76.07.1,76.07,Обязательства по лизингу,AP,analytic,false +76.07.2,76.07,Прочие расчеты по лизингу (подсубсчет 2),AP,analytic,false +76.07.5,76.07,Проценты по лизингу,AP,analytic,false +76.07.9,76.07,Активы по лизингу,AP,analytic,false +76.08,76,Расчеты по процентам,AP,subaccount,false +76.09,76,Прочие расчеты с разными дебиторами и кредиторами,AP,subaccount,false +76.28,76,Расчеты по процентам (в валюте),AP,subaccount,false +76.38,76,Расчеты по процентам (в условных единицах),AP,subaccount,false +77,,Отложенные налоговые обязательства,P,account,false +79,,Внутрихозяйственные расчеты,AP,account,false +80,,Уставный капитал,P,account,false +81,,Собственные акции (доли),A,account,false +82,,Резервный капитал,P,account,false +83,,Добавочный капитал,P,account,false +84,,Нераспределенная прибыль (непокрытый убыток),AP,account,false +86,,Целевое финансирование,P,account,false +90,,Продажи,AP,account,false +90.01,90,Выручка,AP,subaccount,false +90.02,90,Себестоимость продаж,AP,subaccount,false +90.03,90,Налог на добавленную стоимость,AP,subaccount,false +90.04,90,Акцизы,AP,subaccount,false +90.09,90,Прибыль (убыток) от продаж,AP,subaccount,false +91,,Прочие доходы и расходы,AP,account,false +91.01,91,Прочие доходы,AP,subaccount,false +91.02,91,Прочие расходы,AP,subaccount,false +91.09,91,Сальдо прочих доходов и расходов,AP,subaccount,false +94,,Недостачи и потери от порчи ценностей,A,account,false +96,,Резервы предстоящих расходов,P,account,false +97,,Расходы будущих периодов,A,account,false +98,,Доходы будущих периодов,P,account,false +99,,Прибыли и убытки,AP,account,false +001,,Арендованные основные средства,A,account,true +002,,"Товарно-материальные ценности, принятые на ответственное хранение",A,account,true +003,,"Материалы, принятые в переработку",A,account,true +004,,"Товары, принятые на комиссию",A,account,true +005,,"Оборудование, принятое для монтажа",A,account,true +006,,Бланки строгой отчетности,A,account,true +007,,Списанная в убыток задолженность неплатежеспособных дебиторов,A,account,true +008,,Обеспечения обязательств и платежей полученные,A,account,true +009,,Обеспечения обязательств и платежей выданные,A,account,true +010,,Износ основных средств,A,account,true +011,,"Основные средства, сданные в аренду",A,account,true diff --git a/api/tests/test_seed_chart.py b/api/tests/test_seed_chart.py new file mode 100644 index 0000000..850e1e1 --- /dev/null +++ b/api/tests/test_seed_chart.py @@ -0,0 +1,302 @@ +"""Seed плана счетов (spec reference-data-seed, задача 13; design D-9, OQ-47). + +Перечень счетов из §4/§6 правил (`docs/context/05-domain-rules.md`) переписан в тест заново, независимо от +`seed/core/chart_of_accounts.csv` и от yaml правил (он появится в задаче 14). +""" + +from pathlib import Path + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased + +from scripts.seed import SeedError, load_seed, run_seed +from scripts.seed_lib.loaders import load_chart_of_accounts +from scripts.seed_lib.model import account_codes +from src.models.accounting import ChartOfAccount +from src.models.enums import AccountKind, AccountRowType + +API_DIR = Path(__file__).resolve().parent.parent +SEED_DIR = API_DIR / "seed" + +HEADER = "code,parent_code,name,kind,row_type,is_off_balance" + +# Счета, на которые ссылаются правила 1–8 (§4, ББ), ОФР-таблица (§4) и результирующая таблица (§6); +# «76 кроме …» — сам 76 и исключения 76.07.x. Перенесено из текста §4 вручную. +ACCOUNTS_S4_RULES_1_8 = { + "01", "03", "58.01", "58.03", "80", "83", "60.01", "60.02", "60.03", "60.21", "60.22", "60.31", "60.32", + "62.01", "62.02", "62.03", "62.21", "62.22", "62.31", "62.32", "66", "66.03", "66.04", "67", "67.03", "67.04", + "76", "76.07.1", "76.07.5", "76.07.9", +} # fmt: skip +ACCOUNTS_S4_INCOME_AND_S6 = { + "90.01", "76.03", "76.05", "76.07.2", "76.08", "76.09", "76.28", "76.38", + "66.02", "66.04", "66.06", "66.22", "66.24", "66.26", "67.02", "67.04", "67.06", "67.22", "67.24", "67.26", +} # fmt: skip +# Чек-лист: BASIC (01, 58, 60, 62, 66, 67, 76, 90) и EXTENDED (OQ-46) +CHECKLIST_ACCOUNTS = { + "01", "58", "60", "62", "66", "67", "76", "90", + "02", "03", "08", "44", "26", "91", "51", "52", "59", "80", "83", "86", "97", "001", "008", "009", "011", +} # fmt: skip +# Активные синтетические счета Плана счетов 94н (допущение OQ-47) +SYNTHETIC_94N = { + "01", "02", "03", "04", "05", "07", "08", "09", "10", "11", "14", "15", "16", "19", "20", "21", "23", "25", "26", + "28", "29", "40", "41", "42", "43", "44", "45", "46", "50", "51", "52", "55", "57", "58", "59", "60", "62", "63", + "66", "67", "68", "69", "70", "71", "73", "75", "76", "77", "79", "80", "81", "82", "83", "84", "86", "90", "91", + "94", "96", "97", "98", "99", +} # fmt: skip +SYNTHETIC_94N_COUNT = 62 +MAX_CODE_DOTS = 2 # NN.MM.K +OFF_BALANCE = {f"{n:03d}" for n in range(1, 12)} + + +def _write_chart(tmp_path: Path, lines: list[str]) -> Path: + core = tmp_path / "core" + core.mkdir() + (core / "chart_of_accounts.csv").write_text("\n".join([HEADER, *lines]) + "\n", encoding="utf-8") + return core + + +def _chart_error(tmp_path: Path, lines: list[str]) -> str: + core = _write_chart(tmp_path, lines) + with pytest.raises(SeedError) as info: + load_chart_of_accounts(core, tmp_path) + return str(info.value) + + +# ---------- файл репозитория ---------- + + +def test_repository_chart_is_valid() -> None: + accounts = load_seed(SEED_DIR).accounts + assert len(accounts) == len(set(accounts)) + for code, account in accounts.items(): + assert account.code == code + assert account.parent_code is None or account.parent_code in accounts + if account.row_type is AccountRowType.account: + assert account.kind is not None, f"у синтетического счёта {code} не задан kind" + else: + parent = accounts[str(account.parent_code)] + assert account.kind is parent.kind, f"субсчёт {code} должен наследовать kind родителя" + assert "." not in code or code == f"{account.parent_code}.{code.rsplit('.', 1)[1]}" + assert all(code.count(".") <= MAX_CODE_DOTS for code in accounts) + assert "60.01" in accounts + assert "60.1" not in accounts + + +def test_repository_chart_off_balance_flag_only_for_001_011() -> None: + accounts = load_seed(SEED_DIR).accounts + assert {c for c, a in accounts.items() if a.is_off_balance} == OFF_BALANCE + assert all( + accounts[c].row_type is AccountRowType.account and accounts[c].kind is AccountKind.A for c in OFF_BALANCE + ) + + +def test_repository_chart_has_exactly_the_94n_synthetic_accounts() -> None: + accounts = load_seed(SEED_DIR).accounts + synthetic_on_balance = { + c for c, a in accounts.items() if a.row_type is AccountRowType.account and not a.is_off_balance + } + assert synthetic_on_balance == SYNTHETIC_94N + assert len(synthetic_on_balance) == SYNTHETIC_94N_COUNT + + +def test_repository_chart_contains_accounts_of_rules_s4_s6_and_checklist() -> None: + codes = account_codes(load_seed(SEED_DIR)) + required = ACCOUNTS_S4_RULES_1_8 | ACCOUNTS_S4_INCOME_AND_S6 | CHECKLIST_ACCOUNTS + assert required - codes == set() + + +def test_repository_chart_names_are_non_empty_and_codes_normalized() -> None: + accounts = load_seed(SEED_DIR).accounts + assert all(a.name.strip() for a in accounts.values()) + assert accounts["76.07.1"].parent_code == "76.07" + assert accounts["76.07"].parent_code == "76" + assert accounts["76"].parent_code is None + + +# ---------- валидатор ---------- + + +def test_validator_accepts_minimal_valid_chart(tmp_path: Path) -> None: + core = _write_chart( + tmp_path, + [ + "60,,Расчеты с поставщиками,AP,account,false", + "60.01,60,Расчеты с поставщиками (руб),AP,subaccount,false", + "001,,Арендованные ОС,A,account,true", + ], + ) + accounts = load_chart_of_accounts(core, tmp_path) + assert list(accounts) == ["60", "60.01", "001"] + assert accounts["60.01"].parent_code == "60" + assert accounts["60"].parent_code is None + assert accounts["001"].is_off_balance is True + + +def test_validator_rejects_not_normalized_code(tmp_path: Path) -> None: + message = _chart_error( + tmp_path, + ["60,,Расчеты,AP,account,false", "60.1,60,Расчеты (руб),AP,subaccount,false"], + ) + assert message == ( + "core/chart_of_accounts.csv, строка 3 (60.1): код не нормализован, ожидается вид 60, 001, 60.01 или 76.07.1" + ) + + +def test_validator_rejects_duplicate_code(tmp_path: Path) -> None: + message = _chart_error(tmp_path, ["60,,Расчеты,AP,account,false", "60,,Расчеты еще раз,AP,account,false"]) + assert message == "core/chart_of_accounts.csv, строка 3 (60): код уже встречался в строке 2" + + +def test_validator_rejects_missing_parent(tmp_path: Path) -> None: + message = _chart_error(tmp_path, ["60.01,60,Расчеты (руб),AP,subaccount,false"]) + assert message == "core/chart_of_accounts.csv, строка 2 (60.01): родитель «60» не найден в файле" + + +def test_validator_rejects_parent_that_is_not_code_prefix(tmp_path: Path) -> None: + message = _chart_error( + tmp_path, + [ + "60,,Расчеты,AP,account,false", + "62,,Покупатели,AP,account,false", + "60.01,62,Расчеты (руб),AP,subaccount,false", + ], + ) + assert message == "core/chart_of_accounts.csv, строка 4 (60.01): родитель должен быть «60», указан «62»" + + +def test_validator_rejects_row_type_not_matching_depth(tmp_path: Path) -> None: + message = _chart_error(tmp_path, ["60,,Расчеты,AP,account,false", "60.01,60,Расчеты (руб),AP,analytic,false"]) + assert message == ( + "core/chart_of_accounts.csv, строка 3 (60.01): row_type «analytic» не соответствует глубине кода " + "(ожидался «subaccount»)" + ) + + +def test_validator_rejects_off_balance_flag_missing_for_off_balance_account(tmp_path: Path) -> None: + message = _chart_error(tmp_path, ["001,,Арендованные ОС,A,account,false"]) + assert message == "core/chart_of_accounts.csv, строка 2 (001): забалансовый счёт должен иметь is_off_balance=true" + + +def test_validator_rejects_off_balance_flag_for_on_balance_account(tmp_path: Path) -> None: + message = _chart_error(tmp_path, ["60,,Расчеты,AP,account,true"]) + assert ( + message == "core/chart_of_accounts.csv, строка 2 (60): is_off_balance=true допустим только для счетов 001–011" + ) + + +def test_validator_rejects_three_digit_code_outside_off_balance_range(tmp_path: Path) -> None: + message = _chart_error(tmp_path, ["012,,Неизвестный,A,account,true"]) + assert message == "core/chart_of_accounts.csv, строка 2 (012): трёхзначные коды допустимы только для 001–011" + + +def test_validator_rejects_account_without_kind(tmp_path: Path) -> None: + message = _chart_error(tmp_path, ["60,,Расчеты,,account,false"]) + assert message == "core/chart_of_accounts.csv, строка 2 (60): у синтетического счёта должен быть задан kind" + + +def test_validator_rejects_unknown_kind_and_row_type(tmp_path: Path) -> None: + assert _chart_error(tmp_path, ["60,,Расчеты,X,account,false"]) == ( + "core/chart_of_accounts.csv, строка 2 (60): неизвестный kind «X» (допустимы A, P, AP)" + ) + + +def test_validator_rejects_unknown_row_type(tmp_path: Path) -> None: + message = _chart_error(tmp_path, ["60,,Расчеты,AP,synthetic,false"]) + assert message == ( + "core/chart_of_accounts.csv, строка 2 (60): " + "неизвестный row_type «synthetic» (допустимы account, subaccount, analytic)" + ) + + +def test_validator_rejects_non_boolean_flag(tmp_path: Path) -> None: + message = _chart_error(tmp_path, ["60,,Расчеты,AP,account,yes"]) + assert ( + message + == "core/chart_of_accounts.csv, строка 2 (60): is_off_balance должен быть true или false, получено «yes»" + ) + + +def test_validator_rejects_wrong_header(tmp_path: Path) -> None: + core = tmp_path / "core" + core.mkdir() + (core / "chart_of_accounts.csv").write_text("code,name\n60,Расчеты\n", encoding="utf-8") + with pytest.raises(SeedError) as info: + load_chart_of_accounts(core, tmp_path) + assert str(info.value) == ( + "core/chart_of_accounts.csv: заголовок должен быть «code,parent_code,name,kind,row_type,is_off_balance»" + ) + + +def test_validator_rejects_missing_file(tmp_path: Path) -> None: + (tmp_path / "core").mkdir() + with pytest.raises(SeedError, match="core/chart_of_accounts.csv: файл не найден"): + load_chart_of_accounts(tmp_path / "core", tmp_path) + + +# ---------- БД ---------- + + +@pytest.mark.integration +async def test_run_seed_inserts_chart_with_hierarchy(db_session: AsyncSession) -> None: + report = await run_seed(db_session) + expected = len(load_seed(SEED_DIR).accounts) + assert report.tables["chart_of_account"].inserted == expected + assert (await db_session.scalar(select(func.count()).select_from(ChartOfAccount))) == expected + stored = set((await db_session.scalars(select(ChartOfAccount.code))).all()) + assert (ACCOUNTS_S4_RULES_1_8 | ACCOUNTS_S4_INCOME_AND_S6 | CHECKLIST_ACCOUNTS) - stored == set() + + parent = aliased(ChartOfAccount) + pairs = dict( + ( + await db_session.execute( + select(ChartOfAccount.code, parent.code).join(parent, ChartOfAccount.parent_id == parent.id) + ) + ).all() + ) + assert pairs["76.07.1"] == "76.07" + assert pairs["76.07"] == "76" + assert pairs["60.01"] == "60" + assert "76" not in pairs + root_codes = set( + (await db_session.scalars(select(ChartOfAccount.code).where(ChartOfAccount.parent_id.is_(None)))).all() + ) + assert root_codes == SYNTHETIC_94N | OFF_BALANCE + + +@pytest.mark.integration +async def test_chart_columns_stored_as_in_file(db_session: AsyncSession) -> None: + await run_seed(db_session) + rows = {c.code: c for c in (await db_session.scalars(select(ChartOfAccount))).all()} + assert rows["60"].kind is AccountKind.AP + assert rows["01"].kind is AccountKind.A and rows["02"].kind is AccountKind.P + assert rows["76.07.1"].row_type is AccountRowType.analytic + assert rows["60.01"].row_type is AccountRowType.subaccount + assert {c for c, r in rows.items() if r.is_off_balance} == OFF_BALANCE + + +@pytest.mark.integration +async def test_second_run_keeps_chart_row_count(db_session: AsyncSession) -> None: + await run_seed(db_session) + first = await db_session.scalar(select(func.count()).select_from(ChartOfAccount)) + report = await run_seed(db_session) + assert await db_session.scalar(select(func.count()).select_from(ChartOfAccount)) == first + stat = report.tables["chart_of_account"] + assert (stat.inserted, stat.updated, stat.unchanged) == (0, 0, first) + + +@pytest.mark.integration +async def test_chart_name_change_updates_row_without_new_one(db_session: AsyncSession, tmp_path: Path) -> None: + await run_seed(db_session) + seed_copy = tmp_path / "seed" + (seed_copy / "core").mkdir(parents=True) + for file in (SEED_DIR / "core").iterdir(): + (seed_copy / "core" / file.name).write_bytes(file.read_bytes()) + path = seed_copy / "core" / "chart_of_accounts.csv" + path.write_text(path.read_text(encoding="utf-8").replace("Паи и акции", "Паи и доли"), encoding="utf-8") + before = await db_session.scalar(select(func.count()).select_from(ChartOfAccount)) + report = await run_seed(db_session, seed_dir=seed_copy) + assert report.tables["chart_of_account"].updated == 1 + assert await db_session.scalar(select(func.count()).select_from(ChartOfAccount)) == before + assert await db_session.scalar(select(ChartOfAccount.name).where(ChartOfAccount.code == "58.01")) == "Паи и доли" -- 2.47.2 From 54e072947cd0262dc3d304c2078175f8d1e219bc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 21:17:22 +0400 Subject: [PATCH 45/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2013?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index b7b0db6..4a26fd1 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -69,7 +69,7 @@ ### Task 13: [3.2] `seed/core/chart_of_accounts.csv` -- [ ] `seed/core/chart_of_accounts.csv`: План счетов 94н (01–99, 001–011, `kind`, `row_type`, `is_off_balance`) + субсчета, упомянутые в §4/§6 правил и чек-листе (60.xx, 62.xx, 58.01/58.03, 66.xx/67.xx, 76.03/.05/.07.x/.08/.09, 90.01/90.03, 91.x, 80, 83, 03); проверка: все `parent_code` существуют, код уникален, нормализован (`60.01`, не `60.1`) +- [x] `seed/core/chart_of_accounts.csv`: План счетов 94н (01–99, 001–011, `kind`, `row_type`, `is_off_balance`) + субсчета, упомянутые в §4/§6 правил и чек-листе (60.xx, 62.xx, 58.01/58.03, 66.xx/67.xx, 76.03/.05/.07.x/.08/.09, 90.01/90.03, 91.x, 80, 83, 03); проверка: все `parent_code` существуют, код уникален, нормализован (`60.01`, не `60.1`) ### Task 14: [3.3] `seed/core/elimination_rules_v1.yaml` — 8 правил ББ из §4 со счетами сторон и ис diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 961893c..575a741 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -56,7 +56,7 @@ - [x] 3.1 `seed/core/roles.yaml`, `permissions.yaml` (16 кодов), `role_permissions.yaml` (матрица §10, ASSUMPTION(OQ-16)), `regional_office.yaml` (ГО); `scripts/seed.py` — загрузчик yaml/csv с upsert по натуральному ключу, флаг `--dev`; проверка: двойной запуск — одинаковое число строк; тест сравнивает `role_permission` с таблицей §10 -- [ ] 3.2 `seed/core/chart_of_accounts.csv`: План счетов 94н (01–99, 001–011, `kind`, `row_type`, `is_off_balance`) + +- [x] 3.2 `seed/core/chart_of_accounts.csv`: План счетов 94н (01–99, 001–011, `kind`, `row_type`, `is_off_balance`) + субсчета, упомянутые в §4/§6 правил и чек-листе (60.xx, 62.xx, 58.01/58.03, 66.xx/67.xx, 76.03/.05/.07.x/.08/.09, 90.01/90.03, 91.x, 80, 83, 03); проверка: все `parent_code` существуют, код уникален, нормализован (`60.01`, не `60.1`) - [ ] 3.3 `seed/core/elimination_rules_v1.yaml` — 8 правил ББ из §4 со счетами сторон и исключениями; -- 2.47.2 From 176999cb711bc091fd8415b8b1fae08dc7108dcd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 21:29:16 +0400 Subject: [PATCH 46/97] =?UTF-8?q?feat:=20seed=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D1=8D=D0=BB=D0=B8=D0=BC=D0=B8=D0=BD=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=91=D0=91=20(=C2=A74)=20=D0=B8=20=D1=87=D0=B5?= =?UTF-8?q?=D0=BA-=D0=BB=D0=B8=D1=81=D1=82=D0=B0=20=D0=BE=D0=B1=D1=8F?= =?UTF-8?q?=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2,=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D1=81=D1=87?= =?UTF-8?q?=D0=B5=D1=82=D0=BE=D0=B2=20=D0=B4=D0=BE=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/scripts/seed.py | 5 + api/scripts/seed_lib/apply.py | 5 + api/scripts/seed_lib/apply_rules.py | 143 ++++++ api/scripts/seed_lib/loaders.py | 138 ++---- api/scripts/seed_lib/loaders_rules.py | 177 ++++++++ api/scripts/seed_lib/model.py | 50 ++- api/scripts/seed_lib/readers.py | 66 +++ api/seed/core/elimination_rules_v1.yaml | 120 +++++ api/seed/core/required_document_rules.yaml | 45 ++ api/tests/test_seed_rules.py | 492 +++++++++++++++++++++ 10 files changed, 1150 insertions(+), 91 deletions(-) create mode 100644 api/scripts/seed_lib/apply_rules.py create mode 100644 api/scripts/seed_lib/loaders_rules.py create mode 100644 api/scripts/seed_lib/readers.py create mode 100644 api/seed/core/elimination_rules_v1.yaml create mode 100644 api/seed/core/required_document_rules.yaml create mode 100644 api/tests/test_seed_rules.py diff --git a/api/scripts/seed.py b/api/scripts/seed.py index e2f4d1f..478f251 100644 --- a/api/scripts/seed.py +++ b/api/scripts/seed.py @@ -18,6 +18,11 @@ Spec reference-data-seed; design D-4 и решение #18 (R-5): `run_seed(sess Остальное добавляет администратор (ARCH-004). Реестр кодов для проверки ссылок правил и чек-листа — `scripts.seed_lib.model.account_codes(data)`. +Правила элиминации ББ (`core/elimination_rules_v1.yaml`, §4, версия 1) и чек-лист (`core/required_document_rules.yaml`, +ASSUMPTION(OQ-46)): `seed_lib/loaders_rules.py` проверяет, что каждый счёт есть в плане счетов набора, ДО записи в БД; +`seed_lib/apply_rules.py` — upsert (составы счетов и чек-лист приводятся к файлу без удалений; ключи без уникального +индекса — в Python, NULL в `account_id` не плодит дубли). Общие помощники чтения — `seed_lib/readers.py`. + Расширение (задачи 13–15): поле в `SeedData` (`seed_lib/model.py`), читатель в `load_seed`, шаг в `apply_seed`. """ diff --git a/api/scripts/seed_lib/apply.py b/api/scripts/seed_lib/apply.py index 2d4f276..d4bea35 100644 --- a/api/scripts/seed_lib/apply.py +++ b/api/scripts/seed_lib/apply.py @@ -7,6 +7,7 @@ from sqlalchemy import func, select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession +from scripts.seed_lib.apply_rules import apply_elimination_rules, apply_required_document_rules from scripts.seed_lib.model import SeedData, SeedReport, TableStat from src.models.access import Permission, Role, RolePermission from src.models.accounting import ChartOfAccount @@ -175,4 +176,8 @@ async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport: ) await _apply_role_permissions(session, data, report.stat("role_permission")) await _apply_chart_of_accounts(session, data, report.stat("chart_of_account")) + await apply_elimination_rules( + session, data, report.stat("elimination_rule"), report.stat("elimination_rule_account") + ) + await apply_required_document_rules(session, data, report.stat("required_document_rule")) return report diff --git a/api/scripts/seed_lib/apply_rules.py b/api/scripts/seed_lib/apply_rules.py new file mode 100644 index 0000000..dd87db1 --- /dev/null +++ b/api/scripts/seed_lib/apply_rules.py @@ -0,0 +1,143 @@ +"""Фаза 2: правила элиминации и чек-лист обязательных документов — upsert без удалений и без commit.""" + +import uuid +from typing import Any + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from scripts.seed_lib.model import SeedData, TableStat +from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRuleAccount +from src.models.audit_ex import RequiredDocumentRule + +_RULE_VALUE_FIELDS = ( + "article_1_line", + "article_2_line", + "side_1", + "measure", + "elimination_mode", + "vat_handling", + "excise_adjust", + "comment", + "valid_from", +) + + +async def _account_ids(session: AsyncSession) -> dict[str, uuid.UUID]: + return {code: id_ for id_, code in (await session.execute(select(ChartOfAccount.id, ChartOfAccount.code))).all()} + + +async def apply_elimination_rules( + session: AsyncSession, data: SeedData, rule_stat: TableStat, account_stat: TableStat +) -> None: + """Правила — upsert по (statement_type, rule_no, version_no); затем состав счетов приводится к файлу.""" + if not data.elimination_rules: + return + rule_ids = await _upsert_rules(session, data, rule_stat) + await _upsert_rule_accounts(session, data, rule_ids, account_stat) + + +async def _upsert_rules( + session: AsyncSession, data: SeedData, stat: TableStat +) -> dict[tuple[Any, int, int], uuid.UUID]: + stored = { + (rule.statement_type, rule.rule_no, rule.version_no): rule + for rule in (await session.scalars(select(EliminationRule))).all() + } + rule_ids: dict[tuple[Any, int, int], uuid.UUID] = {} + for seed in data.elimination_rules: + key = (seed.statement_type, seed.rule_no, seed.version_no) + values = {name: getattr(seed, name) for name in _RULE_VALUE_FIELDS} + current = stored.get(key) + if current is None: + rule_id = rule_ids[key] = uuid.uuid4() + session.add( + EliminationRule( + id=rule_id, + statement_type=seed.statement_type, + rule_no=seed.rule_no, + version_no=seed.version_no, + **values, + ) + ) + stat.inserted += 1 + continue + rule_ids[key] = current.id + if any(getattr(current, name) != value for name, value in values.items()): + for name, value in values.items(): + setattr(current, name, value) + stat.updated += 1 + else: + stat.unchanged += 1 + await session.flush() + return rule_ids + + +async def _upsert_rule_accounts( + session: AsyncSession, data: SeedData, rule_ids: dict[tuple[Any, int, int], uuid.UUID], stat: TableStat +) -> None: + """Ключ внутри шага — (rule_id, company_no, account_id): отсутствующие вставляются, у существующих при расхождении + обновляется `is_excluded`. Уникального индекса в БД нет (DBML), поэтому выбор и сравнение — в Python. + Удалений нет: счета, убранные из файла, остаются в БД. + """ + account_ids = await _account_ids(session) + existing: dict[tuple[uuid.UUID, int, uuid.UUID], tuple[uuid.UUID, bool]] = {} + for row in (await session.execute(select(EliminationRuleAccount))).scalars().all(): + existing.setdefault((row.rule_id, row.company_no, row.account_id), (row.id, row.is_excluded)) + for seed in data.elimination_rules: + rule_id = rule_ids[(seed.statement_type, seed.rule_no, seed.version_no)] + for company_no, side in seed.sides.items(): + wanted = [(code, False) for code in side.accounts] + [(code, True) for code in side.excluded] + for code, is_excluded in wanted: + account_id = account_ids[code] + current = existing.get((rule_id, company_no, account_id)) + if current is None: + session.add( + EliminationRuleAccount( + rule_id=rule_id, company_no=company_no, account_id=account_id, is_excluded=is_excluded + ) + ) + stat.inserted += 1 + elif current[1] != is_excluded: + await session.execute( + update(EliminationRuleAccount) + .where(EliminationRuleAccount.id == current[0]) + .values(is_excluded=is_excluded) + ) + stat.updated += 1 + else: + stat.unchanged += 1 + await session.flush() + + +async def apply_required_document_rules(session: AsyncSession, data: SeedData, stat: TableStat) -> None: + """Чек-лист — upsert по (approach, document_type, account_id) с учётом NULL в `account_id` (BALANCE / INCOME). + + Уникальный индекс трактует NULL как различные значения и допускает дубли, поэтому `ON CONFLICT` не годится: + существующие строки читаются, ключ сравнивается в Python (None == None). Удалений нет. + """ + if not data.required_document_rules: + return + account_ids = await _account_ids(session) + existing: dict[tuple[Any, Any, uuid.UUID | None], RequiredDocumentRule] = {} + for row in (await session.scalars(select(RequiredDocumentRule))).all(): + existing.setdefault((row.approach, row.document_type, row.account_id), row) + for seed in data.required_document_rules: + account_id = account_ids[seed.account_code] if seed.account_code is not None else None + current = existing.get((seed.approach, seed.document_type, account_id)) + if current is None: + session.add( + RequiredDocumentRule( + approach=seed.approach, + document_type=seed.document_type, + account_id=account_id, + is_mandatory=seed.is_mandatory, + ) + ) + stat.inserted += 1 + elif current.is_mandatory != seed.is_mandatory: + current.is_mandatory = seed.is_mandatory + stat.updated += 1 + else: + stat.unchanged += 1 + await session.flush() diff --git a/api/scripts/seed_lib/loaders.py b/api/scripts/seed_lib/loaders.py index c0636af..36fd046 100644 --- a/api/scripts/seed_lib/loaders.py +++ b/api/scripts/seed_lib/loaders.py @@ -8,91 +8,45 @@ import re from pathlib import Path from typing import Any -import yaml - -from scripts.seed_lib.model import AccountSeed, PermissionSeed, RegionalOfficeSeed, RoleSeed, SeedData, SeedError +from scripts.seed_lib.loaders_rules import load_elimination_rules, load_required_document_rules +from scripts.seed_lib.model import ( + AccountSeed, + PermissionSeed, + RegionalOfficeSeed, + RoleSeed, + SeedData, + SeedError, + account_codes, +) +from scripts.seed_lib.readers import bool_field, checked_records, ensure_unique, place, read_yaml, str_field from src.models.enums import AccountKind, AccountRowType, PermissionAction, PermissionObject DEFAULT_SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed" -def _where(path: Path, seed_dir: Path, index: int | None = None, key: str | None = None) -> str: - place = str(path.relative_to(seed_dir)) if path.is_relative_to(seed_dir) else str(path) - if index is not None: - place += f", запись №{index + 1}" - if key is not None: - place += f" ({key})" - return place - - -def _read_yaml(path: Path, seed_dir: Path) -> Any: - if not path.is_file(): - raise SeedError(f"{_where(path, seed_dir)}: файл не найден") - try: - return yaml.safe_load(path.read_text(encoding="utf-8")) - except (yaml.YAMLError, UnicodeDecodeError) as error: - raise SeedError(f"{_where(path, seed_dir)}: не удалось разобрать файл: {error}") from error - - -def _records(data: Any, allowed: set[str], required: set[str], path: Path, seed_dir: Path) -> list[dict[str, Any]]: - """Список записей-словарей: тип, обязательные поля, отсутствие лишних ключей.""" - if not isinstance(data, list): - raise SeedError(f"{_where(path, seed_dir)}: ожидается список записей") - for index, record in enumerate(data): - if not isinstance(record, dict): - raise SeedError(f"{_where(path, seed_dir, index)}: ожидается словарь полей") - for key in sorted(set(record) - allowed): - raise SeedError(f"{_where(path, seed_dir, index)}: неизвестное поле «{key}»") - for key in sorted(required - set(record)): - raise SeedError(f"{_where(path, seed_dir, index)}: нет обязательного поля «{key}»") - return data - - -def _str(record: dict[str, Any], key: str, where: str, *, optional: bool = False) -> str | None: - value = record.get(key) - if value is None and optional: - return None - if not isinstance(value, str) or not value.strip(): - raise SeedError(f"{where}: поле «{key}» должно быть непустой строкой") - return value.strip() - - -def _bool(record: dict[str, Any], key: str, where: str, *, default: bool) -> bool: - value = record.get(key, default) - if not isinstance(value, bool): - raise SeedError(f"{where}: поле «{key}» должно быть true или false, получено {value!r}") - return value - - -def _unique(values: list[str], what: str, path: Path, seed_dir: Path) -> None: - seen: set[str] = set() - for value in values: - if value in seen: - raise SeedError(f"{_where(path, seed_dir)}: {what} «{value}» встречается больше одного раза") - seen.add(value) - - def _load_regional_offices(core: Path, seed_dir: Path) -> list[RegionalOfficeSeed]: path = core / "regional_office.yaml" - records = _records(_read_yaml(path, seed_dir), {"name", "is_head_office", "is_active"}, {"name"}, path, seed_dir) + records = checked_records( + read_yaml(path, seed_dir), {"name", "is_head_office", "is_active"}, {"name"}, path, seed_dir + ) result = [] for index, record in enumerate(records): - where = _where(path, seed_dir, index) + where = place(path, seed_dir, index) result.append( RegionalOfficeSeed( - name=str(_str(record, "name", where)), - is_head_office=_bool(record, "is_head_office", where, default=False), - is_active=_bool(record, "is_active", where, default=True), + name=str(str_field(record, "name", where)), + is_head_office=bool_field(record, "is_head_office", where, default=False), + is_active=bool_field(record, "is_active", where, default=True), ) ) - _unique([o.name for o in result], "филиал", path, seed_dir) + ensure_unique([o.name for o in result], "филиал", path, seed_dir) return result def _load_roles(core: Path, seed_dir: Path) -> list[RoleSeed]: path = core / "roles.yaml" - records = _records( - _read_yaml(path, seed_dir), + records = checked_records( + read_yaml(path, seed_dir), {"code", "name", "description", "is_system", "is_active"}, {"code", "name"}, path, @@ -100,17 +54,17 @@ def _load_roles(core: Path, seed_dir: Path) -> list[RoleSeed]: ) result = [] for index, record in enumerate(records): - where = _where(path, seed_dir, index, str(record.get("code"))) + where = place(path, seed_dir, index, str(record.get("code"))) result.append( RoleSeed( - code=str(_str(record, "code", where)), - name=str(_str(record, "name", where)), - description=_str(record, "description", where, optional=True), - is_system=_bool(record, "is_system", where, default=False), - is_active=_bool(record, "is_active", where, default=True), + code=str(str_field(record, "code", where)), + name=str(str_field(record, "name", where)), + description=str_field(record, "description", where, optional=True), + is_system=bool_field(record, "is_system", where, default=False), + is_active=bool_field(record, "is_active", where, default=True), ) ) - _unique([r.code for r in result], "роль", path, seed_dir) + ensure_unique([r.code for r in result], "роль", path, seed_dir) return result @@ -123,8 +77,8 @@ def _expected_permission_code(object_type: PermissionObject, action: PermissionA def _load_permissions(core: Path, seed_dir: Path) -> list[PermissionSeed]: path = core / "permissions.yaml" - records = _records( - _read_yaml(path, seed_dir), + records = checked_records( + read_yaml(path, seed_dir), {"code", "object", "action", "name", "description"}, {"code", "object", "action", "name"}, path, @@ -132,7 +86,7 @@ def _load_permissions(core: Path, seed_dir: Path) -> list[PermissionSeed]: ) result = [] for index, record in enumerate(records): - where = _where(path, seed_dir, index, str(record.get("code"))) + where = place(path, seed_dir, index, str(record.get("code"))) try: object_type = PermissionObject(record["object"]) except ValueError: @@ -141,7 +95,7 @@ def _load_permissions(core: Path, seed_dir: Path) -> list[PermissionSeed]: action = PermissionAction(record["action"]) except ValueError: raise SeedError(f"{where}: неизвестное действие права «{record['action']}»") from None - code = str(_str(record, "code", where)) + code = str(str_field(record, "code", where)) if code != _expected_permission_code(object_type, action): raise SeedError( f"{where}: код права «{code}» не соответствует объекту и действию, " @@ -152,13 +106,13 @@ def _load_permissions(core: Path, seed_dir: Path) -> list[PermissionSeed]: code=code, object_type=object_type, action=action, - name=str(_str(record, "name", where)), - description=_str(record, "description", where, optional=True), + name=str(str_field(record, "name", where)), + description=str_field(record, "description", where, optional=True), ) ) - _unique([p.code for p in result], "право", path, seed_dir) + ensure_unique([p.code for p in result], "право", path, seed_dir) pairs = [f"{p.object_type.value}.{p.action.value}" for p in result] - _unique(pairs, "сочетание объект.действие", path, seed_dir) + ensure_unique(pairs, "сочетание объект.действие", path, seed_dir) return result @@ -166,12 +120,12 @@ def _load_role_permissions( core: Path, seed_dir: Path, role_codes: set[str], permission_codes: set[str] ) -> dict[str, list[str]]: path = core / "role_permissions.yaml" - data = _read_yaml(path, seed_dir) + data = read_yaml(path, seed_dir) if not isinstance(data, dict): - raise SeedError(f"{_where(path, seed_dir)}: ожидается словарь «код роли -> список кодов прав»") + raise SeedError(f"{place(path, seed_dir)}: ожидается словарь «код роли -> список кодов прав»") result: dict[str, list[str]] = {} for role_code, codes in data.items(): - where = _where(path, seed_dir, key=str(role_code)) + where = place(path, seed_dir, key=str(role_code)) if role_code not in role_codes: raise SeedError(f"{where}: роль «{role_code}» не описана в roles.yaml") if not isinstance(codes, list) or not all(isinstance(c, str) for c in codes): @@ -243,18 +197,18 @@ def load_chart_of_accounts(core: Path, seed_dir: Path) -> dict[str, AccountSeed] """План счетов из `chart_of_accounts.csv` (UTF-8): проверка до записи, порядок файла сохраняется.""" path = core / "chart_of_accounts.csv" if not path.is_file(): - raise SeedError(f"{_where(path, seed_dir)}: файл не найден") + raise SeedError(f"{place(path, seed_dir)}: файл не найден") try: text = path.read_text(encoding="utf-8") except UnicodeDecodeError as error: - raise SeedError(f"{_where(path, seed_dir)}: не удалось прочитать файл как UTF-8: {error}") from error + raise SeedError(f"{place(path, seed_dir)}: не удалось прочитать файл как UTF-8: {error}") from error reader = csv.DictReader(text.splitlines()) if reader.fieldnames != CHART_COLUMNS: - raise SeedError(f"{_where(path, seed_dir)}: заголовок должен быть «{','.join(CHART_COLUMNS)}»") + raise SeedError(f"{place(path, seed_dir)}: заголовок должен быть «{','.join(CHART_COLUMNS)}»") accounts: dict[str, AccountSeed] = {} lines: dict[str, int] = {} for line_no, row in enumerate(reader, start=2): - where = f"{_where(path, seed_dir)}, строка {line_no}" + where = f"{place(path, seed_dir)}, строка {line_no}" if None in row or any(value is None for value in row.values()): raise SeedError(f"{where}: число полей не совпадает с заголовком") code = str(row["code"]).strip() @@ -264,7 +218,7 @@ def load_chart_of_accounts(core: Path, seed_dir: Path) -> dict[str, AccountSeed] accounts[code] = account lines[code] = line_no for code, account in accounts.items(): - where = f"{_where(path, seed_dir)}, строка {lines[code]} ({code})" + where = f"{place(path, seed_dir)}, строка {lines[code]} ({code})" expected_parent = code.rsplit(".", 1)[0] if "." in code else None if account.parent_code != expected_parent: if expected_parent is None: @@ -287,6 +241,10 @@ def load_seed(seed_dir: Path | None = None, *, dev: bool = False) -> SeedData: ) data.regional_offices = _load_regional_offices(core, seed_dir) data.accounts = load_chart_of_accounts(core, seed_dir) + # правила и чек-лист проверяют свои ссылки на счета по плану счетов набора — до любой записи в БД + codes = account_codes(data) + data.elimination_rules = load_elimination_rules(core, seed_dir, codes) + data.required_document_rules = load_required_document_rules(core, seed_dir, codes) if dev: _load_dev(data, seed_dir / "dev") return data diff --git a/api/scripts/seed_lib/loaders_rules.py b/api/scripts/seed_lib/loaders_rules.py new file mode 100644 index 0000000..452442c --- /dev/null +++ b/api/scripts/seed_lib/loaders_rules.py @@ -0,0 +1,177 @@ +"""Фаза 1: правила элиминации (§4) и чек-лист обязательных документов — чтение и проверка без БД. + +Ссылки на счета проверяются по коду плана счетов того же набора (`account_codes`): неизвестный счёт — `SeedError` +с номером правила / подходом чек-листа до первой записи в БД. +""" + +import datetime +from collections.abc import Collection +from pathlib import Path +from typing import Any, TypeVar + +from scripts.seed_lib.model import EliminationRuleSeed, RequiredDocumentRuleSeed, RuleSideSeed, SeedError +from scripts.seed_lib.readers import bool_field, checked_records, place, read_yaml, str_field +from src.models.enums import ConsolidationApproach, DocumentType, StatementType + +RULES_FILE = "elimination_rules_v1.yaml" +CHECKLIST_FILE = "required_document_rules.yaml" +_SIDES = (1, 2) +_SIDE_1_VALUES = ("D", "K") +_MEASURES = ("closing", "turnover") +_MODES = ("min", "full", "manual") +_RULE_FIELDS_REQUIRED = { + "rule_no", + "article_1_line", + "article_2_line", + "side_1", + "measure", + "elimination_mode", + "company_1", + "company_2", +} +_RULE_FIELDS = _RULE_FIELDS_REQUIRED | {"vat_handling", "excise_adjust", "comment"} + + +_E = TypeVar("_E", StatementType, ConsolidationApproach, DocumentType) + + +def _enum_value(enum_type: type[_E], value: Any, what: str, where: str) -> _E: + try: + return enum_type(value) + except ValueError: + allowed = ", ".join(member.value for member in enum_type) + raise SeedError(f"{where}: неизвестное значение {what} «{value}» (допустимы {allowed})") from None + + +def _choice(record: dict[str, Any], key: str, allowed: tuple[str, ...], where: str) -> str: + value = str_field(record, key, where) + if value not in allowed: + raise SeedError(f"{where}: поле «{key}» должно быть одним из {', '.join(allowed)}, получено «{value}»") + return str(value) + + +def _codes(value: Any, key: str, where: str, known: Collection[str]) -> tuple[str, ...]: + """Список кодов счетов (строки в кавычках), существующих в плане счетов; без дублей.""" + if value is None: + return () + if not isinstance(value, list): + raise SeedError(f"{where}: «{key}» — ожидается список кодов счетов") + result: list[str] = [] + for code in value: + if not isinstance(code, str): + raise SeedError( + f'{where}: код счёта {code!r} в «{key}» — не строка; коды счетов пишутся в кавычках ("01", "60.02")' + ) + if code not in known: + raise SeedError(f"{where}: счёт {code} не найден в плане счетов") + if code in result: + raise SeedError(f"{where}: счёт {code} указан в «{key}» больше одного раза") + result.append(code) + return tuple(result) + + +def _side(record: dict[str, Any], company_no: int, where: str, known: Collection[str]) -> RuleSideSeed: + key = f"company_{company_no}" + side_where = f"{where} (сторона {company_no})" + body = record[key] + if not isinstance(body, dict): + raise SeedError(f"{side_where}: «{key}» — ожидается словарь с полями accounts и excluded") + for extra in sorted(set(body) - {"accounts", "excluded"}): + raise SeedError(f"{side_where}: неизвестное поле «{extra}» (допустимы accounts, excluded)") + accounts = _codes(body.get("accounts"), "accounts", side_where, known) + excluded = _codes(body.get("excluded"), "excluded", side_where, known) + if not accounts: + raise SeedError(f"{side_where}: нет счетов стороны, у правила должны быть счета обеих сторон") + for code in excluded: + if code in accounts: + raise SeedError(f"{side_where}: счёт {code} одновременно включён и исключён") + return RuleSideSeed(accounts, excluded) + + +def load_elimination_rules(core: Path, seed_dir: Path, known_accounts: Collection[str]) -> list[EliminationRuleSeed]: + """Правила элиминации из `elimination_rules_v1.yaml`: общие поля файла + список правил со счетами сторон.""" + path = core / RULES_FILE + data = read_yaml(path, seed_dir) + file_place = place(path, seed_dir) + if not isinstance(data, dict): + raise SeedError(f"{file_place}: ожидается словарь (statement_type, version_no, valid_from, rules)") + for key in sorted(set(data) - {"statement_type", "version_no", "valid_from", "rules"}): + raise SeedError(f"{file_place}: неизвестное поле «{key}»") + for key in ("statement_type", "version_no", "valid_from", "rules"): + if key not in data: + raise SeedError(f"{file_place}: нет обязательного поля «{key}»") + statement_type = _enum_value(StatementType, data["statement_type"], "statement_type", file_place) + version_no = data["version_no"] + if not isinstance(version_no, int) or isinstance(version_no, bool) or version_no < 1: + raise SeedError(f"{file_place}: поле «version_no» должно быть целым числом от 1") + valid_from = data["valid_from"] + if not isinstance(valid_from, datetime.date) or isinstance(valid_from, datetime.datetime): + raise SeedError(f"{file_place}: поле «valid_from» должно быть датой ГГГГ-ММ-ДД") + records = checked_records(data["rules"], _RULE_FIELDS, _RULE_FIELDS_REQUIRED | {"rule_no"}, path, seed_dir) + rules: list[EliminationRuleSeed] = [] + seen: set[int] = set() + for index, record in enumerate(records): + rule_no = record["rule_no"] + where = f"{place(path, seed_dir, index)}: правило № {rule_no}" + if not isinstance(rule_no, int) or isinstance(rule_no, bool) or rule_no < 1: + raise SeedError(f"{place(path, seed_dir, index)}: поле «rule_no» должно быть целым числом от 1") + if rule_no in seen: + raise SeedError(f"{file_place}: правило № {rule_no} встречается больше одного раза") + seen.add(rule_no) + vat = str_field(record, "vat_handling", where, optional=True) + rules.append( + EliminationRuleSeed( + statement_type=statement_type, + rule_no=rule_no, + version_no=version_no, + article_1_line=str(str_field(record, "article_1_line", where)), + article_2_line=str(str_field(record, "article_2_line", where)), + side_1=_choice(record, "side_1", _SIDE_1_VALUES, where), + measure=_choice(record, "measure", _MEASURES, where), + elimination_mode=_choice(record, "elimination_mode", _MODES, where), + vat_handling=vat, + excise_adjust=bool_field(record, "excise_adjust", where, default=False), + comment=str_field(record, "comment", where, optional=True), + valid_from=valid_from, + sides={no: _side(record, no, where, known_accounts) for no in _SIDES}, + ) + ) + return rules + + +_CHECKLIST_FIELDS = {"approach", "document_type", "account", "is_mandatory"} + + +def load_required_document_rules( + core: Path, seed_dir: Path, known_accounts: Collection[str] +) -> list[RequiredDocumentRuleSeed]: + """Чек-лист из `required_document_rules.yaml`: OSV — со счётом из плана, прочие типы — без счёта; дублей нет.""" + path = core / CHECKLIST_FILE + records = checked_records( + read_yaml(path, seed_dir), _CHECKLIST_FIELDS, {"approach", "document_type"}, path, seed_dir + ) + rules: list[RequiredDocumentRuleSeed] = [] + seen: set[tuple[ConsolidationApproach, DocumentType, str | None]] = set() + for index, record in enumerate(records): + where = place(path, seed_dir, index) + approach = _enum_value(ConsolidationApproach, record["approach"], "approach", where) + document_type = _enum_value(DocumentType, record["document_type"], "document_type", where) + where = f"{place(path, seed_dir, index)}: чек-лист ({approach.value})" + account = record.get("account") + if document_type is DocumentType.OSV: + if not isinstance(account, str) or not account.strip(): + raise SeedError(f"{where}: для OSV обязателен account — код счёта строкой в кавычках") + if account not in known_accounts: + raise SeedError(f"{where}: счёт {account} не найден в плане счетов") + elif account is not None: + raise SeedError(f"{where}: account допустим только для OSV, у {document_type.value} счёта нет") + key = (approach, document_type, account) + if key in seen: + raise SeedError(f"{where}: строка {document_type.value} (счёт {account}) встречается больше одного раза") + seen.add(key) + rules.append( + RequiredDocumentRuleSeed( + approach, document_type, account, bool_field(record, "is_mandatory", where, default=True) + ) + ) + return rules diff --git a/api/scripts/seed_lib/model.py b/api/scripts/seed_lib/model.py index 9428bf8..d4ea6b1 100644 --- a/api/scripts/seed_lib/model.py +++ b/api/scripts/seed_lib/model.py @@ -1,8 +1,17 @@ """Модель проверенного набора seed и отчёта о применении (без доступа к БД).""" +import datetime from dataclasses import dataclass, field -from src.models.enums import AccountKind, AccountRowType, PermissionAction, PermissionObject +from src.models.enums import ( + AccountKind, + AccountRowType, + ConsolidationApproach, + DocumentType, + PermissionAction, + PermissionObject, + StatementType, +) class SeedError(Exception): @@ -46,6 +55,43 @@ class AccountSeed: is_off_balance: bool +@dataclass(frozen=True) +class RuleSideSeed: + """Состав счетов одной стороны правила: `accounts` — включённые, `excluded` — исключения внутри маски.""" + + accounts: tuple[str, ...] + excluded: tuple[str, ...] + + +@dataclass(frozen=True) +class EliminationRuleSeed: + """Правило элиминации версии `version_no` (§4); `sides` — company_no (1, 2) -> состав счетов.""" + + statement_type: StatementType + rule_no: int + version_no: int + article_1_line: str + article_2_line: str + side_1: str + measure: str + elimination_mode: str + vat_handling: str | None + excise_adjust: bool + comment: str | None + valid_from: datetime.date + sides: dict[int, RuleSideSeed] + + +@dataclass(frozen=True) +class RequiredDocumentRuleSeed: + """Строка чек-листа; `account_code` задан только для OSV.""" + + approach: ConsolidationApproach + document_type: DocumentType + account_code: str | None + is_mandatory: bool + + @dataclass class SeedData: """Проверенный набор seed; ничего не знает о БД.""" @@ -57,6 +103,8 @@ class SeedData: role_permissions: dict[str, list[str]] = field(default_factory=dict) #: код счёта -> счёт; порядок файла (родитель раньше потомков). Реестр кодов для загрузчика правил: `account_codes` accounts: dict[str, AccountSeed] = field(default_factory=dict) + elimination_rules: list[EliminationRuleSeed] = field(default_factory=list) + required_document_rules: list[RequiredDocumentRuleSeed] = field(default_factory=list) def account_codes(data: SeedData) -> frozenset[str]: diff --git a/api/scripts/seed_lib/readers.py b/api/scripts/seed_lib/readers.py new file mode 100644 index 0000000..97112e1 --- /dev/null +++ b/api/scripts/seed_lib/readers.py @@ -0,0 +1,66 @@ +"""Общие помощники чтения файлов seed: путь в сообщениях, yaml, проверка записей и полей.""" + +from pathlib import Path +from typing import Any + +import yaml + +from scripts.seed_lib.model import SeedError + + +def place(path: Path, seed_dir: Path, index: int | None = None, key: str | None = None) -> str: + text = str(path.relative_to(seed_dir)) if path.is_relative_to(seed_dir) else str(path) + if index is not None: + text += f", запись №{index + 1}" + if key is not None: + text += f" ({key})" + return text + + +def read_yaml(path: Path, seed_dir: Path) -> Any: + if not path.is_file(): + raise SeedError(f"{place(path, seed_dir)}: файл не найден") + try: + return yaml.safe_load(path.read_text(encoding="utf-8")) + except (yaml.YAMLError, UnicodeDecodeError) as error: + raise SeedError(f"{place(path, seed_dir)}: не удалось разобрать файл: {error}") from error + + +def checked_records( + data: Any, allowed: set[str], required: set[str], path: Path, seed_dir: Path +) -> list[dict[str, Any]]: + """Список записей-словарей: тип, обязательные поля, отсутствие лишних ключей.""" + if not isinstance(data, list): + raise SeedError(f"{place(path, seed_dir)}: ожидается список записей") + for index, record in enumerate(data): + if not isinstance(record, dict): + raise SeedError(f"{place(path, seed_dir, index)}: ожидается словарь полей") + for key in sorted(set(record) - allowed): + raise SeedError(f"{place(path, seed_dir, index)}: неизвестное поле «{key}»") + for key in sorted(required - set(record)): + raise SeedError(f"{place(path, seed_dir, index)}: нет обязательного поля «{key}»") + return data + + +def str_field(record: dict[str, Any], key: str, where: str, *, optional: bool = False) -> str | None: + value = record.get(key) + if value is None and optional: + return None + if not isinstance(value, str) or not value.strip(): + raise SeedError(f"{where}: поле «{key}» должно быть непустой строкой") + return value.strip() + + +def bool_field(record: dict[str, Any], key: str, where: str, *, default: bool) -> bool: + value = record.get(key, default) + if not isinstance(value, bool): + raise SeedError(f"{where}: поле «{key}» должно быть true или false, получено {value!r}") + return value + + +def ensure_unique(values: list[str], what: str, path: Path, seed_dir: Path) -> None: + seen: set[str] = set() + for value in values: + if value in seen: + raise SeedError(f"{place(path, seed_dir)}: {what} «{value}» встречается больше одного раза") + seen.add(value) diff --git a/api/seed/core/elimination_rules_v1.yaml b/api/seed/core/elimination_rules_v1.yaml new file mode 100644 index 0000000..2d086dc --- /dev/null +++ b/api/seed/core/elimination_rules_v1.yaml @@ -0,0 +1,120 @@ +# Правила элиминации ББ, версия 1: docs/context/05-domain-rules.md §4 (Прил. 3 ТЗ, DOC-223044, FNR03). +# ОФР (statement_type = income, measure = turnover) сюда НЕ входит — таблица §4 для ОФР помечена [открыто] (OQ-09). +# +# Формат: +# statement_type / version_no / valid_from — общие для всех правил файла (valid_from = 2024-01-01, design D-4); +# rules[]: rule_no, article_1_line / article_2_line (код строки ББ; пара «1450 / 1550» из §4 хранится как строка +# «1450/1550» — составной код через «/», разбирается потребителем), side_1 (D — Компания 1 по дебету, «Статья А»), +# measure (closing — сальдо на конец), elimination_mode (min | full | manual), vat_handling / excise_adjust (только +# ОФР; здесь null / false), comment («Примечание» §4 + трактовка); +# company_1 / company_2: accounts — счета стороны («Счета А» / «Счета Б»), excluded — исключения внутри маски +# («76 кроме 76.07.5, 76.07.9» -> accounts: [..., "76"], excluded: ["76.07.5", "76.07.9"]). +# Коды счетов — ТОЛЬКО строками в кавычках (иначе YAML прочитает 01 как число, а 60.02 как дробь); каждый код обязан +# существовать в chart_of_accounts.csv — иначе seed падает до записи в БД. +# Трактовки неоднозначностей таблицы §4: +# - «01 — резервный» (правило 1, сторона А) — 01 включён в счета А наравне с 03: резервный счёт из примечания +# к колонке «Счета А» участвует в паре, если у компании нет ОСВ 03; +# - «76.07.1 − 76.07.5 − 76.07.9» (правило 1, сторона Б) — вычитание субсчетов: 76.07.1 включён, 76.07.5 и 76.07.9 +# — исключения (is_excluded) той же стороны; 76.07.9 при этом остаётся в счетах стороны А; +# - счёт без указанного субсчёта («76», «66», «67», «80») — маска: сам счёт и все его субсчета. +statement_type: balance +version_no: 1 +valid_from: 2024-01-01 +rules: + - rule_no: 1 + article_1_line: "1150" + article_2_line: "1450/1550" + side_1: D + measure: closing + elimination_mode: manual + comment: >- + Лизинг/аренда внутри группы. Статья Б — 1450 или 1550 (прочие обязательства): «1450/1550». У Б: 76.07.1 − 76.07.5 + − 76.07.9 (76.07.5 и 76.07.9 — исключения). Счёт 01 (резервный) включён в счета А: используется, если у А нет + ОСВ 03. Если у А нет ОСВ 03 — результат в строку «ППА» [открыто]. + company_1: + accounts: ["03", "76.07.9", "01"] + company_2: + accounts: ["76.07.1"] + excluded: ["76.07.5", "76.07.9"] + - rule_no: 2 + article_1_line: "1170" + article_2_line: "1310/1350" + side_1: D + measure: closing + elimination_mode: manual + comment: >- + Гудвилл. Статья Б — 1310 или 1350 (уставный / добавочный капитал): «1310/1350». Если у Б нет ОСВ 80/83 — + элиминируется 58.01 А; 1310(Б) − 58.01(А) > 0 — строка «Гудвилл». + company_1: + accounts: ["58.01"] + company_2: + accounts: ["80", "83"] + - rule_no: 3 + article_1_line: "1170" + article_2_line: "1410" + side_1: D + measure: closing + elimination_mode: min + company_1: + accounts: ["58.03"] + company_2: + accounts: ["67.03"] + - rule_no: 4 + article_1_line: "1240" + article_2_line: "1510" + side_1: D + measure: closing + elimination_mode: min + company_1: + accounts: ["58.03"] + company_2: + accounts: ["66.03"] + - rule_no: 5 + article_1_line: "1230" + article_2_line: "1510" + side_1: D + measure: closing + elimination_mode: min + comment: Проценты. + company_1: + accounts: ["76"] + excluded: ["76.07.5", "76.07.9"] + company_2: + accounts: ["66.04", "67.04"] + - rule_no: 6 + article_1_line: "1230" + article_2_line: "1520" + side_1: D + measure: closing + elimination_mode: min + comment: Основная пара. + company_1: + accounts: ["60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"] + excluded: ["76.07.5", "76.07.9"] + company_2: + accounts: ["60.01", "60.03", "60.21", "60.31", "62.02", "62.22", "62.32", "76"] + excluded: ["76.07.1"] + - rule_no: 7 + article_1_line: "1230" + article_2_line: "1410" + side_1: D + measure: closing + elimination_mode: min + comment: Счета А — те же, что в правиле № 6. + company_1: + accounts: ["60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"] + excluded: ["76.07.5", "76.07.9"] + company_2: + accounts: ["67"] + - rule_no: 8 + article_1_line: "1230" + article_2_line: "1510" + side_1: D + measure: closing + elimination_mode: min + comment: Счета А — те же, что в правиле № 6. + company_1: + accounts: ["60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"] + excluded: ["76.07.5", "76.07.9"] + company_2: + accounts: ["66"] diff --git a/api/seed/core/required_document_rules.yaml b/api/seed/core/required_document_rules.yaml new file mode 100644 index 0000000..3c24c98 --- /dev/null +++ b/api/seed/core/required_document_rules.yaml @@ -0,0 +1,45 @@ +# Чек-лист обязательных документов (spec reference-data-seed; DBML required_document_rule; ТЗ 5.1/6.2). +# BASIC — ББ, ОФР и ОСВ по счетам 01, 58, 60, 62, 66, 67, 76, 90 (10 строк); EXTENDED — то же и ещё 17 ОСВ +# (02, 03, 08, 44, 26, 91, 51, 52, 59, 80, 83, 86, 97, 001, 008, 009, 011) — всего 27 строк. +# ASSUMPTION(OQ-46): все ОСВ расширенного подхода обязательны (is_mandatory = true); иначе EXTENDED ничем не отличался +# бы от BASIC в чек-листе. Правка — одним флагом в этом файле. +# account — код счёта плана (строкой в кавычках), только для document_type = OSV; для BALANCE / INCOME счёта нет. +# Каждый счёт обязан существовать в chart_of_accounts.csv, пара (approach, document_type, account) — уникальна. +# Ключ upsert — (approach, document_type, account); строки, убранные из файла, из БД не удаляются. + - {approach: BASIC, document_type: BALANCE, is_mandatory: true} + - {approach: BASIC, document_type: INCOME, is_mandatory: true} + - {approach: BASIC, document_type: OSV, account: "01", is_mandatory: true} + - {approach: BASIC, document_type: OSV, account: "58", is_mandatory: true} + - {approach: BASIC, document_type: OSV, account: "60", is_mandatory: true} + - {approach: BASIC, document_type: OSV, account: "62", is_mandatory: true} + - {approach: BASIC, document_type: OSV, account: "66", is_mandatory: true} + - {approach: BASIC, document_type: OSV, account: "67", is_mandatory: true} + - {approach: BASIC, document_type: OSV, account: "76", is_mandatory: true} + - {approach: BASIC, document_type: OSV, account: "90", is_mandatory: true} + - {approach: EXTENDED, document_type: BALANCE, is_mandatory: true} + - {approach: EXTENDED, document_type: INCOME, is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "01", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "58", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "60", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "62", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "66", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "67", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "76", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "90", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "02", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "03", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "08", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "44", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "26", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "91", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "51", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "52", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "59", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "80", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "83", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "86", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "97", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "001", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "008", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "009", is_mandatory: true} + - {approach: EXTENDED, document_type: OSV, account: "011", is_mandatory: true} diff --git a/api/tests/test_seed_rules.py b/api/tests/test_seed_rules.py new file mode 100644 index 0000000..e5057d0 --- /dev/null +++ b/api/tests/test_seed_rules.py @@ -0,0 +1,492 @@ +"""Seed правил элиминации ББ (§4) и чек-листа обязательных документов (spec reference-data-seed, задача 14). + +Составы счетов правил §4 и чек-лист (ТЗ 5.1, OQ-46) переписаны в тест заново, независимо от yaml в `seed/core/`. +""" + +import copy +import datetime +import shutil +from pathlib import Path +from typing import Any + +import pytest +import yaml +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from scripts.seed import SeedError, load_seed, run_seed +from scripts.seed_lib.loaders_rules import load_elimination_rules, load_required_document_rules +from src.models.access import Permission, Role, RolePermission +from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRuleAccount +from src.models.audit_ex import RequiredDocumentRule +from src.models.directories import RegionalOffice +from src.models.enums import ConsolidationApproach, DocumentType, StatementType + +API_DIR = Path(__file__).resolve().parent.parent +SEED_DIR = API_DIR / "seed" + +# docs/context/05-domain-rules.md §4, таблица ББ. Состав: rule_no -> (статья А, статья Б, mode, +# счета А, исключения А, счета Б, исключения Б). «Кроме» — исключения; «01 — резервный» включён в счета А правила 1; +# «76.07.1 − 76.07.5 − 76.07.9» (Б, правило 1) — 76.07.1 с исключениями 76.07.5, 76.07.9. +RECEIVABLES_A = ["60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"] +RULES_S4: dict[int, dict[str, Any]] = { + 1: { + "lines": ("1150", "1450/1550"), + "mode": "manual", + "a": ["01", "03", "76.07.9"], + "a_excl": [], + "b": ["76.07.1"], + "b_excl": ["76.07.5", "76.07.9"], + }, + 2: { + "lines": ("1170", "1310/1350"), + "mode": "manual", + "a": ["58.01"], + "a_excl": [], + "b": ["80", "83"], + "b_excl": [], + }, + 3: {"lines": ("1170", "1410"), "mode": "min", "a": ["58.03"], "a_excl": [], "b": ["67.03"], "b_excl": []}, + 4: {"lines": ("1240", "1510"), "mode": "min", "a": ["58.03"], "a_excl": [], "b": ["66.03"], "b_excl": []}, + 5: { + "lines": ("1230", "1510"), + "mode": "min", + "a": ["76"], + "a_excl": ["76.07.5", "76.07.9"], + "b": ["66.04", "67.04"], + "b_excl": [], + }, + 6: { + "lines": ("1230", "1520"), + "mode": "min", + "a": RECEIVABLES_A, + "a_excl": ["76.07.5", "76.07.9"], + "b": ["60.01", "60.03", "60.21", "60.31", "62.02", "62.22", "62.32", "76"], + "b_excl": ["76.07.1"], + }, + 7: { + "lines": ("1230", "1410"), + "mode": "min", + "a": RECEIVABLES_A, + "a_excl": ["76.07.5", "76.07.9"], + "b": ["67"], + "b_excl": [], + }, + 8: { + "lines": ("1230", "1510"), + "mode": "min", + "a": RECEIVABLES_A, + "a_excl": ["76.07.5", "76.07.9"], + "b": ["66"], + "b_excl": [], + }, +} + +# ТЗ 5.1 / spec «Первый запуск»: BASIC — ББ, ОФР и ОСВ по 8 счетам; EXTENDED — BASIC + 17 ОСВ (ASSUMPTION(OQ-46)) +BASIC_OSV = {"01", "58", "60", "62", "66", "67", "76", "90"} +EXTENDED_ONLY_OSV = { + "02", + "03", + "08", + "44", + "26", + "91", + "51", + "52", + "59", + "80", + "83", + "86", + "97", + "001", + "008", + "009", + "011", +} +BASIC_CHECKLIST = {("BALANCE", None), ("INCOME", None)} | {("OSV", code) for code in BASIC_OSV} +EXTENDED_CHECKLIST = BASIC_CHECKLIST | {("OSV", code) for code in EXTENDED_ONLY_OSV} + +#: таблицы, затрагиваемые seed: «ничего не записано» и «повтор не плодит строки» проверяются по ним всем +SEEDED_TABLES = ( + RegionalOffice, + Role, + Permission, + RolePermission, + ChartOfAccount, + EliminationRule, + EliminationRuleAccount, + RequiredDocumentRule, +) +RULES_COUNT = 8 +MAIN_PAIR_NO = 6 +COMPANY_2 = 2 +NULL_ACCOUNT_ROWS = 4 # BALANCE и INCOME для BASIC и EXTENDED +BASIC_COUNT = 10 +EXTENDED_COUNT = 27 + + +def _copy_seed(tmp_path: Path) -> Path: + target = tmp_path / "seed" + shutil.copytree(SEED_DIR, target) + return target + + +def _edit_yaml(path: Path, change: Any) -> None: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + change(data) + path.write_text(yaml.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8") + + +def _rules_error(tmp_path: Path, change: Any, known: set[str] | None = None) -> str: + """Копия yaml правил с правкой -> текст SeedError загрузчика.""" + core = tmp_path / "core" + core.mkdir(exist_ok=True) + target = core / "elimination_rules_v1.yaml" + target.write_text((SEED_DIR / "core" / "elimination_rules_v1.yaml").read_text(encoding="utf-8"), encoding="utf-8") + _edit_yaml(target, change) + with pytest.raises(SeedError) as info: + load_elimination_rules(core, tmp_path, known if known is not None else set(load_seed(SEED_DIR).accounts)) + return str(info.value) + + +def _checklist_error(tmp_path: Path, change: Any) -> str: + core = tmp_path / "core" + core.mkdir(exist_ok=True) + target = core / "required_document_rules.yaml" + target.write_text( + (SEED_DIR / "core" / "required_document_rules.yaml").read_text(encoding="utf-8"), encoding="utf-8" + ) + _edit_yaml(target, change) + with pytest.raises(SeedError) as info: + load_required_document_rules(core, tmp_path, set(load_seed(SEED_DIR).accounts)) + return str(info.value) + + +# ---------- файлы репозитория (без БД) ---------- + + +def test_repository_rules_match_s4_literal() -> None: + rules = {r.rule_no: r for r in load_seed(SEED_DIR).elimination_rules} + assert set(rules) == set(RULES_S4) + for no, expected in RULES_S4.items(): + rule = rules[no] + assert (rule.article_1_line, rule.article_2_line) == expected["lines"], no + assert rule.elimination_mode == expected["mode"], no + assert (rule.statement_type, rule.version_no, rule.measure, rule.side_1) == ( + StatementType.balance, + 1, + "closing", + "D", + ) + assert rule.valid_from == datetime.date(2024, 1, 1) + assert rule.vat_handling is None and rule.excise_adjust is False + assert set(rule.sides[1].accounts) == set(expected["a"]), no + assert set(rule.sides[1].excluded) == set(expected["a_excl"]), no + assert set(rule.sides[2].accounts) == set(expected["b"]), no + assert set(rule.sides[2].excluded) == set(expected["b_excl"]), no + + +def test_repository_checklist_matches_spec() -> None: + rules = load_seed(SEED_DIR).required_document_rules + for approach, expected in (("BASIC", BASIC_CHECKLIST), ("EXTENDED", EXTENDED_CHECKLIST)): + actual = {(r.document_type.value, r.account_code) for r in rules if r.approach.value == approach} + assert actual == expected + assert len([r for r in rules if r.approach.value == approach]) == len(expected) + assert len(rules) == BASIC_COUNT + EXTENDED_COUNT + assert all(r.is_mandatory for r in rules) + + +def test_every_rule_has_accounts_on_both_sides() -> None: + for rule in load_seed(SEED_DIR).elimination_rules: + assert rule.sides[1].accounts and rule.sides[2].accounts, rule.rule_no + + +# ---------- валидатор правил ---------- + + +def test_unknown_account_in_rule_names_account_and_rule(tmp_path: Path) -> None: + def change(data: dict[str, Any]) -> None: + data["rules"][5]["company_1"]["accounts"].append("99.99") + + message = _rules_error(tmp_path, change) + assert "99.99" in message and "№ 6" in message and "сторона 1" in message + assert "не найден в плане счетов" in message + + +def test_unknown_excluded_account_is_rejected(tmp_path: Path) -> None: + def change(data: dict[str, Any]) -> None: + data["rules"][0]["company_2"]["excluded"].append("76.99") + + message = _rules_error(tmp_path, change) + assert "76.99" in message and "№ 1" in message and "сторона 2" in message + + +def test_account_written_without_quotes_is_rejected(tmp_path: Path) -> None: + def change(data: dict[str, Any]) -> None: + data["rules"][2]["company_1"]["accounts"] = [58.03] + + assert "не строка" in _rules_error(tmp_path, change) + + +@pytest.mark.parametrize( + ("change", "fragment"), + [ + (lambda d: d["rules"][1].update(side_1="X"), "side_1"), + (lambda d: d["rules"][1].update(measure="avg"), "measure"), + (lambda d: d["rules"][1].update(elimination_mode="max"), "elimination_mode"), + (lambda d: d["rules"][1].pop("article_1_line"), "article_1_line"), + (lambda d: d["rules"][1].update(excise_adjust="да"), "excise_adjust"), + (lambda d: d["rules"][1].update(unknown=1), "unknown"), + (lambda d: d["rules"][1].update(rule_no=1), "№ 1"), + (lambda d: d["rules"][1].update(rule_no=0), "rule_no"), + (lambda d: d.update(statement_type="profit"), "statement_type"), + (lambda d: d.update(version_no=0), "version_no"), + (lambda d: d.update(valid_from="2024-01-01"), "valid_from"), + (lambda d: d.pop("rules"), "rules"), + (lambda d: d["rules"][1].pop("company_2"), "company_2"), + (lambda d: d["rules"][1].update(company_2={"accounts": []}), "нет счетов стороны"), + (lambda d: d["rules"][1].update(company_2={"accounts": ["80", "80"]}), "больше одного раза"), + (lambda d: d["rules"][1].update(company_2={"accounts": ["80"], "excluded": ["80"]}), "включён и исключён"), + (lambda d: d["rules"][1].update(company_2={"accounts": ["80"], "extra": []}), "extra"), + (lambda d: d["rules"][1].update(company_3={"accounts": ["80"]}), "company_3"), + ], +) +def test_rules_validator_rejects(tmp_path: Path, change: Any, fragment: str) -> None: + assert fragment in _rules_error(tmp_path, change) + + +def test_rules_file_missing(tmp_path: Path) -> None: + (tmp_path / "core").mkdir() + with pytest.raises(SeedError, match="elimination_rules_v1.yaml: файл не найден"): + load_elimination_rules(tmp_path / "core", tmp_path, set()) + + +# ---------- валидатор чек-листа ---------- + + +def test_checklist_unknown_account_names_approach_and_account(tmp_path: Path) -> None: + def change(data: list[dict[str, Any]]) -> None: + data.append({"approach": "EXTENDED", "document_type": "OSV", "account": "95", "is_mandatory": True}) + + message = _checklist_error(tmp_path, change) + assert "чек-лист (EXTENDED)" in message and "счёт 95 не найден в плане счетов" in message + + +@pytest.mark.parametrize( + ("change", "fragment"), + [ + (lambda d: d[0].update(approach="FULL"), "approach"), + (lambda d: d[0].update(document_type="PASSPORT"), "document_type"), + (lambda d: d[0].update(account="01"), "account допустим только для OSV"), + (lambda d: d[2].pop("account"), "для OSV обязателен account"), + (lambda d: d[2].update(account=1), "обязателен account"), + (lambda d: d.append(copy.deepcopy(d[0])), "больше одного раза"), + (lambda d: d.append(copy.deepcopy(d[2])), "больше одного раза"), + (lambda d: d[0].update(is_mandatory="yes"), "is_mandatory"), + (lambda d: d[0].update(extra=1), "extra"), + (lambda d: d[0].pop("document_type"), "document_type"), + ], +) +def test_checklist_validator_rejects(tmp_path: Path, change: Any, fragment: str) -> None: + assert fragment in _checklist_error(tmp_path, change) + + +def test_checklist_file_missing(tmp_path: Path) -> None: + (tmp_path / "core").mkdir() + with pytest.raises(SeedError, match="required_document_rules.yaml: файл не найден"): + load_required_document_rules(tmp_path / "core", tmp_path, set()) + + +def test_load_seed_fails_on_unknown_account_without_database(tmp_path: Path) -> None: + seed = _copy_seed(tmp_path) + _edit_yaml( + seed / "core" / "elimination_rules_v1.yaml", lambda d: d["rules"][5]["company_1"]["accounts"].append("99.99") + ) + with pytest.raises(SeedError, match=r"№ 6.*99\.99"): + load_seed(seed) + + +# ---------- БД ---------- + + +async def _counts(session: AsyncSession) -> dict[str, int]: + return { + model.__tablename__: await session.scalar(select(func.count()).select_from(model)) or 0 + for model in SEEDED_TABLES + } + + +async def _rule_sides(session: AsyncSession) -> set[tuple[int, int, str, bool]]: + rows = ( + await session.execute( + select( + EliminationRule.rule_no, + EliminationRuleAccount.company_no, + ChartOfAccount.code, + EliminationRuleAccount.is_excluded, + ) + .join(EliminationRuleAccount, EliminationRuleAccount.rule_id == EliminationRule.id) + .join(ChartOfAccount, ChartOfAccount.id == EliminationRuleAccount.account_id) + ) + ).all() + return {(no, company, code, excluded) for no, company, code, excluded in rows} + + +@pytest.mark.integration +async def test_first_run_inserts_rules_and_checklist(db_session: AsyncSession) -> None: + report = await run_seed(db_session) + assert report.tables["elimination_rule"].inserted == RULES_COUNT + assert report.tables["required_document_rule"].inserted == BASIC_COUNT + EXTENDED_COUNT + assert report.tables["elimination_rule_account"].inserted == sum( + len(r["a"]) + len(r["a_excl"]) + len(r["b"]) + len(r["b_excl"]) for r in RULES_S4.values() + ) + rules = (await db_session.scalars(select(EliminationRule).order_by(EliminationRule.rule_no))).all() + assert [r.rule_no for r in rules] == list(range(1, RULES_COUNT + 1)) + assert all(r.statement_type is StatementType.balance and r.version_no == 1 for r in rules) + assert all(r.valid_from == datetime.date(2024, 1, 1) and r.valid_to is None for r in rules) + assert all(r.measure == "closing" and r.side_1 == "D" and r.excise_adjust is False for r in rules) + assert all(r.vat_handling is None for r in rules) + sides = await _rule_sides(db_session) + for rule in rules: + companies = {company for no, company, _, _ in sides if no == rule.rule_no} + assert companies == {1, 2}, rule.rule_no + + +@pytest.mark.integration +async def test_rule_6_main_pair_scenario(db_session: AsyncSession) -> None: + await run_seed(db_session) + rule = (await db_session.scalars(select(EliminationRule).where(EliminationRule.rule_no == MAIN_PAIR_NO))).one() + assert rule.elimination_mode == "min" + assert rule.measure == "closing" + assert rule.side_1 == "D" + sides = {(c, code, excluded) for no, c, code, excluded in await _rule_sides(db_session) if no == MAIN_PAIR_NO} + side_1 = {code for c, code, excluded in sides if c == 1 and not excluded} + side_1_excluded = {code for c, code, excluded in sides if c == 1 and excluded} + side_2 = {code for c, code, excluded in sides if c == COMPANY_2 and not excluded} + side_2_excluded = {code for c, code, excluded in sides if c == COMPANY_2 and excluded} + assert side_1 == {"60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"} + assert side_1_excluded == {"76.07.5", "76.07.9"} + assert side_2 == {"60.01", "60.03", "60.21", "60.31", "62.02", "62.22", "62.32", "76"} + assert side_2_excluded == {"76.07.1"} + + +@pytest.mark.integration +async def test_rule_accounts_equal_s4_literal(db_session: AsyncSession) -> None: + await run_seed(db_session) + expected = { + (no, company, code, excluded) + for no, rule in RULES_S4.items() + for company, included, excluded_codes in ((1, rule["a"], rule["a_excl"]), (2, rule["b"], rule["b_excl"])) + for code, excluded in [(c, False) for c in included] + [(c, True) for c in excluded_codes] + } + assert await _rule_sides(db_session) == expected + + +@pytest.mark.integration +async def test_rule_columns_equal_s4(db_session: AsyncSession) -> None: + await run_seed(db_session) + rules = {r.rule_no: r for r in (await db_session.scalars(select(EliminationRule))).all()} + for no, expected in RULES_S4.items(): + assert (rules[no].article_1_line, rules[no].article_2_line) == expected["lines"], no + assert rules[no].elimination_mode == expected["mode"], no + assert rules[1].comment and "76.07.5" in rules[1].comment + + +@pytest.mark.integration +async def test_checklist_rows(db_session: AsyncSession) -> None: + await run_seed(db_session) + rows = ( + await db_session.execute( + select( + RequiredDocumentRule.approach, + RequiredDocumentRule.document_type, + ChartOfAccount.code, + RequiredDocumentRule.is_mandatory, + ).outerjoin(ChartOfAccount, ChartOfAccount.id == RequiredDocumentRule.account_id) + ) + ).all() + by_approach: dict[ConsolidationApproach, set[tuple[str, str | None]]] = {} + for approach, document_type, code, mandatory in rows: + assert mandatory is True + assert (document_type is DocumentType.OSV) == (code is not None) + by_approach.setdefault(approach, set()).add((document_type.value, code)) + assert by_approach[ConsolidationApproach.BASIC] == BASIC_CHECKLIST + assert by_approach[ConsolidationApproach.EXTENDED] == EXTENDED_CHECKLIST + assert len(rows) == BASIC_COUNT + EXTENDED_COUNT + null_account = await db_session.scalar( + select(func.count()).select_from(RequiredDocumentRule).where(RequiredDocumentRule.account_id.is_(None)) + ) + assert null_account == NULL_ACCOUNT_ROWS + + +@pytest.mark.integration +async def test_second_run_keeps_all_row_counts(db_session: AsyncSession) -> None: + await run_seed(db_session) + first = await _counts(db_session) + assert first["required_document_rule"] == BASIC_COUNT + EXTENDED_COUNT + report = await run_seed(db_session) + assert await _counts(db_session) == first + for table in ("elimination_rule", "elimination_rule_account", "required_document_rule"): + stat = report.tables[table] + assert (stat.inserted, stat.updated) == (0, 0), table + assert stat.unchanged == first[table] + + +@pytest.mark.integration +async def test_unknown_account_aborts_before_any_write(db_session: AsyncSession, tmp_path: Path) -> None: + seed = _copy_seed(tmp_path) + _edit_yaml( + seed / "core" / "elimination_rules_v1.yaml", lambda d: d["rules"][5]["company_1"]["accounts"].append("99.99") + ) + before = await _counts(db_session) + assert set(before.values()) == {0} # свежая БД: ничего не загружено + with pytest.raises(SeedError) as info: + await run_seed(db_session, seed_dir=seed) + assert "99.99" in str(info.value) and "№ 6" in str(info.value) + assert await _counts(db_session) == before + + +@pytest.mark.integration +async def test_unknown_account_in_checklist_aborts_before_any_write(db_session: AsyncSession, tmp_path: Path) -> None: + seed = _copy_seed(tmp_path) + _edit_yaml( + seed / "core" / "required_document_rules.yaml", + lambda d: d.append({"approach": "EXTENDED", "document_type": "OSV", "account": "95", "is_mandatory": True}), + ) + with pytest.raises(SeedError, match=r"чек-лист \(EXTENDED\).*95"): + await run_seed(db_session, seed_dir=seed) + assert set((await _counts(db_session)).values()) == {0} + + +@pytest.mark.integration +async def test_changed_rule_and_flags_are_updated_without_duplicates(db_session: AsyncSession, tmp_path: Path) -> None: + await run_seed(db_session) + before = await _counts(db_session) + seed = _copy_seed(tmp_path) + + def change(data: dict[str, Any]) -> None: + data["rules"][2]["comment"] = "уточнено" + side = data["rules"][5]["company_2"] + side["accounts"].remove("62.32") # убран из файла — в БД остаётся + side["excluded"].append("76.07.2") + data["rules"][4]["company_1"][ + "excluded" + ] = [] # было исключение, теперь нет — строка остаётся, флаг не меняется + data["rules"][4]["company_1"]["accounts"].append("76.07.5") # теперь включён: is_excluded true -> false + + _edit_yaml(seed / "core" / "elimination_rules_v1.yaml", change) + _edit_yaml(seed / "core" / "required_document_rules.yaml", lambda d: d[2].update(is_mandatory=False)) + report = await run_seed(db_session, seed_dir=seed) + assert report.tables["elimination_rule"].updated == 1 + assert report.tables["elimination_rule_account"].inserted == 1 # 76.07.2 в исключения правила 6 + assert report.tables["elimination_rule_account"].updated == 1 # 76.07.5 правила 5: исключён -> включён + assert report.tables["required_document_rule"].updated == 1 + after = await _counts(db_session) + assert after["elimination_rule"] == before["elimination_rule"] + assert after["required_document_rule"] == before["required_document_rule"] + assert after["elimination_rule_account"] == before["elimination_rule_account"] + 1 + sides = await _rule_sides(db_session) + assert (6, 2, "62.32", False) in sides # без удалений + assert (6, 2, "76.07.2", True) in sides + assert (5, 1, "76.07.5", False) in sides and (5, 1, "76.07.5", True) not in sides + assert (5, 1, "76.07.9", True) in sides # убранное из excluded не отзывается -- 2.47.2 From 0f800a1bd0e7f6def618a0e88fbb9221694587af Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 21:29:37 +0400 Subject: [PATCH 47/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2014?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 4a26fd1..91b8fab 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -73,7 +73,7 @@ ### Task 14: [3.3] `seed/core/elimination_rules_v1.yaml` — 8 правил ББ из §4 со счетами сторон и ис -- [ ] `seed/core/elimination_rules_v1.yaml` — 8 правил ББ из §4 со счетами сторон и исключениями; `seed/core/required_document_rules.yaml` — BASIC (10) и EXTENDED (27, ASSUMPTION(OQ-46)); загрузчик падает до записи при неизвестном счёте; проверка: сценарии «Правило № 6» и «Ссылка на несуществующий счёт» из spec +- [x] `seed/core/elimination_rules_v1.yaml` — 8 правил ББ из §4 со счетами сторон и исключениями; `seed/core/required_document_rules.yaml` — BASIC (10) и EXTENDED (27, ASSUMPTION(OQ-46)); загрузчик падает до записи при неизвестном счёте; проверка: сценарии «Правило № 6» и «Ссылка на несуществующий счёт» из spec ### Task 15: [3.4] `seed/dev/regional_offices.yaml` (2 РФ), `seed/dev/users.yaml` (7 пользователей diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 575a741..f34d0b8 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -59,7 +59,7 @@ - [x] 3.2 `seed/core/chart_of_accounts.csv`: План счетов 94н (01–99, 001–011, `kind`, `row_type`, `is_off_balance`) + субсчета, упомянутые в §4/§6 правил и чек-листе (60.xx, 62.xx, 58.01/58.03, 66.xx/67.xx, 76.03/.05/.07.x/.08/.09, 90.01/90.03, 91.x, 80, 83, 03); проверка: все `parent_code` существуют, код уникален, нормализован (`60.01`, не `60.1`) -- [ ] 3.3 `seed/core/elimination_rules_v1.yaml` — 8 правил ББ из §4 со счетами сторон и исключениями; +- [x] 3.3 `seed/core/elimination_rules_v1.yaml` — 8 правил ББ из §4 со счетами сторон и исключениями; `seed/core/required_document_rules.yaml` — BASIC (10) и EXTENDED (27, ASSUMPTION(OQ-46)); загрузчик падает до записи при неизвестном счёте; проверка: сценарии «Правило № 6» и «Ссылка на несуществующий счёт» из spec - [ ] 3.4 `seed/dev/regional_offices.yaml` (2 РФ), `seed/dev/users.yaml` (7 пользователей `@armka.local` + `user_role`); -- 2.47.2 From cb2723c3a2bf7961daa3b8b4069727aa08743a78 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 21:41:07 +0400 Subject: [PATCH 48/97] =?UTF-8?q?feat:=20dev-seed=20(2=20=D0=A0=D0=A4=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8E=20=D0=BD=D0=B0=20=D1=80?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C)=20=D0=BF=D0=BE=20=D1=84=D0=BB=D0=B0=D0=B3?= =?UTF-8?q?=D1=83=20--dev,=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=81=D1=81=D1=8B=D0=BB=D0=BE=D0=BA=20=D0=B4=D0=BE=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/scripts/seed.py | 15 +- api/scripts/seed_lib/apply.py | 76 ++++++++- api/scripts/seed_lib/loaders.py | 79 +++++++++- api/scripts/seed_lib/model.py | 16 ++ api/seed/dev/regional_offices.yaml | 9 ++ api/seed/dev/users.yaml | 58 +++++++ api/tests/test_seed_dev.py | 242 +++++++++++++++++++++++++++++ api/tests/test_seed_roles.py | 2 +- 8 files changed, 486 insertions(+), 11 deletions(-) create mode 100644 api/seed/dev/regional_offices.yaml create mode 100644 api/seed/dev/users.yaml create mode 100644 api/tests/test_seed_dev.py diff --git a/api/scripts/seed.py b/api/scripts/seed.py index 478f251..5d3097f 100644 --- a/api/scripts/seed.py +++ b/api/scripts/seed.py @@ -1,7 +1,7 @@ """Загрузчик справочников продукта: `python -m scripts.seed [--dev]` (запуск из `api/`). Spec reference-data-seed; design D-4 и решение #18 (R-5): `run_seed(session, *, dev)` не делает commit — коммитит CLI. -Источник данных — `api/seed/core/*.yaml|csv` (+ `api/seed/dev/` при `--dev`, задача 15). +Источник данных — `api/seed/core/*.yaml|csv` (+ `api/seed/dev/` при `--dev`). Загрузка двухфазная: 1. `load_seed` (`seed_lib/loaders.py`) читает ВСЕ файлы в память и проверяет их (обязательные поля, enum'ы из @@ -23,6 +23,13 @@ ASSUMPTION(OQ-46)): `seed_lib/loaders_rules.py` проверяет, что ка `seed_lib/apply_rules.py` — upsert (составы счетов и чек-лист приводятся к файлу без удалений; ключи без уникального индекса — в Python, NULL в `account_id` не плодит дубли). Общие помощники чтения — `seed_lib/readers.py`. +Dev-данные (`--dev`, `seed/dev/`, задача 15): 2 региональных филиала (`regional_offices.yaml`, upsert по `name`) +и по одному пользователю на каждую системную роль (`users.yaml`, `<роль>@armka.local`, upsert `app_user` по `email`, +`user_role` по (user_id, role_id)). Ссылки пользователей на роли и филиалы проверяются до записи. Без `--dev` ни +пользователи, ни dev-филиалы не загружаются. ASSUMPTION: dev-данные — только для локальной разработки и тестовых +стендов; в прод `--dev` не запускать (защиты флага нет, спека её не требует). Удалений нет: роль, убранная из +`users.yaml`, остаётся назначенной. + Расширение (задачи 13–15): поле в `SeedData` (`seed_lib/model.py`), читатель в `load_seed`, шаг в `apply_seed`. """ @@ -65,7 +72,11 @@ async def _run_cli(data: SeedData) -> SeedReport: def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="python -m scripts.seed", description="Загрузка справочников продукта.") - parser.add_argument("--dev", action="store_true", help="дополнительно загрузить dev-данные из seed/dev/") + parser.add_argument( + "--dev", + action="store_true", + help="дополнительно загрузить dev-данные из seed/dev/ (только для разработки, не для прода)", + ) parser.add_argument("--seed-dir", type=Path, default=DEFAULT_SEED_DIR, help="каталог seed (по умолчанию api/seed)") args = parser.parse_args(argv) try: diff --git a/api/scripts/seed_lib/apply.py b/api/scripts/seed_lib/apply.py index d4bea35..412f0fd 100644 --- a/api/scripts/seed_lib/apply.py +++ b/api/scripts/seed_lib/apply.py @@ -3,13 +3,13 @@ import uuid from typing import Any -from sqlalchemy import func, select +from sqlalchemy import func, select, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from scripts.seed_lib.apply_rules import apply_elimination_rules, apply_required_document_rules from scripts.seed_lib.model import SeedData, SeedReport, TableStat -from src.models.access import Permission, Role, RolePermission +from src.models.access import AppUser, Permission, Role, RolePermission, UserRole from src.models.accounting import ChartOfAccount from src.models.directories import RegionalOffice @@ -132,6 +132,76 @@ async def _apply_chart_of_accounts(session: AsyncSession, data: SeedData, stat: await session.execute(statement, rows) +async def _apply_users(session: AsyncSession, data: SeedData, user_stat: TableStat, link_stat: TableStat) -> None: + """Dev-пользователи: upsert `app_user` по `email`, затем `user_role` по (user_id, role_id). + + `created_at` ставится при вставке, `updated_at` — при вставке и при изменении данных (триггера в БД нет); + неизменившиеся строки не трогаются. Назначения ролей только добавляются: роль, убранная из файла, остаётся + назначенной (удалений нет). `external_id` не задаётся (SSO-субъект dev-пользователя неизвестен). + """ + if not data.users: + return + office_ids = { + name: id_ for id_, name in (await session.execute(select(RegionalOffice.id, RegionalOffice.name))).all() + } + role_ids = {code: id_ for id_, code in (await session.execute(select(Role.id, Role.code))).all()} + stored = { + row.email: row + for row in ( + await session.execute( + select( + AppUser.id, + AppUser.email, + AppUser.full_name, + AppUser.employee_no, + AppUser.regional_office_id, + AppUser.business_segment, + AppUser.is_active, + ) + ) + ).all() + } + user_ids: dict[str, Any] = {} + for user in data.users: + values = { + "full_name": user.full_name, + "employee_no": user.employee_no, + "regional_office_id": office_ids[user.regional_office], + "business_segment": user.business_segment, + "is_active": user.is_active, + } + row = stored.get(user.email) + if row is None: + user_stat.inserted += 1 + user_ids[user.email] = uuid.uuid4() + await session.execute( + pg_insert(AppUser).values( + id=user_ids[user.email], email=user.email, **values, created_at=func.now(), updated_at=func.now() + ) + ) + continue + user_ids[user.email] = row.id + if any(getattr(row, name) != value for name, value in values.items()): + user_stat.updated += 1 + await session.execute(update(AppUser).where(AppUser.id == row.id).values(**values, updated_at=func.now())) + else: + user_stat.unchanged += 1 + existing = set((await session.execute(select(UserRole.user_id, UserRole.role_id))).all()) + new_rows = [] + for user in data.users: + for role_code in user.role_codes: + pair = (user_ids[user.email], role_ids[role_code]) + if pair in existing: + link_stat.unchanged += 1 + else: + link_stat.inserted += 1 + new_rows.append({"id": uuid.uuid4(), "user_id": pair[0], "role_id": pair[1]}) + if new_rows: + await session.execute( + pg_insert(UserRole).on_conflict_do_nothing(index_elements=["user_id", "role_id"]), new_rows + ) + + async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport: """Фаза 2: upsert набора в порядке зависимостей. Без commit — транзакцией управляет вызывающий.""" report = SeedReport() @@ -180,4 +250,6 @@ async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport: session, data, report.stat("elimination_rule"), report.stat("elimination_rule_account") ) await apply_required_document_rules(session, data, report.stat("required_document_rule")) + # dev-пользователи — после ролей и филиалов (в том числе dev-филиалов, они входят в `regional_offices`) + await _apply_users(session, data, report.stat("app_user"), report.stat("user_role")) return report diff --git a/api/scripts/seed_lib/loaders.py b/api/scripts/seed_lib/loaders.py index 36fd046..4f483f6 100644 --- a/api/scripts/seed_lib/loaders.py +++ b/api/scripts/seed_lib/loaders.py @@ -16,16 +16,19 @@ from scripts.seed_lib.model import ( RoleSeed, SeedData, SeedError, + UserSeed, account_codes, ) from scripts.seed_lib.readers import bool_field, checked_records, ensure_unique, place, read_yaml, str_field -from src.models.enums import AccountKind, AccountRowType, PermissionAction, PermissionObject +from src.models.enums import AccountKind, AccountRowType, BusinessSegment, PermissionAction, PermissionObject DEFAULT_SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed" -def _load_regional_offices(core: Path, seed_dir: Path) -> list[RegionalOfficeSeed]: - path = core / "regional_office.yaml" +def _load_regional_offices( + directory: Path, seed_dir: Path, filename: str = "regional_office.yaml" +) -> list[RegionalOfficeSeed]: + path = directory / filename records = checked_records( read_yaml(path, seed_dir), {"name", "is_head_office", "is_active"}, {"name"}, path, seed_dir ) @@ -246,9 +249,73 @@ def load_seed(seed_dir: Path | None = None, *, dev: bool = False) -> SeedData: data.elimination_rules = load_elimination_rules(core, seed_dir, codes) data.required_document_rules = load_required_document_rules(core, seed_dir, codes) if dev: - _load_dev(data, seed_dir / "dev") + _load_dev(data, seed_dir / "dev", seed_dir) return data -def _load_dev(data: SeedData, dev_dir: Path) -> None: - """Заглушка расширения: dev-данные (филиалы, пользователи по ролям) появятся в задаче 15.""" +def _load_dev_regional_offices(data: SeedData, dev_dir: Path, seed_dir: Path) -> None: + """Dev-филиалы (`dev/regional_offices.yaml`) дописываются к филиалам набора; имена уникальны во всём наборе.""" + path = dev_dir / "regional_offices.yaml" + offices = _load_regional_offices(dev_dir, seed_dir, "regional_offices.yaml") + for office in offices: + if office.is_head_office: + raise SeedError( + f"{place(path, seed_dir)}: филиал «{office.name}» объявлен головным офисом, в dev — только РФ" + ) + data.regional_offices.extend(offices) + ensure_unique([o.name for o in data.regional_offices], "филиал", path, seed_dir) + + +def _load_dev_users(data: SeedData, dev_dir: Path, seed_dir: Path) -> None: + """Dev-пользователи (`dev/users.yaml`): ссылки на роли и филиалы проверяются по набору до любой записи в БД.""" + path = dev_dir / "users.yaml" + records = checked_records( + read_yaml(path, seed_dir), + {"email", "full_name", "employee_no", "regional_office", "business_segment", "is_active", "roles"}, + {"email", "full_name", "regional_office", "roles"}, + path, + seed_dir, + ) + role_codes = {r.code for r in data.roles} + office_names = {o.name for o in data.regional_offices} + users = [] + for index, record in enumerate(records): + where = place(path, seed_dir, index, str(record.get("email"))) + office = str(str_field(record, "regional_office", where)) + if office not in office_names: + raise SeedError( + f"{where}: филиал «{office}» не описан ни в core/regional_office.yaml, ни в dev/regional_offices.yaml" + ) + roles = record["roles"] + if not isinstance(roles, list) or not all(isinstance(code, str) for code in roles): + raise SeedError(f"{where}: поле «roles» должно быть списком кодов ролей") + if len(roles) != 1: + raise SeedError(f"{where}: у dev-пользователя должна быть ровно одна роль, указано {len(roles)}") + if roles[0] not in role_codes: + raise SeedError(f"{where}: роль «{roles[0]}» не описана в roles.yaml") + segment_text = str_field(record, "business_segment", where, optional=True) + try: + segment = BusinessSegment(segment_text) if segment_text is not None else None + except ValueError: + raise SeedError( + f"{where}: неизвестный business_segment «{segment_text}» (допустимы DKB, DMSB, DM)" + ) from None + users.append( + UserSeed( + email=str(str_field(record, "email", where)), + full_name=str(str_field(record, "full_name", where)), + employee_no=str_field(record, "employee_no", where, optional=True), + regional_office=office, + business_segment=segment, + is_active=bool_field(record, "is_active", where, default=True), + role_codes=(roles[0],), + ) + ) + ensure_unique([u.email for u in users], "email", path, seed_dir) + data.users = users + + +def _load_dev(data: SeedData, dev_dir: Path, seed_dir: Path) -> None: + """Dev-данные (задача 15): филиалы, затем пользователи — они ссылаются на филиалы и роли набора.""" + _load_dev_regional_offices(data, dev_dir, seed_dir) + _load_dev_users(data, dev_dir, seed_dir) diff --git a/api/scripts/seed_lib/model.py b/api/scripts/seed_lib/model.py index d4ea6b1..fce5301 100644 --- a/api/scripts/seed_lib/model.py +++ b/api/scripts/seed_lib/model.py @@ -6,6 +6,7 @@ from dataclasses import dataclass, field from src.models.enums import ( AccountKind, AccountRowType, + BusinessSegment, ConsolidationApproach, DocumentType, PermissionAction, @@ -92,6 +93,19 @@ class RequiredDocumentRuleSeed: is_mandatory: bool +@dataclass(frozen=True) +class UserSeed: + """Dev-пользователь; `regional_office` — имя филиала, `role_codes` — коды ролей (в dev-наборе ровно одна).""" + + email: str + full_name: str + employee_no: str | None + regional_office: str + business_segment: BusinessSegment | None + is_active: bool + role_codes: tuple[str, ...] + + @dataclass class SeedData: """Проверенный набор seed; ничего не знает о БД.""" @@ -105,6 +119,8 @@ class SeedData: accounts: dict[str, AccountSeed] = field(default_factory=dict) elimination_rules: list[EliminationRuleSeed] = field(default_factory=list) required_document_rules: list[RequiredDocumentRuleSeed] = field(default_factory=list) + #: только при `--dev` (seed/dev/users.yaml); при `--dev` dev-филиалы дописываются в `regional_offices` + users: list[UserSeed] = field(default_factory=list) def account_codes(data: SeedData) -> frozenset[str]: diff --git a/api/seed/dev/regional_offices.yaml b/api/seed/dev/regional_offices.yaml new file mode 100644 index 0000000..dfe4236 --- /dev/null +++ b/api/seed/dev/regional_offices.yaml @@ -0,0 +1,9 @@ +# DEV-данные (только `python -m scripts.seed --dev`; в прод-режиме не загружаются). Региональные филиалы для +# локальной разработки — названия условные. Головной офис — в core/regional_office.yaml. +# Натуральный ключ upsert — name; удаление seed не выполняет. +- name: Ростовский региональный филиал + is_head_office: false + is_active: true +- name: Краснодарский региональный филиал + is_head_office: false + is_active: true diff --git a/api/seed/dev/users.yaml b/api/seed/dev/users.yaml new file mode 100644 index 0000000..57b6e1e --- /dev/null +++ b/api/seed/dev/users.yaml @@ -0,0 +1,58 @@ +# DEV-данные (только `python -m scripts.seed --dev`): по одному пользователю на каждую системную роль ТЗ 8.1. +# Email — `<код роли в нижнем регистре>@armka.local`; dev-вход идёт по email (JWT РАИСА в dev подменяется), +# поэтому external_id не задаётся (NULL). Натуральный ключ upsert — email. +# regional_office — ИМЯ филиала: ГО — из core/regional_office.yaml, РФ — из dev/regional_offices.yaml. +# Раскладка: роли ГО (KA_GO, CURATOR_GO, EXPERT, ADMIN) — в Головном офисе; роли РФ (KA_RF, RKA, CLIENT_MANAGER) — +# в РФ: КА и РКА в Ростовском (один РФ, чтобы проходить маршрут «КА РФ -> РКА РФ»), КМ — в Краснодарском +# (проверка границ «свой РФ / чужой РФ» policy-слоя). +# Роли пользователя в файле приводятся к строке user_role добавлением; снятые из файла роли seed НЕ удаляет +# (смена роли в файле — удалить старую связь вручную через UI администратора). +- email: ka_rf@armka.local + full_name: "Dev: Аналитик РФ" + employee_no: DEV-001 + regional_office: Ростовский региональный филиал + business_segment: DKB + is_active: true + roles: [KA_RF] +- email: ka_go@armka.local + full_name: "Dev: Аналитик ГО" + employee_no: DEV-002 + regional_office: Головной офис + business_segment: DKB + is_active: true + roles: [KA_GO] +- email: rka@armka.local + full_name: "Dev: Руководитель аналитика РФ" + employee_no: DEV-003 + regional_office: Ростовский региональный филиал + business_segment: DKB + is_active: true + roles: [RKA] +- email: client_manager@armka.local + full_name: "Dev: Клиентский менеджер" + employee_no: DEV-004 + regional_office: Краснодарский региональный филиал + business_segment: DKB + is_active: true + roles: [CLIENT_MANAGER] +- email: curator_go@armka.local + full_name: "Dev: Куратор КП ГО" + employee_no: DEV-005 + regional_office: Головной офис + business_segment: DKB + is_active: true + roles: [CURATOR_GO] +- email: expert@armka.local + full_name: "Dev: Эксперт" + employee_no: DEV-006 + regional_office: Головной офис + business_segment: DKB + is_active: true + roles: [EXPERT] +- email: admin@armka.local + full_name: "Dev: Администратор" + employee_no: DEV-007 + regional_office: Головной офис + business_segment: DKB + is_active: true + roles: [ADMIN] diff --git a/api/tests/test_seed_dev.py b/api/tests/test_seed_dev.py new file mode 100644 index 0000000..facc086 --- /dev/null +++ b/api/tests/test_seed_dev.py @@ -0,0 +1,242 @@ +"""Dev-seed (spec reference-data-seed, требование «Dev-данные только по явному флагу», задача 15).""" + +import shutil +from pathlib import Path + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from scripts.seed import SeedError, load_seed, run_seed +from src.models.access import AppUser, Permission, Role, RolePermission, UserRole +from src.models.directories import RegionalOffice +from src.models.enums import BusinessSegment + +API_DIR = Path(__file__).resolve().parent.parent +SEED_DIR = API_DIR / "seed" + +ROLE_CODES = ["KA_RF", "KA_GO", "RKA", "CLIENT_MANAGER", "CURATOR_GO", "EXPERT", "ADMIN"] +USER_COUNT = len(ROLE_CODES) +DEV_BRANCH_COUNT = 2 +DEV_EMAILS = {f"{code.lower()}@armka.local" for code in ROLE_CODES} +HEAD_OFFICE = "Головной офис" +#: роли ГО — пользователь в Головном офисе, роли РФ — в региональном филиале +HEAD_OFFICE_ROLES = {"KA_GO", "CURATOR_GO", "EXPERT", "ADMIN"} +DEV_BRANCHES = {"Ростовский региональный филиал", "Краснодарский региональный филиал"} + + +async def _count(session: AsyncSession, model: type) -> int: + return int(await session.scalar(select(func.count()).select_from(model)) or 0) + + +async def _counts(session: AsyncSession) -> dict[str, int]: + return { + "app_user": await _count(session, AppUser), + "user_role": await _count(session, UserRole), + "regional_office": await _count(session, RegionalOffice), + "role": await _count(session, Role), + "permission": await _count(session, Permission), + "role_permission": await _count(session, RolePermission), + } + + +def _copy_seed(tmp_path: Path) -> Path: + target = tmp_path / "seed" + shutil.copytree(SEED_DIR, target) + return target + + +# ---- интеграция ---- + + +@pytest.mark.integration +async def test_without_dev_flag_only_head_office_and_no_users(db_session: AsyncSession) -> None: + await run_seed(db_session, dev=False) + + assert await _count(db_session, AppUser) == 0 + assert await _count(db_session, UserRole) == 0 + offices = (await db_session.execute(select(RegionalOffice.name, RegionalOffice.is_head_office))).all() + assert [(o.name, o.is_head_office) for o in offices] == [(HEAD_OFFICE, True)] + + +@pytest.mark.integration +async def test_dev_flag_loads_branches_and_one_user_per_role(db_session: AsyncSession) -> None: + report = await run_seed(db_session, dev=True) + + offices = {o.name: o for o in (await db_session.scalars(select(RegionalOffice))).all()} + assert set(offices) == {HEAD_OFFICE, *DEV_BRANCHES} + assert [o.name for o in offices.values() if o.is_head_office] == [HEAD_OFFICE] + assert all(offices[name].is_active and not offices[name].is_head_office for name in DEV_BRANCHES) + + users = (await db_session.scalars(select(AppUser))).all() + assert {u.email for u in users} == DEV_EMAILS + assert all(u.is_active and u.external_id is None and u.full_name for u in users) + assert all(u.business_segment is BusinessSegment.DKB for u in users) + assert all(u.created_at is not None and u.updated_at is not None for u in users) + + links = ( + await db_session.execute( + select(AppUser.email, Role.code, AppUser.regional_office_id) + .join(UserRole, UserRole.user_id == AppUser.id) + .join(Role, Role.id == UserRole.role_id) + ) + ).all() + assert len(links) == len(DEV_EMAILS) # ровно одна роль на пользователя + office_name = {o.id: o.name for o in offices.values()} + for email, role_code, office_id in links: + assert email == f"{role_code.lower()}@armka.local" + if role_code in HEAD_OFFICE_ROLES: + assert office_name[office_id] == HEAD_OFFICE + else: + assert office_name[office_id] in DEV_BRANCHES + + assert report.tables["regional_office"].inserted == DEV_BRANCH_COUNT + 1 + assert report.tables["app_user"].inserted == USER_COUNT + assert report.tables["user_role"].inserted == USER_COUNT + + +@pytest.mark.integration +async def test_second_dev_run_changes_nothing(db_session: AsyncSession) -> None: + await run_seed(db_session, dev=True) + before = await _counts(db_session) + stamps = {u.email: u.updated_at for u in (await db_session.scalars(select(AppUser))).all()} + + report = await run_seed(db_session, dev=True) + + assert await _counts(db_session) == before + assert before["app_user"] == USER_COUNT + assert before["user_role"] == USER_COUNT + for table in ("regional_office", "app_user", "user_role"): + stat = report.tables[table] + assert (stat.inserted, stat.updated) == (0, 0) + db_session.expire_all() + assert {u.email: u.updated_at for u in (await db_session.scalars(select(AppUser))).all()} == stamps + + +@pytest.mark.integration +async def test_changed_user_data_updates_row_and_keeps_old_role_link(db_session: AsyncSession, tmp_path: Path) -> None: + await run_seed(db_session, dev=True) + seed_dir = _copy_seed(tmp_path) + users_file = seed_dir / "dev" / "users.yaml" + text = users_file.read_text(encoding="utf-8") + users_file.write_text( + text.replace("Dev: Эксперт", "Dev: Эксперт ГО").replace("roles: [ADMIN]", "roles: [KA_GO]"), + encoding="utf-8", + ) + + report = await run_seed(db_session, dev=True, seed_dir=seed_dir) + + assert (report.tables["app_user"].inserted, report.tables["app_user"].updated) == (0, 1) + assert (report.tables["user_role"].inserted, report.tables["user_role"].unchanged) == (1, USER_COUNT - 1) + assert await _count(db_session, AppUser) == USER_COUNT + expert = await db_session.scalar(select(AppUser).where(AppUser.email == "expert@armka.local")) + assert expert is not None + assert expert.full_name == "Dev: Эксперт ГО" + assert await _count(db_session, UserRole) == USER_COUNT + 1 + + +@pytest.mark.integration +@pytest.mark.parametrize( + ("old", "new", "expected"), + [ + ("roles: [EXPERT]", "roles: [NO_SUCH_ROLE]", "NO_SUCH_ROLE"), + ( + "regional_office: Головной офис\n business_segment: DKB\n is_active: true\n roles: [EXPERT]", + "regional_office: Несуществующий филиал\n business_segment: DKB\n is_active: true\n roles: [EXPERT]", + "Несуществующий филиал", + ), + ], + ids=["unknown-role", "unknown-branch"], +) +async def test_unknown_reference_fails_before_any_write( + db_session: AsyncSession, tmp_path: Path, old: str, new: str, expected: str +) -> None: + seed_dir = _copy_seed(tmp_path) + users_file = seed_dir / "dev" / "users.yaml" + text = users_file.read_text(encoding="utf-8") + assert old in text + users_file.write_text(text.replace(old, new), encoding="utf-8") + before = await _counts(db_session) + + with pytest.raises(SeedError, match=expected): + await run_seed(db_session, dev=True, seed_dir=seed_dir) + + assert await _counts(db_session) == before == dict.fromkeys(before, 0) + + +# ---- валидатор без БД ---- + + +def test_loader_without_dev_does_not_read_dev_files(tmp_path: Path) -> None: + seed_dir = _copy_seed(tmp_path) + shutil.rmtree(seed_dir / "dev") + + data = load_seed(seed_dir) + + assert data.users == [] + assert [o.name for o in data.regional_offices] == [HEAD_OFFICE] + + +def test_loader_reads_repository_dev_seed() -> None: + data = load_seed(SEED_DIR, dev=True) + + assert {o.name for o in data.regional_offices} == {HEAD_OFFICE, *DEV_BRANCHES} + assert {u.email for u in data.users} == DEV_EMAILS + assert all(len(u.role_codes) == 1 and u.email == f"{u.role_codes[0].lower()}@armka.local" for u in data.users) + + +@pytest.mark.parametrize( + ("file", "old", "new", "expected"), + [ + ("users.yaml", ' full_name: "Dev: Эксперт"\n', "", "full_name"), + ( + "users.yaml", + "business_segment: DKB\n is_active: true\n roles: [EXPERT]", + "business_segment: XX\n is_active: true\n roles: [EXPERT]", + "business_segment", + ), + ("users.yaml", "roles: [EXPERT]", "roles: []", "ровно одна"), + ("users.yaml", "roles: [EXPERT]", "roles: [EXPERT, ADMIN]", "ровно одна"), + ("users.yaml", "email: expert@armka.local", "email: admin@armka.local", "admin@armka.local"), + ("users.yaml", "is_active: true\n roles: [EXPERT]", "is_active: да\n roles: [EXPERT]", "is_active"), + ("users.yaml", " employee_no: DEV-006\n", " employee_no: DEV-006\n extra: 1\n", "extra"), + ("regional_offices.yaml", "Краснодарский региональный филиал", "Ростовский региональный филиал", "Ростовский"), + ("regional_offices.yaml", "Краснодарский региональный филиал", HEAD_OFFICE, HEAD_OFFICE), + ( + "regional_offices.yaml", + "Ростовский региональный филиал\n is_head_office: false", + "Р\n is_head_office: true", + "головным", + ), + ], + ids=[ + "no-full-name", + "bad-segment", + "empty-roles", + "two-roles", + "duplicate-email", + "bad-bool", + "unknown-key", + "duplicate-dev-branch", + "dev-branch-duplicates-head-office", + "dev-branch-is-head-office", + ], +) +def test_validator_rejects_malformed_dev_seed(tmp_path: Path, file: str, old: str, new: str, expected: str) -> None: + seed_dir = _copy_seed(tmp_path) + path = seed_dir / "dev" / file + text = path.read_text(encoding="utf-8") + assert old in text + path.write_text(text.replace(old, new, 1), encoding="utf-8") + + with pytest.raises(SeedError, match=expected): + load_seed(seed_dir, dev=True) + + +def test_missing_dev_file_is_seed_error_only_with_dev_flag(tmp_path: Path) -> None: + seed_dir = _copy_seed(tmp_path) + (seed_dir / "dev" / "users.yaml").unlink() + + load_seed(seed_dir) + with pytest.raises(SeedError, match="users.yaml"): + load_seed(seed_dir, dev=True) diff --git a/api/tests/test_seed_roles.py b/api/tests/test_seed_roles.py index 79deda4..76efd00 100644 --- a/api/tests/test_seed_roles.py +++ b/api/tests/test_seed_roles.py @@ -248,7 +248,7 @@ async def test_without_dev_flag_no_users_are_loaded(db_session: AsyncSession) -> @pytest.mark.integration async def test_dev_flag_is_accepted(db_session: AsyncSession) -> None: - """Каркас --dev: до задачи 15 dev-данных нет, но флаг не должен падать.""" + """Флаг --dev не ломает загрузку core-справочников (dev-данные проверяет test_seed_dev.py).""" await run_seed(db_session, dev=True) assert await _count(db_session, Role) == ROLE_COUNT -- 2.47.2 From 7429240f0138576b09d671e76b201b85b741edce Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 21:41:19 +0400 Subject: [PATCH 49/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2015?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 91b8fab..2650598 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -77,7 +77,7 @@ ### Task 15: [3.4] `seed/dev/regional_offices.yaml` (2 РФ), `seed/dev/users.yaml` (7 пользователей -- [ ] `seed/dev/regional_offices.yaml` (2 РФ), `seed/dev/users.yaml` (7 пользователей `@armka.local` + `user_role`); `make seed` / `make seed-dev`; проверка: без `--dev` `app_user` пуст; с `--dev` — 7 пользователей по одной роли +- [x] `seed/dev/regional_offices.yaml` (2 РФ), `seed/dev/users.yaml` (7 пользователей `@armka.local` + `user_role`); `make seed` / `make seed-dev`; проверка: без `--dev` `app_user` пуст; с `--dev` — 7 пользователей по одной роли ### Task 16: [4.1] `src/core/auth.py` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index f34d0b8..2980e16 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -62,7 +62,7 @@ - [x] 3.3 `seed/core/elimination_rules_v1.yaml` — 8 правил ББ из §4 со счетами сторон и исключениями; `seed/core/required_document_rules.yaml` — BASIC (10) и EXTENDED (27, ASSUMPTION(OQ-46)); загрузчик падает до записи при неизвестном счёте; проверка: сценарии «Правило № 6» и «Ссылка на несуществующий счёт» из spec -- [ ] 3.4 `seed/dev/regional_offices.yaml` (2 РФ), `seed/dev/users.yaml` (7 пользователей `@armka.local` + `user_role`); +- [x] 3.4 `seed/dev/regional_offices.yaml` (2 РФ), `seed/dev/users.yaml` (7 пользователей `@armka.local` + `user_role`); `make seed` / `make seed-dev`; проверка: без `--dev` `app_user` пуст; с `--dev` — 7 пользователей по одной роли ## 4. Аутентификация и права (spec `authn-authz`) -- 2.47.2 From 70b47a097dfcc04c29230c4bd983505f8da1afe3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 21:51:51 +0400 Subject: [PATCH 50/97] =?UTF-8?q?feat:=20=D1=8F=D0=B4=D1=80=D0=BE=20=D0=B0?= =?UTF-8?q?=D1=83=D1=82=D0=B5=D0=BD=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20(Claims,=20TokenVerifier,=20DevHs256Verifi?= =?UTF-8?q?er,=20JwksVerifier,=20build=5Fverifier)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev HS256 включается только при AUTH_DEV_ISSUER и DEBUG одновременно; РАИСА — RS256/ES256 по публичному ключу или JWKS с кэшем. PyJWT[crypto] добавлен в requirements.txt как прод-зависимость. Co-Authored-By: Claude Fable 5.1 --- api/requirements.txt | 1 + api/src/core/auth.py | 250 ++++++++++++++++ api/tests/test_auth_core.py | 554 ++++++++++++++++++++++++++++++++++++ 3 files changed, 805 insertions(+) create mode 100644 api/src/core/auth.py create mode 100644 api/tests/test_auth_core.py diff --git a/api/requirements.txt b/api/requirements.txt index f26f6d9..ba04acc 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -44,6 +44,7 @@ pydantic-extra-types==2.11.0 pydantic-settings==2.14.2 pydantic_core==2.46.4 Pygments==2.20.0 +PyJWT[crypto]==2.15.1 PyNaCl==1.5.0 python-dotenv==1.2.2 python-multipart==0.0.32 diff --git a/api/src/core/auth.py b/api/src/core/auth.py new file mode 100644 index 0000000..2f3e53b --- /dev/null +++ b/api/src/core/auth.py @@ -0,0 +1,250 @@ +"""Аутентификация: проверка JWT и фабрика проверяющего по настройкам (D-5). + +Модуль ничего не знает про HTTP и БД: он превращает строку токена в :class:`Claims` или бросает :class:`AuthError` +(сообщение по-русски, пригодно для ``{"detail": ...}`` ответа 401). Поиск ``app_user`` по email и права — зависимости +``src/api/dependencies/auth.py`` (задача 17). + +Проверяющие: + +* :class:`DevHs256Verifier` — только для разработки: HS256 с общим секретом ``AUTH_DEV_SECRET``; выпускает токены для + ``POST /api/v1/dev/token``. Создаётся фабрикой при ``AUTH_DEV_ISSUER=true`` **и** ``DEBUG=true``. +* :class:`JwksVerifier` — каркас под токены РАИСА (параметры JWT РАИСА неизвестны, решение #5 design.md): подпись + RS256/ES256 по публичному ключу ``AUTH_PUBLIC_KEY`` (PEM) либо по ключам из ``AUTH_JWKS_URL`` (с кэшем). + +Фабрика :func:`build_verifier` возвращает ``None``, если ни dev-режим, ни ключи РАИСА не настроены: в этом случае +токены не принимаются вовсе (зависимость ``current_user`` отвечает 401 «аутентификация не настроена»). +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING, Any, Protocol + +import jwt +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec, rsa +from jwt import PyJWKClient +from loguru import logger + +if TYPE_CHECKING: + from src.core.config import Settings + +# Алгоритмы РАИСА-проверяющего: асимметричные, явно. ``none`` и HS* не допускаются никогда (атака подмены алгоритма). +JWKS_ALGORITHMS: tuple[str, ...] = ("RS256", "ES256") +DEV_ALGORITHM = "HS256" +# Допуск на расхождение часов с эмитентом токена +JWKS_LEEWAY_SECONDS = 10 +# Срок жизни dev-токена (требование спеки authn-authz) +DEV_TOKEN_TTL = timedelta(hours=8) +# Время жизни кэша набора ключей JWKS (PyJWKClient), секунды +JWKS_CACHE_LIFESPAN_SECONDS = 300 +JWKS_FETCH_TIMEOUT_SECONDS = 10 + + +class AuthError(Exception): + """Токен не принят. Текст сообщения — по-русски, без внутренностей; годится для тела 401.""" + + +class TokenExpiredError(AuthError): + """Срок действия токена истёк.""" + + +class TokenSignatureError(AuthError): + """Подпись токена не проходит проверку.""" + + +@dataclass(frozen=True) +class Claims: + """Проверенные данные токена: ``sub``, email пользователя и момент окончания срока (UTC).""" + + subject: str + email: str + exp: datetime + + +class TokenVerifier(Protocol): + """Проверяющий токены. ``verify`` асинхронный: JWKS-проверяющий может ходить за ключами по сети.""" + + async def verify(self, token: str) -> Claims: + """Возвращает claims проверенного токена или бросает :class:`AuthError`.""" + ... + + +class _SigningKeyProvider(Protocol): + """Источник ключа проверки подписи по токену (интерфейс :class:`jwt.PyJWKClient`).""" + + def get_signing_key_from_jwt(self, token: str) -> Any: ... + + +# Порядок важен: более конкретные классы исключений PyJWT — раньше общих +_ERROR_MESSAGES: tuple[tuple[type[jwt.PyJWTError], type[AuthError], str], ...] = ( + (jwt.ExpiredSignatureError, TokenExpiredError, "срок действия токена истёк"), + (jwt.InvalidSignatureError, TokenSignatureError, "недействительная подпись токена"), + (jwt.InvalidAlgorithmError, AuthError, "недопустимый алгоритм подписи токена"), + (jwt.InvalidIssuerError, AuthError, "токен выпущен неизвестным эмитентом"), + (jwt.InvalidAudienceError, AuthError, "токен предназначен для другого сервиса"), + (jwt.ImmatureSignatureError, AuthError, "токен ещё не действителен"), + (jwt.DecodeError, AuthError, "некорректный токен"), +) + + +def _to_auth_error(exc: jwt.PyJWTError) -> AuthError: + """Переводит ошибку PyJWT в :class:`AuthError` с русским сообщением; детали библиотеки наружу не отдаются.""" + for exc_type, auth_type, message in _ERROR_MESSAGES: + if isinstance(exc, exc_type): + return auth_type(message) + if isinstance(exc, jwt.MissingRequiredClaimError): + return AuthError(f"в токене нет обязательного поля «{exc.claim}»") + if isinstance(exc, jwt.PyJWKClientError): + # сбой получения ключей (сеть, неизвестный kid) — деталь только в лог, не в ответ + logger.warning("Не удалось получить ключ проверки подписи JWT: {}", exc) + return AuthError("не удалось получить ключ проверки подписи токена") + return AuthError("недействительный токен") + + +def _claims_from_payload(payload: dict[str, Any], email_claim: str) -> Claims: + """Собирает :class:`Claims` из проверенной полезной нагрузки; ``exp`` и ``sub`` уже проверены на наличие.""" + email = payload.get(email_claim) + if not isinstance(email, str) or not email.strip(): + raise AuthError("в токене нет email") + return Claims( + subject=str(payload["sub"]), + email=email.strip(), + exp=datetime.fromtimestamp(payload["exp"], tz=UTC), + ) + + +class DevHs256Verifier: + """Проверяющий и эмитент dev-токенов: HS256 с общим секретом. Только для разработки (``DEBUG=true``).""" + + def __init__(self, secret: str, *, email_claim: str = "email") -> None: + if not secret: + raise ValueError("секрет dev-эмитента не задан") + self._secret = secret + self._email_claim = email_claim + + async def verify(self, token: str) -> Claims: + try: + payload = jwt.decode( + token, + self._secret, + algorithms=[DEV_ALGORITHM], + options={"require": ["exp", "sub"], "verify_aud": False}, + ) + except jwt.PyJWTError as exc: + raise _to_auth_error(exc) from exc + return _claims_from_payload(payload, self._email_claim) + + def issue_token(self, email: str, *, ttl: timedelta = DEV_TOKEN_TTL) -> str: + """Выпускает HS256 JWT для ``POST /api/v1/dev/token``: ``sub`` = email, email в настроенном claim.""" + now = datetime.now(UTC) + payload = {"sub": email, self._email_claim: email, "iat": now, "exp": now + ttl} + return jwt.encode(payload, self._secret, algorithm=DEV_ALGORITHM) + + +def _load_static_key(pem: str) -> tuple[Any, tuple[str, ...]]: + """Загружает публичный ключ из PEM и выводит допустимый алгоритм из его типа (RSA → RS256, EC P-256 → ES256).""" + # в .env PEM удобно хранить одной строкой с литеральными «\n» + try: + key = serialization.load_pem_public_key(pem.replace("\\n", "\n").strip().encode()) + except (ValueError, TypeError) as exc: + raise ValueError("AUTH_PUBLIC_KEY не является публичным ключом в формате PEM") from exc + if isinstance(key, rsa.RSAPublicKey): + return key, ("RS256",) + if isinstance(key, ec.EllipticCurvePublicKey) and key.curve.name == "secp256r1": + return key, ("ES256",) + raise ValueError("AUTH_PUBLIC_KEY: поддерживаются ключи RSA и EC P-256 (алгоритмы RS256 и ES256)") + + +class JwksVerifier: + """Проверяющий токены РАИСА: RS256/ES256 по статическому публичному ключу либо по JWKS (кэш ключей). + + ``iss`` проверяется, только если задан ``issuer``; ``aud`` — только если задан ``audience``. ``exp`` и ``sub`` + обязательны всегда. ``jwk_client`` — внедряемый источник ключей (тесты); по умолчанию :class:`jwt.PyJWKClient`. + """ + + def __init__( # noqa: PLR0913 - все параметры именованные и соответствуют настройкам AUTH_* + self, + *, + jwks_url: str = "", + public_key: str = "", + issuer: str = "", + audience: str = "", + email_claim: str = "email", + leeway: int = JWKS_LEEWAY_SECONDS, + jwk_client: _SigningKeyProvider | None = None, + ) -> None: + sources = [bool(jwks_url), bool(public_key), jwk_client is not None] + if sum(sources) == 0: + raise ValueError("не задан ключ проверки подписи: нужен AUTH_JWKS_URL или AUTH_PUBLIC_KEY") + if sum(sources) > 1: + raise ValueError("AUTH_JWKS_URL и AUTH_PUBLIC_KEY заданы одновременно: оставьте один источник ключа") + self._static_key: Any = None + self._static_algorithms: tuple[str, ...] = () + self._client: _SigningKeyProvider | None = jwk_client + if public_key: + self._static_key, self._static_algorithms = _load_static_key(public_key) + elif jwks_url: + self._client = PyJWKClient( + jwks_url, + cache_keys=True, + cache_jwk_set=True, + lifespan=JWKS_CACHE_LIFESPAN_SECONDS, + timeout=JWKS_FETCH_TIMEOUT_SECONDS, + ) + self._issuer = issuer or None + self._audience = audience or None + self._email_claim = email_claim + self._leeway = leeway + + async def _signing_key(self, token: str) -> tuple[Any, list[str]]: + if self._client is None: + return self._static_key, list(self._static_algorithms) + # PyJWKClient ходит в сеть блокирующе — выносим из цикла событий + signing_key = await asyncio.to_thread(self._client.get_signing_key_from_jwt, token) + return signing_key.key, list(JWKS_ALGORITHMS) + + async def verify(self, token: str) -> Claims: + try: + key, algorithms = await self._signing_key(token) + payload = jwt.decode( + token, + key, + algorithms=algorithms, + issuer=self._issuer, + audience=self._audience, + leeway=self._leeway, + options={"require": ["exp", "sub"], "verify_aud": self._audience is not None}, + ) + except jwt.PyJWTError as exc: + raise _to_auth_error(exc) from exc + return _claims_from_payload(payload, self._email_claim) + + +def build_verifier(settings: Settings) -> TokenVerifier | None: + """Собирает проверяющего токены по настройкам. + + * ``AUTH_DEV_ISSUER`` **и** ``DEBUG`` → :class:`DevHs256Verifier` (только он; JWKS в этом режиме не используется); + пустой ``AUTH_DEV_SECRET`` — ошибка конфигурации (``RuntimeError``). + * иначе, если задан ``AUTH_JWKS_URL`` или ``AUTH_PUBLIC_KEY`` → :class:`JwksVerifier`. + * иначе ``None``: проверять нечем, токены не принимаются. ``AUTH_DEV_ISSUER`` без ``DEBUG`` dev-проверяющего + не включает никогда, даже при заданном секрете. + """ + if settings.AUTH_DEV_ISSUER and settings.DEBUG: + if not settings.AUTH_DEV_SECRET: + raise RuntimeError("AUTH_DEV_ISSUER=true и DEBUG=true, но AUTH_DEV_SECRET не задан") + logger.warning("Включён dev-эмитент токенов (AUTH_DEV_ISSUER и DEBUG): только для разработки") + return DevHs256Verifier(settings.AUTH_DEV_SECRET, email_claim=settings.AUTH_EMAIL_CLAIM) + if settings.AUTH_DEV_ISSUER: + logger.warning("AUTH_DEV_ISSUER=true проигнорирован: dev-эмитент работает только при DEBUG=true") + if settings.AUTH_JWKS_URL or settings.AUTH_PUBLIC_KEY: + return JwksVerifier( + jwks_url=settings.AUTH_JWKS_URL, + public_key=settings.AUTH_PUBLIC_KEY, + issuer=settings.AUTH_ISSUER, + audience=settings.AUTH_AUDIENCE, + email_claim=settings.AUTH_EMAIL_CLAIM, + ) + return None diff --git a/api/tests/test_auth_core.py b/api/tests/test_auth_core.py new file mode 100644 index 0000000..ff86646 --- /dev/null +++ b/api/tests/test_auth_core.py @@ -0,0 +1,554 @@ +"""Ядро аутентификации: проверяющие токены и фабрика по настройкам (без БД, без внешней сети).""" + +import base64 +import hashlib +import hmac +import json +import os +import threading +from datetime import UTC, datetime, timedelta +from http.server import BaseHTTPRequestHandler, HTTPServer +from types import SimpleNamespace +from typing import Any + +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec, rsa +from jwt.exceptions import PyJWKClientConnectionError + +from src.core.auth import ( + AuthError, + Claims, + DevHs256Verifier, + JwksVerifier, + TokenExpiredError, + TokenSignatureError, + build_verifier, +) +from src.core.config import Settings + +DEV_SECRET = "dev-secret-for-unit-tests-0123456789abcdef" +OTHER_SECRET = "another-secret-for-unit-tests-0123456789" +EMAIL = "ka_rf@armka.local" + + +class _LogSpy: + """Подмена logger модуля auth: собирает сообщения, не пишет в stderr.""" + + def __init__(self) -> None: + self.messages: list[str] = [] + + def warning(self, message: str, *args: Any) -> None: + self.messages.append(message.format(*args)) + + +@pytest.fixture(autouse=True) +def log_messages(monkeypatch: pytest.MonkeyPatch) -> list[str]: + """Перехватывает логи модуля auth (шум не попадает в вывод тестов) и отдаёт их для проверок.""" + spy = _LogSpy() + monkeypatch.setattr("src.core.auth.logger", spy) + return spy.messages + + +def _payload(**overrides: Any) -> dict[str, Any]: + now = datetime.now(UTC) + data: dict[str, Any] = {"sub": EMAIL, "email": EMAIL, "iat": now, "exp": now + timedelta(hours=1)} + data.update(overrides) + return {k: v for k, v in data.items() if v is not None} + + +def _pem_public(key: Any) -> str: + return ( + key.public_key() + .public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo) + .decode() + ) + + +def _pem_private(key: Any) -> str: + return key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ).decode() + + +def _b64(data: bytes) -> bytes: + return base64.urlsafe_b64encode(data).rstrip(b"=") + + +def _forge_hs(payload: dict[str, Any], secret: bytes, alg: str = "HS256") -> str: + """Собирает HS-токен вручную (PyJWT не даст подписать HMAC асимметричным PEM — атаки подмены ключа).""" + header = _b64(json.dumps({"alg": alg, "typ": "JWT"}).encode()) + body = _b64(json.dumps(payload, default=lambda v: int(v.timestamp())).encode()) + digest = {"HS256": hashlib.sha256, "HS384": hashlib.sha384}[alg] + sig = _b64(hmac.new(secret, header + b"." + body, digest).digest()) + return (header + b"." + body + b"." + sig).decode() + + +def _unsigned(payload: dict[str, Any]) -> str: + header = _b64(json.dumps({"alg": "none", "typ": "JWT"}).encode()) + body = _b64(json.dumps(payload, default=lambda v: int(v.timestamp())).encode()) + return (header + b"." + body + b".").decode() + + +@pytest.fixture(scope="module") +def rsa_key() -> rsa.RSAPrivateKey: + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +@pytest.fixture(scope="module") +def other_rsa_key() -> rsa.RSAPrivateKey: + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +@pytest.fixture(scope="module") +def ec_key() -> ec.EllipticCurvePrivateKey: + return ec.generate_private_key(ec.SECP256R1()) + + +@pytest.fixture(scope="module") +def other_ec_key() -> ec.EllipticCurvePrivateKey: + return ec.generate_private_key(ec.SECP256R1()) + + +# --- DevHs256Verifier ------------------------------------------------------------------------------------------- + + +@pytest.fixture +def dev() -> DevHs256Verifier: + return DevHs256Verifier(DEV_SECRET) + + +async def test_dev_valid_token(dev: DevHs256Verifier) -> None: + exp = datetime.now(UTC) + timedelta(hours=2) + token = jwt.encode(_payload(exp=exp), DEV_SECRET, algorithm="HS256") + claims = await dev.verify(token) + assert isinstance(claims, Claims) + assert claims.subject == EMAIL + assert claims.email == EMAIL + assert claims.exp == exp.replace(microsecond=0) + assert claims.exp.tzinfo is not None + + +async def test_dev_expired(dev: DevHs256Verifier) -> None: + token = jwt.encode(_payload(exp=datetime.now(UTC) - timedelta(minutes=5)), DEV_SECRET, algorithm="HS256") + with pytest.raises(TokenExpiredError, match="срок действия"): + await dev.verify(token) + + +async def test_dev_foreign_secret(dev: DevHs256Verifier) -> None: + token = jwt.encode(_payload(), OTHER_SECRET, algorithm="HS256") + with pytest.raises(TokenSignatureError, match="подпись"): + await dev.verify(token) + + +async def test_dev_no_email(dev: DevHs256Verifier) -> None: + token = jwt.encode(_payload(email=None), DEV_SECRET, algorithm="HS256") + with pytest.raises(AuthError, match="нет email"): + await dev.verify(token) + + +async def test_dev_empty_email(dev: DevHs256Verifier) -> None: + token = jwt.encode(_payload(email=" "), DEV_SECRET, algorithm="HS256") + with pytest.raises(AuthError, match="нет email"): + await dev.verify(token) + + +async def test_dev_no_exp(dev: DevHs256Verifier) -> None: + token = jwt.encode(_payload(exp=None), DEV_SECRET, algorithm="HS256") + with pytest.raises(AuthError, match="exp"): + await dev.verify(token) + + +async def test_dev_no_sub(dev: DevHs256Verifier) -> None: + token = jwt.encode(_payload(sub=None), DEV_SECRET, algorithm="HS256") + with pytest.raises(AuthError, match="sub"): + await dev.verify(token) + + +async def test_dev_alg_none(dev: DevHs256Verifier) -> None: + with pytest.raises(AuthError): + await dev.verify(_unsigned(_payload())) + + +async def test_dev_alg_hs384(dev: DevHs256Verifier) -> None: + """Токен с корректной подписью, но другим алгоритмом (HS384) не принимается: алгоритм зафиксирован.""" + with pytest.raises(AuthError, match="алгоритм"): + await dev.verify(_forge_hs(_payload(), DEV_SECRET.encode(), alg="HS384")) + + +@pytest.mark.parametrize("token", ["", "abc", "a.b.c", "не-токен"]) +async def test_dev_garbage(dev: DevHs256Verifier, token: str) -> None: + with pytest.raises(AuthError): + await dev.verify(token) + + +async def test_dev_issue_token_roundtrip(dev: DevHs256Verifier) -> None: + before = datetime.now(UTC) + token = dev.issue_token(EMAIL) + claims = await dev.verify(token) + assert claims.subject == EMAIL + assert claims.email == EMAIL + assert timedelta(hours=8) - timedelta(seconds=5) <= claims.exp - before <= timedelta(hours=8, seconds=5) + header = jwt.get_unverified_header(token) + assert header["alg"] == "HS256" + assert "iat" in jwt.decode(token, options={"verify_signature": False}) + + +async def test_dev_issue_token_custom_ttl_and_claim() -> None: + dev = DevHs256Verifier(DEV_SECRET, email_claim="mail") + token = dev.issue_token(EMAIL, ttl=timedelta(minutes=10)) + assert jwt.decode(token, options={"verify_signature": False})["mail"] == EMAIL + claims = await dev.verify(token) + assert claims.email == EMAIL + assert claims.exp - datetime.now(UTC) < timedelta(minutes=11) + + +def test_dev_empty_secret_forbidden() -> None: + with pytest.raises(ValueError, match="секрет"): + DevHs256Verifier("") + + +# --- JwksVerifier (статический публичный ключ) ------------------------------------------------------------------- + + +@pytest.fixture(params=["rs256", "es256"]) +def asym( + request: pytest.FixtureRequest, + rsa_key: rsa.RSAPrivateKey, + other_rsa_key: rsa.RSAPrivateKey, + ec_key: ec.EllipticCurvePrivateKey, + other_ec_key: ec.EllipticCurvePrivateKey, +) -> SimpleNamespace: + """Пара алгоритм/ключи: подписывающий, публичный PEM для проверяющего и чужой подписывающий ключ.""" + if request.param == "rs256": + return SimpleNamespace( + alg="RS256", priv=_pem_private(rsa_key), pub=_pem_public(rsa_key), other=_pem_private(other_rsa_key) + ) + return SimpleNamespace( + alg="ES256", priv=_pem_private(ec_key), pub=_pem_public(ec_key), other=_pem_private(other_ec_key) + ) + + +async def test_jwks_static_valid(asym: SimpleNamespace) -> None: + exp = datetime.now(UTC) + timedelta(hours=1) + token = jwt.encode(_payload(exp=exp), asym.priv, algorithm=asym.alg) + claims = await JwksVerifier(public_key=asym.pub).verify(token) + assert (claims.subject, claims.email) == (EMAIL, EMAIL) + assert claims.exp == exp.replace(microsecond=0) + + +async def test_jwks_static_pem_with_escaped_newlines(asym: SimpleNamespace) -> None: + """В .env PEM удобно хранить одной строкой с литеральными \\n.""" + token = jwt.encode(_payload(), asym.priv, algorithm=asym.alg) + claims = await JwksVerifier(public_key=asym.pub.replace("\n", "\\n")).verify(token) + assert claims.email == EMAIL + + +async def test_jwks_expired(asym: SimpleNamespace) -> None: + token = jwt.encode(_payload(exp=datetime.now(UTC) - timedelta(minutes=5)), asym.priv, algorithm=asym.alg) + with pytest.raises(TokenExpiredError): + await JwksVerifier(public_key=asym.pub).verify(token) + + +async def test_jwks_leeway_tolerates_small_clock_skew(asym: SimpleNamespace) -> None: + token = jwt.encode(_payload(exp=datetime.now(UTC) - timedelta(seconds=3)), asym.priv, algorithm=asym.alg) + claims = await JwksVerifier(public_key=asym.pub).verify(token) + assert claims.email == EMAIL + + +async def test_jwks_foreign_key(asym: SimpleNamespace) -> None: + token = jwt.encode(_payload(), asym.other, algorithm=asym.alg) + with pytest.raises(TokenSignatureError): + await JwksVerifier(public_key=asym.pub).verify(token) + + +async def test_jwks_no_exp(asym: SimpleNamespace) -> None: + token = jwt.encode(_payload(exp=None), asym.priv, algorithm=asym.alg) + with pytest.raises(AuthError, match="exp"): + await JwksVerifier(public_key=asym.pub).verify(token) + + +async def test_jwks_no_sub(asym: SimpleNamespace) -> None: + token = jwt.encode(_payload(sub=None), asym.priv, algorithm=asym.alg) + with pytest.raises(AuthError, match="sub"): + await JwksVerifier(public_key=asym.pub).verify(token) + + +async def test_jwks_issuer_checked_when_configured(asym: SimpleNamespace) -> None: + verifier = JwksVerifier(public_key=asym.pub, issuer="https://sso.raisa") + ok = jwt.encode(_payload(iss="https://sso.raisa"), asym.priv, algorithm=asym.alg) + assert (await verifier.verify(ok)).email == EMAIL + for bad in (_payload(iss="https://evil"), _payload()): + with pytest.raises(AuthError): + await verifier.verify(jwt.encode(bad, asym.priv, algorithm=asym.alg)) + + +async def test_jwks_issuer_ignored_when_not_configured(asym: SimpleNamespace) -> None: + token = jwt.encode(_payload(iss="whatever"), asym.priv, algorithm=asym.alg) + assert (await JwksVerifier(public_key=asym.pub).verify(token)).email == EMAIL + + +async def test_jwks_audience_checked_when_configured(asym: SimpleNamespace) -> None: + verifier = JwksVerifier(public_key=asym.pub, audience="arm-core") + ok = jwt.encode(_payload(aud="arm-core"), asym.priv, algorithm=asym.alg) + assert (await verifier.verify(ok)).email == EMAIL + for bad in (_payload(aud="other"), _payload()): + with pytest.raises(AuthError): + await verifier.verify(jwt.encode(bad, asym.priv, algorithm=asym.alg)) + + +async def test_jwks_audience_in_token_ignored_when_not_configured(asym: SimpleNamespace) -> None: + token = jwt.encode(_payload(aud="anything"), asym.priv, algorithm=asym.alg) + assert (await JwksVerifier(public_key=asym.pub).verify(token)).email == EMAIL + + +async def test_jwks_custom_email_claim(asym: SimpleNamespace) -> None: + verifier = JwksVerifier(public_key=asym.pub, email_claim="preferred_username") + token = jwt.encode(_payload(preferred_username="ivan@rshb.ru"), asym.priv, algorithm=asym.alg) + assert (await verifier.verify(token)).email == "ivan@rshb.ru" + # claim «email» при настроенном другом имени не подходит + with pytest.raises(AuthError, match="нет email"): + await verifier.verify(jwt.encode(_payload(), asym.priv, algorithm=asym.alg)) + + +async def test_jwks_alg_none(asym: SimpleNamespace) -> None: + with pytest.raises(AuthError): + await JwksVerifier(public_key=asym.pub).verify(_unsigned(_payload())) + + +async def test_jwks_key_confusion_hs256_signed_with_public_key(asym: SimpleNamespace) -> None: + """Атака подмены алгоритма: HS256, подписанный байтами публичного ключа как секретом, не принимается.""" + for secret in (asym.pub.encode(), asym.pub.strip().encode()): + with pytest.raises(AuthError): + await JwksVerifier(public_key=asym.pub).verify(_forge_hs(_payload(), secret)) + + +async def test_jwks_rejects_dev_token(asym: SimpleNamespace) -> None: + token = jwt.encode(_payload(), DEV_SECRET, algorithm="HS256") + with pytest.raises(AuthError): + await JwksVerifier(public_key=asym.pub).verify(token) + + +async def test_jwks_rs256_key_does_not_verify_es256_token( + rsa_key: rsa.RSAPrivateKey, ec_key: ec.EllipticCurvePrivateKey +) -> None: + """Алгоритм выводится из типа ключа: RSA-ключ принимает только RS256, EC-ключ — только ES256.""" + es_token = jwt.encode(_payload(), _pem_private(ec_key), algorithm="ES256") + rs_token = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256") + with pytest.raises(AuthError): + await JwksVerifier(public_key=_pem_public(rsa_key)).verify(es_token) + with pytest.raises(AuthError): + await JwksVerifier(public_key=_pem_public(ec_key)).verify(rs_token) + + +def test_jwks_requires_a_key_source() -> None: + with pytest.raises(ValueError, match="AUTH_JWKS_URL|ключ"): + JwksVerifier() + + +def test_jwks_bad_pem() -> None: + with pytest.raises(ValueError, match="PEM"): + JwksVerifier(public_key="not a pem") + + +def test_jwks_rejects_both_sources(rsa_key: rsa.RSAPrivateKey) -> None: + with pytest.raises(ValueError, match="одновременно"): + JwksVerifier(public_key=_pem_public(rsa_key), jwks_url="http://127.0.0.1:1/jwks") + + +# --- JwksVerifier (JWKS: заглушка клиента и реальный PyJWKClient через локальный HTTP-сервер) -------------------- + + +class _FakeJwkClient: + def __init__(self, key: Any = None, error: Exception | None = None) -> None: + self._key, self._error, self.calls = key, error, 0 + + def get_signing_key_from_jwt(self, token: str) -> Any: + self.calls += 1 + if self._error is not None: + raise self._error + return SimpleNamespace(key=self._key) + + +async def test_jwks_client_valid(rsa_key: rsa.RSAPrivateKey) -> None: + client = _FakeJwkClient(key=rsa_key.public_key()) + verifier = JwksVerifier(jwk_client=client) + token = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256") + assert (await verifier.verify(token)).email == EMAIL + assert client.calls == 1 + + +async def test_jwks_client_foreign_key(rsa_key: rsa.RSAPrivateKey, other_rsa_key: rsa.RSAPrivateKey) -> None: + verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=rsa_key.public_key())) + token = jwt.encode(_payload(), _pem_private(other_rsa_key), algorithm="RS256") + with pytest.raises(TokenSignatureError): + await verifier.verify(token) + + +async def test_jwks_client_es256(ec_key: ec.EllipticCurvePrivateKey) -> None: + verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=ec_key.public_key())) + token = jwt.encode(_payload(), _pem_private(ec_key), algorithm="ES256") + assert (await verifier.verify(token)).email == EMAIL + + +async def test_jwks_client_key_confusion(rsa_key: rsa.RSAPrivateKey) -> None: + verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=rsa_key.public_key())) + with pytest.raises(AuthError): + await verifier.verify(_forge_hs(_payload(), _pem_public(rsa_key).encode())) + + +async def test_jwks_client_failure_is_auth_error_without_internals(log_messages: list[str]) -> None: + secret_detail = "connection to http://internal-host:8443/keys refused" + verifier = JwksVerifier(jwk_client=_FakeJwkClient(error=PyJWKClientConnectionError(secret_detail))) + token = jwt.encode(_payload(), DEV_SECRET, algorithm="HS256") + with pytest.raises(AuthError) as exc: + await verifier.verify(token) + assert "internal-host" not in str(exc.value) + assert "ключ" in str(exc.value) + assert any("internal-host" in m for m in log_messages) # деталь — только в лог + + +class _JwksHandler(BaseHTTPRequestHandler): + body = b"" + + def do_GET(self) -> None: + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(self.body) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - сигнатура базового класса + return + + +async def test_jwks_real_pyjwkclient_over_loopback(rsa_key: rsa.RSAPrivateKey) -> None: + """Реальный PyJWKClient: выбор ключа по kid из JWKS, отдаваемого локальным сервером (только loopback).""" + jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(rsa_key.public_key())) + jwk.update(kid="key-1", use="sig", alg="RS256") + _JwksHandler.body = json.dumps({"keys": [jwk]}).encode() + server = HTTPServer(("127.0.0.1", 0), _JwksHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + url = f"http://127.0.0.1:{server.server_port}/jwks" + verifier = JwksVerifier(jwks_url=url) + good = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256", headers={"kid": "key-1"}) + assert (await verifier.verify(good)).email == EMAIL + unknown_kid = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256", headers={"kid": "nope"}) + with pytest.raises(AuthError, match="ключ"): + await verifier.verify(unknown_kid) + finally: + server.shutdown() + server.server_close() + + +# --- Фабрика -------------------------------------------------------------------------------------------------- + + +def _settings(monkeypatch: pytest.MonkeyPatch, **auth: Any) -> Settings: + """Settings без env-файла и без AUTH_*/DEBUG из окружения процесса; обязательные OPENBAO__* — заглушки.""" + for key in list(os.environ): + if key.startswith(("OPENBAO__", "AUTH_")) or key == "DEBUG": + monkeypatch.delenv(key) + required = { + "OPENBAO__DATABASE__DATABASE_URL": "postgresql+asyncpg://u:p@localhost:5432/d", + "OPENBAO__MINIO__MINIO_URL": "localhost:9000", + "OPENBAO__MINIO__MINIO_ADMIN_LOGIN": "login", + "OPENBAO__MINIO__MINIO_ADMIN_PASSWORD": "password", + "OPENBAO__MINIO__MINIO_BUCKET": "bucket", + "OPENBAO__NATS__NATS_URL": "nats://localhost:4222", + "OPENBAO__SMB__SMB_HOST": "host", + "OPENBAO__SMB__SMB_PATH": "path", + "OPENBAO__SMB__SMB_LOGIN": "login", + "OPENBAO__SMB__SMB_PASSWORD": "password", + } + for key, value in required.items(): + monkeypatch.setenv(key, value) + return Settings(_env_file=None, **auth) # type: ignore[call-arg] + + +def test_factory_dev_off_and_no_jwks_gives_none(monkeypatch: pytest.MonkeyPatch) -> None: + s = _settings(monkeypatch, AUTH_DEV_ISSUER=False, AUTH_DEV_SECRET=DEV_SECRET, DEBUG=True) + assert build_verifier(s) is None + + +def test_factory_dev_flag_without_debug_gives_no_dev(monkeypatch: pytest.MonkeyPatch, log_messages: list[str]) -> None: + s = _settings(monkeypatch, AUTH_DEV_ISSUER=True, AUTH_DEV_SECRET=DEV_SECRET, DEBUG=False) + assert build_verifier(s) is None + assert any("AUTH_DEV_ISSUER=true проигнорирован" in m for m in log_messages) + + +def test_factory_dev_flag_without_debug_falls_back_to_jwks( + monkeypatch: pytest.MonkeyPatch, rsa_key: rsa.RSAPrivateKey +) -> None: + s = _settings( + monkeypatch, AUTH_DEV_ISSUER=True, AUTH_DEV_SECRET=DEV_SECRET, DEBUG=False, AUTH_PUBLIC_KEY=_pem_public(rsa_key) + ) + verifier = build_verifier(s) + assert isinstance(verifier, JwksVerifier) + assert not isinstance(verifier, DevHs256Verifier) + + +def test_factory_both_flags_gives_dev(monkeypatch: pytest.MonkeyPatch) -> None: + s = _settings(monkeypatch, AUTH_DEV_ISSUER=True, AUTH_DEV_SECRET=DEV_SECRET, DEBUG=True, AUTH_EMAIL_CLAIM="mail") + verifier = build_verifier(s) + assert isinstance(verifier, DevHs256Verifier) + token = verifier.issue_token(EMAIL) + assert jwt.decode(token, options={"verify_signature": False})["mail"] == EMAIL + + +def test_factory_dev_wins_over_jwks_when_both_flags( + monkeypatch: pytest.MonkeyPatch, rsa_key: rsa.RSAPrivateKey +) -> None: + s = _settings( + monkeypatch, AUTH_DEV_ISSUER=True, AUTH_DEV_SECRET=DEV_SECRET, DEBUG=True, AUTH_PUBLIC_KEY=_pem_public(rsa_key) + ) + assert isinstance(build_verifier(s), DevHs256Verifier) + + +def test_factory_dev_flags_without_secret_is_config_error(monkeypatch: pytest.MonkeyPatch) -> None: + s = _settings(monkeypatch, AUTH_DEV_ISSUER=True, DEBUG=True) + with pytest.raises(RuntimeError, match="AUTH_DEV_SECRET"): + build_verifier(s) + + +def test_factory_public_key_gives_jwks(monkeypatch: pytest.MonkeyPatch, rsa_key: rsa.RSAPrivateKey) -> None: + s = _settings(monkeypatch, AUTH_PUBLIC_KEY=_pem_public(rsa_key)) + assert isinstance(build_verifier(s), JwksVerifier) + + +def test_factory_jwks_url_gives_jwks(monkeypatch: pytest.MonkeyPatch) -> None: + s = _settings(monkeypatch, AUTH_JWKS_URL="https://sso.example/.well-known/jwks.json") + assert isinstance(build_verifier(s), JwksVerifier) + + +async def test_factory_passes_issuer_audience_and_email_claim( + monkeypatch: pytest.MonkeyPatch, rsa_key: rsa.RSAPrivateKey +) -> None: + s = _settings( + monkeypatch, + AUTH_PUBLIC_KEY=_pem_public(rsa_key), + AUTH_ISSUER="https://sso.raisa", + AUTH_AUDIENCE="arm-core", + AUTH_EMAIL_CLAIM="mail", + ) + verifier = build_verifier(s) + assert verifier is not None + priv = _pem_private(rsa_key) + ok = jwt.encode(_payload(iss="https://sso.raisa", aud="arm-core", mail=EMAIL), priv, algorithm="RS256") + assert (await verifier.verify(ok)).email == EMAIL + with pytest.raises(AuthError): + await verifier.verify(jwt.encode(_payload(mail=EMAIL), priv, algorithm="RS256")) + + +async def test_dev_token_rejected_when_debug_off(monkeypatch: pytest.MonkeyPatch, rsa_key: rsa.RSAPrivateKey) -> None: + """Токен, подписанный dev-секретом, не принимается ни пустой конфигурацией, ни JWKS-проверяющим (DEBUG=false).""" + token = DevHs256Verifier(DEV_SECRET).issue_token(EMAIL) + prod = _settings( + monkeypatch, AUTH_DEV_ISSUER=True, AUTH_DEV_SECRET=DEV_SECRET, DEBUG=False, AUTH_PUBLIC_KEY=_pem_public(rsa_key) + ) + verifier = build_verifier(prod) + assert verifier is not None + with pytest.raises(AuthError): + await verifier.verify(token) -- 2.47.2 From f4699ff86abe573531cb433e7316541a3af43387 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 21:51:55 +0400 Subject: [PATCH 51/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2016?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 2650598..fdd83b6 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -81,7 +81,7 @@ ### Task 16: [4.1] `src/core/auth.py` -- [ ] `src/core/auth.py`: `Claims`, протокол `TokenVerifier`, `DevHs256Verifier`, `JwksVerifier` (PyJWT, RS256/ES256, кэш JWKS), фабрика по настройкам; проверка: unit-тесты — валидный/истёкший/чужой ключ, dev-verifier не создаётся при `AUTH_DEV_ISSUER=false` +- [x] `src/core/auth.py`: `Claims`, протокол `TokenVerifier`, `DevHs256Verifier`, `JwksVerifier` (PyJWT, RS256/ES256, кэш JWKS), фабрика по настройкам; проверка: unit-тесты — валидный/истёкший/чужой ключ, dev-verifier не создаётся при `AUTH_DEV_ISSUER=false` ### Task 17: [4.2] `src/repositories/users.py` (пользователь по email, эффективные права одним запр diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 2980e16..b2913da 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -67,7 +67,7 @@ ## 4. Аутентификация и права (spec `authn-authz`) -- [ ] 4.1 `src/core/auth.py`: `Claims`, протокол `TokenVerifier`, `DevHs256Verifier`, `JwksVerifier` (PyJWT, RS256/ES256, +- [x] 4.1 `src/core/auth.py`: `Claims`, протокол `TokenVerifier`, `DevHs256Verifier`, `JwksVerifier` (PyJWT, RS256/ES256, кэш JWKS), фабрика по настройкам; проверка: unit-тесты — валидный/истёкший/чужой ключ, dev-verifier не создаётся при `AUTH_DEV_ISSUER=false` - [ ] 4.2 `src/repositories/users.py` (пользователь по email, эффективные права одним запросом), `api/dependencies/auth.py` -- 2.47.2 From d7469f31bcb33a7fc608ef9fc54fdb20830cd8fe Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 22:01:33 +0400 Subject: [PATCH 52/97] =?UTF-8?q?chore:=20=D0=B7=D0=B0=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D1=8F=20cryptography=20=D0=B2=20requirements?= =?UTF-8?q?.txt=20(R-18c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth.py импортирует cryptography напрямую, а requirements.txt — полный freeze. Co-Authored-By: Claude Fable 5.1 --- api/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/api/requirements.txt b/api/requirements.txt index ba04acc..babec49 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -13,6 +13,7 @@ attrs==26.1.0 certifi==2025.11.12 cffi==2.1.0 click==8.4.2 +cryptography==50.0.2 dnspython==2.8.0 email-validator==2.3.0 fastapi==0.135.1 -- 2.47.2 From 5ed52a72391dbf4a2b8184f6840d1c9c291c4418 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 22:01:33 +0400 Subject: [PATCH 53/97] =?UTF-8?q?fix:=20JWKS-=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=8F=D1=8E=D1=89=D0=B8=D0=B9=20=E2=80=94=20=D0=B0?= =?UTF-8?q?=D0=BB=D0=B3=D0=BE=D1=80=D0=B8=D1=82=D0=BC=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=82=D0=B8=D0=BF=D1=83=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B0,=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=BA=D1=8D=D1=88=D0=B0=20=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B5=D0=B9=20=D0=BF=D0=BE=20kid;=20email=20=D0=B2?= =?UTF-8?q?=20=D0=BD=D0=B8=D0=B6=D0=BD=D0=B8=D0=B9=20=D1=80=D0=B5=D0=B3?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-1: в jwt.decode передаётся PyJWK (а не голый ключ), рассогласование alg и типа ключа даёт 401, а не TypeError/500; страховка на (TypeError, ValueError). I-2: cache_keys=False — кэш ключей по kid не имеет срока жизни и держал бы отозванный ключ; остаётся кэш набора JWKS на 300 с. R-17: Claims.email = strip().lower(). R-18: запредельный exp не даёт 500; пустой sub отвергается; kid из заголовка пишется в лог через repr и усекается. Co-Authored-By: Claude Fable 5.1 --- api/src/core/auth.py | 41 +++++--- api/tests/test_auth_core.py | 194 ++++++++++++++++++++++++++++++------ 2 files changed, 190 insertions(+), 45 deletions(-) diff --git a/api/src/core/auth.py b/api/src/core/auth.py index 2f3e53b..fe97015 100644 --- a/api/src/core/auth.py +++ b/api/src/core/auth.py @@ -38,9 +38,12 @@ DEV_ALGORITHM = "HS256" JWKS_LEEWAY_SECONDS = 10 # Срок жизни dev-токена (требование спеки authn-authz) DEV_TOKEN_TTL = timedelta(hours=8) -# Время жизни кэша набора ключей JWKS (PyJWKClient), секунды +# Время жизни кэша набора ключей JWKS (PyJWKClient), секунды. Кэш отдельных ключей по kid (cache_keys) выключен +# намеренно: он без срока жизни и оставил бы отозванный ключ действующим до перезапуска процесса. JWKS_CACHE_LIFESPAN_SECONDS = 300 JWKS_FETCH_TIMEOUT_SECONDS = 10 +# Предел длины подконтрольного клиенту текста (kid и т.п.) в строке лога +_LOG_TEXT_LIMIT = 200 class AuthError(Exception): @@ -75,7 +78,7 @@ class TokenVerifier(Protocol): class _SigningKeyProvider(Protocol): """Источник ключа проверки подписи по токену (интерфейс :class:`jwt.PyJWKClient`).""" - def get_signing_key_from_jwt(self, token: str) -> Any: ... + def get_signing_key_from_jwt(self, token: str) -> jwt.PyJWK: ... # Порядок важен: более конкретные классы исключений PyJWT — раньше общих @@ -99,21 +102,30 @@ def _to_auth_error(exc: jwt.PyJWTError) -> AuthError: return AuthError(f"в токене нет обязательного поля «{exc.claim}»") if isinstance(exc, jwt.PyJWKClientError): # сбой получения ключей (сеть, неизвестный kid) — деталь только в лог, не в ответ - logger.warning("Не удалось получить ключ проверки подписи JWT: {}", exc) + # сообщение может содержать kid из заголовка токена (его присылает клиент): repr экранирует переводы строк, + # усечение ограничивает размер + logger.warning("Не удалось получить ключ проверки подписи JWT: {}", repr(str(exc))[:_LOG_TEXT_LIMIT]) return AuthError("не удалось получить ключ проверки подписи токена") return AuthError("недействительный токен") def _claims_from_payload(payload: dict[str, Any], email_claim: str) -> Claims: - """Собирает :class:`Claims` из проверенной полезной нагрузки; ``exp`` и ``sub`` уже проверены на наличие.""" + """Собирает :class:`Claims` из проверенной полезной нагрузки; ``exp`` и ``sub`` уже проверены на наличие. + + Email приводится к нижнему регистру (``strip().lower()``): сопоставление с ``app_user.email`` — без учёта регистра. + """ email = payload.get(email_claim) if not isinstance(email, str) or not email.strip(): raise AuthError("в токене нет email") - return Claims( - subject=str(payload["sub"]), - email=email.strip(), - exp=datetime.fromtimestamp(payload["exp"], tz=UTC), - ) + subject = payload["sub"] + if not isinstance(subject, str) or not subject.strip(): + raise AuthError("в токене нет обязательного поля «sub»") + try: + exp = datetime.fromtimestamp(payload["exp"], tz=UTC) + except (TypeError, ValueError, OverflowError, OSError) as exc: + # нечисловой или запредельный exp, прошедший проверку подписи, не должен давать 500 + raise AuthError("недействительный токен") from exc + return Claims(subject=subject, email=email.strip().lower(), exp=exp) class DevHs256Verifier: @@ -159,7 +171,7 @@ def _load_static_key(pem: str) -> tuple[Any, tuple[str, ...]]: class JwksVerifier: - """Проверяющий токены РАИСА: RS256/ES256 по статическому публичному ключу либо по JWKS (кэш ключей). + """Проверяющий токены РАИСА: RS256/ES256 по статическому публичному ключу либо по JWKS (кэш набора ключей). ``iss`` проверяется, только если задан ``issuer``; ``aud`` — только если задан ``audience``. ``exp`` и ``sub`` обязательны всегда. ``jwk_client`` — внедряемый источник ключей (тесты); по умолчанию :class:`jwt.PyJWKClient`. @@ -189,7 +201,7 @@ class JwksVerifier: elif jwks_url: self._client = PyJWKClient( jwks_url, - cache_keys=True, + cache_keys=False, cache_jwk_set=True, lifespan=JWKS_CACHE_LIFESPAN_SECONDS, timeout=JWKS_FETCH_TIMEOUT_SECONDS, @@ -204,7 +216,9 @@ class JwksVerifier: return self._static_key, list(self._static_algorithms) # PyJWKClient ходит в сеть блокирующе — выносим из цикла событий signing_key = await asyncio.to_thread(self._client.get_signing_key_from_jwt, token) - return signing_key.key, list(JWKS_ALGORITHMS) + # Отдаём сам PyJWK, а не signing_key.key: PyJWT тогда сверяет алгоритм из заголовка токена с типом ключа + # (RSA-ключ с ES256 и наоборот — InvalidAlgorithmError, а не TypeError из недр библиотеки) + return signing_key, list(JWKS_ALGORITHMS) async def verify(self, token: str) -> Claims: try: @@ -220,6 +234,9 @@ class JwksVerifier: ) except jwt.PyJWTError as exc: raise _to_auth_error(exc) from exc + except (TypeError, ValueError) as exc: + # страховка: несовместимые ключ и алгоритм не должны превращаться в 500 + raise AuthError("недействительный токен") from exc return _claims_from_payload(payload, self._email_claim) diff --git a/api/tests/test_auth_core.py b/api/tests/test_auth_core.py index ff86646..2e98a96 100644 --- a/api/tests/test_auth_core.py +++ b/api/tests/test_auth_core.py @@ -6,6 +6,8 @@ import hmac import json import os import threading +import time +from collections.abc import Callable, Iterator from datetime import UTC, datetime, timedelta from http.server import BaseHTTPRequestHandler, HTTPServer from types import SimpleNamespace @@ -15,9 +17,11 @@ import jwt import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec, rsa -from jwt.exceptions import PyJWKClientConnectionError +from jwt import PyJWKClient +from jwt.exceptions import PyJWKClientConnectionError, PyJWKClientError from src.core.auth import ( + JWKS_CACHE_LIFESPAN_SECONDS, AuthError, Claims, DevHs256Verifier, @@ -31,6 +35,8 @@ from src.core.config import Settings DEV_SECRET = "dev-secret-for-unit-tests-0123456789abcdef" OTHER_SECRET = "another-secret-for-unit-tests-0123456789" EMAIL = "ka_rf@armka.local" +SET_CACHE_MAX_SECONDS = 300 # кэш набора ключей JWKS не дольше 5 минут +LOG_LINE_MAX = 400 class _LogSpy: @@ -209,6 +215,35 @@ def test_dev_empty_secret_forbidden() -> None: DevHs256Verifier("") +# --- Нормализация email и защита от запредельных claims (R-17, R-18) ------------------------------------------- + + +async def test_email_is_lowercased_and_stripped(dev: DevHs256Verifier) -> None: + token = jwt.encode(_payload(email=" Ka_RF@ARMKA.Local "), DEV_SECRET, algorithm="HS256") + assert (await dev.verify(token)).email == "ka_rf@armka.local" + + +async def test_email_is_lowercased_in_jwks(asym_pub_priv: tuple[str, str, str]) -> None: + alg, priv, pub = asym_pub_priv + token = jwt.encode(_payload(email="Ivan.Petrov@RSHB.ru"), priv, algorithm=alg) + assert (await JwksVerifier(public_key=pub).verify(token)).email == "ivan.petrov@rshb.ru" + + +@pytest.mark.parametrize("sub", ["", " "]) +async def test_empty_sub_rejected(dev: DevHs256Verifier, sub: str) -> None: + token = jwt.encode(_payload(sub=sub), DEV_SECRET, algorithm="HS256") + with pytest.raises(AuthError, match="sub"): + await dev.verify(token) + + +@pytest.mark.parametrize("exp", ["9999999999", 10**12, 10**30, -(10**12), 1.5e300, True, [1]]) +async def test_garbage_exp_is_auth_error_not_500(dev: DevHs256Verifier, exp: Any) -> None: + """Запредельный или нечисловой exp (даже с верной подписью) — AuthError, а не OverflowError/ValueError/TypeError.""" + token = jwt.encode(_payload(exp=exp), DEV_SECRET, algorithm="HS256") + with pytest.raises(AuthError): + await dev.verify(token) + + # --- JwksVerifier (статический публичный ключ) ------------------------------------------------------------------- @@ -230,6 +265,11 @@ def asym( ) +@pytest.fixture +def asym_pub_priv(asym: SimpleNamespace) -> tuple[str, str, str]: + return asym.alg, asym.priv, asym.pub + + async def test_jwks_static_valid(asym: SimpleNamespace) -> None: exp = datetime.now(UTC) + timedelta(hours=1) token = jwt.encode(_payload(exp=exp), asym.priv, algorithm=asym.alg) @@ -360,15 +400,27 @@ def test_jwks_rejects_both_sources(rsa_key: rsa.RSAPrivateKey) -> None: # --- JwksVerifier (JWKS: заглушка клиента и реальный PyJWKClient через локальный HTTP-сервер) -------------------- -class _FakeJwkClient: - def __init__(self, key: Any = None, error: Exception | None = None) -> None: - self._key, self._error, self.calls = key, error, 0 +def _jwk(public_key: Any, kid: str) -> dict[str, Any]: + """JWK (словарь) публичного ключа RSA или EC P-256.""" + algorithm = jwt.algorithms.RSAAlgorithm if isinstance(public_key, rsa.RSAPublicKey) else jwt.algorithms.ECAlgorithm + jwk = json.loads(algorithm.to_jwk(public_key)) + jwk.update(kid=kid, use="sig") + return jwk - def get_signing_key_from_jwt(self, token: str) -> Any: + +class _FakeJwkClient: + """Заглушка PyJWKClient: отдаёт PyJWK (как настоящий клиент), без сети.""" + + def __init__(self, key: Any = None, error: Exception | None = None) -> None: + self._jwk = jwt.PyJWK(_jwk(key, "k1")) if key is not None else None + self._error, self.calls = error, 0 + + def get_signing_key_from_jwt(self, token: str) -> jwt.PyJWK: self.calls += 1 if self._error is not None: raise self._error - return SimpleNamespace(key=self._key) + assert self._jwk is not None + return self._jwk async def test_jwks_client_valid(rsa_key: rsa.RSAPrivateKey) -> None: @@ -409,38 +461,114 @@ async def test_jwks_client_failure_is_auth_error_without_internals(log_messages: assert any("internal-host" in m for m in log_messages) # деталь — только в лог -class _JwksHandler(BaseHTTPRequestHandler): - body = b"" +class _JwksServer: + """Локальный JWKS-эндпоинт (только loopback): набор ключей можно менять между запросами.""" - def do_GET(self) -> None: - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.end_headers() - self.wfile.write(self.body) + def __init__(self, keys: list[dict[str, Any]]) -> None: + self.keys = keys + outer = self - def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - сигнатура базового класса - return + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"keys": outer.keys}).encode()) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - сигнатура базового класса + return + + self._server = HTTPServer(("127.0.0.1", 0), Handler) + self.url = f"http://127.0.0.1:{self._server.server_port}/jwks" + threading.Thread(target=self._server.serve_forever, daemon=True).start() + + def close(self) -> None: + self._server.shutdown() + self._server.server_close() -async def test_jwks_real_pyjwkclient_over_loopback(rsa_key: rsa.RSAPrivateKey) -> None: +@pytest.fixture +def jwks_server_factory() -> Iterator[Callable[[list[dict[str, Any]]], _JwksServer]]: + servers: list[_JwksServer] = [] + + def make(keys: list[dict[str, Any]]) -> _JwksServer: + servers.append(_JwksServer(keys)) + return servers[-1] + + yield make + for server in servers: + server.close() + + +def _forge(header: dict[str, Any], payload: dict[str, Any]) -> str: + """Токен с произвольным заголовком и фиктивной подписью (проверка алгоритма идёт раньше подписи).""" + body = json.dumps(payload, default=lambda v: int(v.timestamp())).encode() + return (_b64(json.dumps(header).encode()) + b"." + _b64(body) + b"." + _b64(b"x" * 64)).decode() + + +async def test_jwks_real_pyjwkclient_over_loopback( + rsa_key: rsa.RSAPrivateKey, jwks_server_factory: Callable[[list[dict[str, Any]]], _JwksServer] +) -> None: """Реальный PyJWKClient: выбор ключа по kid из JWKS, отдаваемого локальным сервером (только loopback).""" - jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(rsa_key.public_key())) - jwk.update(kid="key-1", use="sig", alg="RS256") - _JwksHandler.body = json.dumps({"keys": [jwk]}).encode() - server = HTTPServer(("127.0.0.1", 0), _JwksHandler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - url = f"http://127.0.0.1:{server.server_port}/jwks" - verifier = JwksVerifier(jwks_url=url) - good = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256", headers={"kid": "key-1"}) - assert (await verifier.verify(good)).email == EMAIL - unknown_kid = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256", headers={"kid": "nope"}) - with pytest.raises(AuthError, match="ключ"): - await verifier.verify(unknown_kid) - finally: - server.shutdown() - server.server_close() + server = jwks_server_factory([_jwk(rsa_key.public_key(), "key-1")]) + verifier = JwksVerifier(jwks_url=server.url) + good = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256", headers={"kid": "key-1"}) + assert (await verifier.verify(good)).email == EMAIL + unknown_kid = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256", headers={"kid": "nope"}) + with pytest.raises(AuthError, match="ключ"): + await verifier.verify(unknown_kid) + + +async def test_jwks_cross_typed_alg_and_kid_is_401_not_500( + rsa_key: rsa.RSAPrivateKey, + ec_key: ec.EllipticCurvePrivateKey, + jwks_server_factory: Callable[[list[dict[str, Any]]], _JwksServer], +) -> None: + """JWKS с RSA- и EC-ключом: алгоритм из заголовка не совпадает с типом ключа -> AuthError, а не TypeError.""" + server = jwks_server_factory([_jwk(rsa_key.public_key(), "r1"), _jwk(ec_key.public_key(), "e1")]) + verifier = JwksVerifier(jwks_url=server.url) + # штатные токены обоих типов проходят + assert (await verifier.verify(jwt.encode(_payload(), _pem_private(rsa_key), "RS256", headers={"kid": "r1"}))).email + assert (await verifier.verify(jwt.encode(_payload(), _pem_private(ec_key), "ES256", headers={"kid": "e1"}))).email + for alg, kid in (("ES256", "r1"), ("RS256", "e1")): + with pytest.raises(AuthError): + await verifier.verify(_forge({"alg": alg, "kid": kid, "typ": "JWT"}, _payload())) + + +async def test_jwks_revoked_key_is_rejected_after_set_cache_expiry( + rsa_key: rsa.RSAPrivateKey, + other_rsa_key: rsa.RSAPrivateKey, + jwks_server_factory: Callable[[list[dict[str, Any]]], _JwksServer], +) -> None: + """Ключ, исчезнувший из JWKS, перестаёт приниматься после истечения кэша набора (кэша отдельных ключей нет).""" + keep = _jwk(other_rsa_key.public_key(), "r2") + server = jwks_server_factory([_jwk(rsa_key.public_key(), "r1"), keep]) + verifier = JwksVerifier(jwks_url=server.url) + client = verifier._client + assert isinstance(client, PyJWKClient) + assert client.jwk_set_cache is not None + assert client.jwk_set_cache.lifespan == JWKS_CACHE_LIFESPAN_SECONDS + assert JWKS_CACHE_LIFESPAN_SECONDS <= SET_CACHE_MAX_SECONDS + token = jwt.encode(_payload(), _pem_private(rsa_key), "RS256", headers={"kid": "r1"}) + assert (await verifier.verify(token)).email == EMAIL + server.keys = [keep] # эмитент отозвал r1 + client.jwk_set_cache.lifespan = 0.05 # ускоренное «истечение» кэша набора + time.sleep(0.1) + with pytest.raises(AuthError, match="ключ"): + await verifier.verify(token) + + +async def test_jwks_kid_is_logged_escaped_and_truncated(log_messages: list[str]) -> None: + """kid приходит от клиента: в логе он экранирован (нет переводов строк) и усечён.""" + hostile = "x\nINJECTED " + "A" * 5000 + verifier = JwksVerifier( + jwk_client=_FakeJwkClient(error=PyJWKClientError(f'Unable to find a signing key that matches: "{hostile}"')) + ) + with pytest.raises(AuthError): + await verifier.verify(jwt.encode(_payload(), DEV_SECRET, algorithm="HS256")) + (message,) = log_messages + assert "\n" not in message + assert len(message) < LOG_LINE_MAX # --- Фабрика -------------------------------------------------------------------------------------------------- -- 2.47.2 From 11941caa202815cfa83b277aa76a6165b9dfe4bb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 22:14:10 +0400 Subject: [PATCH 54/97] =?UTF-8?q?feat:=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81?= =?UTF-8?q?=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8=20current=5Fuser=20=D0=B8?= =?UTF-8?q?=20require(code),=20=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=B9=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9,=20=D1=81=D1=85?= =?UTF-8?q?=D0=B5=D0=BC=D0=B0=20me?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пользователь, филиал, роли и права читаются одним запросом; роль is_active=false прав не даёт; email без учёта регистра, неоднозначность — 403 (R-17). Co-Authored-By: Claude Fable 5.1 --- api/src/api/dependencies/auth.py | 109 +++++++ api/src/repositories/users.py | 73 +++++ api/src/schemas/me.py | 29 ++ api/tests/test_auth_deps.py | 479 +++++++++++++++++++++++++++++++ 4 files changed, 690 insertions(+) create mode 100644 api/src/api/dependencies/auth.py create mode 100644 api/src/repositories/users.py create mode 100644 api/src/schemas/me.py create mode 100644 api/tests/test_auth_deps.py diff --git a/api/src/api/dependencies/auth.py b/api/src/api/dependencies/auth.py new file mode 100644 index 0000000..69ad7f1 --- /dev/null +++ b/api/src/api/dependencies/auth.py @@ -0,0 +1,109 @@ +"""Зависимости FastAPI: личность из JWT, пользователь из БД, право `require(code)` (spec authn-authz, D-5, D-10). + +Цепочка: заголовок `Authorization: Bearer` → проверяющий токены (`TokenVerifier`) → ОДИН запрос в БД +(`load_principal_data`) → :class:`Principal`. Зависимости кэшируются FastAPI в пределах запроса, поэтому любое число +`require(...)` и `current_user` на одном маршруте даёт один запрос к БД. + +Ответы: 401 — нет/невалидный токен (с `WWW-Authenticate: Bearer`); 403 — пользователь не найден, неоднозначен, +отключён либо нет права. Тело — `{"detail": "<причина по-русски>"}` (форматирует обработчик `HTTPException`). +Права проверяются только по кодам прав: код роли в логике не используется. +""" + +from collections.abc import Awaitable, Callable +from typing import Annotated + +from fastapi import Depends, Header, HTTPException, Request, status +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.auth import AuthError, Claims, TokenVerifier, build_verifier +from src.core.config import get_settings +from src.db.db import get_async_session +from src.repositories.users import AmbiguousUserError, UserWithAccess, load_principal_data + +# Текущий пользователь вместе с филиалом, ролями и эффективными правами (то, что нужно `GET /me` и `require`) +Principal = UserWithAccess + +_BEARER = "Bearer" +_UNAUTHORIZED_HEADERS = {"WWW-Authenticate": _BEARER} +_MISSING_HEADER = "требуется заголовок Authorization: Bearer <токен>" +_BEARER_PARTS = 2 + + +def _unauthorized(detail: str) -> HTTPException: + return HTTPException(status.HTTP_401_UNAUTHORIZED, detail=detail, headers=dict(_UNAUTHORIZED_HEADERS)) + + +def _forbidden(detail: str) -> HTTPException: + return HTTPException(status.HTTP_403_FORBIDDEN, detail=detail) + + +def parse_bearer(authorization: str | None) -> str: + """Токен из значения заголовка `Authorization: Bearer `; иначе 401 (схема без учёта регистра).""" + if authorization is not None: + parts = authorization.split() + if len(parts) == _BEARER_PARTS and parts[0].lower() == _BEARER.lower(): + return parts[1] + raise _unauthorized(_MISSING_HEADER) + + +def get_token_verifier(request: Request) -> TokenVerifier | None: + """Проверяющий токены приложения: строится лениво по настройкам и кэшируется в `app.state.token_verifier`. + + `None` — проверять нечем (ничего не настроено). В тестах переопределяется через `app.dependency_overrides`. + """ + state = request.app.state + if not hasattr(state, "token_verifier"): + state.token_verifier = build_verifier(get_settings()) + verifier: TokenVerifier | None = state.token_verifier + return verifier + + +async def verified_claims( + verifier: Annotated[TokenVerifier | None, Depends(get_token_verifier)], + authorization: Annotated[str | None, Header()] = None, +) -> Claims: + """Claims проверенного токена. Не обращается к БД: все 401 решаются до неё.""" + token = parse_bearer(authorization) + if verifier is None: + raise _unauthorized("аутентификация не настроена") + try: + return await verifier.verify(token) + except AuthError as error: + raise _unauthorized(str(error)) from error + + +async def current_user( + claims: Annotated[Claims, Depends(verified_claims)], + session: Annotated[AsyncSession, Depends(get_async_session)], +) -> Principal: + """Пользователь по email из токена с филиалом, ролями и эффективными правами (один запрос к БД). + + `Claims.email` уже нормализован (`strip().lower()`), сравнение в БД — по `lower(app_user.email)` (R-17). + 403: пользователя нет в АРМ; учётная запись неоднозначна (несколько email, различающихся регистром) или отключена. + """ + try: + principal = await load_principal_data(session, claims.email) + except AmbiguousUserError as error: + raise _forbidden("неоднозначная учётная запись") from error + if principal is None: + raise _forbidden("пользователь не зарегистрирован в АРМ") + if not principal.user.is_active: + raise _forbidden("учётная запись отключена") + return principal + + +def require(code: str) -> Callable[..., Awaitable[Principal]]: + """Фабрика зависимости: пропускает, только если среди эффективных прав есть `code`; иначе 403 с названием права. + + Код права не сверяется с enum на этапе создания (проверяется лишь непустота): права — данные. Роли не + используются. Возвращает :class:`Principal`, так что зависимость можно использовать и как `current_user`. + """ + if not code or not code.strip(): + raise ValueError("код права для require() не задан") + + async def check(principal: Annotated[Principal, Depends(current_user)]) -> Principal: + if code not in principal.permissions: + raise _forbidden(f"недостаточно прав: требуется {code}") + return principal + + return check diff --git a/api/src/repositories/users.py b/api/src/repositories/users.py new file mode 100644 index 0000000..5c66039 --- /dev/null +++ b/api/src/repositories/users.py @@ -0,0 +1,73 @@ +"""Репозиторий пользователей: личность и эффективные права одним запросом (spec authn-authz, D-5, R-17). + +`effective_permissions(user) = ∪ permission.code по всем активным ролям пользователя` (CLAUDE.md: права — данные). +Код роли в проверках не участвует: роли возвращаются только для показа (`GET /me`). +""" + +from dataclasses import dataclass + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased + +from src.models.access import AppUser, Permission, Role, RolePermission, UserRole +from src.models.directories import RegionalOffice + +# Достаточно двух строк, чтобы заметить неоднозначность; больше пользователей читать незачем +_AMBIGUITY_PROBE_LIMIT = 2 + + +class AmbiguousUserError(Exception): + """По email найдено больше одного пользователя (email в БД уникален с учётом регистра, сравнение — без него).""" + + +@dataclass(frozen=True) +class UserWithAccess: + """Пользователь, его филиал, коды активных ролей и эффективные права (коды). + + `roles` — отсортированный кортеж кодов активных ролей; `permissions` — объединение прав этих ролей без дубликатов. + """ + + user: AppUser + regional_office: RegionalOffice + roles: tuple[str, ...] + permissions: frozenset[str] + + +async def load_principal_data(session: AsyncSession, email: str) -> UserWithAccess | None: + """Пользователь по email (без учёта регистра) вместе с филиалом, ролями и правами — ОДИН SQL-запрос. + + Возвращает `None`, если пользователя нет; бросает :class:`AmbiguousUserError`, если подходит больше одного + (fail-closed, R-17). Неактивный пользователь возвращается как есть (решение — за вызывающим). Роль с + `is_active=false` не даёт прав и в `roles` не попадает. + """ + matched = ( + select(AppUser) + .where(func.lower(AppUser.email) == email.lower()) + .order_by(AppUser.id) + .limit(_AMBIGUITY_PROBE_LIMIT) + .subquery() + ) + user = aliased(AppUser, matched) + statement = ( + select(user, RegionalOffice, Role.code, Permission.code) + .join(RegionalOffice, RegionalOffice.id == user.regional_office_id) + .outerjoin(UserRole, UserRole.user_id == user.id) + .outerjoin(Role, and_(Role.id == UserRole.role_id, Role.is_active.is_(True))) + .outerjoin(RolePermission, RolePermission.role_id == Role.id) + .outerjoin(Permission, Permission.id == RolePermission.permission_id) + ) + rows = (await session.execute(statement)).all() + if not rows: + return None + users = {row[0].id for row in rows} + if len(users) > 1: + raise AmbiguousUserError(email) + roles = {row[2] for row in rows if row[2] is not None} + permissions = frozenset(row[3] for row in rows if row[3] is not None) + return UserWithAccess( + user=rows[0][0], + regional_office=rows[0][1], + roles=tuple(sorted(roles)), + permissions=permissions, + ) diff --git a/api/src/schemas/me.py b/api/src/schemas/me.py new file mode 100644 index 0000000..ea11a2a --- /dev/null +++ b/api/src/schemas/me.py @@ -0,0 +1,29 @@ +"""Схема ответа `GET /api/v1/me` (spec authn-authz): профиль, роли и эффективные права текущего пользователя.""" + +import uuid + +from pydantic import BaseModel, ConfigDict + +from src.models.enums import BusinessSegment + + +class RegionalOfficeOut(BaseModel): + """Филиал пользователя.""" + + id: uuid.UUID + name: str + is_head_office: bool + + +class MeResponse(BaseModel): + """Текущий пользователь. `roles` и `permissions` — коды, отсортированные по алфавиту.""" + + model_config = ConfigDict(use_enum_values=True) + + id: uuid.UUID + email: str + full_name: str + regional_office: RegionalOfficeOut + business_segment: BusinessSegment | None + roles: list[str] + permissions: list[str] diff --git a/api/tests/test_auth_deps.py b/api/tests/test_auth_deps.py new file mode 100644 index 0000000..4123d94 --- /dev/null +++ b/api/tests/test_auth_deps.py @@ -0,0 +1,479 @@ +"""Зависимости аутентификации и прав (spec authn-authz, задача 17): `current_user`, `require(code)`, 401/403. + +БД-тесты (`integration`) работают на `db_session` с seed dev-данных; мини-приложение собрано в тесте без lifespan, +верификатор токенов подменён на `DevHs256Verifier`. Быстрые тесты (без БД) проверяют разбор заголовка и то, что +401-ветки не обращаются к сессии. +""" + +import ast +import uuid +from collections.abc import AsyncIterator, Callable, Iterator +from datetime import timedelta +from pathlib import Path +from typing import Annotated, Any + +import httpx +import pytest +import yaml +from fastapi import Depends, FastAPI, HTTPException +from sqlalchemy import event, select, update +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession + +from scripts.seed import run_seed +from src.api.dependencies.auth import ( + Principal, + current_user, + get_token_verifier, + parse_bearer, + require, +) +from src.core.auth import DevHs256Verifier +from src.core.exception_handlers import register_exception_handlers +from src.db.db import get_async_session +from src.models.access import AppUser, Role, UserRole +from src.models.directories import RegionalOffice +from src.models.enums import BusinessSegment +from src.repositories.users import AmbiguousUserError, load_principal_data +from src.schemas.me import MeResponse, RegionalOfficeOut + +SECRET = "test-secret-for-auth-deps-0123456789abcdef" +OTHER_SECRET = "foreign-secret-for-auth-deps-0123456789ab" +SRC = Path(__file__).resolve().parent.parent / "src" +SEED_CORE = Path(__file__).resolve().parent.parent / "seed" / "core" + +NOT_REGISTERED = "пользователь не зарегистрирован в АРМ" +DISABLED = "учётная запись отключена" +AMBIGUOUS = "неоднозначная учётная запись" + + +class _ForbiddenSession: + """Заглушка сессии для 401-веток: любое обращение — ошибка теста.""" + + def __getattr__(self, name: str) -> Any: + raise AssertionError(f"обращение к БД при 401: session.{name}") + + +def _build_app(session_factory: Callable[[], AsyncIterator[Any]], verifier: DevHs256Verifier | None) -> FastAPI: + app = FastAPI() + register_exception_handlers(app) + + @app.get("/whoami") + async def whoami(principal: Annotated[Principal, Depends(current_user)]) -> dict[str, Any]: + return { + "email": principal.user.email, + "roles": list(principal.roles), + "permissions": sorted(principal.permissions), + "office": principal.regional_office.name, + } + + @app.get("/kfo-verify", dependencies=[Depends(require("KFO_VERIFY"))]) + async def kfo_verify() -> dict[str, bool]: + return {"ok": True} + + @app.get("/doc-edit", dependencies=[Depends(require("DOCUMENT_EDIT"))]) + async def doc_edit() -> dict[str, bool]: + return {"ok": True} + + @app.get("/both", dependencies=[Depends(require("KFO_VERIFY")), Depends(require("DOCUMENT_EDIT"))]) + async def both(principal: Annotated[Principal, Depends(current_user)]) -> dict[str, bool]: + return {"ok": True} + + app.dependency_overrides[get_async_session] = session_factory + app.dependency_overrides[get_token_verifier] = lambda: verifier + return app + + +def _client(app: FastAPI) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") + + +def _bearer(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +# --- быстрые тесты без БД --------------------------------------------------------------------------------------- + + +def test_parse_bearer_ok() -> None: + assert parse_bearer("Bearer abc.def.ghi") == "abc.def.ghi" + assert parse_bearer("bearer abc") == "abc" + + +@pytest.mark.parametrize("header", [None, "", "Bearer", "Bearer ", "Bearer ", "Basic abc", "abc.def", "Bearer a b"]) +def test_parse_bearer_rejects(header: str | None) -> None: + with pytest.raises(HTTPException) as info: + parse_bearer(header) + assert info.value.status_code == 401 # noqa: PLR2004 + assert info.value.headers == {"WWW-Authenticate": "Bearer"} + assert "Authorization: Bearer" in info.value.detail + + +def test_require_rejects_empty_code() -> None: + with pytest.raises(ValueError, match="код права"): + require("") + with pytest.raises(ValueError, match="код права"): + require(" ") + + +@pytest.fixture +def untouchable_app() -> tuple[FastAPI, list[str]]: + """Приложение с заглушкой сессии; список `touched` пополняется, если зависимость сессии вообще вызвана.""" + touched: list[str] = [] + + async def session() -> AsyncIterator[_ForbiddenSession]: + touched.append("session") + yield _ForbiddenSession() + + verifier = DevHs256Verifier(SECRET) + return _build_app(session, verifier), touched + + +async def _expect_401(app: FastAPI, headers: dict[str, str]) -> httpx.Response: + async with _client(app) as client: + response = await client.get("/whoami", headers=headers) + assert response.status_code == 401 # noqa: PLR2004 + assert response.headers["www-authenticate"] == "Bearer" + assert isinstance(response.json()["detail"], str) + return response + + +async def test_401_without_header_does_not_touch_db(untouchable_app: tuple[FastAPI, list[str]]) -> None: + app, touched = untouchable_app + response = await _expect_401(app, {}) + assert "Authorization: Bearer" in response.json()["detail"] + assert touched == [] + + +async def test_401_not_bearer_does_not_touch_db(untouchable_app: tuple[FastAPI, list[str]]) -> None: + app, touched = untouchable_app + await _expect_401(app, {"Authorization": "Basic dXNlcjpwYXNz"}) + assert touched == [] + + +async def test_401_garbage_token_does_not_touch_db(untouchable_app: tuple[FastAPI, list[str]]) -> None: + app, touched = untouchable_app + response = await _expect_401(app, _bearer("not-a-jwt")) + assert response.json() == {"detail": "некорректный токен"} + assert touched == [] + + +async def test_401_expired_token_does_not_touch_db(untouchable_app: tuple[FastAPI, list[str]]) -> None: + app, touched = untouchable_app + token = DevHs256Verifier(SECRET).issue_token("ka_go@armka.local", ttl=timedelta(minutes=-5)) + response = await _expect_401(app, _bearer(token)) + assert response.json() == {"detail": "срок действия токена истёк"} + assert touched == [] + + +async def test_401_foreign_signature_does_not_touch_db(untouchable_app: tuple[FastAPI, list[str]]) -> None: + app, touched = untouchable_app + token = DevHs256Verifier(OTHER_SECRET).issue_token("ka_go@armka.local") + response = await _expect_401(app, _bearer(token)) + assert response.json() == {"detail": "недействительная подпись токена"} + assert touched == [] + + +async def test_401_when_verifier_not_configured() -> None: + touched: list[str] = [] + + async def session() -> AsyncIterator[_ForbiddenSession]: + touched.append("session") + yield _ForbiddenSession() + + app = _build_app(session, None) + token = DevHs256Verifier(SECRET).issue_token("ka_go@armka.local") + response = await _expect_401(app, _bearer(token)) + assert response.json() == {"detail": "аутентификация не настроена"} + assert touched == [] + + +def test_get_token_verifier_builds_lazily_and_caches(monkeypatch: pytest.MonkeyPatch) -> None: + """Проверяющий строится один раз на приложение (в том числе `None`) и кладётся в `app.state.token_verifier`.""" + from types import SimpleNamespace # noqa: PLC0415 + + calls: list[object] = [] + sentinel = DevHs256Verifier(SECRET) + + def fake_build(settings: object) -> DevHs256Verifier: + calls.append(settings) + return sentinel + + monkeypatch.setattr("src.api.dependencies.auth.build_verifier", fake_build) + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace())) + assert get_token_verifier(request) is sentinel # type: ignore[arg-type] + assert get_token_verifier(request) is sentinel # type: ignore[arg-type] + assert len(calls) == 1 + assert request.app.state.token_verifier is sentinel + + monkeypatch.setattr("src.api.dependencies.auth.build_verifier", lambda settings: None) + request_none = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace())) + assert get_token_verifier(request_none) is None # type: ignore[arg-type] + assert request_none.app.state.token_verifier is None + + +# --- статическая проверка: код роли в логике не используется ------------------------------------------------------ + + +def test_role_codes_are_not_used_in_auth_logic() -> None: + """В `dependencies/auth.py` и `repositories/users.py` нет строковых констант — кодов ролей (права — данные).""" + roles = yaml.safe_load((SEED_CORE / "roles.yaml").read_text(encoding="utf-8")) + role_codes = {item["code"] for item in roles} + assert {"KA_RF", "KA_GO", "EXPERT"} <= role_codes + for relative in ("api/dependencies/auth.py", "repositories/users.py"): + tree = ast.parse((SRC / relative).read_text(encoding="utf-8")) + literals = { + node.value for node in ast.walk(tree) if isinstance(node, ast.Constant) and isinstance(node.value, str) + } + assert not literals & role_codes, f"{relative}: код роли в логике: {sorted(literals & role_codes)}" + + +# --- интеграционные тесты на seed ------------------------------------------------------------------------------- + + +@pytest.fixture +async def seeded(db_session: AsyncSession) -> AsyncSession: + await run_seed(db_session, dev=True) + await db_session.flush() + return db_session + + +@pytest.fixture +async def app(seeded: AsyncSession) -> FastAPI: + async def session() -> AsyncIterator[AsyncSession]: + yield seeded + + return _build_app(session, DevHs256Verifier(SECRET)) + + +@pytest.fixture +def token_for() -> Callable[[str], dict[str, str]]: + verifier = DevHs256Verifier(SECRET) + + def make(email: str) -> dict[str, str]: + return _bearer(verifier.issue_token(email)) + + return make + + +async def _add_user(session: AsyncSession, email: str, *, active: bool = True, roles: tuple[str, ...] = ()) -> AppUser: + office_id = (await session.execute(select(RegionalOffice.id).limit(1))).scalar_one() + user = AppUser(email=email, full_name="Тест Тестов", regional_office_id=office_id, is_active=active) + session.add(user) + await session.flush() + for code in roles: + role_id = (await session.execute(select(Role.id).where(Role.code == code))).scalar_one() + session.add(UserRole(user_id=user.id, role_id=role_id)) + await session.flush() + return user + + +def _seed_permissions(*role_codes: str) -> set[str]: + matrix = yaml.safe_load((SEED_CORE / "role_permissions.yaml").read_text(encoding="utf-8")) + return {code for role in role_codes for code in matrix[role]} + + +@pytest.mark.integration +async def test_permission_present(app: FastAPI, token_for: Callable[[str], dict[str, str]]) -> None: + """Сценарий «Право есть»: KA_GO, KFO_VERIFY.""" + async with _client(app) as client: + response = await client.get("/kfo-verify", headers=token_for("ka_go@armka.local")) + assert response.status_code == 200 # noqa: PLR2004 + assert response.json() == {"ok": True} + + +@pytest.mark.integration +async def test_permission_absent(app: FastAPI, token_for: Callable[[str], dict[str, str]]) -> None: + """Сценарий «Права нет»: EXPERT, DOCUMENT_EDIT → 403 с названием права.""" + async with _client(app) as client: + response = await client.get("/doc-edit", headers=token_for("expert@armka.local")) + assert response.status_code == 403 # noqa: PLR2004 + assert response.json() == {"detail": "недостаточно прав: требуется DOCUMENT_EDIT"} + + +@pytest.mark.integration +async def test_whoami_returns_user_office_roles_and_permissions( + app: FastAPI, token_for: Callable[[str], dict[str, str]] +) -> None: + async with _client(app) as client: + response = await client.get("/whoami", headers=token_for("ka_go@armka.local")) + assert response.status_code == 200 # noqa: PLR2004 + body = response.json() + assert body["email"] == "ka_go@armka.local" + assert body["roles"] == ["KA_GO"] + assert body["permissions"] == sorted(_seed_permissions("KA_GO")) + assert body["office"] + + +@pytest.mark.integration +async def test_two_roles_union_without_duplicates( + seeded: AsyncSession, app: FastAPI, token_for: Callable[[str], dict[str, str]] +) -> None: + """Сценарий «Две роли»: KA_RF + CURATOR_GO → объединение прав без дубликатов.""" + await _add_user(seeded, "two.roles@armka.local", roles=("KA_RF", "CURATOR_GO")) + ka_rf, curator = _seed_permissions("KA_RF"), _seed_permissions("CURATOR_GO") + assert ka_rf & curator, "тест осмыслен, только если у ролей есть общие права" + async with _client(app) as client: + response = await client.get("/whoami", headers=token_for("two.roles@armka.local")) + assert response.status_code == 200 # noqa: PLR2004 + body = response.json() + assert body["roles"] == ["CURATOR_GO", "KA_RF"] + assert body["permissions"] == sorted(ka_rf | curator) + assert len(body["permissions"]) == len(set(body["permissions"])) + + +@pytest.mark.integration +async def test_unknown_user_is_403(app: FastAPI, token_for: Callable[[str], dict[str, str]]) -> None: + async with _client(app) as client: + response = await client.get("/whoami", headers=token_for("nobody@armka.local")) + assert response.status_code == 403 # noqa: PLR2004 + assert response.json() == {"detail": NOT_REGISTERED} + + +@pytest.mark.integration +async def test_inactive_user_is_403( + seeded: AsyncSession, app: FastAPI, token_for: Callable[[str], dict[str, str]] +) -> None: + await _add_user(seeded, "off@armka.local", active=False, roles=("KA_GO",)) + async with _client(app) as client: + response = await client.get("/kfo-verify", headers=token_for("off@armka.local")) + assert response.status_code == 403 # noqa: PLR2004 + assert response.json() == {"detail": DISABLED} + + +@pytest.mark.integration +async def test_email_case_variants_are_ambiguous_403( + seeded: AsyncSession, app: FastAPI, token_for: Callable[[str], dict[str, str]] +) -> None: + """R-17: два пользователя с email, различающимися регистром → 403, а не «первый попавшийся».""" + await _add_user(seeded, "Ivan@x.local", roles=("KA_GO",)) + await _add_user(seeded, "ivan@x.local", roles=("EXPERT",)) + async with _client(app) as client: + response = await client.get("/whoami", headers=token_for("IVAN@x.local")) + assert response.status_code == 403 # noqa: PLR2004 + assert response.json() == {"detail": AMBIGUOUS} + + +@pytest.mark.integration +async def test_token_email_case_insensitive(app: FastAPI, token_for: Callable[[str], dict[str, str]]) -> None: + async with _client(app) as client: + response = await client.get("/kfo-verify", headers=token_for("KA_GO@Armka.Local")) + assert response.status_code == 200 # noqa: PLR2004 + + +@pytest.mark.integration +async def test_user_email_stored_in_mixed_case_is_found( + seeded: AsyncSession, app: FastAPI, token_for: Callable[[str], dict[str, str]] +) -> None: + await _add_user(seeded, "Mixed.Case@Armka.Local", roles=("KA_GO",)) + async with _client(app) as client: + response = await client.get("/kfo-verify", headers=token_for("mixed.case@armka.local")) + assert response.status_code == 200 # noqa: PLR2004 + + +@pytest.mark.integration +async def test_inactive_role_grants_nothing( + seeded: AsyncSession, app: FastAPI, token_for: Callable[[str], dict[str, str]] +) -> None: + await _add_user(seeded, "mix@armka.local", roles=("KA_GO", "EXPERT")) + await seeded.execute(update(Role).where(Role.code == "KA_GO").values(is_active=False)) + async with _client(app) as client: + verify = await client.get("/kfo-verify", headers=token_for("mix@armka.local")) + who = await client.get("/whoami", headers=token_for("mix@armka.local")) + assert verify.status_code == 403 # noqa: PLR2004 + assert who.json()["roles"] == ["EXPERT"] + assert who.json()["permissions"] == sorted(_seed_permissions("EXPERT")) + + +@pytest.mark.integration +async def test_user_without_roles_has_no_permissions( + seeded: AsyncSession, app: FastAPI, token_for: Callable[[str], dict[str, str]] +) -> None: + await _add_user(seeded, "norole@armka.local") + async with _client(app) as client: + who = await client.get("/whoami", headers=token_for("norole@armka.local")) + verify = await client.get("/kfo-verify", headers=token_for("norole@armka.local")) + assert who.status_code == 200 # noqa: PLR2004 + assert who.json()["roles"] == [] + assert who.json()["permissions"] == [] + assert verify.status_code == 403 # noqa: PLR2004 + + +@pytest.fixture +def sql_log(db_engine: AsyncEngine) -> Iterator[list[str]]: + """Все SQL-выражения, выполненные через движок тестовой БД с момента запроса фикстуры.""" + statements: list[str] = [] + + def record(_conn: Any, _cursor: Any, statement: str, *_rest: Any) -> None: + statements.append(statement) + + event.listen(db_engine.sync_engine, "before_cursor_execute", record) + try: + yield statements + finally: + event.remove(db_engine.sync_engine, "before_cursor_execute", record) + + +def _principal_queries(statements: list[str]) -> list[str]: + return [s for s in statements if "app_user" in s] + + +@pytest.mark.integration +async def test_db_queried_once_per_request( + app: FastAPI, token_for: Callable[[str], dict[str, str]], sql_log: list[str] +) -> None: + """Два `require` и `current_user` на одном маршруте — один запрос к пользователю и правам.""" + async with _client(app) as client: + single = await client.get("/whoami", headers=token_for("ka_go@armka.local")) + assert single.status_code == 200 # noqa: PLR2004 + queries_single = len(_principal_queries(sql_log)) + sql_log.clear() + combined = await client.get("/both", headers=token_for("ka_go@armka.local")) + assert combined.status_code == 200 # noqa: PLR2004 + assert queries_single == 1 + assert len(_principal_queries(sql_log)) == 1 + assert len(sql_log) == 1, sql_log + + +@pytest.mark.integration +async def test_both_requires_checked_on_one_route(app: FastAPI, token_for: Callable[[str], dict[str, str]]) -> None: + async with _client(app) as client: + response = await client.get("/both", headers=token_for("expert@armka.local")) + assert response.status_code == 403 # noqa: PLR2004 + assert response.json() == {"detail": "недостаточно прав: требуется KFO_VERIFY"} + + +@pytest.mark.integration +async def test_repository_load_principal_data(seeded: AsyncSession) -> None: + assert await load_principal_data(seeded, "nobody@armka.local") is None + data = await load_principal_data(seeded, "ka_go@armka.local") + assert data is not None + assert data.user.email == "ka_go@armka.local" + assert data.roles == ("KA_GO",) + assert data.permissions == frozenset(_seed_permissions("KA_GO")) + assert data.regional_office.id == data.user.regional_office_id + + +@pytest.mark.integration +async def test_repository_raises_on_ambiguity(seeded: AsyncSession) -> None: + await _add_user(seeded, "Dup@armka.local", roles=("EXPERT",)) + await _add_user(seeded, "dup@armka.local", roles=("KA_GO",)) + with pytest.raises(AmbiguousUserError): + await load_principal_data(seeded, "dup@armka.local") + + +def test_me_response_schema_serializes_enum_and_null_segment() -> None: + office = RegionalOfficeOut(id=uuid.uuid4(), name="ГО", is_head_office=True) + fields: dict[str, Any] = { + "id": uuid.uuid4(), + "email": "a@b.c", + "full_name": "А", + "regional_office": office, + "roles": ["KA_GO"], + "permissions": ["KFO_VIEW"], + } + empty = MeResponse(business_segment=None, **fields) + assert empty.model_dump(mode="json")["business_segment"] is None + segment = next(iter(BusinessSegment)) + filled = MeResponse(business_segment=segment, **fields) + assert filled.model_dump()["business_segment"] == str(segment) + assert type(filled.model_dump()["business_segment"]) is str -- 2.47.2 From aaf9490a8d1ba9e8e6b6fd0af8ebb945ccc5647d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 22:14:29 +0400 Subject: [PATCH 55/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2017?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index fdd83b6..6b116da 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -85,7 +85,7 @@ ### Task 17: [4.2] `src/repositories/users.py` (пользователь по email, эффективные права одним запр -- [ ] `src/repositories/users.py` (пользователь по email, эффективные права одним запросом), `api/dependencies/auth.py` (`current_user` → 401/403, `require(code)`), `src/schemas/me.py`; проверка: тесты на dev-пользователях seed — сценарии «Право есть», «Права нет», «Две роли» +- [x] `src/repositories/users.py` (пользователь по email, эффективные права одним запросом), `api/dependencies/auth.py` (`current_user` → 401/403, `require(code)`), `src/schemas/me.py`; проверка: тесты на dev-пользователях seed — сценарии «Право есть», «Права нет», «Две роли» ### Task 18: [4.3] `api/endpoints/me.py` (`GET /api/v1/me`), `api/endpoints/dev_auth.py` (`POST /ap diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index b2913da..f2a973f 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -70,7 +70,7 @@ - [x] 4.1 `src/core/auth.py`: `Claims`, протокол `TokenVerifier`, `DevHs256Verifier`, `JwksVerifier` (PyJWT, RS256/ES256, кэш JWKS), фабрика по настройкам; проверка: unit-тесты — валидный/истёкший/чужой ключ, dev-verifier не создаётся при `AUTH_DEV_ISSUER=false` -- [ ] 4.2 `src/repositories/users.py` (пользователь по email, эффективные права одним запросом), `api/dependencies/auth.py` +- [x] 4.2 `src/repositories/users.py` (пользователь по email, эффективные права одним запросом), `api/dependencies/auth.py` (`current_user` → 401/403, `require(code)`), `src/schemas/me.py`; проверка: тесты на dev-пользователях seed — сценарии «Право есть», «Права нет», «Две роли» - [ ] 4.3 `api/endpoints/me.py` (`GET /api/v1/me`), `api/endpoints/dev_auth.py` (`POST /api/v1/dev/token`, подключается -- 2.47.2 From 953d978cc7e6881ae84a4b88229afca41058b30d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 22:26:18 +0400 Subject: [PATCH 56/97] =?UTF-8?q?fix:=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D1=87=D0=B8=D0=BA=20422=20=E2=80=94=20=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D1=83=D0=B0=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=82=D0=B0=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=83=D1=81=D0=B0=20=D0=B8=20=D1=81=D0=B5=D1=80=D0=B8?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20ctx=20=D0=BE?= =?UTF-8?q?=D1=88=D0=B8=D0=B1=D0=BE=D0=BA=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Без jsonable_encoder ValueError из field_validator давал 500 вместо 422; HTTP_422_UNPROCESSABLE_ENTITY помечена устаревшей в starlette. Co-Authored-By: Claude Fable 5.1 --- api/src/core/exception_handlers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index f7643c2..42a585b 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -1,4 +1,5 @@ from fastapi import FastAPI, HTTPException, Request, status +from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from sqlalchemy.exc import IntegrityError, SQLAlchemyError @@ -8,8 +9,9 @@ def register_exception_handlers(app: FastAPI) -> None: @app.exception_handler(RequestValidationError) async def request_validation_exception_handler(_: Request, exc: RequestValidationError): return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - content={"detail": exc.errors()}, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + # ctx ошибки может содержать исключение (ValueError из валидатора) — сериализуем через jsonable_encoder + content={"detail": jsonable_encoder(exc.errors())}, ) @app.exception_handler(IntegrityError) -- 2.47.2 From cd64087b0d0479e5e40f441b867592e87316c7d9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 22:26:18 +0400 Subject: [PATCH 57/97] =?UTF-8?q?feat:=20dev-=D1=81=D0=B5=D0=BA=D1=80?= =?UTF-8?q?=D0=B5=D1=82=20HS256=20=D0=BA=D0=BE=D1=80=D0=BE=D1=87=D0=B5=203?= =?UTF-8?q?2=20=D0=B1=D0=B0=D0=B9=D1=82=20=D0=BE=D1=82=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B3=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20(T16.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/src/core/auth.py | 5 +++++ api/tests/test_auth_core.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/api/src/core/auth.py b/api/src/core/auth.py index fe97015..19af572 100644 --- a/api/src/core/auth.py +++ b/api/src/core/auth.py @@ -36,6 +36,8 @@ JWKS_ALGORITHMS: tuple[str, ...] = ("RS256", "ES256") DEV_ALGORITHM = "HS256" # Допуск на расхождение часов с эмитентом токена JWKS_LEEWAY_SECONDS = 10 +# Минимальная длина dev-секрета HS256 (RFC 7518 §3.2: не короче выхода SHA-256) +DEV_SECRET_MIN_BYTES = 32 # Срок жизни dev-токена (требование спеки authn-authz) DEV_TOKEN_TTL = timedelta(hours=8) # Время жизни кэша набора ключей JWKS (PyJWKClient), секунды. Кэш отдельных ключей по kid (cache_keys) выключен @@ -134,6 +136,9 @@ class DevHs256Verifier: def __init__(self, secret: str, *, email_claim: str = "email") -> None: if not secret: raise ValueError("секрет dev-эмитента не задан") + if len(secret.encode()) < DEV_SECRET_MIN_BYTES: + # HS256 по RFC 7518 §3.2 требует ключ не короче хэша (32 байта); PyJWT иначе шумит предупреждением + raise RuntimeError(f"AUTH_DEV_SECRET короче {DEV_SECRET_MIN_BYTES} байт") self._secret = secret self._email_claim = email_claim diff --git a/api/tests/test_auth_core.py b/api/tests/test_auth_core.py index 2e98a96..5df00f9 100644 --- a/api/tests/test_auth_core.py +++ b/api/tests/test_auth_core.py @@ -210,6 +210,17 @@ async def test_dev_issue_token_custom_ttl_and_claim() -> None: assert claims.exp - datetime.now(UTC) < timedelta(minutes=11) +def test_dev_short_secret_forbidden() -> None: + """T16.7: секрет короче 32 байт отвергается (RuntimeError), 32 байта — допустимы; предупреждений PyJWT нет.""" + with pytest.raises(RuntimeError, match="AUTH_DEV_SECRET короче 32 байт"): + DevHs256Verifier("x" * 31) + # «байт», а не символов: 16 кириллических символов = 32 байта в UTF-8 + DevHs256Verifier("я" * 16) + with pytest.raises(RuntimeError, match="короче 32 байт"): + DevHs256Verifier("я" * 15) + DevHs256Verifier("x" * 32) + + def test_dev_empty_secret_forbidden() -> None: with pytest.raises(ValueError, match="секрет"): DevHs256Verifier("") @@ -635,6 +646,12 @@ def test_factory_dev_wins_over_jwks_when_both_flags( assert isinstance(build_verifier(s), DevHs256Verifier) +def test_factory_dev_short_secret_is_config_error(monkeypatch: pytest.MonkeyPatch) -> None: + s = _settings(monkeypatch, AUTH_DEV_ISSUER=True, AUTH_DEV_SECRET="short-secret", DEBUG=True) + with pytest.raises(RuntimeError, match="AUTH_DEV_SECRET короче 32 байт"): + build_verifier(s) + + def test_factory_dev_flags_without_secret_is_config_error(monkeypatch: pytest.MonkeyPatch) -> None: s = _settings(monkeypatch, AUTH_DEV_ISSUER=True, DEBUG=True) with pytest.raises(RuntimeError, match="AUTH_DEV_SECRET"): -- 2.47.2 From f5a5bbf95f00ebff9f27eb7736e0160eff7eecbb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 22:26:18 +0400 Subject: [PATCH 58/97] =?UTF-8?q?feat:=20GET=20/api/v1/me=20=D0=B8=20POST?= =?UTF-8?q?=20/api/v1/dev/token,=20=D1=83=D1=81=D0=BB=D0=BE=D0=B2=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20build=5Fapi=5Frouter=20(T17.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev-эмитент подключается только при AUTH_DEV_ISSUER и DEBUG; статический тест «нет кодов ролей» расширен на src/api/** и src/services/**. Co-Authored-By: Claude Fable 5.1 --- api/src/api/endpoints/dev_auth.py | 70 ++++++ api/src/api/endpoints/me.py | 31 +++ api/src/api/router.py | 24 +- api/tests/test_auth_deps.py | 39 +++- api/tests/test_me_and_dev_token.py | 364 +++++++++++++++++++++++++++++ 5 files changed, 520 insertions(+), 8 deletions(-) create mode 100644 api/src/api/endpoints/dev_auth.py create mode 100644 api/src/api/endpoints/me.py create mode 100644 api/tests/test_me_and_dev_token.py diff --git a/api/src/api/endpoints/dev_auth.py b/api/src/api/endpoints/dev_auth.py new file mode 100644 index 0000000..e1708d6 --- /dev/null +++ b/api/src/api/endpoints/dev_auth.py @@ -0,0 +1,70 @@ +"""`POST /api/v1/dev/token` — выпуск dev-токена по email (только разработка; spec authn-authz, D-5). + +Маршрут существует лишь при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true`: роутер собирается фабрикой +:func:`build_dev_router`, которую `build_api_router` вызывает только при обоих флагах. При любом другом сочетании +маршрута нет вовсе (404 «Not Found» от самого приложения). +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field, field_validator +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.auth import DevHs256Verifier +from src.core.config import Settings +from src.db.db import get_async_session +from src.repositories.users import AmbiguousUserError, load_principal_data + +USER_NOT_FOUND = "пользователь не найден или отключён" +USER_AMBIGUOUS = "неоднозначная учётная запись" + + +class DevTokenRequest(BaseModel): + """Тело запроса: email существующего активного пользователя (регистр и пробелы по краям не важны, R-17). + + Строка, а не `EmailStr`: email-validator отвергает зарезервированный домен `.local` dev-пользователей. + """ + + email: Annotated[str, Field(min_length=1)] + + @field_validator("email") + @classmethod + def _normalize(cls, value: str) -> str: + value = value.strip().lower() + if not value: + raise ValueError("email не может быть пустым") + return value + + +class DevTokenResponse(BaseModel): + """Ответ: JWT для заголовка `Authorization: Bearer`.""" + + access_token: str + token_type: str = "bearer" # noqa: S105 - тип схемы авторизации OAuth2, не секрет + + +def build_dev_router(settings: Settings) -> APIRouter: + """Роутер dev-эмитента. Токен подписывается тем же секретом и пишет email в тот же claim, что проверяет + dev-проверяющий приложения (`build_verifier`): оба строятся из одних настроек (`AUTH_DEV_SECRET`, + `AUTH_EMAIL_CLAIM`). + """ + if not settings.AUTH_DEV_SECRET: + raise RuntimeError("AUTH_DEV_ISSUER=true и DEBUG=true, но AUTH_DEV_SECRET не задан") + issuer = DevHs256Verifier(settings.AUTH_DEV_SECRET, email_claim=settings.AUTH_EMAIL_CLAIM) + router = APIRouter(tags=["dev"]) + + @router.post("/dev/token", response_model=DevTokenResponse, summary="Dev-токен по email (только DEBUG)") + async def issue_dev_token( + body: DevTokenRequest, session: Annotated[AsyncSession, Depends(get_async_session)] + ) -> DevTokenResponse: + """HS256 JWT на 8 часов для существующего активного пользователя.""" + try: + principal = await load_principal_data(session, body.email) + except AmbiguousUserError as error: + raise HTTPException(status.HTTP_409_CONFLICT, detail=USER_AMBIGUOUS) from error + if principal is None or not principal.user.is_active: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=USER_NOT_FOUND) + return DevTokenResponse(access_token=issuer.issue_token(body.email)) + + return router diff --git a/api/src/api/endpoints/me.py b/api/src/api/endpoints/me.py new file mode 100644 index 0000000..30be0b4 --- /dev/null +++ b/api/src/api/endpoints/me.py @@ -0,0 +1,31 @@ +"""`GET /api/v1/me` — профиль, роли и эффективные права текущего пользователя (spec authn-authz). + +Единственный маршрут Среза 0 без `require(...)`: нужен любой действующий пользователь (`current_user`), права +не проверяются — фронтенд по ответу решает, что показывать. +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends + +from src.api.dependencies.auth import Principal, current_user +from src.schemas.me import MeResponse, RegionalOfficeOut + +router = APIRouter(tags=["me"]) + + +@router.get("/me", response_model=MeResponse, summary="Текущий пользователь, его роли и права") +async def get_me(principal: Annotated[Principal, Depends(current_user)]) -> MeResponse: + """Профиль, филиал, коды ролей и эффективные права (коды, по алфавиту).""" + user = principal.user + office = principal.regional_office + return MeResponse( + id=user.id, + # пользователь найден по email, поэтому он заполнен; пустая строка — лишь для типов (колонка nullable) + email=user.email or "", + full_name=user.full_name, + regional_office=RegionalOfficeOut(id=office.id, name=office.name, is_head_office=office.is_head_office), + business_segment=user.business_segment, + roles=sorted(principal.roles), + permissions=sorted(principal.permissions), + ) diff --git a/api/src/api/router.py b/api/src/api/router.py index 91129c5..ce6ef43 100644 --- a/api/src/api/router.py +++ b/api/src/api/router.py @@ -1,3 +1,25 @@ +"""Корневой роутер API `/api/v1` (R-6: эндпоинты — `src/api/endpoints/`). + +`build_api_router(settings)` собирает роутер под настройки: `/me` — всегда, dev-эмитент токенов — только при +`AUTH_DEV_ISSUER and DEBUG`. Модульный `router` собран по настройкам процесса и подключается в `src/app.py`. +""" + from fastapi import APIRouter -router = APIRouter(prefix="/api/v1") +from src.api.endpoints import me +from src.api.endpoints.dev_auth import build_dev_router +from src.core.config import Settings, get_settings + +API_PREFIX = "/api/v1" + + +def build_api_router(settings: Settings) -> APIRouter: + """Роутер `/api/v1` под настройки; dev-маршруты при любом другом сочетании флагов отсутствуют.""" + api_router = APIRouter(prefix=API_PREFIX) + api_router.include_router(me.router) + if settings.AUTH_DEV_ISSUER and settings.DEBUG: + api_router.include_router(build_dev_router(settings)) + return api_router + + +router = build_api_router(get_settings()) diff --git a/api/tests/test_auth_deps.py b/api/tests/test_auth_deps.py index 4123d94..db37f87 100644 --- a/api/tests/test_auth_deps.py +++ b/api/tests/test_auth_deps.py @@ -214,17 +214,42 @@ def test_get_token_verifier_builds_lazily_and_caches(monkeypatch: pytest.MonkeyP # --- статическая проверка: код роли в логике не используется ------------------------------------------------------ +def _non_docstring_string_constants(tree: ast.AST) -> set[str]: + """Строковые литералы модуля, кроме docstring'ов (модуля, классов, функций).""" + docstrings: set[int] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Module | ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef): + first = node.body[0] if node.body else None + if isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant): + docstrings.add(id(first.value)) + return { + node.value + for node in ast.walk(tree) + if isinstance(node, ast.Constant) and isinstance(node.value, str) and id(node) not in docstrings + } + + def test_role_codes_are_not_used_in_auth_logic() -> None: - """В `dependencies/auth.py` и `repositories/users.py` нет строковых констант — кодов ролей (права — данные).""" + """В `src/api/**` и `src/services/**` нет строковых констант — кодов ролей (права — данные; T17.4).""" roles = yaml.safe_load((SEED_CORE / "roles.yaml").read_text(encoding="utf-8")) role_codes = {item["code"] for item in roles} assert {"KA_RF", "KA_GO", "EXPERT"} <= role_codes - for relative in ("api/dependencies/auth.py", "repositories/users.py"): - tree = ast.parse((SRC / relative).read_text(encoding="utf-8")) - literals = { - node.value for node in ast.walk(tree) if isinstance(node, ast.Constant) and isinstance(node.value, str) - } - assert not literals & role_codes, f"{relative}: код роли в логике: {sorted(literals & role_codes)}" + files = [ + *sorted((SRC / "api").rglob("*.py")), + *sorted((SRC / "services").rglob("*.py")), + SRC / "repositories" / "users.py", + ] + assert SRC / "api" / "dependencies" / "auth.py" in files + assert SRC / "api" / "endpoints" / "me.py" in files + for path in files: + literals = _non_docstring_string_constants(ast.parse(path.read_text(encoding="utf-8"))) + assert not literals & role_codes, f"{path.relative_to(SRC)}: код роли в логике: {sorted(literals & role_codes)}" + + +def test_role_code_scan_detects_literal() -> None: + """Сканер видит код роли в обычной константе и пропускает docstring.""" + tree = ast.parse('"""KA_RF в docstring модуля."""\n\ndef f():\n """KA_GO в docstring."""\n return "EXPERT"\n') + assert _non_docstring_string_constants(tree) == {"EXPERT"} # --- интеграционные тесты на seed ------------------------------------------------------------------------------- diff --git a/api/tests/test_me_and_dev_token.py b/api/tests/test_me_and_dev_token.py new file mode 100644 index 0000000..e85c9b6 --- /dev/null +++ b/api/tests/test_me_and_dev_token.py @@ -0,0 +1,364 @@ +"""`GET /api/v1/me` и `POST /api/v1/dev/token` (spec authn-authz, задача 18). + +Мини-приложения собираются в тесте на `build_api_router(settings)` под разные сочетания `DEBUG` / `AUTH_DEV_ISSUER` +(без lifespan, сессия — `db_session` с откатом); отдельный тест проходит через полный `src.app.app`. +""" + +import importlib +from collections.abc import AsyncIterator +from types import SimpleNamespace +from typing import Any + +import httpx +import jwt +import pytest +from fastapi import FastAPI +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from scripts.seed import run_seed +from src.api.dependencies.auth import current_user +from src.api.router import build_api_router, router +from src.core.auth import DevHs256Verifier, build_verifier +from src.core.config import Settings, get_settings +from src.core.exception_handlers import register_exception_handlers +from src.db.db import get_async_session +from src.models.access import AppUser +from src.schemas.me import MeResponse + +HTTP_OK = 200 +HTTP_UNAUTHORIZED = 401 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +HTTP_UNPROCESSABLE = 422 +USER_NOT_FOUND = {"detail": "пользователь не найден или отключён"} + +# Матрица §10 (docs/context/05-domain-rules.md), записанная независимо от seed: коды прав по ролям. +KA_RF_PERMISSIONS = sorted( + { + "DIRECTORY_VIEW", + "DIRECTORY_EDIT", + "DOCUMENT_VIEW", + "DOCUMENT_EDIT", + "CONSOLIDATION_VIEW", + "CONSOLIDATION_EDIT", + "KFO_VIEW", + "KFO_EXPORT", + "EX_VIEW", + "EXPORT", + } +) +ADMIN_PERMISSIONS = sorted( + {"DIRECTORY_VIEW", "DIRECTORY_EDIT", "DIRECTORY_DELETE", "DIRECTORY_ADMIN", "EX_VIEW", "USERS_ADMIN", "AUDIT_VIEW"} +) +assert len(KA_RF_PERMISSIONS) == 10 # noqa: PLR2004,S101 - матрица §10 для KA_RF + + +@pytest.fixture(autouse=True) +def _quiet_auth_log(monkeypatch: pytest.MonkeyPatch) -> None: + """Предупреждение «включён dev-эмитент» из `build_verifier` в выводе тестов не нужно.""" + monkeypatch.setattr("src.core.auth.logger", SimpleNamespace(warning=lambda *args, **kwargs: None)) + + +def _settings(*, debug: bool, dev_issuer: bool) -> Settings: + """Настройки из `.env.test` с переопределением флагов (секрет dev-эмитента — из окружения теста).""" + return get_settings().model_copy(update={"DEBUG": debug, "AUTH_DEV_ISSUER": dev_issuer}) + + +def _client(app: FastAPI) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") + + +def _bearer(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _build_app(session: AsyncSession, settings: Settings) -> FastAPI: + """Мини-приложение на маршрутах `build_api_router(settings)`; проверяющий токены — по тем же настройкам.""" + app = FastAPI() + register_exception_handlers(app) + app.include_router(build_api_router(settings)) + + async def override_session() -> AsyncIterator[AsyncSession]: + yield session + + app.dependency_overrides[get_async_session] = override_session + app.state.token_verifier = build_verifier(settings) + return app + + +@pytest.fixture +async def seeded(db_session: AsyncSession) -> AsyncSession: + await run_seed(db_session, dev=True) + await db_session.flush() + return db_session + + +@pytest.fixture +async def dev_app(seeded: AsyncSession) -> FastAPI: + return _build_app(seeded, _settings(debug=True, dev_issuer=True)) + + +async def _dev_token(client: httpx.AsyncClient, email: str) -> str: + response = await client.post("/api/v1/dev/token", json={"email": email}) + assert response.status_code == HTTP_OK, response.text + return str(response.json()["access_token"]) + + +# --- GET /me ------------------------------------------------------------------------------------------------------ + + +@pytest.mark.integration +async def test_me_for_ka_rf_matches_matrix(dev_app: FastAPI, seeded: AsyncSession) -> None: + """Сценарий «Ответ для dev-пользователя»: roles = ["KA_RF"], permissions = права KA_RF по §10, отсортированы.""" + async with _client(dev_app) as client: + token = await _dev_token(client, "ka_rf@armka.local") + response = await client.get("/api/v1/me", headers=_bearer(token)) + assert response.status_code == HTTP_OK, response.text + body = response.json() + assert body["roles"] == ["KA_RF"] + assert body["permissions"] == KA_RF_PERMISSIONS + assert body["permissions"] == sorted(body["permissions"]) + user = (await seeded.execute(select(AppUser).where(AppUser.email == "ka_rf@armka.local"))).scalar_one() + assert set(body) == {"id", "email", "full_name", "regional_office", "business_segment", "roles", "permissions"} + assert body["id"] == str(user.id) + assert body["email"] == "ka_rf@armka.local" + assert body["full_name"] == user.full_name + assert set(body["regional_office"]) == {"id", "name", "is_head_office"} + assert body["regional_office"]["id"] == str(user.regional_office_id) + assert isinstance(body["regional_office"]["name"], str) + assert isinstance(body["regional_office"]["is_head_office"], bool) + expected_segment = user.business_segment.value if user.business_segment is not None else None + assert body["business_segment"] == expected_segment + # ответ укладывается в схему + MeResponse.model_validate(body) + + +@pytest.mark.integration +async def test_me_for_admin(dev_app: FastAPI) -> None: + async with _client(dev_app) as client: + token = await _dev_token(client, "admin@armka.local") + response = await client.get("/api/v1/me", headers=_bearer(token)) + assert response.status_code == HTTP_OK + body = response.json() + assert body["roles"] == ["ADMIN"] + assert body["permissions"] == ADMIN_PERMISSIONS + + +@pytest.mark.integration +async def test_me_head_office_flag(dev_app: FastAPI) -> None: + """У пользователя головного офиса `is_head_office` = true, у филиала — false (по данным seed).""" + async with _client(dev_app) as client: + flags = {} + for email in ("ka_go@armka.local", "ka_rf@armka.local"): + token = await _dev_token(client, email) + body = (await client.get("/api/v1/me", headers=_bearer(token))).json() + flags[email] = body["regional_office"]["is_head_office"] + assert flags["ka_go@armka.local"] is True + assert flags["ka_rf@armka.local"] is False + + +@pytest.mark.integration +async def test_me_accepts_token_in_any_email_case(dev_app: FastAPI) -> None: + verifier = DevHs256Verifier(get_settings().AUTH_DEV_SECRET) + async with _client(dev_app) as client: + response = await client.get("/api/v1/me", headers=_bearer(verifier.issue_token("KA_RF@Armka.Local"))) + assert response.status_code == HTTP_OK + assert response.json()["email"] == "ka_rf@armka.local" + + +@pytest.mark.integration +async def test_me_without_token_is_401(dev_app: FastAPI) -> None: + async with _client(dev_app) as client: + response = await client.get("/api/v1/me") + assert response.status_code == HTTP_UNAUTHORIZED + assert response.headers["www-authenticate"] == "Bearer" + + +@pytest.mark.integration +async def test_me_unknown_user_is_403(dev_app: FastAPI) -> None: + verifier = DevHs256Verifier(get_settings().AUTH_DEV_SECRET) + async with _client(dev_app) as client: + response = await client.get("/api/v1/me", headers=_bearer(verifier.issue_token("nobody@armka.local"))) + assert response.status_code == HTTP_FORBIDDEN + + +@pytest.mark.integration +async def test_me_user_without_roles_has_empty_lists(dev_app: FastAPI, seeded: AsyncSession) -> None: + office_id = (await seeded.execute(select(AppUser.regional_office_id).limit(1))).scalar_one() + seeded.add(AppUser(email="norole@armka.local", full_name="Без Ролей", regional_office_id=office_id)) + await seeded.flush() + verifier = DevHs256Verifier(get_settings().AUTH_DEV_SECRET) + async with _client(dev_app) as client: + response = await client.get("/api/v1/me", headers=_bearer(verifier.issue_token("norole@armka.local"))) + assert response.status_code == HTTP_OK + assert response.json()["roles"] == [] + assert response.json()["permissions"] == [] + + +def test_me_route_has_no_permission_requirement() -> None: + """`/me` — единственный маршрут Среза 0 без `require`: в зависимостях только `current_user`.""" + router = build_api_router(_settings(debug=False, dev_issuer=False)) + route = next(r for r in router.routes if getattr(r, "path", "") == "/api/v1/me") + deps = [d.call for d in route.dependant.dependencies] # type: ignore[attr-defined] + assert deps == [current_user] + + +# --- POST /dev/token ---------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_dev_token_for_admin(dev_app: FastAPI) -> None: + async with _client(dev_app) as client: + response = await client.post("/api/v1/dev/token", json={"email": "admin@armka.local"}) + assert response.status_code == HTTP_OK + body = response.json() + assert set(body) == {"access_token", "token_type"} + assert body["token_type"] == "bearer" + me = await client.get("/api/v1/me", headers=_bearer(body["access_token"])) + assert me.status_code == HTTP_OK + assert me.json()["roles"] == ["ADMIN"] + + +@pytest.mark.integration +async def test_dev_token_is_hs256_for_8_hours(dev_app: FastAPI) -> None: + async with _client(dev_app) as client: + token = await _dev_token(client, "ka_rf@armka.local") + header = jwt.get_unverified_header(token) + payload = jwt.decode(token, options={"verify_signature": False}) + assert header["alg"] == "HS256" + assert payload["email"] == "ka_rf@armka.local" + assert payload["exp"] - payload["iat"] == 8 * 3600 + + +@pytest.mark.integration +async def test_dev_token_email_case_insensitive(dev_app: FastAPI) -> None: + async with _client(dev_app) as client: + token = await _dev_token(client, " KA_RF@Armka.Local ") + me = await client.get("/api/v1/me", headers=_bearer(token)) + assert me.status_code == HTTP_OK + + +@pytest.mark.integration +async def test_dev_token_unknown_user_is_404(dev_app: FastAPI) -> None: + async with _client(dev_app) as client: + response = await client.post("/api/v1/dev/token", json={"email": "nobody@armka.local"}) + assert response.status_code == HTTP_NOT_FOUND + assert response.json() == USER_NOT_FOUND + + +@pytest.mark.integration +async def test_dev_token_inactive_user_is_404(dev_app: FastAPI, seeded: AsyncSession) -> None: + user = (await seeded.execute(select(AppUser).where(AppUser.email == "expert@armka.local"))).scalar_one() + user.is_active = False + await seeded.flush() + async with _client(dev_app) as client: + response = await client.post("/api/v1/dev/token", json={"email": "expert@armka.local"}) + assert response.status_code == HTTP_NOT_FOUND + assert response.json() == USER_NOT_FOUND + + +@pytest.mark.integration +async def test_dev_token_ambiguous_email_is_error(dev_app: FastAPI, seeded: AsyncSession) -> None: + """Два пользователя с email, различающимся регистром, — отказ (fail-closed), токен не выпускается.""" + office_id = (await seeded.execute(select(AppUser.regional_office_id).limit(1))).scalar_one() + seeded.add(AppUser(email="KA_RF@armka.local", full_name="Двойник", regional_office_id=office_id)) + await seeded.flush() + async with _client(dev_app) as client: + response = await client.post("/api/v1/dev/token", json={"email": "ka_rf@armka.local"}) + assert response.status_code == 409 # noqa: PLR2004 + assert response.json() == {"detail": "неоднозначная учётная запись"} + + +@pytest.mark.integration +@pytest.mark.parametrize( + "body", [{}, {"email": ""}, {"email": " "}, {"email": 5}, {"email": None}, {"mail": "a@b.c"}] +) +async def test_dev_token_invalid_body_is_422(dev_app: FastAPI, body: dict[str, Any]) -> None: + async with _client(dev_app) as client: + response = await client.post("/api/v1/dev/token", json=body) + assert response.status_code == HTTP_UNPROCESSABLE + + +@pytest.mark.integration +async def test_dev_token_not_json_is_422(dev_app: FastAPI) -> None: + async with _client(dev_app) as client: + response = await client.post( + "/api/v1/dev/token", content=b"not-json", headers={"content-type": "application/json"} + ) + assert response.status_code == HTTP_UNPROCESSABLE + + +# --- прод-конфигурация -------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +@pytest.mark.parametrize( + ("debug", "dev_issuer"), + [(False, False), (False, True), (True, False)], + ids=["prod", "issuer-without-debug", "debug-without-issuer"], +) +async def test_dev_token_route_absent_unless_both_flags(seeded: AsyncSession, *, debug: bool, dev_issuer: bool) -> None: + app = _build_app(seeded, _settings(debug=debug, dev_issuer=dev_issuer)) + async with _client(app) as client: + response = await client.post("/api/v1/dev/token", json={"email": "ka_rf@armka.local"}) + # маршрут именно отсутствует (а не отвечает 404 по своей логике) + assert response.json() == {"detail": "Not Found"} + assert response.status_code == HTTP_NOT_FOUND + assert "/api/v1/dev/token" not in {getattr(r, "path", None) for r in app.routes} + assert "/api/v1/me" in {getattr(r, "path", None) for r in app.routes} + + +@pytest.mark.integration +@pytest.mark.parametrize(("debug", "dev_issuer"), [(False, False), (False, True), (True, False)]) +async def test_dev_secret_token_rejected_unless_both_flags( + seeded: AsyncSession, *, debug: bool, dev_issuer: bool +) -> None: + """Токен, подписанный dev-секретом, при любом сочетании флагов, кроме «оба», → 401 (проверяющего нет/JWKS).""" + settings = _settings(debug=debug, dev_issuer=dev_issuer) + token = DevHs256Verifier(settings.AUTH_DEV_SECRET).issue_token("ka_rf@armka.local") + app = _build_app(seeded, settings) + async with _client(app) as client: + response = await client.get("/api/v1/me", headers=_bearer(token)) + assert response.status_code == HTTP_UNAUTHORIZED + + +# --- полное приложение -------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_full_app_me_dev_token_and_openapi(seeded: AsyncSession) -> None: + app_module = importlib.import_module("src.app") + full_app: FastAPI = app_module.app + + async def override_session() -> AsyncIterator[AsyncSession]: + yield seeded + + full_app.dependency_overrides[get_async_session] = override_session + # проверяющий приложения строится лениво по `.env.test`; сбрасываем возможный кэш предыдущих тестов + if hasattr(full_app.state, "token_verifier"): + del full_app.state.token_verifier + try: + async with _client(full_app) as client: + token = await _dev_token(client, "ka_rf@armka.local") + me = await client.get("/api/v1/me", headers=_bearer(token)) + health = await client.get("/healthcheck") + openapi = (await client.get("/openapi.json")).json() + finally: + full_app.dependency_overrides.pop(get_async_session, None) + if hasattr(full_app.state, "token_verifier"): + del full_app.state.token_verifier + assert me.status_code == HTTP_OK + assert me.json()["roles"] == ["KA_RF"] + assert me.json()["permissions"] == KA_RF_PERMISSIONS + assert health.status_code == HTTP_OK + assert "uptime" in health.json() + assert "/api/v1/me" in openapi["paths"] + assert "/api/v1/dev/token" in openapi["paths"] + assert "/healthcheck" in openapi["paths"] + + +def test_module_router_matches_settings() -> None: + """Модульный `router` собран по настройкам процесса (`.env.test`: DEBUG и AUTH_DEV_ISSUER включены).""" + paths = {getattr(r, "path", None) for r in router.routes} + assert {"/api/v1/me", "/api/v1/dev/token"} <= paths -- 2.47.2 From 048e09f7242afc98b9353cfbb9ee6f93e44c4e7a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 22:28:52 +0400 Subject: [PATCH 59/97] =?UTF-8?q?fix:=20=D0=BD=D0=B5=D1=81=D1=83=D1=89?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B2=D1=83=D1=8E=D1=89=D0=B8=D0=B9=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=20=D0=BF=D0=BE=D0=B4=20/ap?= =?UTF-8?q?i/v1=20=E2=80=94=20404,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=82=20SPA-=D0=B7=D0=B0=D0=B3=D0=BB=D1=83=D1=88?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При выключенном dev-эмитенте POST /api/v1/dev/token отвечал 405 от StaticFiles. Co-Authored-By: Claude Fable 5.1 --- api/src/app.py | 9 ++++++++- api/tests/test_me_and_dev_token.py | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/api/src/app.py b/api/src/app.py index f3ff93e..370099a 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -27,7 +27,7 @@ from nats.aio.client import Client as NATS from starlette.middleware.gzip import GZipMiddleware from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware -from src.api.router import router +from src.api.router import API_PREFIX, router from src.core.clients import S3Storage from src.core.config import NATSSettings, get_settings from src.core.exception_handlers import register_exception_handlers @@ -211,8 +211,15 @@ def sep_static(path: str, stat_dir: str) -> str: return "/".join(sep_path[index:]) +API_STATIC_PATH = API_PREFIX.strip("/") + + class SPAStaticFiles(StaticFiles): async def get_response(self, path: str, scope): + # Несуществующий маршрут API — честный 404 (в т.ч. для POST), а не index.html SPA и не 405 от StaticFiles: + # иначе dev-маршруты, которых при выключенных флагах нет, выглядели бы как «существующие» + if path == API_STATIC_PATH or path.startswith(API_STATIC_PATH + "/"): + raise HTTPException(status_code=HTTPStatus.NOT_FOUND) try: return await super().get_response(path, scope) except (HTTPException, StarletteHTTPException) as ex: diff --git a/api/tests/test_me_and_dev_token.py b/api/tests/test_me_and_dev_token.py index e85c9b6..b773e8e 100644 --- a/api/tests/test_me_and_dev_token.py +++ b/api/tests/test_me_and_dev_token.py @@ -362,3 +362,12 @@ def test_module_router_matches_settings() -> None: """Модульный `router` собран по настройкам процесса (`.env.test`: DEBUG и AUTH_DEV_ISSUER включены).""" paths = {getattr(r, "path", None) for r in router.routes} assert {"/api/v1/me", "/api/v1/dev/token"} <= paths + + +@pytest.mark.parametrize("method", ["GET", "POST"]) +async def test_unknown_api_path_is_404_not_spa(method: str) -> None: + """Под `/api/v1` SPA-заглушка не отвечает: выключенный dev-маршрут — 404 (не 405, не index.html).""" + app_module = importlib.import_module("src.app") + async with _client(app_module.app) as client: + response = await client.request(method, "/api/v1/dev/no-such-route", json={}) + assert response.status_code == HTTP_NOT_FOUND -- 2.47.2 From 64668085abe275e8c96712d82d75813da0499c21 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 22:29:02 +0400 Subject: [PATCH 60/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2018?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 6b116da..6c8e5d2 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -89,7 +89,7 @@ ### Task 18: [4.3] `api/endpoints/me.py` (`GET /api/v1/me`), `api/endpoints/dev_auth.py` (`POST /ap -- [ ] `api/endpoints/me.py` (`GET /api/v1/me`), `api/endpoints/dev_auth.py` (`POST /api/v1/dev/token`, подключается только при `AUTH_DEV_ISSUER and DEBUG`); проверка: тесты — ответ `/me` для `ka_rf@armka.local` равен правам §10; при `DEBUG=false` маршрут `dev/token` → 404 и dev-токен → 401 +- [x] `api/endpoints/me.py` (`GET /api/v1/me`), `api/endpoints/dev_auth.py` (`POST /api/v1/dev/token`, подключается только при `AUTH_DEV_ISSUER and DEBUG`); проверка: тесты — ответ `/me` для `ka_rf@armka.local` равен правам §10; при `DEBUG=false` маршрут `dev/token` → 404 и dev-токен → 401 ### Task 19: [4.4] `README.md` репозитория diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index f2a973f..4b6c303 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -73,7 +73,7 @@ - [x] 4.2 `src/repositories/users.py` (пользователь по email, эффективные права одним запросом), `api/dependencies/auth.py` (`current_user` → 401/403, `require(code)`), `src/schemas/me.py`; проверка: тесты на dev-пользователях seed — сценарии «Право есть», «Права нет», «Две роли» -- [ ] 4.3 `api/endpoints/me.py` (`GET /api/v1/me`), `api/endpoints/dev_auth.py` (`POST /api/v1/dev/token`, подключается +- [x] 4.3 `api/endpoints/me.py` (`GET /api/v1/me`), `api/endpoints/dev_auth.py` (`POST /api/v1/dev/token`, подключается только при `AUTH_DEV_ISSUER and DEBUG`); проверка: тесты — ответ `/me` для `ka_rf@armka.local` равен правам §10; при `DEBUG=false` маршрут `dev/token` → 404 и dev-токен → 401 - [ ] 4.4 `README.md` репозитория: замена шаблонного текста — назначение, команды (`make`), env, работа без Nexus, -- 2.47.2 From 4080a8ff18e918bccc223644378a53804d5ebe39 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 22:39:49 +0400 Subject: [PATCH 61/97] =?UTF-8?q?docs:=20README.md=20=D0=BE=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20arm-core=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Назначение, предварительные требования (Python 3.11, uv, Docker), быстрый старт до зелёного /api/v1/me (make venv/up/migrate/seed-dev, dev-токен), таблица env, команды make, тесты, проверка ORM и DBML, миграции, раскладка репозитория, работа без Nexus, безопасность dev-режима, ссылки на соседние документы. Co-Authored-By: Claude Fable 5.1 --- README.md | 408 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 285 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index 6b8cbb1..1714fb9 100644 --- a/README.md +++ b/README.md @@ -1,163 +1,325 @@ -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) [![linting: pylint](https://img.shields.io/badge/linting-pylint-yellowgreen)](https://github.com/pylint-dev/pylint) -__________________________________________________________________________________________________________ -# dkb_armka, v 0.0.1 -____ +# arm-core — ядро АРМ КА -| | Исполнитель | Заказчик | -|-----------|:----------------------------------------:|:----------------------------------------------:| -| Сотрудник | raykov-mse | raykov-mse | -| Отдел | aurora | aurora | -| Контакты | raykovmse@rshb.ru | raykovmse@rshb.ru | ------ -# Шаблон FastAPI +Ядро автоматизированного рабочего места кредитного аналитика Департамента крупного бизнеса (АРМ КА, АО «Россельхозбанк»): +справочники, файлы и документы, приём результатов распознавания, вычисления консолидации (КФО ГСК), маршрут +согласования, Единое хранилище (ЕХ), аудит и права. **Единственный писатель в PostgreSQL**: сервисы распознавания +(`tabular-parser`, `scan-gateway`) видят только NATS и S3. -## Описание +Монорепозиторий по шаблону команды «Шаблон FastAPI»: `api/` — бэкенд (FastAPI, SQLAlchemy 2 async, Alembic, +PostgreSQL 16, core NATS без JetStream), `web/` — фронтенд (React + TypeScript, rsbuild). -Данное шаблон предоставляет проект, в котором приложение состоит из API и UI. При использовании данного типа проекта, Вы можете разрабатывать, как отдельные API сервисы, так и WEB приложения с интерфейсом. Для построения WEB интерфейса используется React. Далее описаны основные иснтрументы предустановленные данным шаблоном: +Этот репозиторий — часть workspace АРМ КА. Общий контекст, модель данных, правила предметной области и контракты — +в соседних репозиториях: -### Подготовка к разработке +- [`../CLAUDE.md`](../CLAUDE.md) — инварианты и процесс работы всего workspace; +- [`../docs/`](../docs/) — контекст (`context/`), модель данных (`model/`), контракты (`contracts/`), окружение (`dev/`); +- [`CLAUDE.md`](CLAUDE.md) — соглашения этого репозитория. -Для удобной настройки проекта под разработку. Перейдите в каталог setup и выполните слудеющие команды: +Срез 0 («скелет ядра»): схема БД по DBML v5.13, справочники (seed), аутентификация и права, `GET /api/v1/me`, +`/healthcheck`. Спецификация — `openspec/changes/slice-0-skeleton/`. + +## Предварительные требования + +- **Python 3.11** (версия платформы) и **[`uv`](https://docs.astral.sh/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. Виртуальное окружение ```bash -cd setup -chmod +x setup.sh -./setup.sh +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 не попадает: ```bash -complete setup web application -To start api, cd to api directory, and use venv, start run_server.py -To start api, cd to web directory, and use command npm run start! -Happy Hacking! :) +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. Готовность — «healthy» у трёх долгоживущих +сервисов: `docker compose -f ../docs/dev/docker-compose.yml ps`. Для скриптов, которым нужно дождаться готовности: +`docker compose -f ../docs/dev/docker-compose.yml up -d --wait postgres nats minio` (голый `--wait` возвращает код 1 +из-за завершающегося `minio-init`). Подробности, смена портов, сброс — [`../docs/dev/README.md`](../docs/dev/README.md). -### Backend - -Исходный код API находится в каталоге api. После клонирования репозитория требуется создать виртуальное окружение и установить зависимости, это достигается следующими командами: +### 3. Настройки приложения ```bash -cd api -python -m venv ./.venv -cp ./../setup/pip.conf ./.venv/pip.conf -source ./.venv/bin/python -pip install -r requirements.txt +cp api/.env.example api/.env ``` -Запуск приложения осуществляется через файл: run_server.py +Значения из `api/.env.example` уже совпадают с `../docs/dev/.env.example` (стандартные порты и учётные данные compose), +менять ничего не нужно. Менять `api/.env` придётся, только если вы изменили порты или пароли в `../docs/dev/.env` +(правьте обе части). `api/.env` в git не попадает. -В качестве фреймворка ля api используется FastAPI. (Как понятно из названия шаблона) Подробнее ознакомится с возможностями Fast API вы можете в официальной документации: [Fast API документация](https://fastapi.tiangolo.com/) - -Вы можете дополнительно устанавливать библиотеки необходимые для разработки. Настоятельно не рекомендуется изменять базовые предустановки из файла requirements.txt - -Базовый шаблон предоставляет минимальный функционал по работе с данными. Далее описаны основные моменты влияющие на работу приложения. - -#### Основная структура проекта +### 4. Миграции и справочники ```bash -api -├── requirements.txt -├── run_server.py -├── src -│ ├── app.py -│ ├── __init__.py -│ ├── settings -│ │ ├── __init__.py -│ │ ├── models.py -│ │ └── settings.py -└── web +make migrate # alembic upgrade head: схема dkb, 27 enum-типов, 43 таблицы +make seed-dev # справочники продукта + dev-пользователи (по одному на каждую роль) ``` -Вы можете придерживатся любой структуры проекта, главное, чтобы у Вас присутствовал файл run_server.py. Который запускает web сервер uvicorn или gunicorn. Также обязательно Ваш сервер должен подниматся на определенном хосте и порте. Требуемый хост и порт передаются внутрь через переменные окружения SERVER_HOST, SERVER_PORT +`make seed` загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без +dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует. -```python -uvicorn.run( - app, - host=settings.server.HOST, - port=settings.server.PORT, - lifespan="on" - ) -``` - -#### Отказ от ui (react) - -Если внутри вашего приложения не требуется добавлять визуальный интерфейс, а Вам требуется исключительно разработать API, то в таком случае не стоит удалять директорию web находящуюся на уровне с директорией api. ТРебуется внести небольшие правки в конфигурацию приложения, в частности в файл: /api/src/app.py, удалив следующее содержимое. В таком случае Fast API сервис не будет обрабатывать статические файлы, от web приложения. - -```python -#Подключение собранного web приложения -app.mount("/", StaticFiles(directory="web", html=True), name="web") -``` - -### Frontend - -Исходный код UI находится в каталоге web. После клонирования репозитория требуется установить зависимости, это достигается следующими командами: +### 5. Запуск ```bash -cd web -npm install +cd api && .venv/bin/python run_server.py ``` -Запуск приложения осуществляется через файл команду: +Сервер слушает `0.0.0.0:8000` (`SERVER__HOST`, `SERVER__PORT`). Дальнейшие команды — из другого терминала. + +### 6. Проверка ```bash -npm run start +curl -s localhost:8000/healthcheck ``` -В качестве фреймворка для ui используется React. Подробнее ознакомится с возможностями React вы можете в официальной документации: [React документация](https://react.dev/reference/react) +```json +{"uptime":"2026-10-03T18:37:15.121394+00:00","version":"1.0.0","nats":"ok"} +``` -Вы можете дополнительно устанавливать библиотеки необходимые для разработки. Настоятельно не рекомендуется изменять базовые предустановки из файла package.json +`nats`: `ok` — подключён, `down` — включён (`NATS_ENABLED=true`), но связи нет, `off` — выключен. -Базовый шаблон предоставляет минимальный интерфейс по работе с данными. Далее описаны основные моменты влияющие на работу web приложения. - -#### Основная структура проекта +Dev-токен по email любого dev-пользователя и профиль текущего пользователя: ```bash -web -├── biome.json -├── .npmrc -├── dist -├── package.json -├── postcss.config.cjs -├── public -│ └── index.html -├── README.md -├── rsbuild.config.ts -├── src -│ ├── App.tsx -│ ├── config.ts -│ ├── env.d.ts -│ ├── index.css -│ └── index.tsx -├── tailwind.config.js -└── tsconfig.json +curl -s -X POST localhost:8000/api/v1/dev/token \ + -H 'Content-Type: application/json' \ + -d '{"email":"ka_rf@armka.local"}' +# {"access_token":"","token_type":"bearer"} + +curl -s localhost:8000/api/v1/me -H "Authorization: Bearer " ``` -Вы можете придерживатся любой структуры проекта, внутри src. Но на верхнем уровне находятся файлы конфигурации и сборки их требуется изменять только в крайнем случае. Для сборки приложения используется [RSBuild](https://rsbuild.dev/) - -Для создания UI настоятельно рекомендуется использовать предустановленную библиотеку компонентови стилей: - -- tailwindcss -- daisy-ui -- react-daisy-ui -- raisa-ui - -!!! Важно, если внутри web приложения Вы хотите обращатся к API данного проекта, используйте следующую конструкцию: - -```javascript - -fetch(`${Config.API_URL}/api`, { - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - method: 'GET' -}) - +```json +{ + "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" + ] +} ``` -Речь идет об вызываемом эндпоинте ${Config.API_URL}/api, Обязательно используйте конструкцию данного вида т.к. при сборке приложения статический сайт обращается относительно api и вместо -запросов вида http://localhost:8000/api должно быть так: /api +(идентификаторы в вашей БД будут другими). Токен действует 8 часов. Интерактивная документация API — `/docs` +(Swagger UI), схема — `/openapi.json`. +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`). + +### Что вы увидите в логе при запуске + +- Предупреждение «Включён dev-эмитент токенов … только для разработки» — штатно (см. «Безопасность dev-режима»). +- Трейсбэк `ClientConnectorSSLError ... localhost:9000` при проверке бакета MinIO. Клиент S3 из шаблона ходит по + `https`, а MinIO из compose слушает `http`. Приложение стартует и работает, S3 в Срезе 0 не используется + (`app.state.storage` остаётся пустым). Недоступность S3 или NATS при старте приложение не роняет: ошибка логируется, + а состояние NATS видно в `/healthcheck`. + +### Остановка и сброс + +```bash +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. + +## Конфигурация + +Настройки — pydantic-settings; читаются из переменных окружения и файла `api/.env`. Вложенные настройки +разделяются `__`. Файл окружения выбирается переменной `ENV_FILE` (по умолчанию `.env`, путь относительно текущего +каталога — `api/`; значение читается при импорте). Тесты используют `api/.env.test` (`pytest.ini`). +Полный пример — `api/.env.example`. + +| Переменная | По умолчанию | Назначение | +|---|---|---| +| `ENV_FILE` | `.env` | файл окружения (относительно `api/`) | +| `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов; при `false` пакеты РАИСА подгружаются условно | +| `SERVER__HOST`, `SERVER__PORT` | `0.0.0.0`, `8000` | адрес и порт сервера (`run_server.py`) | +| `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`, `..._PASSWORD`, `..._BUCKET` | — (обязательно) | учётные данные и бакет MinIO | +| `OPENBAO__NATS__NATS_URL` | — (обязательно) | адрес NATS (`nats://localhost:4222`) | +| `OPENBAO__NATS__NATS_SEED`, `OPENBAO__NATS__NATS_JWT` | пусто | nkeys; пусто — подключение без учётных данных (compose, тесты). Задаются оба или ни одного | +| `OPENBAO__SMB__SMB_HOST`, `SMB_PATH`, `SMB_LOGIN`, `SMB_PASSWORD` | — (обязательно) | заглушки SMB (в Срезе 0 не используется, блок обязателен) | +| `NATS_ENABLED` | `false` | подключение к NATS и обмен через `recognition_job`; без него `/healthcheck` показывает `nats=off` | +| `OUTBOX_POLL_SECONDS` | `5` | период опроса outbox | +| `ACK_TIMEOUT_SECONDS` | `30` | ожидание ack сервиса распознавания до повторной публикации | +| `OUTBOX_MAX_REPUBLISH` | `3` | число повторных публикаций до перевода в `dlq` | +| `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`](../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`) — только для разработки | +| `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.13.dbml` | +| `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate | +| `make check` | быстрая проверка без Docker: `lint` + `test` | +| `make check-full` | `check` + `dbml-check` + `test-integration`; выполняется перед PR (нужна поднятая инфраструктура) | + +`make check-full` пока не включает проверки фронта, OpenAPI и откат миграций отдельным шагом (задача 25 среза). + +### Тесты + +- **Юнит-тесты** (`make test`) не обращаются ни к БД, ни к NATS, Docker не нужен. +- **Интеграционные** (`make test-integration`) требуют PostgreSQL, тесты обмена через NATS — ещё и NATS. Рабочую БД + `armka` они не трогают: на каждую сессию pytest создаётся свежая БД `armcore_pytest_`, мигрируется `alembic` + и удаляется в конце. Параметры подключения берутся из `api/.env.test` (`pytest.ini` задаёт `ENV_FILE=.env.test`). + Сейчас этот файл указывает на временную тестовую инфраструктуру (PostgreSQL `55432`, NATS `54222`, MinIO `59000`), + а не на стандартные порты compose: чтобы гонять интеграционные тесты против `make up`, поправьте порты в + `api/.env.test` на `5432`, `4222`, `9000` (не коммитьте это изменение без согласования). +- **Реальные данные** — репозиторий `../fixtures/` (путь можно задать переменной `ARM_KA_FIXTURES`). Тесты с маркером + `fixtures` без каталога пропускаются с понятной причиной. + +### Проверка ORM и DBML + +Модель данных — `../docs/model/data-model-v5.13.dbml`. ORM обязана совпадать с ней один в один: + +```bash +make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно +``` + +Другой путь к DBML: `make dbml-check DBML=<путь относительно api/>`. + +### Миграции + +```bash +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/ миграции (versions/0001_dbml_v5_13.py — вся модель v5.13) + 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); router.py собирает /api/v1 + api/dependencies/ зависимости FastAPI (current_user, проверка прав) + core/ config, auth (JWT), clients (S3), nats_callback, logger, exception_handlers + db/ движок и сессия (схема dkb) + models/ ORM-модели, 1:1 с DBML + repositories/ доступ к данным + schemas/ pydantic-схемы запросов и ответов + services/ бизнес-логика + common/ исключения + domain/ (появится) чистые функции по §§ правил предметной области, без ORM + tests/ pytest (юнит и integration/fixtures по маркерам) +web/ фронтенд (React 18 + TypeScript, rsbuild, antd); сборка 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` в прод не + запускать. +- Порты compose по умолчанию публикуются только на `127.0.0.1` (`BIND_ADDR`); пароли `armka`/`minioadmin` — только dev. + +## Соседние документы + +- [`CLAUDE.md`](CLAUDE.md) — соглашения этого репозитория; +- [`../CLAUDE.md`](../CLAUDE.md) — workspace: инварианты, источники истины, процесс; +- [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы; +- [`../docs/context/03-architecture.md`](../docs/context/03-architecture.md) — архитектура, соглашения шаблона, NATS и S3; +- [`../docs/context/04-data-model-guide.md`](../docs/context/04-data-model-guide.md) и + [`../docs/model/data-model-v5.13.dbml`](../docs/model/data-model-v5.13.dbml) — модель данных; +- [`../docs/context/05-domain-rules.md`](../docs/context/05-domain-rules.md) — алгоритмы предметной области; +- [`../docs/context/07-open-questions.md`](../docs/context/07-open-questions.md) — открытые вопросы; +- [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов; +- [`../docs/contracts/`](../docs/contracts/) — JSON-схемы обмена с сервисами распознавания; +- `openspec/changes/slice-0-skeleton/` — спецификация и решения Среза 0. -- 2.47.2 From 0c1e1b94b21e1db50575abbe8f06a8e6d2a5c780 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 22:39:56 +0400 Subject: [PATCH 62/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2019?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 6c8e5d2..962e297 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -93,7 +93,7 @@ ### Task 19: [4.4] `README.md` репозитория -- [ ] `README.md` репозитория: замена шаблонного текста — назначение, команды (`make`), env, работа без Nexus, получение dev-токена, запуск против compose; проверка: новый разработчик проходит README до зелёного `/me` +- [x] `README.md` репозитория: замена шаблонного текста — назначение, команды (`make`), env, работа без Nexus, получение dev-токена, запуск против compose; проверка: новый разработчик проходит README до зелёного `/me` ### Task 20: [5.1] `api/contracts/` — копии трёх схем из `../docs/contracts/` + `examples/` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 4b6c303..0c44229 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -76,7 +76,7 @@ - [x] 4.3 `api/endpoints/me.py` (`GET /api/v1/me`), `api/endpoints/dev_auth.py` (`POST /api/v1/dev/token`, подключается только при `AUTH_DEV_ISSUER and DEBUG`); проверка: тесты — ответ `/me` для `ka_rf@armka.local` равен правам §10; при `DEBUG=false` маршрут `dev/token` → 404 и dev-токен → 401 -- [ ] 4.4 `README.md` репозитория: замена шаблонного текста — назначение, команды (`make`), env, работа без Nexus, +- [x] 4.4 `README.md` репозитория: замена шаблонного текста — назначение, команды (`make`), env, работа без Nexus, получение dev-токена, запуск против compose; проверка: новый разработчик проходит README до зелёного `/me` ## 5. Обмен через `recognition_job` и аудит (spec `recognition-job-exchange`, `audit-trail`) -- 2.47.2 From f6fa38f9dd1e52b2954e5afb047d41002cddfa24 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 22:47:06 +0400 Subject: [PATCH 63/97] =?UTF-8?q?docs:=20README=20=E2=80=94=20=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D0=B9=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA?= =?UTF-8?q?=20=D1=82=D1=80=D0=B5=D0=B9=D1=81=D0=B1=D1=8D=D0=BA=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=B5,?= =?UTF-8?q?=20=D0=BE=D0=B6=D0=B8=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20healthy,?= =?UTF-8?q?=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B0=20env,=20.env.test=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20migration-cycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-1: предупреждение dev-эмитента появляется при первом запросе к /me, а не при старте. I-2: добавлен трейсбэк SMB (заглушка). S3-трейсбэк помечен «до исправления в задаче 22». M-3: дождаться healthy перед make migrate. M-5: таблица env с полными именами OPENBAO__MINIO__*, __NATS__*, __SMB__*. M-2: migration-cycle и check-full читают .env.test. Co-Authored-By: Claude Fable 5.1 --- README.md | 50 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 1714fb9..0f36f1a 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,16 @@ 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. Готовность — «healthy» у трёх долгоживущих -сервисов: `docker compose -f ../docs/dev/docker-compose.yml ps`. Для скриптов, которым нужно дождаться готовности: -`docker compose -f ../docs/dev/docker-compose.yml up -d --wait postgres nats minio` (голый `--wait` возвращает код 1 -из-за завершающегося `minio-init`). Подробности, смена портов, сброс — [`../docs/dev/README.md`](../docs/dev/README.md). +`minio-init`, который создаёт бакет `arm-ka` и завершается с кодом 0. Команда возвращается сразу, а PostgreSQL становится +готовым примерно через 6 секунд: **дождитесь `healthy`** у трёх долгоживущих сервисов, прежде чем выполнять `make migrate`: + +```bash +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`](../docs/dev/README.md). ### 3. Настройки приложения @@ -149,11 +155,22 @@ Dev-пользователи (`api/seed/dev/users.yaml`, создаются `mak ### Что вы увидите в логе при запуске -- Предупреждение «Включён dev-эмитент токенов … только для разработки» — штатно (см. «Безопасность dev-режима»). -- Трейсбэк `ClientConnectorSSLError ... localhost:9000` при проверке бакета MinIO. Клиент S3 из шаблона ходит по - `https`, а MinIO из compose слушает `http`. Приложение стартует и работает, S3 в Срезе 0 не используется - (`app.state.storage` остаётся пустым). Недоступность S3 или NATS при старте приложение не роняет: ошибка логируется, - а состояние NATS видно в `/healthcheck`. +Старт занимает около секунды; в логе два трейсбэка — это ожидаемо в dev, приложение при этом работает: + +- Трейсбэк SMB: `Ошибка при регистрации сессии SMB ... Failed to connect to 'localhost:445': Connection refused`. + Настройки `OPENBAO__SMB__*` — заглушки, SMB в Срезе 0 не используется; игнорируйте. Пока на `localhost:445` ничего + не слушает, отказ приходит сразу (старт не задерживается); если порт отвечает, но молчит, ожидание ограничено + тайм-аутом `smbclient` (60 с). +- Трейсбэк S3: `ClientConnectorSSLError ... localhost:9000`. Клиент S3 из шаблона ходит по `https`, а MinIO из compose + слушает `http`. S3 в Срезе 0 не используется (`app.state.storage` остаётся пустым). Это до исправления в задаче 22 + (отдельная настройка схемы подключения к MinIO). + +Недоступность SMB, S3 или NATS при старте приложение не роняет: ошибка логируется, состояние NATS видно в +`/healthcheck`. + +Предупреждение «Включён dev-эмитент токенов … только для разработки» появляется не при старте, а при первом +запросе к защищённому эндпоинту (`/api/v1/me`): проверяющий токены создаётся лениво (штатно, см. «Безопасность +dev-режима»). ### Остановка и сброс @@ -180,10 +197,16 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб | `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`, `..._PASSWORD`, `..._BUCKET` | — (обязательно) | учётные данные и бакет MinIO | +| `OPENBAO__MINIO__MINIO_ADMIN_LOGIN` | — (обязательно) | логин MinIO | +| `OPENBAO__MINIO__MINIO_ADMIN_PASSWORD` | — (обязательно) | пароль MinIO | +| `OPENBAO__MINIO__MINIO_BUCKET` | — (обязательно) | бакет MinIO (`arm-ka`) | | `OPENBAO__NATS__NATS_URL` | — (обязательно) | адрес NATS (`nats://localhost:4222`) | -| `OPENBAO__NATS__NATS_SEED`, `OPENBAO__NATS__NATS_JWT` | пусто | nkeys; пусто — подключение без учётных данных (compose, тесты). Задаются оба или ни одного | -| `OPENBAO__SMB__SMB_HOST`, `SMB_PATH`, `SMB_LOGIN`, `SMB_PASSWORD` | — (обязательно) | заглушки SMB (в Срезе 0 не используется, блок обязателен) | +| `OPENBAO__NATS__NATS_SEED` | пусто | nkey seed; пусто — подключение без учётных данных (compose, тесты). Задаётся вместе с `NATS_JWT` или не задаётся вовсе | +| `OPENBAO__NATS__NATS_JWT` | пусто | JWT NATS; см. `NATS_SEED` | +| `OPENBAO__SMB__SMB_HOST` | — (обязательно) | заглушка SMB (в Срезе 0 не используется, блок обязателен) | +| `OPENBAO__SMB__SMB_PATH` | — (обязательно) | заглушка SMB | +| `OPENBAO__SMB__SMB_LOGIN` | — (обязательно) | заглушка SMB | +| `OPENBAO__SMB__SMB_PASSWORD` | — (обязательно) | заглушка SMB | | `NATS_ENABLED` | `false` | подключение к NATS и обмен через `recognition_job`; без него `/healthcheck` показывает `nats=off` | | `OUTBOX_POLL_SECONDS` | `5` | период опроса outbox | | `ACK_TIMEOUT_SECONDS` | `30` | ожидание ack сервиса распознавания до повторной публикации | @@ -231,7 +254,8 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб и удаляется в конце. Параметры подключения берутся из `api/.env.test` (`pytest.ini` задаёт `ENV_FILE=.env.test`). Сейчас этот файл указывает на временную тестовую инфраструктуру (PostgreSQL `55432`, NATS `54222`, MinIO `59000`), а не на стандартные порты compose: чтобы гонять интеграционные тесты против `make up`, поправьте порты в - `api/.env.test` на `5432`, `4222`, `9000` (не коммитьте это изменение без согласования). + `api/.env.test` на `5432`, `4222`, `9000` (не коммитьте это изменение без согласования). То же относится к + `make migration-cycle` и `make check-full`: они тоже читают `.env.test` через `pytest.ini`. - **Реальные данные** — репозиторий `../fixtures/` (путь можно задать переменной `ARM_KA_FIXTURES`). Тесты с маркером `fixtures` без каталога пропускаются с понятной причиной. -- 2.47.2 From 490d2568bcbf426d90bd4a8771286c4d748a37df Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 23:01:23 +0400 Subject: [PATCH 64/97] =?UTF-8?q?chore:=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B8?= =?UTF-8?q?=20=D1=81=D1=85=D0=B5=D0=BC=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=B0=20=D1=80=D0=B0=D1=81=D0=BF=D0=BE=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20v1.0=20=D0=B8=20js?= =?UTF-8?q?onschema=20=D0=B2=20dev-=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8?= =?UTF-8?q?=D0=BC=D0=BE=D1=81=D1=82=D1=8F=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/contracts/examples/request-image.json | 14 ++ api/contracts/examples/request-tabular.json | 14 ++ api/contracts/examples/result-error.json | 12 ++ .../examples/result-message-done.json | 21 ++ .../examples/result-message-error.json | 11 + .../examples/result-message-processing.json | 7 + .../examples/result-osv-minimal.json | 79 +++++++ .../examples/result-statement-minimal.json | 50 +++++ api/contracts/recognition-request.schema.json | 28 +++ .../recognition-result-message.schema.json | 70 +++++++ api/contracts/recognition-result.schema.json | 197 ++++++++++++++++++ api/requirements-dev.txt | 1 + 12 files changed, 504 insertions(+) create mode 100644 api/contracts/examples/request-image.json create mode 100644 api/contracts/examples/request-tabular.json create mode 100644 api/contracts/examples/result-error.json create mode 100644 api/contracts/examples/result-message-done.json create mode 100644 api/contracts/examples/result-message-error.json create mode 100644 api/contracts/examples/result-message-processing.json create mode 100644 api/contracts/examples/result-osv-minimal.json create mode 100644 api/contracts/examples/result-statement-minimal.json create mode 100644 api/contracts/recognition-request.schema.json create mode 100644 api/contracts/recognition-result-message.schema.json create mode 100644 api/contracts/recognition-result.schema.json diff --git a/api/contracts/examples/request-image.json b/api/contracts/examples/request-image.json new file mode 100644 index 0000000..2de9cd5 --- /dev/null +++ b/api/contracts/examples/request-image.json @@ -0,0 +1,14 @@ +{ + "schema_version": "1.0", + "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", + "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", + "pipeline": "image", + "file_type": "PDF", + "original_name": "bb_2025_q3.pdf", + "s3": { + "bucket": "arm-ka", + "key": "uploads/c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03/bb_2025_q3.pdf" + }, + "reply_deadline": "2026-10-03T12:45:00Z", + "attempt": 1 +} diff --git a/api/contracts/examples/request-tabular.json b/api/contracts/examples/request-tabular.json new file mode 100644 index 0000000..e1a26c1 --- /dev/null +++ b/api/contracts/examples/request-tabular.json @@ -0,0 +1,14 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "pipeline": "tabular", + "file_type": "XLSX", + "original_name": "osv_60_2025_q1.xlsx", + "s3": { + "bucket": "arm-ka", + "key": "uploads/a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02/3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01/osv_60_2025_q1.xlsx" + }, + "reply_deadline": "2026-10-03T12:30:00Z", + "attempt": 1 +} diff --git a/api/contracts/examples/result-error.json b/api/contracts/examples/result-error.json new file mode 100644 index 0000000..54af7cb --- /dev/null +++ b/api/contracts/examples/result-error.json @@ -0,0 +1,12 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "service": { "name": "tabular-parser", "version": "0.1.0" }, + "status": "error", + "error": { + "code": "NOT_IMPLEMENTED", + "message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet" + }, + "documents": [] +} diff --git a/api/contracts/examples/result-message-done.json b/api/contracts/examples/result-message-done.json new file mode 100644 index 0000000..6d56f17 --- /dev/null +++ b/api/contracts/examples/result-message-done.json @@ -0,0 +1,21 @@ +{ + "schema_version": "1.0", + "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", + "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", + "status": "done", + "service": { "name": "scan-gateway", "version": "0.1.0" }, + "result_s3": { + "bucket": "arm-ka", + "key": "results/c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03/result.json" + }, + "result_schema_version": "1.0", + "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", + "metrics": { + "duration_ms": 18400, + "pages": 2, + "fields_total": 4, + "fields_low_confidence": 0, + "fields_unrecognized": 0, + "warnings": [] + } +} diff --git a/api/contracts/examples/result-message-error.json b/api/contracts/examples/result-message-error.json new file mode 100644 index 0000000..f5ec77a --- /dev/null +++ b/api/contracts/examples/result-message-error.json @@ -0,0 +1,11 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "status": "error", + "service": { "name": "tabular-parser", "version": "0.1.0" }, + "error": { + "code": "NOT_IMPLEMENTED", + "message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet" + } +} diff --git a/api/contracts/examples/result-message-processing.json b/api/contracts/examples/result-message-processing.json new file mode 100644 index 0000000..2087150 --- /dev/null +++ b/api/contracts/examples/result-message-processing.json @@ -0,0 +1,7 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "status": "processing", + "service": { "name": "tabular-parser", "version": "0.1.0" } +} diff --git a/api/contracts/examples/result-osv-minimal.json b/api/contracts/examples/result-osv-minimal.json new file mode 100644 index 0000000..6b6d122 --- /dev/null +++ b/api/contracts/examples/result-osv-minimal.json @@ -0,0 +1,79 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "service": { "name": "tabular-parser", "version": "0.1.0" }, + "status": "done", + "documents": [ + { + "document_type": "OSV", + "locator": "sheet:Лист_1", + "header": { + "organization_name": "ООО Ромашка", + "organization_inn": "7700000000", + "account_code_raw": "60", + "period_text": "1 квартал 2025 г.", + "period_year": 2025, + "period_quarter": "Q1", + "is_cumulative": false, + "data_type": "BU", + "currency": "RUB", + "header_variant": 1 + }, + "group_schema": [ + { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } + ], + "columns": { + "opening_debit": "C", + "opening_credit": "D", + "turnover_debit": "E", + "turnover_credit": "F", + "closing_debit": "G", + "closing_credit": "H" + }, + "rows": [ + { + "row_number": 1, + "level": 0, + "parent_row_number": null, + "row_value": "60", + "group_type": "ACCOUNT", + "group_name": null, + "account_code_raw": "60", + "counterparty_name": null, + "counterparty_inn": null, + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50" + }, + { + "row_number": 2, + "level": 1, + "parent_row_number": 1, + "row_value": "ООО Лютик, ИНН 7711111111", + "group_type": "COUNTERPARTY", + "group_name": "Контрагенты", + "account_code_raw": "60", + "counterparty_name": "ООО Лютик", + "counterparty_inn": "7711111111", + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50" + } + ], + "checks": { + "total_row_matches_root": true, + "balance_identity_violations": [], + "parent_sum_violations": [] + } + } + ] +} diff --git a/api/contracts/examples/result-statement-minimal.json b/api/contracts/examples/result-statement-minimal.json new file mode 100644 index 0000000..484f222 --- /dev/null +++ b/api/contracts/examples/result-statement-minimal.json @@ -0,0 +1,50 @@ +{ + "schema_version": "1.0", + "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", + "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", + "service": { "name": "scan-gateway", "version": "0.1.0" }, + "status": "done", + "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", + "documents": [ + { + "document_type": "BALANCE", + "locator": "pages:1-2", + "header": { + "form_name": "Бухгалтерский баланс", + "organization_name": "ООО Ромашка", + "organization_name_confidence": 97.5, + "inn": "7700000000", + "inn_confidence": 99, + "report_date_text": "30 сентября 2025 г.", + "report_date": "2025-09-30", + "units": "тыс. руб." + }, + "columns": [ + { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, + { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + ], + "lines": [ + { + "line_code": "1100", + "line_code_confidence": 99, + "line_name": "Итого по разделу I", + "line_name_confidence": 96, + "values": [ + { "column_index": 0, "amount": "1234.50", "confidence": 95, "is_recognized": true }, + { "column_index": 1, "amount": "1100", "confidence": 93, "is_recognized": true } + ] + }, + { + "line_code": "1600", + "line_code_confidence": 99, + "line_name": "БАЛАНС (актив)", + "line_name_confidence": 98, + "values": [ + { "column_index": 0, "amount": "5678.00", "confidence": 94.5, "is_recognized": true }, + { "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true } + ] + } + ] + } + ] +} diff --git a/api/contracts/recognition-request.schema.json b/api/contracts/recognition-request.schema.json new file mode 100644 index 0000000..780573c --- /dev/null +++ b/api/contracts/recognition-request.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "arm-ka/recognition-request/v1", + "title": "Запрос распознавания файла (arm-core → tabular-parser / scan-gateway), контракт v1.0", + "description": "Тело сообщения на subject recognition.tabular.request или recognition.image.request; subject должен соответствовать полю pipeline (tabular → recognition.tabular.request, image → recognition.image.request). Заголовок Nats-Msg-Id = : (не поле тела). Один файл (stored_file) → один job.", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", + "type": "object", + "required": ["schema_version", "job_id", "file_id", "pipeline", "file_type", "original_name", "s3", "reply_deadline", "attempt"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" }, + "job_id": { "type": "string", "format": "uuid", "description": "recognition_job.id" }, + "file_id": { "type": "string", "format": "uuid", "description": "stored_file.id" }, + "pipeline": { "enum": ["tabular", "image"], "description": "конвейер распознавания; определяет subject запроса" }, + "file_type": { "enum": ["XLS", "XLSX", "XLSB", "PDF", "PNG", "JPG"] }, + "original_name": { "type": "string", "minLength": 1, "description": "имя файла, как загрузил пользователь" }, + "s3": { + "type": "object", + "description": "исходный файл в роли uploads", + "required": ["bucket", "key"], + "properties": { + "bucket": { "type": "string", "minLength": 1 }, + "key": { "type": "string", "minLength": 1 } + } + }, + "reply_deadline": { "type": "string", "format": "date-time", "description": "срок ответа, UTC" }, + "attempt": { "type": "integer", "minimum": 1, "description": "номер попытки, с 1" } + } +} diff --git a/api/contracts/recognition-result-message.schema.json b/api/contracts/recognition-result-message.schema.json new file mode 100644 index 0000000..1f20727 --- /dev/null +++ b/api/contracts/recognition-result-message.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "arm-ka/recognition-result-message/v1", + "title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0", + "description": "Тело сообщения на subject recognition.result. Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:: — для status = processing; result:: — для done, needs_check и error. Форма запроса : описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", + "type": "object", + "required": ["schema_version", "job_id", "file_id", "status", "service"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" }, + "job_id": { "type": "string", "format": "uuid", "description": "recognition_job.id" }, + "file_id": { "type": "string", "format": "uuid", "description": "stored_file.id" }, + "status": { "enum": ["processing", "done", "needs_check", "error"] }, + "service": { + "type": "object", + "required": ["name", "version"], + "properties": { + "name": { "enum": ["tabular-parser", "scan-gateway"] }, + "version": { "type": "string" } + } + }, + "result_s3": { + "type": "object", + "description": "файл результата (recognition-result): results/{file_id}/{job_id}/result.json", + "required": ["bucket", "key"], + "properties": { + "bucket": { "type": "string", "minLength": 1 }, + "key": { "type": "string", "minLength": 1 } + } + }, + "result_schema_version": { "type": "string", "pattern": "^\\d+\\.\\d+$", "description": "schema_version файла результата, MAJOR.MINOR" }, + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { "enum": ["UNSUPPORTED_FORMAT", "UNREADABLE", "TIMEOUT", "SCHEMA_MISMATCH", "NOT_IMPLEMENTED", "FILE_READ_ERROR", "INVALID_FORMAT", "INVALID_DOCUMENT", "RECOGNITION_FAILED", "UNKNOWN_ERROR"] }, + "message": { "type": "string" } + } + }, + "raw_s3_key": { "type": ["string", "null"], "description": "scan-gateway: raw/{job_id}.json — сырой ответ ИС Сканирование; в БД не регистрируется" }, + "metrics": { + "type": "object", + "properties": { + "duration_ms": { "type": "integer" }, + "pages": { "type": "integer" }, + "rows": { "type": "integer" }, + "fields_total": { "type": "integer" }, + "fields_low_confidence": { "type": "integer" }, + "fields_unrecognized": { "type": "integer" }, + "warnings": { "type": "array", "items": { "type": "string" } } + } + } + }, + "allOf": [ + { + "if": { + "properties": { "status": { "enum": ["done", "needs_check"] } }, + "required": ["status"] + }, + "then": { "required": ["result_s3", "result_schema_version"] } + }, + { + "if": { + "properties": { "status": { "const": "error" } }, + "required": ["status"] + }, + "then": { "required": ["error"] } + } + ] +} diff --git a/api/contracts/recognition-result.schema.json b/api/contracts/recognition-result.schema.json new file mode 100644 index 0000000..c5eea87 --- /dev/null +++ b/api/contracts/recognition-result.schema.json @@ -0,0 +1,197 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "arm-ka/recognition-result/v1", + "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.0", + "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", + "type": "object", + "required": ["schema_version", "job_id", "file_id", "service", "status", "documents"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" }, + "job_id": { "type": "string", "format": "uuid" }, + "file_id": { "type": "string", "format": "uuid" }, + "service": { + "type": "object", + "required": ["name", "version"], + "properties": { + "name": { "enum": ["tabular-parser", "scan-gateway"] }, + "version": { "type": "string" } + } + }, + "status": { "enum": ["done", "needs_check", "error"] }, + "error": { + "type": ["object", "null"], + "properties": { + "code": { "enum": ["UNSUPPORTED_FORMAT", "UNREADABLE", "TIMEOUT", "SCHEMA_MISMATCH", "NOT_IMPLEMENTED", "FILE_READ_ERROR", "INVALID_FORMAT", "INVALID_DOCUMENT", "RECOGNITION_FAILED", "UNKNOWN_ERROR"] }, + "message": { "type": "string" } + } + }, + "metrics": { + "type": "object", + "properties": { + "duration_ms": { "type": "integer" }, + "pages": { "type": "integer" }, + "rows": { "type": "integer" }, + "fields_total": { "type": "integer" }, + "fields_low_confidence": { "type": "integer" }, + "fields_unrecognized": { "type": "integer" }, + "warnings": { "type": "array", "items": { "type": "string" } } + } + }, + "raw_s3_key": { "type": ["string", "null"], "description": "scan-gateway: raw/{job_id}.json — сырой ответ ИС Сканирование; в БД не регистрируется" }, + "documents": { + "type": "array", + "items": { "oneOf": [ { "$ref": "#/$defs/osvDocument" }, { "$ref": "#/$defs/statementDocument" } ] } + } + }, + "if": { + "properties": { "status": { "enum": ["done", "needs_check"] } }, + "required": ["status"] + }, + "then": { + "properties": { "documents": { "minItems": 1 } } + }, + "$defs": { + "money": { "type": ["string", "null"], "pattern": "^-?\\d+(\\.\\d{1,2})?$", "description": "Decimal как строка, чтобы не терять копейки в JSON" }, + "osvDocument": { + "type": "object", + "required": ["document_type", "locator", "header", "group_schema", "rows"], + "properties": { + "document_type": { "const": "OSV" }, + "locator": { "type": "string", "examples": ["sheet:Лист_1"] }, + "header": { + "type": "object", + "required": ["organization_name", "account_code_raw", "period_text"], + "properties": { + "organization_name": { "type": "string", "description": "как в r1 файла" }, + "organization_inn": { "type": ["string", "null"] }, + "account_code_raw": { "type": "string", "examples": ["60", "58.03"] }, + "period_text": { "type": "string", "examples": ["1 квартал 2025 г.", "2024 г."] }, + "period_year": { "type": ["integer", "null"] }, + "period_quarter": { "enum": ["Q1", "Q2", "Q3", "Q4", null] }, + "is_cumulative": { "type": ["boolean", "null"], "description": "по тексту; ядро может переопределить по данным (OQ-02)" }, + "data_type": { "enum": ["BU", "NU", "SUM", "CURRENCY", "QUANTITY", null] }, + "currency": { "type": ["string", "null"], "examples": ["RUB", "руб."] }, + "header_variant": { "enum": [1, 2] } + } + }, + "group_schema": { + "type": "array", + "description": "порядок группировок из шапки, после первого ACCOUNT", + "items": { + "type": "object", + "required": ["type"], + "properties": { + "type": { "enum": ["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"] }, + "name": { "type": "string", "description": "исходный текст из шапки" }, + "has_inn": { "type": "boolean" } + } + } + }, + "columns": { + "type": "object", + "description": "диагностика: какие колонки листа были приняты за шесть сумм и ИНН", + "additionalProperties": { "type": "string" } + }, + "rows": { + "type": "array", + "items": { + "type": "object", + "required": ["row_number", "level", "row_value", "group_type"], + "properties": { + "row_number": { "type": "integer" }, + "level": { "type": "integer" }, + "parent_row_number": { "type": ["integer", "null"] }, + "row_value": { "type": "string" }, + "group_type": { "enum": ["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER", "TOTAL"] }, + "group_name": { "type": ["string", "null"] }, + "account_code_raw": { "type": ["string", "null"], "description": "код текущей ветки, как в файле (60.1)" }, + "counterparty_name": { "type": ["string", "null"] }, + "counterparty_inn": { "type": ["string", "null"] }, + "data_type": { "enum": ["BU", "NU", "SUM", "CURRENCY", "QUANTITY", null] }, + "opening_debit": { "$ref": "#/$defs/money" }, + "opening_credit": { "$ref": "#/$defs/money" }, + "turnover_debit": { "$ref": "#/$defs/money" }, + "turnover_credit": { "$ref": "#/$defs/money" }, + "closing_debit": { "$ref": "#/$defs/money" }, + "closing_credit": { "$ref": "#/$defs/money" } + } + } + }, + "checks": { + "type": "object", + "properties": { + "total_row_matches_root": { "type": "boolean" }, + "balance_identity_violations": { "type": "array", "items": { "type": "integer" }, "description": "row_number строк, где opening−turnover≠closing" }, + "parent_sum_violations": { "type": "array", "items": { "type": "integer" } } + } + } + } + }, + "statementDocument": { + "type": "object", + "required": ["document_type", "locator", "header", "columns", "lines"], + "properties": { + "document_type": { "enum": ["BALANCE", "INCOME", "OTHER"] }, + "locator": { "type": "string", "examples": ["pages:1-2"] }, + "header": { + "type": "object", + "properties": { + "form_name": { "type": "string" }, + "organization_name": { "type": ["string", "null"] }, + "organization_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "inn": { "type": ["string", "null"] }, + "inn_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "report_date_text": { "type": ["string", "null"] }, + "report_date": { "type": ["string", "null"], "format": "date" }, + "units": { "type": ["string", "null"], "examples": ["тыс. руб."] }, + "okud": { "type": ["string", "null"] }, + "okpo": { "type": ["string", "null"] }, + "okopf": { "type": ["string", "null"] }, + "okfs": { "type": ["string", "null"] }, + "address": { "type": ["string", "null"] } + } + }, + "columns": { + "type": "array", + "items": { + "type": "object", + "required": ["column_index", "label"], + "properties": { + "column_index": { "type": "integer" }, + "label": { "type": "string", "examples": ["На 30 сентября 2025 г."] }, + "period_date": { "type": ["string", "null"], "format": "date" } + } + } + }, + "lines": { + "type": "array", + "items": { + "type": "object", + "required": ["line_code", "line_name", "values"], + "properties": { + "line_code": { "type": "string" }, + "line_code_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "line_name": { "type": "string" }, + "line_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "values": { + "type": "array", + "items": { + "type": "object", + "required": ["column_index"], + "properties": { + "column_index": { "type": "integer" }, + "amount": { "$ref": "#/$defs/money" }, + "string_value": { "type": ["string", "null"] }, + "confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "is_recognized": { "type": "boolean" } + } + } + } + } + } + } + } + } + } +} diff --git a/api/requirements-dev.txt b/api/requirements-dev.txt index 4b595ce..f067282 100644 --- a/api/requirements-dev.txt +++ b/api/requirements-dev.txt @@ -9,4 +9,5 @@ pytest-env>=1.1 pytest-cov>=6.0 PyJWT[crypto]>=2.9 pydbml>=1.1 +jsonschema>=4.23 types-PyYAML>=6.0 -- 2.47.2 From c4afe4bc0bf436b29ee28adf7f075701c0733255 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 23:01:23 +0400 Subject: [PATCH 65/97] =?UTF-8?q?feat:=20pydantic-=D0=BC=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=B0=20=D1=80=D0=B0=D1=81=D0=BF=D0=BE=D0=B7=D0=BD=D0=B0=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20v1.0=20=D0=B8=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8B=20=D1=81=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20=D1=81=D0=BE=20?= =?UTF-8?q?=D1=81=D1=85=D0=B5=D0=BC=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/src/contract/__init__.py | 63 ++++ api/src/contract/common.py | 212 +++++++++++++ api/src/contract/message.py | 63 ++++ api/src/contract/request.py | 27 ++ api/src/contract/result.py | 187 ++++++++++++ api/src/contract/subjects.py | 45 +++ api/tests/test_contract.py | 562 +++++++++++++++++++++++++++++++++++ 7 files changed, 1159 insertions(+) create mode 100644 api/src/contract/__init__.py create mode 100644 api/src/contract/common.py create mode 100644 api/src/contract/message.py create mode 100644 api/src/contract/request.py create mode 100644 api/src/contract/result.py create mode 100644 api/src/contract/subjects.py create mode 100644 api/tests/test_contract.py diff --git a/api/src/contract/__init__.py b/api/src/contract/__init__.py new file mode 100644 index 0000000..79922fd --- /dev/null +++ b/api/src/contract/__init__.py @@ -0,0 +1,63 @@ +"""Контракт обмена с сервисами распознавания v1.0 (схемы — `api/contracts/`, копии `docs/contracts/`). + +Публичный API: pydantic-модели запроса, конверта и файла результата, subject'ы NATS, идентификаторы сообщений. +""" + +from src.contract.common import ( + CONTRACT_VERSION, + ContractModel, + ErrorCode, + Metrics, + S3Ref, + ServiceInfo, + ServiceName, +) +from src.contract.message import MessageError, MessageStatus, RecognitionResultMessage +from src.contract.request import FileType, RecognitionRequest +from src.contract.result import ( + OsvDocument, + RecognitionResult, + ResultStatus, + StatementDocument, +) +from src.contract.subjects import ( + IMAGE_REQUEST_SUBJECT, + NATS_MSG_ID_HEADER, + REQUEST_SUBJECTS, + RESULT_SUBJECT, + TABULAR_REQUEST_SUBJECT, + Pipeline, + ack_msg_id, + request_msg_id, + request_subject, + result_msg_id, +) + +__all__ = [ + "CONTRACT_VERSION", + "IMAGE_REQUEST_SUBJECT", + "NATS_MSG_ID_HEADER", + "REQUEST_SUBJECTS", + "RESULT_SUBJECT", + "TABULAR_REQUEST_SUBJECT", + "ContractModel", + "ErrorCode", + "FileType", + "MessageError", + "MessageStatus", + "Metrics", + "OsvDocument", + "Pipeline", + "RecognitionRequest", + "RecognitionResult", + "RecognitionResultMessage", + "ResultStatus", + "S3Ref", + "ServiceInfo", + "ServiceName", + "StatementDocument", + "ack_msg_id", + "request_msg_id", + "request_subject", + "result_msg_id", +] diff --git a/api/src/contract/common.py b/api/src/contract/common.py new file mode 100644 index 0000000..6d14baf --- /dev/null +++ b/api/src/contract/common.py @@ -0,0 +1,212 @@ +"""Общие основы моделей контракта v1.0: базовый класс, версия, типы UUID / деньги / время, метрики и ошибки. + +Модели повторяют JSON Schema (`api/contracts/`) буквально: схемы не содержат `additionalProperties: false` +(кроме значений `columns` ОСВ), поэтому незнакомые поля принимаются и отбрасываются (`extra="ignore"`) — +так обратно совместимый MINOR-релиз (необязательные поля) не ломает потребителя. Типы строгие (`strict=True`): +JSON Schema не приводит `"1"` к числу, а число — к строке; поля со строковой записью (uuid, date-time, date, деньги) +разбирают строки явно. +""" + +import re +import uuid +from datetime import UTC, date, datetime +from decimal import Decimal +from enum import StrEnum +from typing import Annotated, Any, Literal, TypeVar + +from pydantic import ( + AfterValidator, + AwareDatetime, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + PlainSerializer, + PlainValidator, + WithJsonSchema, + model_validator, +) + +CONTRACT_VERSION = "1.0" + +# Шаблоны версий — буквально из схем +SCHEMA_VERSION_PATTERN = r"^1\.\d+$" # свой MAJOR, любой MINOR +RESULT_SCHEMA_VERSION_PATTERN = r"^\d+\.\d+$" # recognition-result-message.result_schema_version + +T = TypeVar("T") + + +class _NotNull: + """Метка поля, необязательного в схеме, но не допускающего явный `null` (в схеме тип без `null`).""" + + +NOT_NULL = _NotNull() +# Необязательное поле без `null`: отсутствует (None в модели) или значение типа T; явный null во входе — ошибка +OptionalNotNull = Annotated[T | None, NOT_NULL] + + +class ContractModel(BaseModel): + """База моделей контракта: неизменяемые, строгие, незнакомые поля отбрасываются.""" + + model_config = ConfigDict(extra="ignore", strict=True, frozen=True) + + @model_validator(mode="before") + @classmethod + def _reject_explicit_null(cls, data: Any) -> Any: + """Схема не допускает `null`, где тип без `null`: `{"result_s3": null}` — ошибка, а не «нет поля».""" + if isinstance(data, dict): + for name, field in cls.model_fields.items(): + key = field.alias or name + if NOT_NULL in field.metadata and key in data and data[key] is None: + raise ValueError(f"поле {key!r} не допускает null (в схеме контракта нет null в типе)") + return data + + def to_wire(self) -> dict[str, Any]: + """Тело для NATS / файла S3: JSON-словарь только из заданных полей (uuid, время, деньги — строки).""" + return self.model_dump(mode="json", exclude_unset=True) + + +# --- UUID ----------------------------------------------------------------------------------------------------------- + +_UUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", re.ASCII) + + +def _require_canonical_uuid(value: object) -> object: + """Строка канонического вида 8-4-4-4-12 (как `format: uuid` схемы) либо готовый `uuid.UUID`.""" + if isinstance(value, uuid.UUID) or (isinstance(value, str) and _UUID_RE.fullmatch(value)): + return value + raise ValueError("ожидается uuid строкой вида xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx") + + +ContractUUID = Annotated[uuid.UUID, BeforeValidator(_require_canonical_uuid), Field(strict=False)] + +# --- время ---------------------------------------------------------------------------------------------------------- + +_DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}", re.ASCII) + + +def _require_text_or_datetime(value: object) -> object: + if isinstance(value, str | datetime): + return value + raise ValueError("ожидается строка date-time (RFC 3339) с часовым поясом") + + +def _to_utc(value: datetime) -> datetime: + return value.astimezone(UTC) + + +# `reply_deadline`: срок ответа, UTC. Часовой пояс обязателен (aware); смещение приводится к UTC +UtcDatetime = Annotated[ + AwareDatetime, + BeforeValidator(_require_text_or_datetime), + AfterValidator(_to_utc), + Field(strict=False), +] + + +def _require_iso_date(value: object) -> object: + if isinstance(value, date) and not isinstance(value, datetime): + return value + if isinstance(value, str) and _DATE_RE.fullmatch(value): + return value + raise ValueError("ожидается дата строкой YYYY-MM-DD") + + +ContractDate = Annotated[date, BeforeValidator(_require_iso_date), Field(strict=False)] + +# --- деньги --------------------------------------------------------------------------------------------------------- + +MONEY_PATTERN = r"^-?\d+(\.\d{1,2})?$" # буквально из `$defs.money` +_MONEY_RE = re.compile(r"-?\d+(\.\d{1,2})?", re.ASCII) + + +def _parse_money(value: object) -> Decimal | None: + """Сумма — строка по шаблону схемы (копейки не теряются) или `null`; `float` / `int` — ошибка.""" + if value is None: + return None + if isinstance(value, Decimal): + text = format(value, "f") if value.is_finite() else "" + elif isinstance(value, str): + text = value + else: + raise ValueError( + "сумма должна быть строкой вида '106311116.46' (float и int недопустимы, чтобы не терять копейки)" + ) + if not _MONEY_RE.fullmatch(text): + raise ValueError(f"сумма {text!r} не соответствует шаблону {MONEY_PATTERN} (до двух знаков после точки)") + return Decimal(text) + + +def _dump_money(value: Decimal | None) -> str | None: + return None if value is None else format(value, "f") + + +# В модели — `Decimal`; в JSON — строка ровно в той записи, как пришла («1100», «-12.30») +Money = Annotated[ + Decimal | None, + PlainValidator(_parse_money), + PlainSerializer(_dump_money, return_type=str | None, when_used="json"), + WithJsonSchema({"type": ["string", "null"], "pattern": MONEY_PATTERN}), +] + +# --- скаляры -------------------------------------------------------------------------------------------------------- + +NonEmptyStr = Annotated[str, Field(min_length=1)] +SchemaVersion = Annotated[str, Field(pattern=SCHEMA_VERSION_PATTERN)] +ResultSchemaVersion = Annotated[str, Field(pattern=RESULT_SCHEMA_VERSION_PATTERN)] +# Уверенность распознавания, проценты (число 0–100) +Confidence = Annotated[float, Field(ge=0, le=100)] | None + +# --- общие объекты -------------------------------------------------------------------------------------------------- + + +class ServiceName(StrEnum): + TABULAR_PARSER = "tabular-parser" + SCAN_GATEWAY = "scan-gateway" + + +class ErrorCode(StrEnum): + UNSUPPORTED_FORMAT = "UNSUPPORTED_FORMAT" + UNREADABLE = "UNREADABLE" + TIMEOUT = "TIMEOUT" + SCHEMA_MISMATCH = "SCHEMA_MISMATCH" + NOT_IMPLEMENTED = "NOT_IMPLEMENTED" + FILE_READ_ERROR = "FILE_READ_ERROR" + INVALID_FORMAT = "INVALID_FORMAT" + INVALID_DOCUMENT = "INVALID_DOCUMENT" + RECOGNITION_FAILED = "RECOGNITION_FAILED" + UNKNOWN_ERROR = "UNKNOWN_ERROR" + + +# Строгий режим модели не принимает значение enum строкой: поле разбирает строку само (как JSON Schema `enum`) +LaxServiceName = Annotated[ServiceName, Field(strict=False)] +LaxErrorCode = Annotated[ErrorCode, Field(strict=False)] + + +class ServiceInfo(ContractModel): + """Кто ответил: `service {name, version}`.""" + + name: LaxServiceName + version: str + + +class S3Ref(ContractModel): + """Указатель на объект S3: `{bucket, key}`.""" + + bucket: NonEmptyStr + key: NonEmptyStr + + +class Metrics(ContractModel): + """Метрики обработки; все поля необязательны.""" + + duration_ms: OptionalNotNull[int] = None + pages: OptionalNotNull[int] = None + rows: OptionalNotNull[int] = None + fields_total: OptionalNotNull[int] = None + fields_low_confidence: OptionalNotNull[int] = None + fields_unrecognized: OptionalNotNull[int] = None + warnings: OptionalNotNull[list[str]] = None + + +DataType = Literal["BU", "NU", "SUM", "CURRENCY", "QUANTITY"] diff --git a/api/src/contract/message.py b/api/src/contract/message.py new file mode 100644 index 0000000..828b109 --- /dev/null +++ b/api/src/contract/message.py @@ -0,0 +1,63 @@ +"""Конверт сообщения на `recognition.result`: `recognition-result-message.schema.json` v1.0. + +`processing` — ack «взял в работу» (до чтения файла из S3); `done | needs_check | error` — итог. +Условная обязательность — буквально из `allOf` схемы: для `done | needs_check` обязательны `result_s3` и +`result_schema_version`; для `error` — `error`. Других правил нет (например, `error` при `done` схема не запрещает). +""" + +from enum import StrEnum +from typing import Annotated, Self + +from pydantic import Field, model_validator + +from src.contract.common import ( + ContractModel, + ContractUUID, + LaxErrorCode, + Metrics, + OptionalNotNull, + ResultSchemaVersion, + S3Ref, + SchemaVersion, + ServiceInfo, +) + + +class MessageStatus(StrEnum): + PROCESSING = "processing" + DONE = "done" + NEEDS_CHECK = "needs_check" + ERROR = "error" + + +LaxMessageStatus = Annotated[MessageStatus, Field(strict=False)] + + +class MessageError(ContractModel): + """`error {code, message}` конверта: оба поля обязательны.""" + + code: LaxErrorCode + message: str + + +class RecognitionResultMessage(ContractModel): + schema_version: SchemaVersion + job_id: ContractUUID # recognition_job.id + file_id: ContractUUID # stored_file.id + status: LaxMessageStatus + service: ServiceInfo + result_s3: OptionalNotNull[S3Ref] = None # results/{file_id}/{job_id}/result.json + result_schema_version: OptionalNotNull[ResultSchemaVersion] = None # schema_version файла результата + error: OptionalNotNull[MessageError] = None + raw_s3_key: str | None = None # scan-gateway: raw/{job_id}.json; в БД не регистрируется + metrics: OptionalNotNull[Metrics] = None + + @model_validator(mode="after") + def _check_status_requirements(self) -> Self: + if self.status in (MessageStatus.DONE, MessageStatus.NEEDS_CHECK): + missing = [name for name in ("result_s3", "result_schema_version") if getattr(self, name) is None] + if missing: + raise ValueError(f"при status = {self.status.value} обязательны поля: {', '.join(missing)}") + elif self.status is MessageStatus.ERROR and self.error is None: + raise ValueError("при status = error обязательно поле error") + return self diff --git a/api/src/contract/request.py b/api/src/contract/request.py new file mode 100644 index 0000000..c5eb3f8 --- /dev/null +++ b/api/src/contract/request.py @@ -0,0 +1,27 @@ +"""Запрос распознавания файла (arm-core → tabular-parser / scan-gateway): `recognition-request.schema.json` v1.0. + +Формируется из `recognition_job ⋈ stored_file ⋈ s3_object` (spec recognition-job-exchange); один файл — один job. +Subject — по `pipeline` (`request_subject`); заголовок `Nats-Msg-Id = :` +(`request_msg_id`) — не поле тела. +""" + +from typing import Annotated, Literal + +from pydantic import Field + +from src.contract.common import ContractModel, ContractUUID, NonEmptyStr, S3Ref, SchemaVersion, UtcDatetime +from src.contract.subjects import Pipeline + +FileType = Literal["XLS", "XLSX", "XLSB", "PDF", "PNG", "JPG"] + + +class RecognitionRequest(ContractModel): + schema_version: SchemaVersion + job_id: ContractUUID # recognition_job.id + file_id: ContractUUID # stored_file.id + pipeline: Pipeline + file_type: FileType + original_name: NonEmptyStr # имя файла, как загрузил пользователь + s3: S3Ref # исходный файл в роли uploads + reply_deadline: UtcDatetime # срок ответа, UTC + attempt: Annotated[int, Field(ge=1)] # номер попытки, с 1 diff --git a/api/src/contract/result.py b/api/src/contract/result.py new file mode 100644 index 0000000..6e37dea --- /dev/null +++ b/api/src/contract/result.py @@ -0,0 +1,187 @@ +"""Файл результата в S3 (`results/{file_id}/{job_id}/result.json`): `recognition-result.schema.json` v1.0. + +Один файл → один job → N документов (ОСВ по счёту / ББ / ОФР / прочее). В Срезе 0 — модели и валидация примеров; +маппинг в таблицы — Срез 2. Суммы — `Decimal` в модели и строка в JSON (`"106311116.46"`, без `float`). +Нормализация счетов, резолв контрагентов и проверки — в arm-core, не здесь. +""" + +from enum import StrEnum +from typing import Annotated, Literal, Self + +from pydantic import Field, model_validator + +from src.contract.common import ( + Confidence, + ContractDate, + ContractModel, + ContractUUID, + DataType, + LaxErrorCode, + Metrics, + Money, + OptionalNotNull, + SchemaVersion, + ServiceInfo, +) + +__all__ = [ + "OsvChecks", + "OsvDocument", + "OsvGroup", + "OsvHeader", + "OsvRow", + "RecognitionResult", + "ResultError", + "ResultStatus", + "StatementColumn", + "StatementDocument", + "StatementHeader", + "StatementLine", + "StatementValue", +] + + +class ResultStatus(StrEnum): + DONE = "done" + NEEDS_CHECK = "needs_check" + ERROR = "error" + + +LaxResultStatus = Annotated[ResultStatus, Field(strict=False)] + + +class ResultError(ContractModel): + """`error` файла результата: в отличие от конверта, `code` и `message` необязательны.""" + + code: OptionalNotNull[LaxErrorCode] = None + message: OptionalNotNull[str] = None + + +# --- ОСВ ------------------------------------------------------------------------------------------------------------ + + +class OsvHeader(ContractModel): + organization_name: str # как в r1 файла + organization_inn: str | None = None + account_code_raw: str # «60», «58.03» + period_text: str # «1 квартал 2025 г.» + period_year: int | None = None + period_quarter: Literal["Q1", "Q2", "Q3", "Q4"] | None = None + is_cumulative: bool | None = None # по тексту; ядро может переопределить по данным (OQ-02) + data_type: DataType | None = None + currency: str | None = None + header_variant: OptionalNotNull[Literal[1, 2]] = None + + +class OsvGroup(ContractModel): + """Группировка из шапки (порядок — после первого ACCOUNT).""" + + type: Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"] + name: OptionalNotNull[str] = None # исходный текст из шапки + has_inn: OptionalNotNull[bool] = None + + +class OsvRow(ContractModel): + row_number: int + level: int + parent_row_number: int | None = None + row_value: str + group_type: Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER", "TOTAL"] + group_name: str | None = None + account_code_raw: str | None = None # код текущей ветки, как в файле (60.1) + counterparty_name: str | None = None + counterparty_inn: str | None = None + data_type: DataType | None = None + opening_debit: Money = None + opening_credit: Money = None + turnover_debit: Money = None + turnover_credit: Money = None + closing_debit: Money = None + closing_credit: Money = None + + +class OsvChecks(ContractModel): + total_row_matches_root: OptionalNotNull[bool] = None + balance_identity_violations: OptionalNotNull[list[int]] = None # row_number, где opening−turnover≠closing + parent_sum_violations: OptionalNotNull[list[int]] = None + + +class OsvDocument(ContractModel): + document_type: Literal["OSV"] + locator: str # «sheet:Лист_1» + header: OsvHeader + group_schema: list[OsvGroup] + columns: OptionalNotNull[dict[str, str]] = None # диагностика: какие колонки приняты за шесть сумм и ИНН + rows: list[OsvRow] + checks: OptionalNotNull[OsvChecks] = None + + +# --- ББ / ОФР / прочее ---------------------------------------------------------------------------------------------- + + +class StatementHeader(ContractModel): + form_name: OptionalNotNull[str] = None + organization_name: str | None = None + organization_name_confidence: Confidence = None + inn: str | None = None + inn_confidence: Confidence = None + report_date_text: str | None = None + report_date: ContractDate | None = None + units: str | None = None # «тыс. руб.» + okud: str | None = None + okpo: str | None = None + okopf: str | None = None + okfs: str | None = None + address: str | None = None + + +class StatementColumn(ContractModel): + column_index: int + label: str # «На 30 сентября 2025 г.» + period_date: ContractDate | None = None + + +class StatementValue(ContractModel): + column_index: int + amount: Money = None + string_value: str | None = None + confidence: Confidence = None + is_recognized: OptionalNotNull[bool] = None + + +class StatementLine(ContractModel): + line_code: str + line_code_confidence: Confidence = None + line_name: str + line_name_confidence: Confidence = None + values: list[StatementValue] + + +class StatementDocument(ContractModel): + document_type: Literal["BALANCE", "INCOME", "OTHER"] + locator: str # «pages:1-2» + header: StatementHeader + columns: list[StatementColumn] + lines: list[StatementLine] + + +ResultDocument = Annotated[OsvDocument | StatementDocument, Field(discriminator="document_type")] + + +class RecognitionResult(ContractModel): + schema_version: SchemaVersion + job_id: ContractUUID + file_id: ContractUUID + service: ServiceInfo + status: LaxResultStatus + error: ResultError | None = None + metrics: OptionalNotNull[Metrics] = None + raw_s3_key: str | None = None # scan-gateway: raw/{job_id}.json + documents: list[ResultDocument] + + @model_validator(mode="after") + def _check_documents_for_success(self) -> Self: + """`if status in (done, needs_check) then documents.minItems = 1` — буквально из схемы.""" + if self.status in (ResultStatus.DONE, ResultStatus.NEEDS_CHECK) and not self.documents: + raise ValueError(f"при status = {self.status.value} список documents не может быть пустым") + return self diff --git a/api/src/contract/subjects.py b/api/src/contract/subjects.py new file mode 100644 index 0000000..be58d01 --- /dev/null +++ b/api/src/contract/subjects.py @@ -0,0 +1,45 @@ +"""Subject'ы NATS, заголовок дедупликации и идентификаторы сообщений контракта v1.0. + +Источник: `docs/context/03-architecture.md`, «Контракт обмена через NATS»; схема recognition-request +(`Nats-Msg-Id = :`) и recognition-result-message +(`ack::` / `result::`). +""" + +import uuid +from collections.abc import Mapping +from typing import Literal, get_args + +Pipeline = Literal["tabular", "image"] + +NATS_MSG_ID_HEADER = "Nats-Msg-Id" +RESULT_SUBJECT = "recognition.result" +TABULAR_REQUEST_SUBJECT = "recognition.tabular.request" +IMAGE_REQUEST_SUBJECT = "recognition.image.request" + +REQUEST_SUBJECTS: Mapping[str, str] = { + "tabular": TABULAR_REQUEST_SUBJECT, + "image": IMAGE_REQUEST_SUBJECT, +} + + +def request_subject(pipeline: str) -> str: + """Subject запроса по pipeline: `tabular` → `recognition.tabular.request`, `image` → `recognition.image.request`.""" + try: + return REQUEST_SUBJECTS[pipeline] + except KeyError: + raise ValueError(f"неизвестный pipeline {pipeline!r}: допустимы {', '.join(get_args(Pipeline))}") from None + + +def request_msg_id(job_id: uuid.UUID | str, attempt: int) -> str: + """`Nats-Msg-Id` запроса: `:`.""" + return f"{job_id}:{attempt}" + + +def ack_msg_id(job_id: uuid.UUID | str, attempt: int) -> str: + """`Nats-Msg-Id` ack (`status = processing`) на `recognition.result`: `ack::`.""" + return f"ack:{job_id}:{attempt}" + + +def result_msg_id(job_id: uuid.UUID | str, attempt: int) -> str: + """`Nats-Msg-Id` результата (`done | needs_check | error`) на `recognition.result`: `result::`.""" + return f"result:{job_id}:{attempt}" diff --git a/api/tests/test_contract.py b/api/tests/test_contract.py new file mode 100644 index 0000000..c992588 --- /dev/null +++ b/api/tests/test_contract.py @@ -0,0 +1,562 @@ +"""Контракт обмена с сервисами распознавания v1.0 (spec recognition-job-exchange, design D-6). + +Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/`; каждый пример проходит и +JSON Schema, и pydantic-модель (с round-trip); модели и схемы выносят один и тот же вердикт на негативных кейсах. +""" + +import copy +import json +import uuid +from collections.abc import Callable +from datetime import UTC, datetime +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pytest +from jsonschema import Draft202012Validator, FormatChecker +from pydantic import ValidationError +from referencing import Registry, Resource + +from scripts.dbml_parity import default_dbml_path +from src.contract import ( + CONTRACT_VERSION, + NATS_MSG_ID_HEADER, + RESULT_SUBJECT, + ContractModel, + MessageStatus, + RecognitionRequest, + RecognitionResult, + RecognitionResultMessage, + ack_msg_id, + request_msg_id, + request_subject, + result_msg_id, +) + +CONTRACTS_DIR = Path(__file__).resolve().parents[1] / "contracts" +EXAMPLES_DIR = CONTRACTS_DIR / "examples" +SCHEMA_FILES = { + "request": "recognition-request.schema.json", + "message": "recognition-result-message.schema.json", + "result": "recognition-result.schema.json", +} +MODELS: dict[str, type[ContractModel]] = { + "request": RecognitionRequest, + "message": RecognitionResultMessage, + "result": RecognitionResult, +} +EXAMPLE_KIND = { + "request-image.json": "request", + "request-tabular.json": "request", + "result-message-done.json": "message", + "result-message-error.json": "message", + "result-message-processing.json": "message", + "result-error.json": "result", + "result-osv-minimal.json": "result", + "result-statement-minimal.json": "result", +} +EXAMPLES_COUNT = 8 + + +def _load(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def _example(name: str) -> dict[str, Any]: + data = _load(EXAMPLES_DIR / name) + assert isinstance(data, dict) + return data + + +def _build_validators() -> dict[str, Draft202012Validator]: + """Валидаторы по трём схемам; `$ref` между схемами разрешаются локальным реестром (без сети).""" + schemas = {kind: _load(CONTRACTS_DIR / file_name) for kind, file_name in SCHEMA_FILES.items()} + registry: Registry = Registry() + for schema in schemas.values(): + registry = registry.with_resource(schema["$id"], Resource.from_contents(schema)) + return { + kind: Draft202012Validator(schema, registry=registry, format_checker=FormatChecker()) + for kind, schema in schemas.items() + } + + +VALIDATORS = _build_validators() + + +def _schema_ok(kind: str, payload: dict[str, Any]) -> bool: + return VALIDATORS[kind].is_valid(payload) + + +def _model_ok(kind: str, payload: dict[str, Any]) -> bool: + try: + MODELS[kind].model_validate(payload) + except ValidationError: + return False + return True + + +# --- идентичность вендоренных копий ------------------------------------------------------------------------------- + + +def _docs_contracts_dir() -> Path: + """`docs/contracts` рядом с `arm-core` или из `ARM_KA_DOCS` (тот же поиск, что у DBML).""" + return default_dbml_path().parents[1] / "contracts" + + +def test_vendored_contracts_are_identical_to_docs() -> None: + docs_dir = _docs_contracts_dir() + if not docs_dir.is_dir(): + pytest.skip( + "docs недоступен: тест идентичности контракта пропущен (задайте ARM_KA_DOCS или положите docs рядом)" + ) + docs_files = {p.relative_to(docs_dir).as_posix() for p in docs_dir.rglob("*.json")} + own_files = {p.relative_to(CONTRACTS_DIR).as_posix() for p in CONTRACTS_DIR.rglob("*.json")} + assert own_files == docs_files, ( + f"набор файлов контракта разошёлся: только в api/contracts: {sorted(own_files - docs_files)}, " + f"только в docs/contracts: {sorted(docs_files - own_files)}" + ) + for name in sorted(docs_files): + assert (CONTRACTS_DIR / name).read_bytes() == (docs_dir / name).read_bytes(), ( + f"файл {name} в api/contracts отличается от docs/contracts: скопируйте заново (cp), не правьте руками" + ) + + +def test_example_table_covers_all_example_files() -> None: + files = {p.name for p in EXAMPLES_DIR.glob("*.json")} + assert files == set(EXAMPLE_KIND) + assert len(files) == EXAMPLES_COUNT + + +# --- примеры: JSON Schema, модель, round-trip --------------------------------------------------------------------- + + +@pytest.mark.parametrize("name", sorted(EXAMPLE_KIND)) +def test_example_passes_json_schema(name: str) -> None: + VALIDATORS[EXAMPLE_KIND[name]].validate(_example(name)) + + +@pytest.mark.parametrize("name", sorted(EXAMPLE_KIND)) +def test_example_passes_model_and_round_trips(name: str) -> None: + kind = EXAMPLE_KIND[name] + original = _example(name) + model = MODELS[kind].model_validate(original) + dumped = model.model_dump(mode="json", exclude_unset=True) + # Ничего не потеряно и не изменено (в том числе суммы-строки: «1100», «-12.30», «0.00») + assert dumped == original + VALIDATORS[kind].validate(dumped) + # to_wire() — то, что уйдёт в NATS/S3: тоже проходит схему и совпадает с исходным примером + wire = model.to_wire() + assert wire == original + VALIDATORS[kind].validate(wire) + # повторный разбор даёт равную модель + assert MODELS[kind].model_validate(wire) == model + + +def test_result_amounts_are_decimal_in_model_and_strings_on_the_wire() -> None: + result = RecognitionResult.model_validate(_example("result-statement-minimal.json")) + values = result.documents[0].lines[1].values # type: ignore[union-attr] + assert values[0].amount == Decimal("5678.00") + assert values[1].amount == Decimal("-12.30") + assert isinstance(values[1].amount, Decimal) + wire = result.to_wire() + assert wire["documents"][0]["lines"][1]["values"][1]["amount"] == "-12.30" + assert wire["documents"][0]["lines"][0]["values"][1]["amount"] == "1100" # без «.00» — как пришло + + +def test_osv_amount_keeps_kopecks() -> None: + payload = _example("result-osv-minimal.json") + payload["documents"][0]["rows"][0]["closing_credit"] = "106311116.46" + result = RecognitionResult.model_validate(payload) + assert result.documents[0].rows[0].closing_credit == Decimal("106311116.46") # type: ignore[union-attr] + wire = result.to_wire() + assert wire["documents"][0]["rows"][0]["closing_credit"] == "106311116.46" + VALIDATORS["result"].validate(wire) + + +def test_models_expose_json_schema() -> None: + for model in MODELS.values(): + assert model.model_json_schema()["type"] == "object" + + +# --- согласованность моделей и схем ------------------------------------------------------------------------------- + + +def _set(path: str, value: Any) -> Callable[[dict[str, Any]], None]: + """Мутация: положить `value` по пути `a.b.0.c` (числа — индексы списков).""" + + def apply(payload: dict[str, Any]) -> None: + *parents, last = path.split(".") + node: Any = payload + for part in parents: + node = node[int(part)] if part.isdigit() else node[part] + if isinstance(node, list): + node[int(last)] = value + else: + node[last] = value + + return apply + + +def _drop(path: str) -> Callable[[dict[str, Any]], None]: + def apply(payload: dict[str, Any]) -> None: + *parents, last = path.split(".") + node: Any = payload + for part in parents: + node = node[int(part)] if part.isdigit() else node[part] + del node[last] + + return apply + + +# (идентификатор, пример-основа, мутация, ожидаемый вердикт «валидно») +CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [ + # --- запрос --- + ("request/base", "request-tabular.json", lambda p: None, True), + ("request/no-job_id", "request-tabular.json", _drop("job_id"), False), + ("request/no-attempt", "request-tabular.json", _drop("attempt"), False), + ("request/attempt-0", "request-tabular.json", _set("attempt", 0), False), + ("request/attempt-2", "request-tabular.json", _set("attempt", 2), True), + ("request/attempt-string", "request-tabular.json", _set("attempt", "1"), False), + ("request/attempt-float", "request-tabular.json", _set("attempt", 1.5), False), + ("request/attempt-bool", "request-tabular.json", _set("attempt", True), False), + ("request/pipeline-unknown", "request-tabular.json", _set("pipeline", "ocr"), False), + ("request/file_type-unknown", "request-tabular.json", _set("file_type", "DOCX"), False), + ("request/schema_version-2.0", "request-tabular.json", _set("schema_version", "2.0"), False), + ("request/schema_version-1.7", "request-tabular.json", _set("schema_version", "1.7"), True), + ("request/schema_version-1", "request-tabular.json", _set("schema_version", "1"), False), + ("request/schema_version-1.0.1", "request-tabular.json", _set("schema_version", "1.0.1"), False), + ("request/job_id-not-uuid", "request-tabular.json", _set("job_id", "not-a-uuid"), False), + ( + "request/job_id-uuid-without-dashes", + "request-tabular.json", + _set("job_id", "3f2a9c1e7b4d4e0a9d512c8e6a1b0f01"), + False, + ), + ("request/file_id-number", "request-tabular.json", _set("file_id", 12345), False), + ("request/original_name-empty", "request-tabular.json", _set("original_name", ""), False), + ("request/s3-no-key", "request-tabular.json", _drop("s3.key"), False), + ("request/s3-empty-bucket", "request-tabular.json", _set("s3.bucket", ""), False), + ("request/s3-null", "request-tabular.json", _set("s3", None), False), + ("request/extra-field", "request-tabular.json", _set("trace_id", "abc"), True), + ("request/extra-field-in-s3", "request-tabular.json", _set("s3.version_id", "v1"), True), + # --- конверт --- + ("message/processing", "result-message-processing.json", lambda p: None, True), + ("message/done", "result-message-done.json", lambda p: None, True), + ("message/status-unknown", "result-message-done.json", _set("status", "queued"), False), + ("message/no-service", "result-message-processing.json", _drop("service"), False), + ("message/service-unknown", "result-message-processing.json", _set("service.name", "other-service"), False), + ("message/schema_version-2.0", "result-message-processing.json", _set("schema_version", "2.0"), False), + ("message/schema_version-1.7", "result-message-processing.json", _set("schema_version", "1.7"), True), + ("message/done-no-result_s3", "result-message-done.json", _drop("result_s3"), False), + ("message/done-no-result_schema_version", "result-message-done.json", _drop("result_schema_version"), False), + ("message/needs_check-ok", "result-message-done.json", _set("status", "needs_check"), True), + ( + "message/needs_check-no-result_s3", + "result-message-done.json", + lambda p: (p.pop("result_s3"), p.update(status="needs_check")), + False, + ), + ("message/result_s3-no-key", "result-message-done.json", _drop("result_s3.key"), False), + ("message/result_s3-null", "result-message-done.json", _set("result_s3", None), False), + ("message/result_schema_version-2.0", "result-message-done.json", _set("result_schema_version", "2.0"), True), + ("message/result_schema_version-bad", "result-message-done.json", _set("result_schema_version", "1"), False), + ("message/error-ok", "result-message-error.json", lambda p: None, True), + ("message/error-no-error", "result-message-error.json", _drop("error"), False), + ("message/error-null", "result-message-error.json", _set("error", None), False), + ("message/error-unknown-code", "result-message-error.json", _set("error.code", "BOOM"), False), + ("message/error-no-message", "result-message-error.json", _drop("error.message"), False), + ("message/processing-no-result_s3", "result-message-processing.json", lambda p: None, True), + ( + "message/done-with-error-block", + "result-message-done.json", + _set("error", {"code": "TIMEOUT", "message": "x"}), + True, + ), + ("message/raw_s3_key-null", "result-message-done.json", _set("raw_s3_key", None), True), + ("message/metrics-warnings-not-strings", "result-message-done.json", _set("metrics.warnings", [1]), False), + ("message/metrics-duration-float", "result-message-done.json", _set("metrics.duration_ms", 1.5), False), + ("message/metrics-extra-field", "result-message-done.json", _set("metrics.cpu", 3), True), + ("message/metrics-rows", "result-message-done.json", _set("metrics.rows", 10), True), + ("message/extra-field", "result-message-done.json", _set("trace_id", "abc"), True), + # --- файл результата --- + ("result/osv-base", "result-osv-minimal.json", lambda p: None, True), + ("result/statement-base", "result-statement-minimal.json", lambda p: None, True), + ("result/error-base", "result-error.json", lambda p: None, True), + ( + "result/amount-float", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.amount", 1234.5), + False, + ), + ("result/amount-int", "result-statement-minimal.json", _set("documents.0.lines.0.values.0.amount", 1234), False), + ("result/amount-null", "result-statement-minimal.json", _set("documents.0.lines.0.values.0.amount", None), True), + ( + "result/amount-3-decimals", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.amount", "1.234"), + False, + ), + ("result/amount-comma", "result-statement-minimal.json", _set("documents.0.lines.0.values.0.amount", "1,5"), False), + ( + "result/amount-negative", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.amount", "-0.5"), + True, + ), + ( + "result/amount-exponent", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.amount", "1e3"), + False, + ), + ("result/amount-empty", "result-statement-minimal.json", _set("documents.0.lines.0.values.0.amount", ""), False), + ("result/osv-amount-float", "result-osv-minimal.json", _set("documents.0.rows.0.opening_credit", 1000.0), False), + ("result/osv-amount-missing", "result-osv-minimal.json", _drop("documents.0.rows.0.opening_credit"), True), + ("result/done-empty-documents", "result-osv-minimal.json", _set("documents", []), False), + ( + "result/needs_check-empty-documents", + "result-osv-minimal.json", + lambda p: p.update(status="needs_check", documents=[]), + False, + ), + ("result/error-empty-documents", "result-error.json", lambda p: None, True), + ("result/no-documents", "result-error.json", _drop("documents"), False), + ("result/status-processing", "result-osv-minimal.json", _set("status", "processing"), False), + ("result/error-null", "result-error.json", _set("error", None), True), + ("result/error-empty-object", "result-error.json", _set("error", {}), True), + ("result/error-bad-code", "result-error.json", _set("error.code", "BOOM"), False), + ("result/schema_version-2.0", "result-osv-minimal.json", _set("schema_version", "2.0"), False), + ("result/schema_version-1.3", "result-osv-minimal.json", _set("schema_version", "1.3"), True), + ("result/document_type-unknown", "result-osv-minimal.json", _set("documents.0.document_type", "MEMO"), False), + ("result/document-no-document_type", "result-osv-minimal.json", _drop("documents.0.document_type"), False), + ("result/osv-no-rows", "result-osv-minimal.json", _drop("documents.0.rows"), False), + ("result/osv-no-group_schema", "result-osv-minimal.json", _drop("documents.0.group_schema"), False), + ("result/osv-no-header-period_text", "result-osv-minimal.json", _drop("documents.0.header.period_text"), False), + ("result/osv-header_variant-3", "result-osv-minimal.json", _set("documents.0.header.header_variant", 3), False), + ("result/osv-period_quarter-Q5", "result-osv-minimal.json", _set("documents.0.header.period_quarter", "Q5"), False), + ( + "result/osv-period_quarter-null", + "result-osv-minimal.json", + _set("documents.0.header.period_quarter", None), + True, + ), + ( + "result/osv-header-null-for-non-nullable", + "result-osv-minimal.json", + _set("documents.0.header.header_variant", None), + False, + ), + ( + "result/osv-row-group_type-unknown", + "result-osv-minimal.json", + _set("documents.0.rows.0.group_type", "FOLDER"), + False, + ), + ( + "result/osv-row-group_type-TOTAL", + "result-osv-minimal.json", + _set("documents.0.rows.0.group_type", "TOTAL"), + True, + ), + ("result/osv-row-no-row_number", "result-osv-minimal.json", _drop("documents.0.rows.0.row_number"), False), + ("result/osv-row-level-float", "result-osv-minimal.json", _set("documents.0.rows.0.level", 0.5), False), + ("result/osv-columns-non-string", "result-osv-minimal.json", _set("documents.0.columns.opening_debit", 3), False), + ("result/osv-columns-extra-key", "result-osv-minimal.json", _set("documents.0.columns.inn", "B"), True), + ( + "result/osv-group_schema-bad-type", + "result-osv-minimal.json", + _set("documents.0.group_schema.0.type", "FOLDER"), + False, + ), + ( + "result/osv-checks-violations-not-ints", + "result-osv-minimal.json", + _set("documents.0.checks.parent_sum_violations", ["a"]), + False, + ), + ("result/osv-extra-field", "result-osv-minimal.json", _set("documents.0.debug", {"a": 1}), True), + ("result/statement-no-lines", "result-statement-minimal.json", _drop("documents.0.lines"), False), + ("result/statement-no-columns", "result-statement-minimal.json", _drop("documents.0.columns"), False), + ( + "result/statement-type-INCOME", + "result-statement-minimal.json", + _set("documents.0.document_type", "INCOME"), + True, + ), + ( + "result/statement-type-OSV-with-statement-body", + "result-statement-minimal.json", + _set("documents.0.document_type", "OSV"), + False, + ), + ( + "result/statement-confidence-101", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.confidence", 101), + False, + ), + ( + "result/statement-confidence-negative", + "result-statement-minimal.json", + _set("documents.0.lines.0.line_code_confidence", -1), + False, + ), + ( + "result/statement-confidence-null", + "result-statement-minimal.json", + _set("documents.0.lines.0.line_code_confidence", None), + True, + ), + ( + "result/statement-confidence-bool", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.confidence", True), + False, + ), + ( + "result/statement-confidence-100.0", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.confidence", 100.0), + True, + ), + ( + "result/statement-report_date-bad", + "result-statement-minimal.json", + _set("documents.0.header.report_date", "2025-13-45"), + False, + ), + ( + "result/statement-report_date-null", + "result-statement-minimal.json", + _set("documents.0.header.report_date", None), + True, + ), + ( + "result/statement-value-no-column_index", + "result-statement-minimal.json", + _drop("documents.0.lines.0.values.0.column_index"), + False, + ), + ( + "result/statement-is_recognized-string", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.is_recognized", "yes"), + False, + ), + ( + "result/statement-string_value-null", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.string_value", None), + True, + ), + ( + "result/statement-period_date-bad", + "result-statement-minimal.json", + _set("documents.0.columns.0.period_date", "30.09.2025"), + False, + ), + ("result/statement-extra-field", "result-statement-minimal.json", _set("documents.0.header.fax", "1"), True), +] + + +@pytest.mark.parametrize(("case_id", "example", "mutate", "expected_valid"), CASES, ids=[c[0] for c in CASES]) +def test_model_and_schema_agree( + case_id: str, example: str, mutate: Callable[[dict[str, Any]], None], expected_valid: bool +) -> None: + kind = EXAMPLE_KIND[example] + payload = copy.deepcopy(_example(example)) + mutate(payload) + schema_verdict = _schema_ok(kind, payload) + model_verdict = _model_ok(kind, payload) + assert schema_verdict == expected_valid, ( + f"{case_id}: JSON Schema вынесла вердикт {schema_verdict}, ожидали {expected_valid}" + ) + assert model_verdict == schema_verdict, ( + f"{case_id}: модель {'приняла' if model_verdict else 'отвергла'}, схема " + f"{'приняла' if schema_verdict else 'отвергла'} — расхождение модели и схемы" + ) + + +def test_consistency_table_covers_every_kind() -> None: + assert {EXAMPLE_KIND[c[1]] for c in CASES} == set(MODELS) + + +# --- то, что модель проверяет строже валидатора схемы без дополнительных библиотек --------------------------------- + + +def test_reply_deadline_must_be_aware_and_is_normalized_to_utc() -> None: + naive = _example("request-tabular.json") + naive["reply_deadline"] = "2026-10-03T12:30:00" + with pytest.raises(ValidationError): + RecognitionRequest.model_validate(naive) + shifted = _example("request-tabular.json") + shifted["reply_deadline"] = "2026-10-03T15:30:00+03:00" + request = RecognitionRequest.model_validate(shifted) + assert request.reply_deadline == datetime(2026, 10, 3, 12, 30, tzinfo=UTC) + assert request.reply_deadline.utcoffset() == UTC.utcoffset(None) + assert request.to_wire()["reply_deadline"] == "2026-10-03T12:30:00Z" + + +def test_uuid_fields_are_uuid_objects() -> None: + request = RecognitionRequest.model_validate(_example("request-tabular.json")) + assert request.job_id == uuid.UUID("3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01") + assert isinstance(request.file_id, uuid.UUID) + + +def test_message_status_enum_values() -> None: + assert [s.value for s in MessageStatus] == ["processing", "done", "needs_check", "error"] + message = RecognitionResultMessage.model_validate(_example("result-message-done.json")) + assert message.status is MessageStatus.DONE + + +def test_contract_version_constant() -> None: + assert CONTRACT_VERSION == "1.0" + + +def test_python_float_amount_is_rejected_with_clear_error() -> None: + payload = _example("result-statement-minimal.json") + payload["documents"][0]["lines"][0]["values"][0]["amount"] = 1234.5 + with pytest.raises(ValidationError, match="строк"): + RecognitionResult.model_validate(payload) + + +def test_decimal_object_input_is_accepted_and_dumped_as_string() -> None: + """Для кода, собирающего результат из `Decimal` (тесты, будущие производители): объект `Decimal` принимается.""" + payload = _example("result-statement-minimal.json") + payload["documents"][0]["lines"][0]["values"][0]["amount"] = Decimal("1234.50") + result = RecognitionResult.model_validate(payload) + assert result.to_wire()["documents"][0]["lines"][0]["values"][0]["amount"] == "1234.50" + + +# --- subject'ы, заголовок и идентификаторы сообщений --------------------------------------------------------------- + + +def test_request_msg_id() -> None: + job_id = uuid.UUID("3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01") + assert request_msg_id(job_id, 1) == "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01:1" + assert request_msg_id(str(job_id), 3) == "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01:3" + + +def test_ack_and_result_msg_ids() -> None: + job_id = uuid.UUID("3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01") + assert ack_msg_id(job_id, 1) == "ack:3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01:1" + assert result_msg_id(job_id, 2) == "result:3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01:2" + + +def test_request_subject_by_pipeline() -> None: + assert request_subject("tabular") == "recognition.tabular.request" + assert request_subject("image") == "recognition.image.request" + assert RESULT_SUBJECT == "recognition.result" + assert NATS_MSG_ID_HEADER == "Nats-Msg-Id" + + +def test_request_subject_rejects_unknown_pipeline() -> None: + with pytest.raises(ValueError, match="pipeline"): + request_subject("ocr") + + +def test_request_subject_matches_example_pipeline() -> None: + for name in ("request-tabular.json", "request-image.json"): + request = RecognitionRequest.model_validate(_example(name)) + assert request_subject(request.pipeline) == f"recognition.{request.pipeline}.request" -- 2.47.2 From 0837e18a0a6510395978c97082877b52f1e49e06 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 23:01:34 +0400 Subject: [PATCH 66/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2020?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 962e297..25a93cf 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -97,7 +97,7 @@ ### Task 20: [5.1] `api/contracts/` — копии трёх схем из `../docs/contracts/` + `examples/` -- [ ] `api/contracts/` — копии трёх схем из `../docs/contracts/` + `examples/`; `src/contract/` — pydantic-модели запроса, конверта (статусы `processing | done | needs_check | error`), файла результата (Decimal как строка); `tests/test_contract.py` — идентичность с `../docs/contracts` (skip с предупреждением без docs), валидация примеров; проверка: тесты зелёные +- [x] `api/contracts/` — копии трёх схем из `../docs/contracts/` + `examples/`; `src/contract/` — pydantic-модели запроса, конверта (статусы `processing | done | needs_check | error`), файла результата (Decimal как строка); `tests/test_contract.py` — идентичность с `../docs/contracts` (skip с предупреждением без docs), валидация примеров; проверка: тесты зелёные ### Task 21: [5.2] `src/services/audit.py` — `AuditService.record(...)` в текущей сессии diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 0c44229..e606b7d 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -81,7 +81,7 @@ ## 5. Обмен через `recognition_job` и аудит (spec `recognition-job-exchange`, `audit-trail`) -- [ ] 5.1 `api/contracts/` — копии трёх схем из `../docs/contracts/` + `examples/`; `src/contract/` — pydantic-модели +- [x] 5.1 `api/contracts/` — копии трёх схем из `../docs/contracts/` + `examples/`; `src/contract/` — pydantic-модели запроса, конверта (статусы `processing | done | needs_check | error`), файла результата (Decimal как строка); `tests/test_contract.py` — идентичность с `../docs/contracts` (skip с предупреждением без docs), валидация примеров; проверка: тесты зелёные -- 2.47.2 From d7f6c280bb8cc5ae01c040103971dbed409b41da Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 23:08:25 +0400 Subject: [PATCH 67/97] =?UTF-8?q?fix:=20=D1=86=D0=B5=D0=BB=D0=BE=D1=87?= =?UTF-8?q?=D0=B8=D1=81=D0=BB=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20float=20(1.0?= =?UTF-8?q?)=20=D0=BF=D1=80=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D1=8E=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=BA=D0=B0=D0=BA=20=D1=86=D0=B5=D0=BB=D1=8B?= =?UTF-8?q?=D0=B5,=20bool=20=D0=B2=20header=5Fvariant=20=D0=BE=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B3=D0=B0=D0=B5=D1=82=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/src/contract/common.py | 33 +++++++++++--- api/src/contract/request.py | 4 +- api/src/contract/result.py | 20 +++++---- api/tests/test_contract.py | 86 +++++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 17 deletions(-) diff --git a/api/src/contract/common.py b/api/src/contract/common.py index 6d14baf..d0e27a8 100644 --- a/api/src/contract/common.py +++ b/api/src/contract/common.py @@ -149,6 +149,27 @@ Money = Annotated[ WithJsonSchema({"type": ["string", "null"], "pattern": MONEY_PATTERN}), ] +# --- целые ----------------------------------------------------------------------------------------------------------- + + +def int_like(value: object) -> object: + """JSON Schema 2020-12 считает `1.0` целым: `float` с целым значением приводится к `int` (pandas пишет `5.0`). + + `bool` (в JSON это не число) отвергается явно; нецелые `float` (в том числе NaN и inf) и строки остаются как есть + и отвергаются строгой проверкой `int`. + """ + if isinstance(value, bool): + raise ValueError("ожидается целое число, а не булево значение") + if isinstance(value, float) and value.is_integer(): + return int(value) + return value + + +# Целое по схеме (`type: integer`): `int` или целочисленный `float`; bool, строка и `1.5` — ошибка +IntLike = Annotated[int, BeforeValidator(int_like)] +# `header_variant`: enum [1, 2] схемы; `True` и `1.5` отвергаются, `1.0` равно 1 +HeaderVariant = Annotated[Literal[1, 2], BeforeValidator(int_like)] + # --- скаляры -------------------------------------------------------------------------------------------------------- NonEmptyStr = Annotated[str, Field(min_length=1)] @@ -200,12 +221,12 @@ class S3Ref(ContractModel): class Metrics(ContractModel): """Метрики обработки; все поля необязательны.""" - duration_ms: OptionalNotNull[int] = None - pages: OptionalNotNull[int] = None - rows: OptionalNotNull[int] = None - fields_total: OptionalNotNull[int] = None - fields_low_confidence: OptionalNotNull[int] = None - fields_unrecognized: OptionalNotNull[int] = None + duration_ms: OptionalNotNull[IntLike] = None + pages: OptionalNotNull[IntLike] = None + rows: OptionalNotNull[IntLike] = None + fields_total: OptionalNotNull[IntLike] = None + fields_low_confidence: OptionalNotNull[IntLike] = None + fields_unrecognized: OptionalNotNull[IntLike] = None warnings: OptionalNotNull[list[str]] = None diff --git a/api/src/contract/request.py b/api/src/contract/request.py index c5eb3f8..5165080 100644 --- a/api/src/contract/request.py +++ b/api/src/contract/request.py @@ -9,7 +9,7 @@ from typing import Annotated, Literal from pydantic import Field -from src.contract.common import ContractModel, ContractUUID, NonEmptyStr, S3Ref, SchemaVersion, UtcDatetime +from src.contract.common import ContractModel, ContractUUID, IntLike, NonEmptyStr, S3Ref, SchemaVersion, UtcDatetime from src.contract.subjects import Pipeline FileType = Literal["XLS", "XLSX", "XLSB", "PDF", "PNG", "JPG"] @@ -24,4 +24,4 @@ class RecognitionRequest(ContractModel): original_name: NonEmptyStr # имя файла, как загрузил пользователь s3: S3Ref # исходный файл в роли uploads reply_deadline: UtcDatetime # срок ответа, UTC - attempt: Annotated[int, Field(ge=1)] # номер попытки, с 1 + attempt: Annotated[IntLike, Field(ge=1)] # номер попытки, с 1 diff --git a/api/src/contract/result.py b/api/src/contract/result.py index 6e37dea..d591872 100644 --- a/api/src/contract/result.py +++ b/api/src/contract/result.py @@ -16,6 +16,8 @@ from src.contract.common import ( ContractModel, ContractUUID, DataType, + HeaderVariant, + IntLike, LaxErrorCode, Metrics, Money, @@ -65,12 +67,12 @@ class OsvHeader(ContractModel): organization_inn: str | None = None account_code_raw: str # «60», «58.03» period_text: str # «1 квартал 2025 г.» - period_year: int | None = None + period_year: IntLike | None = None period_quarter: Literal["Q1", "Q2", "Q3", "Q4"] | None = None is_cumulative: bool | None = None # по тексту; ядро может переопределить по данным (OQ-02) data_type: DataType | None = None currency: str | None = None - header_variant: OptionalNotNull[Literal[1, 2]] = None + header_variant: OptionalNotNull[HeaderVariant] = None class OsvGroup(ContractModel): @@ -82,9 +84,9 @@ class OsvGroup(ContractModel): class OsvRow(ContractModel): - row_number: int - level: int - parent_row_number: int | None = None + row_number: IntLike + level: IntLike + parent_row_number: IntLike | None = None row_value: str group_type: Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER", "TOTAL"] group_name: str | None = None @@ -102,8 +104,8 @@ class OsvRow(ContractModel): class OsvChecks(ContractModel): total_row_matches_root: OptionalNotNull[bool] = None - balance_identity_violations: OptionalNotNull[list[int]] = None # row_number, где opening−turnover≠closing - parent_sum_violations: OptionalNotNull[list[int]] = None + balance_identity_violations: OptionalNotNull[list[IntLike]] = None # row_number, где opening−turnover≠closing + parent_sum_violations: OptionalNotNull[list[IntLike]] = None class OsvDocument(ContractModel): @@ -136,13 +138,13 @@ class StatementHeader(ContractModel): class StatementColumn(ContractModel): - column_index: int + column_index: IntLike label: str # «На 30 сентября 2025 г.» period_date: ContractDate | None = None class StatementValue(ContractModel): - column_index: int + column_index: IntLike amount: Money = None string_value: str | None = None confidence: Confidence = None diff --git a/api/tests/test_contract.py b/api/tests/test_contract.py index c992588..4d315f9 100644 --- a/api/tests/test_contract.py +++ b/api/tests/test_contract.py @@ -219,6 +219,9 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [ ("request/attempt-2", "request-tabular.json", _set("attempt", 2), True), ("request/attempt-string", "request-tabular.json", _set("attempt", "1"), False), ("request/attempt-float", "request-tabular.json", _set("attempt", 1.5), False), + ("request/attempt-1.0", "request-tabular.json", _set("attempt", 1.0), True), + ("request/attempt-0.0", "request-tabular.json", _set("attempt", 0.0), False), + ("request/attempt-nan", "request-tabular.json", _set("attempt", float("nan")), False), ("request/attempt-bool", "request-tabular.json", _set("attempt", True), False), ("request/pipeline-unknown", "request-tabular.json", _set("pipeline", "ocr"), False), ("request/file_type-unknown", "request-tabular.json", _set("file_type", "DOCX"), False), @@ -276,6 +279,8 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [ ("message/raw_s3_key-null", "result-message-done.json", _set("raw_s3_key", None), True), ("message/metrics-warnings-not-strings", "result-message-done.json", _set("metrics.warnings", [1]), False), ("message/metrics-duration-float", "result-message-done.json", _set("metrics.duration_ms", 1.5), False), + ("message/metrics-duration-1.0", "result-message-done.json", _set("metrics.duration_ms", 18400.0), True), + ("message/metrics-pages-bool", "result-message-done.json", _set("metrics.pages", True), False), ("message/metrics-extra-field", "result-message-done.json", _set("metrics.cpu", 3), True), ("message/metrics-rows", "result-message-done.json", _set("metrics.rows", 10), True), ("message/extra-field", "result-message-done.json", _set("trace_id", "abc"), True), @@ -360,6 +365,74 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [ True, ), ("result/osv-row-no-row_number", "result-osv-minimal.json", _drop("documents.0.rows.0.row_number"), False), + ("result/osv-level-1.0", "result-osv-minimal.json", _set("documents.0.rows.0.level", 1.0), True), + ("result/osv-level-1.5", "result-osv-minimal.json", _set("documents.0.rows.0.level", 1.5), False), + ("result/osv-level-bool", "result-osv-minimal.json", _set("documents.0.rows.0.level", True), False), + ("result/osv-level-string", "result-osv-minimal.json", _set("documents.0.rows.0.level", "1"), False), + ("result/osv-row_number-2.0", "result-osv-minimal.json", _set("documents.0.rows.0.row_number", 2.0), True), + ( + "result/osv-parent_row_number-1.0", + "result-osv-minimal.json", + _set("documents.0.rows.1.parent_row_number", 1.0), + True, + ), + ( + "result/osv-parent_row_number-1.5", + "result-osv-minimal.json", + _set("documents.0.rows.1.parent_row_number", 1.5), + False, + ), + ("result/osv-period_year-2025.0", "result-osv-minimal.json", _set("documents.0.header.period_year", 2025.0), True), + ( + "result/osv-period_year-nan", + "result-osv-minimal.json", + _set("documents.0.header.period_year", float("nan")), + False, + ), + ("result/osv-header_variant-2.0", "result-osv-minimal.json", _set("documents.0.header.header_variant", 2.0), True), + ( + "result/osv-header_variant-true", + "result-osv-minimal.json", + _set("documents.0.header.header_variant", True), + False, + ), + ("result/osv-header_variant-1.5", "result-osv-minimal.json", _set("documents.0.header.header_variant", 1.5), False), + ( + "result/osv-violations-1.0", + "result-osv-minimal.json", + _set("documents.0.checks.parent_sum_violations", [3.0]), + True, + ), + ( + "result/osv-violations-1.5", + "result-osv-minimal.json", + _set("documents.0.checks.parent_sum_violations", [3.5]), + False, + ), + ( + "result/statement-column_index-1.0", + "result-statement-minimal.json", + _set("documents.0.columns.0.column_index", 1.0), + True, + ), + ( + "result/statement-column_index-1.5", + "result-statement-minimal.json", + _set("documents.0.columns.0.column_index", 1.5), + False, + ), + ( + "result/statement-value-column_index-0.0", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.column_index", 0.0), + True, + ), + ( + "result/statement-value-column_index-bool", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.column_index", False), + False, + ), ("result/osv-row-level-float", "result-osv-minimal.json", _set("documents.0.rows.0.level", 0.5), False), ("result/osv-columns-non-string", "result-osv-minimal.json", _set("documents.0.columns.opening_debit", 3), False), ("result/osv-columns-extra-key", "result-osv-minimal.json", _set("documents.0.columns.inn", "B"), True), @@ -560,3 +633,16 @@ def test_request_subject_matches_example_pipeline() -> None: for name in ("request-tabular.json", "request-image.json"): request = RecognitionRequest.model_validate(_example(name)) assert request_subject(request.pipeline) == f"recognition.{request.pipeline}.request" + + +def test_integer_valued_float_is_normalized_to_int() -> None: + """`1.0` (pandas) принимается как целое и уходит дальше целым числом: `1`.""" + payload = _example("result-osv-minimal.json") + payload["documents"][0]["rows"][0]["level"] = 1.0 + payload["documents"][0]["header"]["header_variant"] = 2.0 + result = RecognitionResult.model_validate(payload) + wire = result.to_wire() + assert wire["documents"][0]["rows"][0]["level"] == 1 + assert type(wire["documents"][0]["rows"][0]["level"]) is int + assert type(wire["documents"][0]["header"]["header_variant"]) is int + VALIDATORS["result"].validate(wire) -- 2.47.2 From a58f5d9df175a2744fe8e247ca7a27dd651adb51 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 23:21:15 +0400 Subject: [PATCH 68/97] =?UTF-8?q?feat:=20AuditService.record=20=E2=80=94?= =?UTF-8?q?=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=81=D1=82=D0=B2=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D1=82=D0=BE=D1=87=D0=BA=D0=B0=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B8=20audit=5Fevent,=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B8=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=20=D0=B7=D0=B0=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D1=82=20UPDATE/DELETE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/src/services/audit.py | 119 ++++++++++ api/tests/test_audit_service.py | 381 ++++++++++++++++++++++++++++++++ 2 files changed, 500 insertions(+) create mode 100644 api/src/services/audit.py create mode 100644 api/tests/test_audit_service.py diff --git a/api/src/services/audit.py b/api/src/services/audit.py new file mode 100644 index 0000000..474fec4 --- /dev/null +++ b/api/src/services/audit.py @@ -0,0 +1,119 @@ +"""Сервис аудита: единственная точка записи `audit_event` (spec audit-trail, ТЗ 8.2). + +Журнал append-only: у сервиса один публичный метод — `record`; методов изменения и удаления нет и не будет +(проверяется тестом `tests/test_audit_service.py`, он же сканирует `src/` на UPDATE/DELETE по `AuditEvent`). + +Запись выполняется в ТЕКУЩЕЙ транзакции сессии: сервис делает `add` + `flush` и не вызывает `commit`/`rollback` — +фиксация и откат остаются за вызывающим кодом, поэтому откат изменения данных откатывает и строку аудита. +""" + +import datetime +import decimal +import enum +import math +import uuid +from collections.abc import Mapping +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.audit_ex import AuditEvent +from src.models.enums import AuditAction, AuditSection + + +def _scalar_to_jsonable(value: Any) -> Any: + if value is None or isinstance(value, bool | int | str): + return value + if isinstance(value, enum.Enum): + return to_jsonable(value.value) + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError("Нечисловое значение float (NaN/Infinity) нельзя записать в jsonb") + return value + if isinstance(value, decimal.Decimal): + return format(value, "f") + if isinstance(value, uuid.UUID): + return str(value) + if isinstance(value, datetime.datetime | datetime.date | datetime.time): + return value.isoformat() + raise TypeError(f"Значение типа {type(value).__name__} не сериализуется в jsonb для audit_event") + + +def to_jsonable(value: Any) -> Any: + """Приводит значение к JSON-совместимому виду для jsonb. + + UUID -> str; Decimal -> строка без экспоненты (деньги не превращаются в float); datetime/date/time -> isoformat; + Enum -> `.value`; Mapping/list/tuple/set -> рекурсивно. Неизвестный тип — `TypeError`, а не молчаливое `str()`. + """ + if isinstance(value, Mapping): + return {str(key): to_jsonable(item) for key, item in value.items()} + if isinstance(value, list | tuple | set | frozenset): + return [to_jsonable(item) for item in value] + return _scalar_to_jsonable(value) + + +def _normalize_payload(name: str, payload: Mapping[str, Any] | None) -> Any: + if payload is None: + return None + if not isinstance(payload, Mapping): + raise TypeError(f"{name} должен быть словарём (Mapping) или None, получено {type(payload).__name__}") + return to_jsonable(payload) + + +class AuditService: + """Запись событий журнала аудита в сессию вызывающего (ручной DI: сессия приходит в конструктор).""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def record( # noqa: PLR0913 - сигнатура зафиксирована спекой audit-trail (все поля audit_event) + self, + *, + action: AuditAction, + section: AuditSection, + object_type: str, + object_id: uuid.UUID | None = None, + user_id: uuid.UUID | None = None, + consolidation_version_id: uuid.UUID | None = None, + description: str | None = None, + before_value: Mapping[str, Any] | None = None, + after_value: Mapping[str, Any] | None = None, + comment: str | None = None, + result: str | None = None, + changed_field: str | None = None, + ip_address: str | None = None, + user_agent: str | None = None, + ) -> AuditEvent: + """Добавляет событие в текущую транзакцию и делает `flush` (id, ранние нарушения ограничений); без `commit`. + + `user_id=None` — системное действие. `occurred_at` ставит сервис (UTC), параметром он не принимается. + """ + if not isinstance(action, AuditAction): + raise TypeError(f"action должен быть членом AuditAction, получено {type(action).__name__}: {action!r}") + if not isinstance(section, AuditSection): + raise TypeError(f"section должен быть членом AuditSection, получено {type(section).__name__}: {section!r}") + if not isinstance(object_type, str): + raise TypeError(f"object_type должен быть строкой, получено {type(object_type).__name__}") + if not object_type.strip(): + raise ValueError("object_type не может быть пустым") + + event = AuditEvent( + occurred_at=datetime.datetime.now(datetime.UTC), + user_id=user_id, + action=action, + section=section, + object_type=object_type, + object_id=object_id, + consolidation_version_id=consolidation_version_id, + description=description, + before_value=_normalize_payload("before_value", before_value), + after_value=_normalize_payload("after_value", after_value), + comment=comment, + result=result, + changed_field=changed_field, + ip_address=ip_address, + user_agent=user_agent, + ) + self._session.add(event) + await self._session.flush() + return event diff --git a/api/tests/test_audit_service.py b/api/tests/test_audit_service.py new file mode 100644 index 0000000..2378472 --- /dev/null +++ b/api/tests/test_audit_service.py @@ -0,0 +1,381 @@ +"""Сервис аудита (spec audit-trail, задача 21): системное событие, откат, append-only, статический запрет UPDATE/DELETE. + +Быстрые тесты (валидация, сканер, статический обход `src/`) идут без БД; интеграционные — на `db_session`. +""" + +import ast +import datetime +import inspect +import re +import uuid +from decimal import Decimal +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.audit_ex import AuditEvent +from src.models.directories import RegionalOffice +from src.models.enums import AuditAction, AuditSection +from src.services.audit import AuditService, to_jsonable + +SRC_DIR = Path(__file__).resolve().parent.parent / "src" + +# --------------------------------------------------------------------------------------------------------------------- +# Статический сканер: обращения, изменяющие или удаляющие audit_event +# --------------------------------------------------------------------------------------------------------------------- + +MUTATING_CALLS = {"update", "delete"} +AUDIT_FIELDS = { + "occurred_at", + "user_id", + "action", + "section", + "object_type", + "object_id", + "consolidation_version_id", + "description", + "before_value", + "after_value", + "comment", + "result", + "changed_field", + "ip_address", + "user_agent", + "id", +} +RAW_SQL_MUTATION = re.compile( + r"\b(update|delete\s+from|truncate(\s+table)?)\s+(only\s+)?(\"?dkb\"?\.)?\"?audit_event\b", re.I +) + + +def _call_name(node: ast.Call) -> str | None: + func = node.func + if isinstance(func, ast.Name): + return func.id + if isinstance(func, ast.Attribute): + return func.attr + return None + + +def _mentions_audit_event(node: ast.AST) -> bool: + return any( + (isinstance(sub, ast.Name) and sub.id == "AuditEvent") + or (isinstance(sub, ast.Attribute) and sub.attr == "AuditEvent") + for sub in ast.walk(node) + ) + + +def _is_audit_event_expr(node: ast.AST | None) -> bool: + """Выражение, дающее экземпляр AuditEvent: `AuditEvent(...)` / `mod.AuditEvent(...)`.""" + return isinstance(node, ast.Call) and _mentions_audit_event(node.func) + + +def _tainted_names(tree: ast.AST) -> set[str]: + """Имена, связанные с экземплярами AuditEvent: присваивание `AuditEvent(...)`, аннотация `: AuditEvent`.""" + names: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and _is_audit_event_expr(node.value): + names.update(t.id for t in node.targets if isinstance(t, ast.Name)) + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + if _mentions_audit_event(node.annotation) or _is_audit_event_expr(node.value): + names.add(node.target.id) + elif isinstance(node, ast.arg) and node.annotation is not None and _mentions_audit_event(node.annotation): + names.add(node.arg) + elif isinstance(node, ast.For | ast.comprehension) and _is_audit_event_expr(getattr(node, "iter", None)): + pass + return names + + +def find_audit_event_mutations(source: str, filename: str = "") -> list[str]: + """Находки вида `file:line: описание` — любое изменение/удаление audit_event в исходнике. + + Ловит: `update(AuditEvent...)` / `delete(AuditEvent...)` (в т.ч. `sa.update`, `AuditEvent.__table__`); + `.delete(x)`/`delete(x)`, где x — экземпляр AuditEvent (внутримодульное отслеживание имён); присваивание/удаление + атрибутов таких экземпляров и `AuditEvent.<поле> = ...`; сырой SQL UPDATE/DELETE/TRUNCATE по audit_event. + """ + tree = ast.parse(source, filename=filename) + tainted = _tainted_names(tree) + findings: list[str] = [] + + def add(node: ast.AST, message: str) -> None: + findings.append(f"{filename}:{getattr(node, 'lineno', 0)}: {message}") + + for node in ast.walk(tree): + if isinstance(node, ast.Call): + name = _call_name(node) + args: list[ast.AST] = [*node.args, *(kw.value for kw in node.keywords)] + if name in MUTATING_CALLS: + if any(_mentions_audit_event(arg) for arg in args): + add(node, f"{name}() по AuditEvent") + elif any(isinstance(arg, ast.Name) and arg.id in tainted for arg in args): + add(node, f"{name}() экземпляра AuditEvent") + elif isinstance(node, ast.Assign | ast.AugAssign | ast.AnnAssign | ast.Delete): + targets = node.targets if isinstance(node, ast.Assign | ast.Delete) else [node.target] + for target in targets: + if isinstance(target, ast.Attribute) and target.attr in AUDIT_FIELDS: + base = target.value + if ( + isinstance(base, ast.Name) and (base.id in tainted or base.id == "AuditEvent") + ) or _is_audit_event_expr(base): + add(node, f"изменение поля {target.attr} у AuditEvent") + elif isinstance(node, ast.Constant) and isinstance(node.value, str) and RAW_SQL_MUTATION.search(node.value): + add(node, "сырой SQL, изменяющий audit_event") + return findings + + +def scan_directory(root: Path) -> list[str]: + findings: list[str] = [] + for path in sorted(root.rglob("*.py")): + findings.extend(find_audit_event_mutations(path.read_text(encoding="utf-8"), str(path.relative_to(root)))) + return findings + + +def test_src_has_no_audit_event_update_or_delete() -> None: + assert SRC_DIR.is_dir() + assert list(SRC_DIR.rglob("audit.py")), "сканер должен видеть src/services/audit.py" + assert scan_directory(SRC_DIR) == [] + + +@pytest.mark.parametrize( + "source", + [ + "from sqlalchemy import update\nstmt = update(AuditEvent).values(comment='x')", + "import sqlalchemy as sa\nstmt = sa.delete(AuditEvent).where(AuditEvent.id == 1)", + "stmt = update(AuditEvent.__table__).values(result='x')", + "stmt = delete(models.audit_ex.AuditEvent)", + "async def f(session):\n event = AuditEvent(object_type='x')\n await session.delete(event)", + "def f(event: AuditEvent):\n event.result = 'changed'", + "def f(s):\n e = AuditEvent(object_type='x')\n e.comment = 'x'", + "def f(s):\n e = AuditEvent(object_type='x')\n del e.comment", + "AuditEvent.comment = 'x'", + "stmt = text('UPDATE dkb.audit_event SET comment = 1')", + "stmt = text('delete from audit_event')", + "stmt = text('TRUNCATE TABLE dkb.audit_event')", + ], +) +def test_scanner_detects_violation(source: str) -> None: + assert find_audit_event_mutations(source), f"сканер пропустил нарушение: {source!r}" + + +@pytest.mark.parametrize( + "source", + [ + "stmt = select(AuditEvent).where(AuditEvent.id == 1)", + "event = AuditEvent(object_type='x')\nsession.add(event)", + "stmt = update(RegionalOffice).values(name='x')", + "async def f(session, office):\n await session.delete(office)", + "def f(office):\n office.comment = 'x'", + "stmt = text('select * from audit_event')", + ], +) +def test_scanner_passes_allowed_code(source: str) -> None: + assert find_audit_event_mutations(source) == [] + + +def test_scan_directory_fails_on_synthetic_violation(tmp_path: Path) -> None: + (tmp_path / "ok.py").write_text("x = 1\n", encoding="utf-8") + sub = tmp_path / "services" + sub.mkdir() + (sub / "bad.py").write_text("from sqlalchemy import update\nstmt = update(AuditEvent)\n", encoding="utf-8") + findings = scan_directory(tmp_path) + assert len(findings) == 1 + assert findings[0].startswith("services/bad.py:2:") + + +# --------------------------------------------------------------------------------------------------------------------- +# Append-only и валидация (без БД) +# --------------------------------------------------------------------------------------------------------------------- + + +def test_service_exposes_only_record() -> None: + public = {name for name in dir(AuditService) if not name.startswith("_")} + assert public == {"record"} + + +async def test_record_rejects_empty_object_type() -> None: + service = AuditService(AsyncMock(spec=AsyncSession)) + for bad in ("", " "): + with pytest.raises(ValueError, match="object_type"): + await service.record(action=AuditAction.LOGIN, section=AuditSection.SESSION, object_type=bad) + + +async def test_record_rejects_string_enums() -> None: + session = AsyncMock(spec=AsyncSession) + service = AuditService(session) + with pytest.raises(TypeError, match="action"): + await service.record(action="LOGIN", section=AuditSection.SESSION, object_type="app_user") # type: ignore[arg-type] + with pytest.raises(TypeError, match="section"): + await service.record(action=AuditAction.LOGIN, section="SESSION", object_type="app_user") # type: ignore[arg-type] + session.add.assert_not_called() + + +async def test_record_rejects_non_mapping_payload_and_unknown_types() -> None: + service = AuditService(AsyncMock(spec=AsyncSession)) + with pytest.raises(TypeError, match="before_value"): + await service.record( + action=AuditAction.LOGIN, + section=AuditSection.SESSION, + object_type="app_user", + before_value=["x"], # type: ignore[arg-type] + ) + with pytest.raises(TypeError, match="object"): + await service.record( + action=AuditAction.LOGIN, + section=AuditSection.SESSION, + object_type="app_user", + after_value={"x": object()}, + ) + + +def test_record_does_not_accept_occurred_at() -> None: + assert "occurred_at" not in inspect.signature(AuditService.record).parameters + + +def test_to_jsonable_types() -> None: + uid = uuid.UUID("12345678-1234-5678-1234-567812345678") + moment = datetime.datetime(2026, 10, 3, 12, 0, tzinfo=datetime.UTC) + result = to_jsonable( + { + "sum": Decimal("106311116.46"), + "tiny": Decimal("1E+2"), + "id": uid, + "at": moment, + "day": datetime.date(2026, 10, 3), + "action": AuditAction.LOGIN, + "nested": [{"k": Decimal("0.10")}, (1, 2)], + 3: None, + } + ) + assert result == { + "sum": "106311116.46", + "tiny": "100", + "id": str(uid), + "at": moment.isoformat(), + "day": "2026-10-03", + "action": "LOGIN", + "nested": [{"k": "0.10"}, [1, 2]], + "3": None, + } + with pytest.raises(ValueError, match="NaN"): + to_jsonable({"x": float("nan")}) + + +# --------------------------------------------------------------------------------------------------------------------- +# Интеграция: запись в текущую транзакцию +# --------------------------------------------------------------------------------------------------------------------- + + +async def _count_events(session: AsyncSession, object_id: uuid.UUID) -> int: + return ( + await session.scalar(select(func.count()).select_from(AuditEvent).where(AuditEvent.object_id == object_id)) or 0 + ) + + +async def _count_offices(session: AsyncSession, name: str) -> int: + return ( + await session.scalar(select(func.count()).select_from(RegionalOffice).where(RegionalOffice.name == name)) or 0 + ) + + +@pytest.mark.integration +async def test_system_event_without_user(db_session: AsyncSession) -> None: + before = datetime.datetime.now(datetime.UTC) + event = await AuditService(db_session).record( + action=AuditAction.RECOGNIZED_DATA_EDIT, + section=AuditSection.RECOGNITION, + object_type="recognition_job", + user_id=None, + before_value={"status": "queued"}, + after_value={"status": "published"}, + ) + assert event.id is not None + await db_session.refresh(event) # перечитать строку из БД (jsonb round-trip) + + row = (await db_session.execute(select(AuditEvent).where(AuditEvent.id == event.id))).scalar_one() + assert row.user_id is None + assert row.action is AuditAction.RECOGNIZED_DATA_EDIT + assert row.section is AuditSection.RECOGNITION + assert row.before_value == {"status": "queued"} + assert row.after_value == {"status": "published"} + assert row.occurred_at.tzinfo is not None + assert row.occurred_at.utcoffset() == datetime.timedelta(0) + assert ( + before - datetime.timedelta(seconds=5) + <= row.occurred_at + <= datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=5) + ) + + +@pytest.mark.integration +async def test_payload_decimal_uuid_datetime_stored_as_strings(db_session: AsyncSession) -> None: + uid = uuid.uuid4() + moment = datetime.datetime(2026, 10, 3, 9, 30, 15, tzinfo=datetime.UTC) + event = await AuditService(db_session).record( + action=AuditAction.CORRECTION_CREATE, + section=AuditSection.CONSOLIDATION, + object_type="correction", + object_id=uid, + after_value={"amount": Decimal("106311116.46"), "ref": uid, "at": moment, "kind": AuditSection.KFO}, + changed_field="amount", + comment="ручная корректировка", + result="ok", + ip_address="10.0.0.1", + user_agent="pytest", + description="тест", + ) + await db_session.refresh(event) + assert event.after_value == { + "amount": "106311116.46", + "ref": str(uid), + "at": moment.isoformat(), + "kind": "KFO", + } + assert isinstance(event.after_value["amount"], str) + assert (event.object_id, event.changed_field, event.comment, event.result) == ( + uid, + "amount", + "ручная корректировка", + "ok", + ) + assert (event.ip_address, event.user_agent, event.description) == ("10.0.0.1", "pytest", "тест") + + +@pytest.mark.integration +async def test_rollback_removes_audit_with_data(db_session: AsyncSession) -> None: + office_name = f"Филиал-аудит-{uuid.uuid4().hex[:8]}" + object_id = uuid.uuid4() + + with pytest.raises(RuntimeError, match="отмена"): + async with db_session.begin_nested(): # SAVEPOINT: откат при исключении, как откат транзакции запроса + db_session.add(RegionalOffice(name=office_name)) + event = await AuditService(db_session).record( + action=AuditAction.DIRECTORY_IMPORT, + section=AuditSection.DIRECTORIES, + object_type="regional_office", + object_id=object_id, + ) + # внутри транзакции запись и данные видны + assert event.occurred_at is not None + seen = await _count_events(db_session, object_id) + assert seen == 1 + assert await _count_offices(db_session, office_name) == 1 + raise RuntimeError("отмена") + + assert await _count_events(db_session, object_id) == 0 + assert await _count_offices(db_session, office_name) == 0 + + +@pytest.mark.integration +async def test_record_does_not_commit_and_external_rollback_discards(db_session: AsyncSession) -> None: + object_id = uuid.uuid4() + await AuditService(db_session).record( + action=AuditAction.LOGIN, section=AuditSection.SESSION, object_type="app_user", object_id=object_id + ) + assert db_session.in_transaction() # record не закрыл транзакцию (нет commit) + assert await _count_events(db_session, object_id) == 1 + + await db_session.rollback() # внешний откат (в фикстуре — к SAVEPOINT) уничтожает запись + assert await _count_events(db_session, object_id) == 0 -- 2.47.2 From 1c51fb5459c2f20747e79634f2b50ff1985d5c93 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 23:21:30 +0400 Subject: [PATCH 69/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2021?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 25a93cf..7cdddd9 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -101,7 +101,7 @@ ### Task 21: [5.2] `src/services/audit.py` — `AuditService.record(...)` в текущей сессии -- [ ] `src/services/audit.py` — `AuditService.record(...)` в текущей сессии; тест: системное событие с `user_id = NULL`, откат транзакции не оставляет записи; статический тест — в `src/` нет `update()/delete()` по `AuditEvent` +- [x] `src/services/audit.py` — `AuditService.record(...)` в текущей сессии; тест: системное событие с `user_id = NULL`, откат транзакции не оставляет записи; статический тест — в `src/` нет `update()/delete()` по `AuditEvent` ### Task 22: [5.3] `src/core/nats.py` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index e606b7d..ad302c6 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -85,7 +85,7 @@ запроса, конверта (статусы `processing | done | needs_check | error`), файла результата (Decimal как строка); `tests/test_contract.py` — идентичность с `../docs/contracts` (skip с предупреждением без docs), валидация примеров; проверка: тесты зелёные -- [ ] 5.2 `src/services/audit.py` — `AuditService.record(...)` в текущей сессии; тест: системное событие с `user_id = NULL`, +- [x] 5.2 `src/services/audit.py` — `AuditService.record(...)` в текущей сессии; тест: системное событие с `user_id = NULL`, откат транзакции не оставляет записи; статический тест — в `src/` нет `update()/delete()` по `AuditEvent` - [ ] 5.3 `src/core/nats.py`: подключение (с nkeys/JWT или без; автопереподключение), `publish(subject, payload, msg_id)` с заголовком `Nats-Msg-Id`, подписка `recognition.result` в queue group `arm-core`; проверка: интеграционный тест -- 2.47.2 From dcbeea575655be3cf4c91cd199570f55cec28a38 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 23:30:27 +0400 Subject: [PATCH 70/97] =?UTF-8?q?fix:=20=D1=81=D0=BA=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D1=80=20audit=5Fevent=20=D0=BB=D0=BE=D0=B2=D0=B8=D1=82=20?= =?UTF-8?q?=D0=BF=D1=81=D0=B5=D0=B2=D0=B4=D0=BE=D0=BD=D0=B8=D0=BC=D1=8B=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D0=B0,=20=5F=5Ftable=5F=5F?= =?UTF-8?q?.update()/delete()=20=D0=B8=20Query=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/tests/test_audit_service.py | 101 +++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 20 deletions(-) diff --git a/api/tests/test_audit_service.py b/api/tests/test_audit_service.py index 2378472..bfe9ac6 100644 --- a/api/tests/test_audit_service.py +++ b/api/tests/test_audit_service.py @@ -60,44 +60,79 @@ def _call_name(node: ast.Call) -> str | None: return None -def _mentions_audit_event(node: ast.AST) -> bool: +def _audit_event_aliases(tree: ast.AST) -> set[str]: + """Имена AuditEvent в модуле: сам `AuditEvent`, `from ... import AuditEvent as AE`, `AE2 = AE` (до фикспойнта).""" + aliases = {"AuditEvent"} + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + aliases.update(alias.asname or alias.name for alias in node.names if alias.name == "AuditEvent") + changed = True + while changed: + changed = False + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and _mentions(node.value, aliases) and not isinstance(node.value, ast.Call): + for target in node.targets: + if isinstance(target, ast.Name) and target.id not in aliases: + aliases.add(target.id) + changed = True + return aliases + + +def _mutating_names(tree: ast.AST) -> set[str]: + """`update`/`delete` и их импортные псевдонимы (`from sqlalchemy import update as upd`).""" + names = set(MUTATING_CALLS) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + names.update(alias.asname for alias in node.names if alias.name in MUTATING_CALLS and alias.asname) + return names + + +def _mentions(node: ast.AST, aliases: set[str]) -> bool: return any( - (isinstance(sub, ast.Name) and sub.id == "AuditEvent") - or (isinstance(sub, ast.Attribute) and sub.attr == "AuditEvent") + (isinstance(sub, ast.Name) and sub.id in aliases) or (isinstance(sub, ast.Attribute) and sub.attr in aliases) for sub in ast.walk(node) ) -def _is_audit_event_expr(node: ast.AST | None) -> bool: - """Выражение, дающее экземпляр AuditEvent: `AuditEvent(...)` / `mod.AuditEvent(...)`.""" - return isinstance(node, ast.Call) and _mentions_audit_event(node.func) +def _is_audit_event_expr(node: ast.AST | None, aliases: set[str]) -> bool: + """Выражение, дающее экземпляр AuditEvent: `AuditEvent(...)` / `mod.AuditEvent(...)` / `AE(...)`.""" + return isinstance(node, ast.Call) and _mentions(node.func, aliases) -def _tainted_names(tree: ast.AST) -> set[str]: +def _tainted_names(tree: ast.AST, aliases: set[str]) -> set[str]: """Имена, связанные с экземплярами AuditEvent: присваивание `AuditEvent(...)`, аннотация `: AuditEvent`.""" names: set[str] = set() for node in ast.walk(tree): - if isinstance(node, ast.Assign) and _is_audit_event_expr(node.value): + if isinstance(node, ast.Assign) and _is_audit_event_expr(node.value, aliases): names.update(t.id for t in node.targets if isinstance(t, ast.Name)) elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): - if _mentions_audit_event(node.annotation) or _is_audit_event_expr(node.value): + if _mentions(node.annotation, aliases) or _is_audit_event_expr(node.value, aliases): names.add(node.target.id) - elif isinstance(node, ast.arg) and node.annotation is not None and _mentions_audit_event(node.annotation): + elif isinstance(node, ast.arg) and node.annotation is not None and _mentions(node.annotation, aliases): names.add(node.arg) - elif isinstance(node, ast.For | ast.comprehension) and _is_audit_event_expr(getattr(node, "iter", None)): - pass return names def find_audit_event_mutations(source: str, filename: str = "") -> list[str]: """Находки вида `file:line: описание` — любое изменение/удаление audit_event в исходнике. - Ловит: `update(AuditEvent...)` / `delete(AuditEvent...)` (в т.ч. `sa.update`, `AuditEvent.__table__`); - `.delete(x)`/`delete(x)`, где x — экземпляр AuditEvent (внутримодульное отслеживание имён); присваивание/удаление - атрибутов таких экземпляров и `AuditEvent.<поле> = ...`; сырой SQL UPDATE/DELETE/TRUNCATE по audit_event. + Ловит (best-effort, статически, в пределах одного модуля): + - `update(AuditEvent...)` / `delete(AuditEvent...)`, в т.ч. `sa.update`, `update as upd`, `AuditEvent.__table__` + как аргумент; псевдонимы `from ... import AuditEvent as AE` и `AE = AuditEvent`; + - вызов `.update()`/`.delete()`, где приёмник упоминает AuditEvent: `AuditEvent.__table__.update()`, + `session.query(AuditEvent).update(...)/.delete()`; + - `.delete(x)`/`delete(x)`, где x — экземпляр AuditEvent (имя связано с `AuditEvent(...)` или аннотацией); + - присваивание/удаление атрибутов таких экземпляров и `AuditEvent.<поле> = ...`; + - сырой SQL UPDATE/DELETE/TRUNCATE по audit_event в строковой константе. + + НЕ ловит: объекты, загруженные из БД без аннотации (`obj = (await s.execute(select(...))).scalar_one()`), + `setattr`, `session.merge()`, SQL, собранный конкатенацией/f-строкой, межмодульный поток типов. Защита на уровне + БД (триггер/REVOKE UPDATE, DELETE) в Срез 0 не входит. """ tree = ast.parse(source, filename=filename) - tainted = _tainted_names(tree) + aliases = _audit_event_aliases(tree) + tainted = _tainted_names(tree, aliases) + mutating = _mutating_names(tree) findings: list[str] = [] def add(node: ast.AST, message: str) -> None: @@ -107,9 +142,12 @@ def find_audit_event_mutations(source: str, filename: str = "") -> list[str if isinstance(node, ast.Call): name = _call_name(node) args: list[ast.AST] = [*node.args, *(kw.value for kw in node.keywords)] - if name in MUTATING_CALLS: - if any(_mentions_audit_event(arg) for arg in args): + if name in mutating: + receiver = node.func.value if isinstance(node.func, ast.Attribute) else None + if any(_mentions(arg, aliases) for arg in args): add(node, f"{name}() по AuditEvent") + elif receiver is not None and _mentions(receiver, aliases): + add(node, f".{name}() на выражении с AuditEvent") elif any(isinstance(arg, ast.Name) and arg.id in tainted for arg in args): add(node, f"{name}() экземпляра AuditEvent") elif isinstance(node, ast.Assign | ast.AugAssign | ast.AnnAssign | ast.Delete): @@ -118,8 +156,8 @@ def find_audit_event_mutations(source: str, filename: str = "") -> list[str if isinstance(target, ast.Attribute) and target.attr in AUDIT_FIELDS: base = target.value if ( - isinstance(base, ast.Name) and (base.id in tainted or base.id == "AuditEvent") - ) or _is_audit_event_expr(base): + isinstance(base, ast.Name) and (base.id in tainted or base.id in aliases) + ) or _is_audit_event_expr(base, aliases): add(node, f"изменение поля {target.attr} у AuditEvent") elif isinstance(node, ast.Constant) and isinstance(node.value, str) and RAW_SQL_MUTATION.search(node.value): add(node, "сырой SQL, изменяющий audit_event") @@ -154,6 +192,20 @@ def test_src_has_no_audit_event_update_or_delete() -> None: "stmt = text('UPDATE dkb.audit_event SET comment = 1')", "stmt = text('delete from audit_event')", "stmt = text('TRUNCATE TABLE dkb.audit_event')", + # обходы через псевдонимы + "from src.models.audit_ex import AuditEvent as AE\nstmt = delete(AE)", + "from src.models.audit_ex import AuditEvent as AE\nstmt = sa.update(AE).values(result='x')", + "from src.models.audit_ex import AuditEvent as AE\nAE.comment = 'x'", + "from src.models.audit_ex import AuditEvent as AE\ndef f(s):\n e = AE(object_type='x')\n s.delete(e)", + "AE = AuditEvent\nstmt = update(AE)", + "from sqlalchemy import update as upd\nstmt = upd(AuditEvent)", + # __table__ как приёмник + "stmt = AuditEvent.__table__.update().values(result='x')", + "stmt = AuditEvent.__table__.delete()", + # устаревший Query API + "def f(s):\n s.query(AuditEvent).update({'result': 'x'})", + "def f(s):\n s.query(AuditEvent).filter(AuditEvent.id == 1).delete()", + "def f(s):\n from src.models.audit_ex import AuditEvent as AE\n s.query(AE).delete()", ], ) def test_scanner_detects_violation(source: str) -> None: @@ -169,6 +221,13 @@ def test_scanner_detects_violation(source: str) -> None: "async def f(session, office):\n await session.delete(office)", "def f(office):\n office.comment = 'x'", "stmt = text('select * from audit_event')", + "from src.models.audit_ex import AuditEvent as AE\nstmt = select(AE).where(AE.id == 1)", + "stmt = select(AuditEvent.__table__)", + "def f(s):\n return s.query(AuditEvent).filter(AuditEvent.id == 1).all()", + "def f(s):\n s.query(RegionalOffice).update({'name': 'x'})\n s.query(RegionalOffice).delete()", + "async def f(session):\n await AuditService(session).record(action=A, section=S, object_type='x')", + "from src.services.audit import AuditService\nservice = AuditService(session)\nservice.update_cache()", + "def f(d):\n d.update({'a': 1})\n d.get('x').update({})", ], ) def test_scanner_passes_allowed_code(source: str) -> None: @@ -282,6 +341,8 @@ async def _count_offices(session: AsyncSession, name: str) -> int: @pytest.mark.integration async def test_system_event_without_user(db_session: AsyncSession) -> None: + # action/section выбраны произвольно: тест проверяет user_id=NULL и jsonb, а не предметный сценарий (по R-7 + # обмен recognition_job audit_event в Срезе 0 не пишет) before = datetime.datetime.now(datetime.UTC) event = await AuditService(db_session).record( action=AuditAction.RECOGNIZED_DATA_EDIT, -- 2.47.2 From 4e5e1a7b5be2d60823215bc9c96b689d599f0016 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 23:47:39 +0400 Subject: [PATCH 71/97] =?UTF-8?q?feat:=20src/core/nats.py=20=E2=80=94=20?= =?UTF-8?q?=D1=84=D0=BE=D0=BD=D0=BE=D0=B2=D0=BE=D0=B5=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=20cor?= =?UTF-8?q?e=20NATS,=20publish=20=D1=81=20Nats-Msg-Id,=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=20recognition.result=20?= =?UTF-8?q?=D0=B2=20queue=20group=20arm-core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NatsConnection: supervisor первого подключения в фоне (старт приложения не ждёт NATS), nats-py переподключается сам (max_reconnect_attempts=-1, reconnect_time_wait=2) и восстанавливает подписки; экспоненциальный backoff 1→30 с, если connect завершился исключением; логирование ошибок без шума (первая ошибка серии — warning, дальше не чаще раза в минуту). - publish(subject, payload, msg_id): заголовок Nats-Msg-Id + flush; нет соединения или нет подтверждения — NatsPublishError. subscribe_results(handler): queue group arm-core, исключения обработчика логируются. - build_nats_auth перенесён из app.py; ошибка учётных данных — NatsConfigError, категория config_error, без повторов (T4.1). Первое подключение повторяется (T4.2): прежний await connect в lifespan при недоступном NATS висел бесконечно (nats-py с max_reconnect_attempts=-1 повторяет первое подключение сам). - app.py: подключение переехало в core/nats.py (R-3), lifespan запускает/останавливает NatsConnection, подписка — заглушка services/recognition_exchange.handle_message (полная обработка — задача 24). - Тесты: юнит (поддельный клиент, инжектируемый sleep, отсутствие JetStream в src/) и интеграционные против NATS без JetStream (заголовок, queue group без дублей, недоступный сервер). Co-Authored-By: Claude Fable 5.1 --- README.md | 9 +- api/src/app.py | 87 ++---- api/src/core/nats.py | 310 ++++++++++++++++++ api/src/services/recognition_exchange.py | 14 + api/tests/test_app.py | 115 ++++--- api/tests/test_nats_core.py | 379 +++++++++++++++++++++++ api/tests/test_nats_integration.py | 169 ++++++++++ 7 files changed, 968 insertions(+), 115 deletions(-) create mode 100644 api/src/core/nats.py create mode 100644 api/src/services/recognition_exchange.py create mode 100644 api/tests/test_nats_core.py create mode 100644 api/tests/test_nats_integration.py diff --git a/README.md b/README.md index 0f36f1a..593359b 100644 --- a/README.md +++ b/README.md @@ -165,8 +165,10 @@ Dev-пользователи (`api/seed/dev/users.yaml`, создаются `mak слушает `http`. S3 в Срезе 0 не используется (`app.state.storage` остаётся пустым). Это до исправления в задаче 22 (отдельная настройка схемы подключения к MinIO). -Недоступность SMB, S3 или NATS при старте приложение не роняет: ошибка логируется, состояние NATS видно в -`/healthcheck`. +Недоступность SMB, S3 или NATS при старте приложение не роняет и не задерживает: ошибка логируется, состояние NATS +видно в `/healthcheck`. К NATS ядро подключается в фоне и повторяет попытки само (`nats=down`, пока связи нет; warning +в логе — на первую ошибку и дальше не чаще раза в минуту); после разрыва nats-py переподключается и восстанавливает +подписку. Предупреждение «Включён dev-эмитент токенов … только для разработки» появляется не при старте, а при первом запросе к защищённому эндпоинту (`/api/v1/me`): проверяющий токены создаётся лениво (штатно, см. «Безопасность @@ -292,7 +294,8 @@ api/ app.py FastAPI-приложение: lifespan (S3, NATS, SMB), /healthcheck, раздача SPA api/endpoints/ эндпоинты (me.py, dev_auth.py); router.py собирает /api/v1 api/dependencies/ зависимости FastAPI (current_user, проверка прав) - core/ config, auth (JWT), clients (S3), nats_callback, logger, exception_handlers + core/ config, auth (JWT), clients (S3), nats (подключение, publish, подписка), nats_callback, + logger, exception_handlers db/ движок и сессия (схема dkb) models/ ORM-модели, 1:1 с DBML repositories/ доступ к данным diff --git a/api/src/app.py b/api/src/app.py index 370099a..2a39269 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -1,21 +1,18 @@ """Точка входа FastAPI. Каркас из шаблона команды: инфраструктура без доменного кода. -Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT; пустые NATS_SEED/NATS_JWT — без учётных данных; -подключение при NATS_ENABLED=true независимо от DEBUG), SMB (горячие папки ИС Сканирование), middleware (gzip, proxy -headers, CORS, РАИСА при DEBUG=False), healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA. -Недоступность S3 или NATS при старте не роняет приложение: ошибка логируется, healthcheck показывает состояние. -Что добавляет Срез 0: роутеры в src/api/router.py, outbox-паблишер и consumer recognition.result -(docs/context/03-architecture.md), зависимость require(permission). +Что здесь есть: настройки, подключение S3 (MinIO), NATS (`src/core/nats.py`: core +NATS без JetStream, подключение в фоне при NATS_ENABLED=true независимо от DEBUG, подписка `recognition.result` в queue +group `arm-core`), SMB (горячие папки ИС Сканирование), middleware (gzip, proxy headers, CORS, РАИСА при DEBUG=False), +healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA. +Недоступность S3 или NATS при старте не роняет и не задерживает приложение: ошибка логируется, healthcheck показывает +состояние. Outbox-паблишер и вахтёр сроков — задача 23 (docs/context/03-architecture.md). """ -import base64 import os from contextlib import asynccontextmanager from datetime import UTC, datetime from http import HTTPStatus -from typing import Any -import nkeys import smbclient from fastapi import FastAPI, Request from fastapi.exceptions import HTTPException, StarletteHTTPException @@ -23,14 +20,15 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from loguru import logger from miniopy_async import Minio -from nats.aio.client import Client as NATS from starlette.middleware.gzip import GZipMiddleware from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from src.api.router import API_PREFIX, router from src.core.clients import S3Storage -from src.core.config import NATSSettings, get_settings +from src.core.config import get_settings from src.core.exception_handlers import register_exception_handlers +from src.core.nats import NatsConnection +from src.services.recognition_exchange import handle_message settings = get_settings() @@ -38,34 +36,6 @@ VERSION = settings.VERSION UP_TIME = datetime.now(UTC) -# --- NATS: аутентификация по nkeys/JWT --------------------------------------------------------- -def _build_nats_auth(nats_settings: NATSSettings) -> dict[str, Any]: - """Параметры аутентификации для ``NATS.connect``. - - Пустые NATS_SEED и NATS_JWT — подключение без учётных данных (docker compose, тесты): ``{}``. Заданы оба — - колбэки nkeys/JWT, как в шаблоне. Задан только один — ошибка конфигурации. Подключение переедет в core/nats.py - (задача 22). - """ - seed = nats_settings.NATS_SEED - jwt = nats_settings.NATS_JWT - if not seed and not jwt: - return {} - if not seed or not jwt: - raise RuntimeError("Неполные учётные данные NATS: NATS_SEED и NATS_JWT задаются вместе или не задаются вовсе") - - keypair = nkeys.from_seed(seed.encode("utf-8")) - - def user_jwt_cb() -> bytes: - return jwt.encode("utf-8") - - def signature_cb(nonce: str | bytes) -> bytes: - if isinstance(nonce, str): - nonce = nonce.encode("utf-8") - return base64.b64encode(keypair.sign(nonce)) - - return {"user_jwt_cb": user_jwt_cb, "signature_cb": signature_cb} - - # --- РАИСА: защищённый API (только вне DEBUG) --------------------------------------------------- protected_middleware_available = False if not settings.DEBUG: @@ -118,33 +88,21 @@ async def _connect_s3() -> S3Storage | None: return S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET) -async def _connect_nats() -> NATS | None: - """Подключение к NATS при NATS_ENABLED=true; при ошибке — ``None`` (healthcheck покажет ``down``).""" +async def _subscribe_results(nats: NatsConnection) -> None: + """Подписки после первого подключения к NATS: `recognition.result` в queue group `arm-core`.""" + await nats.subscribe_results(handle_message) + + +def _create_nats() -> NatsConnection | None: + """Соединение NATS при NATS_ENABLED=true; при false — ``None`` (healthcheck покажет ``off``).""" if not settings.NATS_ENABLED: logger.info("NATS выключен (NATS_ENABLED=false)") return None - nc = NATS() - try: - await nc.connect( - settings.OPENBAO.NATS.NATS_URL, - max_reconnect_attempts=-1, - reconnect_time_wait=2, - **_build_nats_auth(settings.OPENBAO.NATS), - ) - except Exception as exc: - logger.exception( - "Не удалось подключиться к NATS. error_category=nats_error, error_type={error_type}, error={error}", - error_type=type(exc).__name__, - error=repr(exc), - ) - return None - logger.info("Подключение к NATS произошло") - return nc + return NatsConnection(settings.OPENBAO.NATS, on_connected=_subscribe_results) @asynccontextmanager async def lifespan(app: FastAPI): - app.state.nats_subs = [] app.state.storage = None app.state.nats = None @@ -157,18 +115,17 @@ async def lifespan(app: FastAPI): try: app.state.storage = await _connect_s3() - app.state.nats = await _connect_nats() - # Подписки (recognition.result) и outbox-паблишер — Срез 0, см. docs/context/03-architecture.md + app.state.nats = _create_nats() + if app.state.nats is not None: + await app.state.nats.start() # подключение в фоне: старт приложения не ждёт NATS logger.info("Приложение запущено") yield finally: logger.info("Приложение закрывается") smbclient.reset_connection_cache() - for sub in app.state.nats_subs: - await sub.unsubscribe() - if app.state.nats: - await app.state.nats.close() + if app.state.nats is not None: + await app.state.nats.stop() if app.state.storage: await app.state.storage.client.close_session() diff --git a/api/src/core/nats.py b/api/src/core/nats.py new file mode 100644 index 0000000..a798c0c --- /dev/null +++ b/api/src/core/nats.py @@ -0,0 +1,310 @@ +"""Соединение с NATS: core NATS без JetStream (design D-6; spec recognition-job-exchange, «Соединение с NATS по +шаблону, без JetStream»; docs/context/03-architecture.md, «Контракт обмена через NATS»). + +- Аутентификация как в шаблоне: заданы `NATS_SEED` и `NATS_JWT` — nkeys/JWT; оба пусты — без учётных данных. +- `publish(subject, payload, msg_id)` — публикация с заголовком `Nats-Msg-Id` и `flush`: ошибка соединения проявляется + сразу исключением `NatsPublishError` (outbox оставляет задание в `queued`). +- `subscribe_results(handler)` — подписка на `recognition.result` в queue group `arm-core`: из нескольких инстансов ядра + сообщение получает ровно один. Исключения обработчика логируются, подписка не падает. +- Stream'ов и consumer'ов JetStream нет. Переподключение после разрыва — встроенное в nats-py + (`max_reconnect_attempts=-1`, `reconnect_time_wait=2`), подписки nats-py восстанавливает сам. +- Первое подключение выполняет фоновый supervisor (`start()`): старт приложения не ждёт NATS. nats-py 2.14 с + `max_reconnect_attempts=-1` сам повторяет и первое подключение (раз в `reconnect_time_wait`), поэтому `connect` + возвращается только при успехе; если он всё же завершился исключением, supervisor повторяет попытку с + экспоненциальной паузой 1 → 2 → 4 → … ≤ 30 с. Ошибки подключения логируются без шума: первая ошибка серии — warning + с причиной, дальше не чаще раза в минуту; серия сбрасывается при (пере)подключении. +""" + +import asyncio +import base64 +import time +from collections.abc import Awaitable, Callable +from contextlib import suppress +from typing import Any + +import nkeys +from loguru import logger +from nats.aio.client import Client as NATS +from nats.aio.msg import Msg + +from src.contract.subjects import NATS_MSG_ID_HEADER, RESULT_SUBJECT +from src.core.config import NATSSettings + +RESULT_QUEUE_GROUP = "arm-core" +CLIENT_NAME = "arm-core" +MAX_RECONNECT_ATTEMPTS = -1 # переподключаться бесконечно +RECONNECT_TIME_WAIT = 2 # секунд между попытками переподключения nats-py +CONNECT_TIMEOUT = 2 # секунд на одну попытку TCP-подключения +FLUSH_TIMEOUT = 5 # секунд на подтверждение публикации сервером (PING/PONG) +BACKOFF_INITIAL = 1.0 +BACKOFF_MAX = 30.0 +ERROR_LOG_INTERVAL = 60.0 # не чаще раза в минуту повторяем warning об ошибках одной серии + +MessageHandler = Callable[[Msg], Awaitable[None]] +SleepFn = Callable[[float], Awaitable[None]] + + +class NatsError(Exception): + """Базовая ошибка слоя NATS ядра.""" + + +class NatsConfigError(NatsError, RuntimeError): + """Ошибка конфигурации учётных данных NATS: повтор подключения бесполезен.""" + + +class NatsNotConnectedError(NatsError): + """Операция требует установленного соединения с NATS.""" + + +class NatsPublishError(NatsError): + """Публикация не подтверждена: соединения нет или сервер не ответил на flush.""" + + +def build_nats_auth(settings: NATSSettings) -> dict[str, Any]: + """Параметры аутентификации для `Client.connect`. + + Пустые `NATS_SEED` и `NATS_JWT` — подключение без учётных данных (docker compose, тесты): `{}`. Заданы оба — + колбэки nkeys/JWT, как в шаблоне. Задан только один или seed не читается — `NatsConfigError`. + """ + seed = settings.NATS_SEED + jwt = settings.NATS_JWT + if not seed and not jwt: + return {} + if not seed or not jwt: + raise NatsConfigError( + "Неполные учётные данные NATS: NATS_SEED и NATS_JWT задаются вместе или не задаются вовсе" + ) + try: + keypair = nkeys.from_seed(seed.encode("utf-8")) + except Exception as exc: + raise NatsConfigError(f"Некорректный NATS_SEED: {type(exc).__name__}") from None + + def user_jwt_cb() -> bytes: + return jwt.encode("utf-8") + + def signature_cb(nonce: str | bytes) -> bytes: + if isinstance(nonce, str): + nonce = nonce.encode("utf-8") + return base64.b64encode(keypair.sign(nonce)) + + return {"user_jwt_cb": user_jwt_cb, "signature_cb": signature_cb} + + +class NatsConnection: + """Соединение ядра с NATS: фоновое первое подключение, публикация с `Nats-Msg-Id`, подписка на результаты. + + `on_connected(conn)` вызывается один раз после первого успешного подключения (подписки); при исключении + повторяется с той же экспоненциальной паузой. `result_subject` и `queue` параметризуются для тестов — + по умолчанию `recognition.result` и `arm-core`. + """ + + def __init__( # noqa: PLR0913 - все параметры, кроме settings, именованные: подмены для тестов и тайминги + self, + settings: NATSSettings, + *, + on_connected: Callable[["NatsConnection"], Awaitable[None]] | None = None, + result_subject: str = RESULT_SUBJECT, + queue: str = RESULT_QUEUE_GROUP, + client_factory: Callable[[], Any] = NATS, + sleep: SleepFn = asyncio.sleep, + backoff_initial: float = BACKOFF_INITIAL, + backoff_max: float = BACKOFF_MAX, + error_log_interval: float = ERROR_LOG_INTERVAL, + flush_timeout: int = FLUSH_TIMEOUT, + ) -> None: + self._settings = settings + self._on_connected = on_connected + self.result_subject = result_subject + self.queue = queue + self._client_factory = client_factory + self._sleep = sleep + self._backoff_initial = backoff_initial + self._backoff_max = backoff_max + self._error_log_interval = error_log_interval + self._flush_timeout = flush_timeout + self._client: Any = None + self._supervisor: asyncio.Task[None] | None = None + self._ready = asyncio.Event() + self._stopping = False + # Серия ошибок подключения: сколько подавлено и когда последний раз писали warning + self._error_streak = 0 + self._last_error_log = 0.0 + + # --- состояние ------------------------------------------------------------------------------ + @property + def is_connected(self) -> bool: + """Фактическое состояние соединения (False до первого подключения, во время переподключения и после stop).""" + return self._client is not None and bool(self._client.is_connected) + + async def wait_ready(self, timeout: float) -> bool: + """Ждёт первого подключения и выполнения `on_connected`; `False`, если не дождались за `timeout` секунд.""" + try: + await asyncio.wait_for(self._ready.wait(), timeout) + except TimeoutError: + return False + return True + + # --- жизненный цикл ------------------------------------------------------------------------- + async def start(self) -> None: + """Запускает фоновый supervisor первого подключения; не ждёт NATS.""" + if self._supervisor is None: + self._supervisor = asyncio.create_task(self._supervise(), name="nats-supervisor") + + async def stop(self) -> None: + """Останавливает supervisor и закрывает соединение; исключения наружу не выходят.""" + self._stopping = True + if self._supervisor is not None: + self._supervisor.cancel() + with suppress(asyncio.CancelledError, Exception): + await self._supervisor + self._supervisor = None + await self.close() + + async def close(self) -> None: + """Подключённый клиент — `drain` (дообработать полученное), иначе `close`; без исключений наружу.""" + client = self._client + if client is None or client.is_closed: + return + try: + if client.is_connected: + await client.drain() + else: + await client.close() + except Exception as exc: + logger.warning( + "Ошибка при закрытии соединения NATS. error_category=nats_error, error_type={error_type}, " + "error={error}", + error_type=type(exc).__name__, + error=repr(exc), + ) + with suppress(Exception): + await client.close() + + async def connect(self) -> None: + """Одна попытка подключения новым клиентом. nats-py с `max_reconnect_attempts=-1` повторяет её сам.""" + auth = build_nats_auth(self._settings) + client = self._client_factory() + self._client = client + try: + await client.connect( + self._settings.NATS_URL, + name=CLIENT_NAME, + error_cb=self._on_error, + disconnected_cb=self._on_disconnected, + reconnected_cb=self._on_reconnected, + closed_cb=self._on_closed, + max_reconnect_attempts=MAX_RECONNECT_ATTEMPTS, + reconnect_time_wait=RECONNECT_TIME_WAIT, + connect_timeout=CONNECT_TIMEOUT, + **auth, + ) + except BaseException: + with suppress(Exception): + await client.close() + raise + + async def _supervise(self) -> None: + delay = self._backoff_initial + while True: + try: + await self.connect() + break + except NatsConfigError as exc: + logger.error( + "Конфигурация NATS некорректна, подключение не выполняется. error_category=config_error, " + "error={error}", + error=str(exc), + ) + return + except Exception as exc: + self._report_error(exc) + await self._sleep(delay) + delay = min(delay * 2, self._backoff_max) + self._reset_error_streak() + logger.info("Подключение к NATS установлено. url={url}", url=self._settings.NATS_URL) + + delay = self._backoff_initial + while self._on_connected is not None: + try: + await self._on_connected(self) + break + except Exception as exc: + logger.error( + "Не удалось выполнить подписки NATS. error_category=nats_error, error_type={error_type}, " + "error={error}", + error_type=type(exc).__name__, + error=repr(exc), + ) + await self._sleep(delay) + delay = min(delay * 2, self._backoff_max) + self._ready.set() + + # --- публикация и подписка ------------------------------------------------------------------ + async def publish(self, subject: str, payload: bytes, msg_id: str) -> None: + """Публикует сообщение с заголовком `Nats-Msg-Id: ` и ждёт подтверждения сервера (`flush`).""" + if not self.is_connected: + raise NatsPublishError(f"Нет соединения с NATS: сообщение {msg_id} в {subject} не опубликовано") + try: + await self._client.publish(subject, payload, headers={NATS_MSG_ID_HEADER: msg_id}) + await self._client.flush(timeout=self._flush_timeout) + except Exception as exc: + raise NatsPublishError( + f"Публикация в NATS не подтверждена: {subject}, {msg_id} ({type(exc).__name__})" + ) from exc + + async def subscribe_results(self, handler: MessageHandler) -> Any: + """Подписка на `result_subject` в queue group `queue`; исключения `handler` логируются, подписка живёт.""" + if not self.is_connected: + raise NatsNotConnectedError(f"Нет соединения с NATS: подписка на {self.result_subject} невозможна") + + async def safe_handler(msg: Msg) -> None: + try: + await handler(msg) + except Exception as exc: + logger.exception( + "Ошибка обработки сообщения NATS. error_category=handler_error, subject={subject}, " + "error_type={error_type}, error={error}", + subject=msg.subject, + error_type=type(exc).__name__, + error=repr(exc), + ) + + subscription = await self._client.subscribe(self.result_subject, queue=self.queue, cb=safe_handler) + logger.info( + "Подписка NATS активна. subject={subject}, queue={queue}", subject=self.result_subject, queue=self.queue + ) + return subscription + + # --- колбэки nats-py и логирование без шума ------------------------------------------------- + def _report_error(self, exc: BaseException) -> None: + now = time.monotonic() + self._error_streak += 1 + if self._error_streak == 1 or now - self._last_error_log >= self._error_log_interval: + logger.warning( + "Нет связи с NATS, повторяем подключение. error_category=nats_error, url={url}, " + "error_type={error_type}, error={error}, attempts={attempts}", + url=self._settings.NATS_URL, + error_type=type(exc).__name__, + error=repr(exc), + attempts=self._error_streak, + ) + self._last_error_log = now + + def _reset_error_streak(self) -> None: + self._error_streak = 0 + self._last_error_log = 0.0 + + async def _on_error(self, exc: Exception) -> None: + self._report_error(exc) + + async def _on_disconnected(self) -> None: + if self._stopping: + return + logger.warning("Соединение с NATS потеряно, nats-py переподключается") + + async def _on_reconnected(self) -> None: + self._reset_error_streak() + logger.info("Соединение с NATS восстановлено") + + async def _on_closed(self) -> None: + logger.info("Соединение с NATS закрыто") diff --git a/api/src/services/recognition_exchange.py b/api/src/services/recognition_exchange.py new file mode 100644 index 0000000..3ddb740 --- /dev/null +++ b/api/src/services/recognition_exchange.py @@ -0,0 +1,14 @@ +"""Обмен с сервисами распознавания через `recognition_job` (design D-6; docs/context/03-architecture.md). + +Пока здесь только заглушка обработчика `recognition.result`: подписка в `src/core/nats.py` уже вызывает её. +Полноценная обработка (ack `processing`, результат, дедуп по `result_msg_id`) — задача 24 (рулинг R-12, design #23); +`publish_pending()` и `expire_deadlines()` — задача 23. +""" + +from loguru import logger +from nats.aio.msg import Msg + + +async def handle_message(msg: Msg) -> None: + """Заглушка: логирует subject и размер полученного сообщения, ничего не меняет.""" + logger.info("Получено сообщение NATS. subject={subject}, size={size}", subject=msg.subject, size=len(msg.data)) diff --git a/api/tests/test_app.py b/api/tests/test_app.py index 6dc057b..b0d0fda 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -1,53 +1,24 @@ -"""Быстрые тесты каркаса приложения: аутентификация NATS, /healthcheck, префикс API (без БД и NATS).""" +"""Быстрые тесты каркаса приложения: lifespan, /healthcheck, префикс API (без БД и NATS). +Аутентификация и supervisor NATS — tests/test_nats_core.py (подключение переехало в src/core/nats.py, рулинг R-3). +""" + +import asyncio import importlib -import os from http import HTTPStatus from types import SimpleNamespace +from typing import Any import httpx -import nkeys import pytest from src.api.router import router -from src.core.config import NATSSettings +from src.core.nats import NatsConnection # src/__init__.py реэкспортирует объект app под именем модуля, поэтому модуль берём через importlib app_module = importlib.import_module("src.app") -def _nats_settings(seed: str = "", jwt: str = "") -> NATSSettings: - return NATSSettings(NATS_URL="nats://localhost:4222", NATS_SEED=seed, NATS_JWT=jwt) - - -def _valid_seed() -> str: - return nkeys.encode_seed(os.urandom(32), nkeys.PREFIX_BYTE_USER).decode("utf-8") - - -def test_build_nats_auth_empty_credentials_gives_no_kwargs(): - assert app_module._build_nats_auth(_nats_settings()) == {} - - -@pytest.mark.parametrize( - ("seed", "jwt"), - [("SU-only-seed", ""), ("", "only-jwt")], -) -def test_build_nats_auth_partial_credentials_is_config_error(seed: str, jwt: str): - with pytest.raises(RuntimeError, match="NATS_SEED"): - app_module._build_nats_auth(_nats_settings(seed, jwt)) - - -def test_build_nats_auth_valid_credentials_give_callbacks(): - seed = _valid_seed() - auth = app_module._build_nats_auth(_nats_settings(seed, "header.payload.signature")) - - assert set(auth) == {"user_jwt_cb", "signature_cb"} - assert auth["user_jwt_cb"]() == b"header.payload.signature" - signature = auth["signature_cb"](b"nonce") - assert isinstance(signature, bytes) - assert signature - - @pytest.fixture async def client(): # Без lifespan: app.state.nats не установлен — healthcheck обязан это пережить @@ -97,10 +68,15 @@ def test_router_prefix(): class _FakeNats: def __init__(self) -> None: - self.closed = False + self.started = False + self.stopped = False + self.is_connected = False - async def close(self) -> None: - self.closed = True + async def start(self) -> None: + self.started = True + + async def stop(self) -> None: + self.stopped = True class _FakeS3Client: @@ -123,7 +99,7 @@ async def test_lifespan_starts_when_s3_and_nats_unavailable(monkeypatch: pytest. return None monkeypatch.setattr(app_module, "_connect_s3", unavailable) - monkeypatch.setattr(app_module, "_connect_nats", unavailable) + monkeypatch.setattr(app_module, "_create_nats", lambda: None) reached_yield = False async with app_module.lifespan(app_module.app): @@ -135,7 +111,7 @@ async def test_lifespan_starts_when_s3_and_nats_unavailable(monkeypatch: pytest. @pytest.mark.usefixtures("_no_smb") -async def test_lifespan_closes_created_resources(monkeypatch: pytest.MonkeyPatch): +async def test_lifespan_starts_and_stops_nats_and_closes_s3(monkeypatch: pytest.MonkeyPatch): fake_nats = _FakeNats() s3_client = _FakeS3Client() storage = SimpleNamespace(client=s3_client) @@ -143,17 +119,62 @@ async def test_lifespan_closes_created_resources(monkeypatch: pytest.MonkeyPatch async def connect_s3() -> SimpleNamespace: return storage - async def connect_nats() -> _FakeNats: - return fake_nats - monkeypatch.setattr(app_module, "_connect_s3", connect_s3) - monkeypatch.setattr(app_module, "_connect_nats", connect_nats) + monkeypatch.setattr(app_module, "_create_nats", lambda: fake_nats) async with app_module.lifespan(app_module.app): assert app_module.app.state.storage is storage assert app_module.app.state.nats is fake_nats - assert not fake_nats.closed + assert fake_nats.started + assert not fake_nats.stopped assert not s3_client.session_closed - assert fake_nats.closed + assert fake_nats.stopped assert s3_client.session_closed + + +class _NeverConnectingClient: + """Клиент nats-py, который никогда не подключается: connect висит (как nats-py без сервера).""" + + is_connected = False + is_closed = False + + async def connect(self, servers: str, **options: Any) -> None: + await asyncio.Event().wait() + + async def close(self) -> None: + self.is_closed = True + + +@pytest.mark.usefixtures("_no_smb") +async def test_lifespan_does_not_wait_for_nats_and_healthcheck_is_down(monkeypatch: pytest.MonkeyPatch): + """Регрессия: недоступный NATS не задерживает старт; до первого подключения healthcheck показывает down.""" + + async def unavailable() -> None: + return None + + nats = NatsConnection(app_module.settings.OPENBAO.NATS, client_factory=_NeverConnectingClient) + monkeypatch.setattr(app_module, "_connect_s3", unavailable) + monkeypatch.setattr(app_module, "_create_nats", lambda: nats) + monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True) + + async with app_module.lifespan(app_module.app): + transport = httpx.ASGITransport(app=app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + response = await c.get("/healthcheck") + assert response.json()["nats"] == "down" + + assert not nats.is_connected + + +def test_create_nats_disabled_returns_none(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(app_module.settings, "NATS_ENABLED", False) + assert app_module._create_nats() is None + + +def test_create_nats_enabled_returns_connection_for_results(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True) + nats = app_module._create_nats() + assert isinstance(nats, NatsConnection) + assert (nats.result_subject, nats.queue) == ("recognition.result", "arm-core") + assert not nats.is_connected # создание не подключает: подключение — в start() diff --git a/api/tests/test_nats_core.py b/api/tests/test_nats_core.py new file mode 100644 index 0000000..176d242 --- /dev/null +++ b/api/tests/test_nats_core.py @@ -0,0 +1,379 @@ +"""Быстрые тесты `src/core/nats.py` без сервера NATS: аутентификация, supervisor первого подключения, publish. + +Клиент nats-py подменяется фабрикой (`client_factory`), паузы backoff — инжектируемым `sleep` +(spec recognition-job-exchange, «Соединение с NATS по шаблону, без JetStream»; design D-6). +""" + +import ast +import asyncio +import os +from collections.abc import Iterator +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import nkeys +import pytest +from loguru import logger + +from src.contract.subjects import NATS_MSG_ID_HEADER, RESULT_SUBJECT +from src.core.config import NATSSettings +from src.core.nats import ( + RESULT_QUEUE_GROUP, + NatsConfigError, + NatsConnection, + NatsNotConnectedError, + NatsPublishError, + build_nats_auth, +) + +SRC_DIR = Path(__file__).resolve().parent.parent / "src" + + +def _nats_settings(seed: str = "", jwt: str = "") -> NATSSettings: + return NATSSettings(NATS_URL="nats://localhost:4222", NATS_SEED=seed, NATS_JWT=jwt) + + +def _valid_seed() -> str: + return nkeys.encode_seed(os.urandom(32), nkeys.PREFIX_BYTE_USER).decode("utf-8") + + +@pytest.fixture +def log_records() -> Iterator[list[Any]]: + """Записи loguru, выпущенные за время теста.""" + records: list[Any] = [] + handler_id = logger.add(lambda message: records.append(message.record), level="DEBUG") + yield records + logger.remove(handler_id) + + +# --- build_nats_auth ---------------------------------------------------------------------------- +def test_build_nats_auth_empty_credentials_gives_no_kwargs(): + assert build_nats_auth(_nats_settings()) == {} + + +@pytest.mark.parametrize(("seed", "jwt"), [("SU-only-seed", ""), ("", "only-jwt")]) +def test_build_nats_auth_partial_credentials_is_config_error(seed: str, jwt: str): + with pytest.raises(NatsConfigError, match="NATS_SEED"): + build_nats_auth(_nats_settings(seed, jwt)) + + +def test_build_nats_auth_invalid_seed_is_config_error(): + with pytest.raises(NatsConfigError, match="NATS_SEED"): + build_nats_auth(_nats_settings("not-a-seed", "header.payload.signature")) + + +def test_nats_config_error_is_runtime_error(): + assert issubclass(NatsConfigError, RuntimeError) + + +def test_build_nats_auth_valid_credentials_give_callbacks(): + auth = build_nats_auth(_nats_settings(_valid_seed(), "header.payload.signature")) + + assert set(auth) == {"user_jwt_cb", "signature_cb"} + assert auth["user_jwt_cb"]() == b"header.payload.signature" + signature = auth["signature_cb"]("nonce") + assert isinstance(signature, bytes) + assert signature + + +# --- поддельный клиент nats-py ------------------------------------------------------------------ +class _FakeClient: + """Минимальный двойник `nats.aio.client.Client`: подключение, подписка, публикация, закрытие.""" + + def __init__(self, world: "_FakeWorld") -> None: + self.world = world + self.is_connected = False + self.is_closed = False + self.connect_options: dict[str, Any] = {} + self.subscriptions: list[dict[str, Any]] = [] + self.published: list[dict[str, Any]] = [] + self.flush_error: Exception | None = None + self.drained = False + + async def connect(self, servers: str, **options: Any) -> None: + self.world.connect_calls += 1 + self.connect_options = {"servers": servers, **options} + if self.world.connect_calls <= self.world.fail_times: + raise OSError("Connection refused") + self.is_connected = True + + async def subscribe(self, subject: str, queue: str = "", cb: Any = None) -> SimpleNamespace: + self.subscriptions.append({"subject": subject, "queue": queue, "cb": cb}) + return SimpleNamespace(subject=subject, queue=queue) + + async def publish(self, subject: str, payload: bytes = b"", headers: dict[str, str] | None = None) -> None: + self.published.append({"subject": subject, "payload": payload, "headers": headers}) + + async def flush(self, timeout: int = 10) -> None: + if self.flush_error is not None: + raise self.flush_error + + async def drain(self) -> None: + self.drained = True + await self.close() + + async def close(self) -> None: + self.is_connected = False + self.is_closed = True + + +class _FakeWorld: + """Фабрика клиентов: первые `fail_times` подключений падают, затем успех.""" + + def __init__(self, fail_times: int = 0) -> None: + self.fail_times = fail_times + self.connect_calls = 0 + self.clients: list[_FakeClient] = [] + + def __call__(self) -> _FakeClient: + client = _FakeClient(self) + self.clients.append(client) + return client + + +class _RecordingSleep: + """Подмена `asyncio.sleep`: запоминает интервалы backoff и не ждёт.""" + + def __init__(self) -> None: + self.delays: list[float] = [] + + async def __call__(self, delay: float) -> None: + self.delays.append(delay) + await asyncio.sleep(0) + + +async def _handler(msg: Any) -> None: + return None + + +# --- supervisor первого подключения ------------------------------------------------------------- +async def test_supervisor_retries_with_backoff_then_connects_and_subscribes_once(log_records: list[Any]): + world = _FakeWorld(fail_times=7) + sleep = _RecordingSleep() + subscribed: list[NatsConnection] = [] + + async def on_connected(conn: NatsConnection) -> None: + subscribed.append(conn) + await conn.subscribe_results(_handler) + + conn = NatsConnection(_nats_settings(), on_connected=on_connected, client_factory=world, sleep=sleep) + assert not conn.is_connected + + await conn.start() + assert await conn.wait_ready(timeout=2) + + assert conn.is_connected + assert world.connect_calls == 8 # noqa: PLR2004 + assert sleep.delays == [1, 2, 4, 8, 16, 30, 30] + assert subscribed == [conn] + connected_client = world.clients[-1] + assert [(s["subject"], s["queue"]) for s in connected_client.subscriptions] == [ + (RESULT_SUBJECT, RESULT_QUEUE_GROUP) + ] + options = connected_client.connect_options + assert options["servers"] == "nats://localhost:4222" + assert options["max_reconnect_attempts"] == -1 + assert options["reconnect_time_wait"] == 2 # noqa: PLR2004 + for callback in ("error_cb", "disconnected_cb", "reconnected_cb", "closed_cb"): + assert callable(options[callback]) + + # Первая ошибка — warning с причиной, остальные в пределах минуты подавлены + warnings = [r for r in log_records if r["level"].name == "WARNING"] + assert len(warnings) == 1 + assert "error_category=nats_error" in warnings[0]["message"] + assert "Connection refused" in warnings[0]["message"] + + await conn.stop() + assert connected_client.is_closed + + +async def test_supervisor_connects_first_time_without_sleep(): + world = _FakeWorld() + sleep = _RecordingSleep() + conn = NatsConnection(_nats_settings(), client_factory=world, sleep=sleep) + + await conn.start() + assert await conn.wait_ready(timeout=2) + + assert sleep.delays == [] + assert world.connect_calls == 1 + await conn.stop() + + +async def test_start_does_not_block_and_stop_cancels_supervisor(): + world = _FakeWorld(fail_times=10**6) + never = asyncio.Event() + + async def blocking_sleep(delay: float) -> None: + await never.wait() + + conn = NatsConnection(_nats_settings(), client_factory=world, sleep=blocking_sleep) + await conn.start() + await asyncio.sleep(0.01) + + assert not conn.is_connected + assert world.connect_calls == 1 + + await conn.stop() # без исключений наружу + assert not conn.is_connected + assert not await conn.wait_ready(timeout=0.01) + + +async def test_config_error_logged_as_config_error_and_not_retried(log_records: list[Any]): + world = _FakeWorld() + sleep = _RecordingSleep() + conn = NatsConnection(_nats_settings(seed="SU-only-seed"), client_factory=world, sleep=sleep) + + await conn.start() + assert not await conn.wait_ready(timeout=0.05) + + assert world.connect_calls == 0 + assert sleep.delays == [] + assert not conn.is_connected + errors = [r for r in log_records if r["level"].name == "ERROR"] + assert len(errors) == 1 + assert "error_category=config_error" in errors[0]["message"] + assert "nats_error" not in errors[0]["message"] + await conn.stop() + + +async def test_on_connected_failure_is_retried(log_records: list[Any]): + world = _FakeWorld() + sleep = _RecordingSleep() + calls = 0 + + async def flaky_on_connected(conn: NatsConnection) -> None: + nonlocal calls + calls += 1 + if calls < 3: # noqa: PLR2004 + raise RuntimeError("подписка не удалась") + + conn = NatsConnection(_nats_settings(), on_connected=flaky_on_connected, client_factory=world, sleep=sleep) + await conn.start() + assert await conn.wait_ready(timeout=2) + + assert calls == 3 # noqa: PLR2004 + assert sleep.delays == [1, 2] + await conn.stop() + + +async def test_stop_without_start_is_noop(): + conn = NatsConnection(_nats_settings(), client_factory=_FakeWorld()) + await conn.stop() + assert not conn.is_connected + + +async def test_error_callback_logging_is_throttled_and_reset_on_reconnect(log_records: list[Any]): + world = _FakeWorld() + conn = NatsConnection(_nats_settings(), client_factory=world, sleep=_RecordingSleep()) + await conn.start() + assert await conn.wait_ready(timeout=2) + options = world.clients[-1].connect_options + + await options["disconnected_cb"]() + for _ in range(5): + await options["error_cb"](OSError("Connection refused")) + await options["reconnected_cb"]() + await options["error_cb"](OSError("Connection reset")) + + warnings = [r["message"] for r in log_records if r["level"].name == "WARNING"] + errors_logged = [m for m in warnings if "error_category=nats_error" in m] + assert len(errors_logged) == 2 # noqa: PLR2004 - первая ошибка серии и первая после переподключения + assert any("Connection reset" in m for m in errors_logged) + await conn.stop() + + +# --- publish / subscribe ------------------------------------------------------------------------ +async def test_publish_without_connection_raises_publish_error(): + conn = NatsConnection(_nats_settings(), client_factory=_FakeWorld()) + + with pytest.raises(NatsPublishError, match="NATS"): + await conn.publish("recognition.tabular.request", b"{}", "job:1") + + +async def test_publish_sends_msg_id_header_and_flushes(): + world = _FakeWorld() + conn = NatsConnection(_nats_settings(), client_factory=world, sleep=_RecordingSleep()) + await conn.start() + assert await conn.wait_ready(timeout=2) + + await conn.publish("recognition.tabular.request", b'{"a": 1}', "job:1") + + assert world.clients[-1].published == [ + {"subject": "recognition.tabular.request", "payload": b'{"a": 1}', "headers": {NATS_MSG_ID_HEADER: "job:1"}} + ] + await conn.stop() + + +async def test_publish_flush_failure_raises_publish_error(): + world = _FakeWorld() + conn = NatsConnection(_nats_settings(), client_factory=world, sleep=_RecordingSleep()) + await conn.start() + assert await conn.wait_ready(timeout=2) + world.clients[-1].flush_error = TimeoutError() + + with pytest.raises(NatsPublishError): + await conn.publish("recognition.tabular.request", b"{}", "job:1") + await conn.stop() + + +async def test_subscribe_results_handler_errors_are_logged_not_raised(log_records: list[Any]): + world = _FakeWorld() + conn = NatsConnection( + _nats_settings(), client_factory=world, sleep=_RecordingSleep(), result_subject="test.subject", queue="g" + ) + await conn.start() + assert await conn.wait_ready(timeout=2) + + async def failing_handler(msg: Any) -> None: + raise ValueError("сломанный обработчик") + + await conn.subscribe_results(failing_handler) + subscription = world.clients[-1].subscriptions[0] + assert (subscription["subject"], subscription["queue"]) == ("test.subject", "g") + + await subscription["cb"](SimpleNamespace(subject="test.subject", data=b"xyz", headers={})) + + errors = [r for r in log_records if r["level"].name == "ERROR"] + assert len(errors) == 1 + assert "error_category=handler_error" in errors[0]["message"] + await conn.stop() + + +async def test_subscribe_results_without_connection_raises(): + conn = NatsConnection(_nats_settings(), client_factory=_FakeWorld()) + with pytest.raises(NatsNotConnectedError): + await conn.subscribe_results(_handler) + + +async def test_stop_drains_connected_client(): + world = _FakeWorld() + conn = NatsConnection(_nats_settings(), client_factory=world, sleep=_RecordingSleep()) + await conn.start() + assert await conn.wait_ready(timeout=2) + + await conn.stop() + + assert world.clients[-1].drained + assert not conn.is_connected + + +# --- без JetStream ------------------------------------------------------------------------------- +def test_src_does_not_use_jetstream(): + """JetStream на платформе нет: в src/ нет вызовов `.jetstream()` и импортов `nats.js`.""" + offenders: list[str] = [] + for path in SRC_DIR.rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Attribute) and node.attr.lower() == "jetstream": + offenders.append(f"{path}:{node.lineno} .{node.attr}") + elif isinstance(node, ast.ImportFrom) and (node.module or "").startswith("nats.js"): + offenders.append(f"{path}:{node.lineno} from {node.module}") + elif isinstance(node, ast.Import): + offenders.extend( + f"{path}:{node.lineno} import {alias.name}" + for alias in node.names + if alias.name.startswith("nats.js") + ) + assert offenders == [] diff --git a/api/tests/test_nats_integration.py b/api/tests/test_nats_integration.py new file mode 100644 index 0000000..0c224d3 --- /dev/null +++ b/api/tests/test_nats_integration.py @@ -0,0 +1,169 @@ +"""Интеграционные тесты `src/core/nats.py` против NATS без JetStream из `.env.test` (spec recognition-job-exchange, +«Соединение с NATS по шаблону, без JetStream»; design D-6). + +Subject'ы — уникальные на тест (`test.`): общий `recognition.result` не трогаем, чтобы не пересекаться +с другими запущенными инстансами ядра. +""" + +import asyncio +import time +import uuid +from collections import Counter +from typing import Any + +import pytest +from nats.aio.client import Client as NATS +from nats.aio.msg import Msg + +from src.contract.subjects import NATS_MSG_ID_HEADER +from src.core.config import NATSSettings, get_settings +from src.core.nats import RESULT_QUEUE_GROUP, NatsConnection, NatsPublishError + +pytestmark = pytest.mark.integration + +READY_TIMEOUT = 5.0 +START_MAX_SECONDS = 0.5 # start() не ждёт подключения +STOP_MAX_SECONDS = 2.0 # stop() не висит в цикле подключения nats-py + + +def _nats_settings() -> NATSSettings: + return get_settings().OPENBAO.NATS + + +def _unique_subject() -> str: + return f"test.{uuid.uuid4().hex}" + + +async def _wait_until(predicate: Any, timeout: float = 5.0) -> None: + deadline = time.monotonic() + timeout + while not predicate(): + if time.monotonic() > deadline: + raise AssertionError("условие не выполнено за отведённое время") + await asyncio.sleep(0.02) + + +async def _start(conn: NatsConnection) -> NatsConnection: + await conn.start() + if not await conn.wait_ready(timeout=READY_TIMEOUT): + await conn.stop() + pytest.fail(f"NATS недоступен ({_nats_settings().NATS_URL}, .env.test)", pytrace=False) + return conn + + +async def test_publish_delivers_payload_and_msg_id_header(): + subject = _unique_subject() + received: list[Msg] = [] + + async def handler(msg: Msg) -> None: + received.append(msg) + + async def subscribe(conn: NatsConnection) -> None: + await conn.subscribe_results(handler) + + conn = await _start(NatsConnection(_nats_settings(), on_connected=subscribe, result_subject=subject)) + try: + assert conn.is_connected + await conn.publish(subject, b'{"job": 1}', "job-1:1") + await _wait_until(lambda: len(received) == 1) + finally: + await conn.stop() + + msg = received[0] + assert msg.subject == subject + assert msg.data == b'{"job": 1}' + assert msg.headers == {NATS_MSG_ID_HEADER: "job-1:1"} + + +async def test_queue_group_delivers_each_message_to_exactly_one_instance(): + """Два инстанса ядра в queue group `arm-core`: каждое сообщение обработано ровно одним (без дублей).""" + subject = _unique_subject() + received: dict[str, list[str]] = {"a": [], "b": []} + + def make_on_connected(name: str) -> Any: + async def handler(msg: Msg) -> None: + assert msg.headers is not None + received[name].append(msg.headers[NATS_MSG_ID_HEADER]) + + async def on_connected(conn: NatsConnection) -> None: + await conn.subscribe_results(handler) + + return on_connected + + instance_a = await _start( + NatsConnection(_nats_settings(), on_connected=make_on_connected("a"), result_subject=subject) + ) + instance_b = await _start( + NatsConnection(_nats_settings(), on_connected=make_on_connected("b"), result_subject=subject) + ) + assert instance_a.queue == instance_b.queue == RESULT_QUEUE_GROUP + total = 20 + try: + for i in range(total): + await instance_a.publish(subject, f"result {i}".encode(), f"result:job-{i}:1") + await _wait_until(lambda: len(received["a"]) + len(received["b"]) >= total) + await asyncio.sleep(0.2) # дубль, если бы он был, успел бы прийти + finally: + await instance_a.stop() + await instance_b.stop() + + all_ids = received["a"] + received["b"] + assert len(all_ids) == total + assert Counter(all_ids) == Counter(f"result:job-{i}:1" for i in range(total)) + + +async def test_subscription_survives_handler_error(): + subject = _unique_subject() + received: list[bytes] = [] + + async def handler(msg: Msg) -> None: + if msg.data == b"boom": + raise ValueError("сломанное сообщение") + received.append(msg.data) + + async def subscribe(conn: NatsConnection) -> None: + await conn.subscribe_results(handler) + + conn = await _start(NatsConnection(_nats_settings(), on_connected=subscribe, result_subject=subject)) + try: + await conn.publish(subject, b"boom", "m:1") + await conn.publish(subject, b"ok", "m:2") + await _wait_until(lambda: received == [b"ok"]) + finally: + await conn.stop() + + +async def test_start_against_unreachable_server_does_not_block_and_stops_cleanly(): + """Регрессия T4.2: недоступный NATS при старте — start() не ждёт, is_connected=False, stop() быстрый.""" + settings = NATSSettings(NATS_URL="nats://127.0.0.1:54999") + conn = NatsConnection(settings) + + started = time.monotonic() + await conn.start() + assert time.monotonic() - started < START_MAX_SECONDS + await asyncio.sleep(0.3) + assert not conn.is_connected + with pytest.raises(NatsPublishError): + await conn.publish("test.unreachable", b"{}", "m:1") + + started = time.monotonic() + await conn.stop() + assert time.monotonic() - started < STOP_MAX_SECONDS + assert not conn.is_connected + + +async def test_plain_client_receives_publish_from_connection(): + """Сторонний подписчик (как сервис распознавания) видит заголовок `Nats-Msg-Id` от `NatsConnection.publish`.""" + subject = _unique_subject() + nc = NATS() + await nc.connect(_nats_settings().NATS_URL, connect_timeout=2, max_reconnect_attempts=1) + subscription = await nc.subscribe(subject) + conn = await _start(NatsConnection(_nats_settings())) + try: + await conn.publish(subject, b"payload", "job-x:2") + msg = await subscription.next_msg(timeout=5) + finally: + await conn.stop() + await nc.close() + + assert msg.data == b"payload" + assert msg.headers == {NATS_MSG_ID_HEADER: "job-x:2"} -- 2.47.2 From 4d13a1ddb2c639b27bc9645e2d3f46a1092be5da Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 23:47:39 +0400 Subject: [PATCH 72/97] =?UTF-8?q?fix:=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B0=20OPENBAO=5F=5FMINIO=5F=5FMINIO=5FSECU?= =?UTF-8?q?RE=20=E2=80=94=20http-MinIO=20=D0=B8=D0=B7=20compose=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20SSL-=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=B5=20(R-?= =?UTF-8?q?20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MinioSettings.MINIO_SECURE (по умолчанию true — https, как в шаблоне и на платформе) передаётся в Minio(secure=...); .env.example и .env.test — false (MinIO из docker compose слушает http). README: трейсбэк S3 из раздела «что в логе» убран, переменная добавлена в таблицу конфигурации. Co-Authored-By: Claude Fable 5.1 --- README.md | 10 ++++++---- api/.env.example | 2 ++ api/.env.test | 2 ++ api/src/app.py | 3 ++- api/src/core/config.py | 1 + api/tests/test_app.py | 22 +++++++++++++++++++++- api/tests/test_config.py | 2 ++ 7 files changed, 36 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 593359b..99037f8 100644 --- a/README.md +++ b/README.md @@ -155,15 +155,16 @@ Dev-пользователи (`api/seed/dev/users.yaml`, создаются `mak ### Что вы увидите в логе при запуске -Старт занимает около секунды; в логе два трейсбэка — это ожидаемо в dev, приложение при этом работает: +Старт занимает около секунды; в логе один трейсбэк — это ожидаемо в dev, приложение при этом работает: - Трейсбэк SMB: `Ошибка при регистрации сессии SMB ... Failed to connect to 'localhost:445': Connection refused`. Настройки `OPENBAO__SMB__*` — заглушки, SMB в Срезе 0 не используется; игнорируйте. Пока на `localhost:445` ничего не слушает, отказ приходит сразу (старт не задерживается); если порт отвечает, но молчит, ожидание ограничено тайм-аутом `smbclient` (60 с). -- Трейсбэк S3: `ClientConnectorSSLError ... localhost:9000`. Клиент S3 из шаблона ходит по `https`, а MinIO из compose - слушает `http`. S3 в Срезе 0 не используется (`app.state.storage` остаётся пустым). Это до исправления в задаче 22 - (отдельная настройка схемы подключения к MinIO). + +Строки `Бакет проверен`, `Подключение к NATS установлено` и `Подписка NATS активна. subject=recognition.result, +queue=arm-core` означают, что S3 и NATS из compose подключены (MinIO из compose слушает `http`, поэтому в `.env` +задано `OPENBAO__MINIO__MINIO_SECURE=false`). Недоступность SMB, S3 или NATS при старте приложение не роняет и не задерживает: ошибка логируется, состояние NATS видно в `/healthcheck`. К NATS ядро подключается в фоне и повторяет попытки само (`nats=down`, пока связи нет; warning @@ -202,6 +203,7 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб | `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` | diff --git a/api/.env.example b/api/.env.example index 11c2988..952c90f 100644 --- a/api/.env.example +++ b/api/.env.example @@ -11,6 +11,8 @@ OPENBAO__MINIO__MINIO_URL=localhost:9000 OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=minioadmin OPENBAO__MINIO__MINIO_BUCKET=arm-ka +# false — MinIO по http (docker compose); по умолчанию true (https, как на платформе) +OPENBAO__MINIO__MINIO_SECURE=false # NATS без JetStream; пустые NATS_SEED/NATS_JWT — подключение без nkeys OPENBAO__NATS__NATS_URL=nats://localhost:4222 diff --git a/api/.env.test b/api/.env.test index e348f24..fe6f5cb 100644 --- a/api/.env.test +++ b/api/.env.test @@ -9,6 +9,8 @@ OPENBAO__MINIO__MINIO_URL=localhost:59000 OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=minioadmin OPENBAO__MINIO__MINIO_BUCKET=arm-ka +# false — MinIO по http (docker compose); по умолчанию true (https, как на платформе) +OPENBAO__MINIO__MINIO_SECURE=false OPENBAO__NATS__NATS_URL=nats://localhost:54222 OPENBAO__NATS__NATS_SEED= diff --git a/api/src/app.py b/api/src/app.py index 2a39269..ba70527 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -1,6 +1,6 @@ """Точка входа FastAPI. Каркас из шаблона команды: инфраструктура без доменного кода. -Что здесь есть: настройки, подключение S3 (MinIO), NATS (`src/core/nats.py`: core +Что здесь есть: настройки, подключение S3 (MinIO; `MINIO_SECURE` — https или http), NATS (`src/core/nats.py`: core NATS без JetStream, подключение в фоне при NATS_ENABLED=true независимо от DEBUG, подписка `recognition.result` в queue group `arm-core`), SMB (горячие папки ИС Сканирование), middleware (gzip, proxy headers, CORS, РАИСА при DEBUG=False), healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA. @@ -72,6 +72,7 @@ async def _connect_s3() -> S3Storage | None: settings.OPENBAO.MINIO.MINIO_URL, access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN, secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD, + secure=settings.OPENBAO.MINIO.MINIO_SECURE, cert_check=False, ) try: diff --git a/api/src/core/config.py b/api/src/core/config.py index 7648784..52604b7 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -12,6 +12,7 @@ class MinioSettings(BaseSettings): MINIO_ADMIN_LOGIN: str MINIO_ADMIN_PASSWORD: str MINIO_BUCKET: str + MINIO_SECURE: bool = True # https (шаблон, платформа); false — http (MinIO из docker compose) class NATSSettings(BaseSettings): diff --git a/api/tests/test_app.py b/api/tests/test_app.py index b0d0fda..5e5ff58 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -1,4 +1,4 @@ -"""Быстрые тесты каркаса приложения: lifespan, /healthcheck, префикс API (без БД и NATS). +"""Быстрые тесты каркаса приложения: lifespan, /healthcheck, префикс API, клиент S3 (без БД и NATS). Аутентификация и supervisor NATS — tests/test_nats_core.py (подключение переехало в src/core/nats.py, рулинг R-3). """ @@ -178,3 +178,23 @@ def test_create_nats_enabled_returns_connection_for_results(monkeypatch: pytest. assert isinstance(nats, NatsConnection) assert (nats.result_subject, nats.queue) == ("recognition.result", "arm-core") assert not nats.is_connected # создание не подключает: подключение — в start() + + +@pytest.mark.parametrize("secure", [True, False]) +async def test_connect_s3_passes_secure_flag(monkeypatch: pytest.MonkeyPatch, secure: bool): + created: dict[str, Any] = {} + + class _FakeMinio: + def __init__(self, endpoint: str, **kwargs: Any) -> None: + created.update(kwargs, endpoint=endpoint) + + async def bucket_exists(self, bucket: str) -> bool: + return True + + monkeypatch.setattr(app_module, "Minio", _FakeMinio) + monkeypatch.setattr(app_module.settings.OPENBAO.MINIO, "MINIO_SECURE", secure) + + storage = await app_module._connect_s3() + + assert storage is not None + assert created["secure"] is secure diff --git a/api/tests/test_config.py b/api/tests/test_config.py index e014a37..c4be000 100644 --- a/api/tests/test_config.py +++ b/api/tests/test_config.py @@ -26,6 +26,7 @@ def test_env_test_is_read(clean_env: pytest.MonkeyPatch) -> None: assert s.OPENBAO.NATS.NATS_URL == "nats://localhost:54222" assert s.AUTH_DEV_ISSUER is True assert s.NATS_ENABLED is False + assert s.OPENBAO.MINIO.MINIO_SECURE is False def test_defaults(clean_env: pytest.MonkeyPatch) -> None: @@ -57,6 +58,7 @@ def test_defaults(clean_env: pytest.MonkeyPatch) -> None: assert (s.OUTBOX_POLL_SECONDS, s.ACK_TIMEOUT_SECONDS, s.OUTBOX_MAX_REPUBLISH) == (5, 30, 3) assert s.OPENBAO.NATS.NATS_SEED == "" assert s.OPENBAO.NATS.NATS_JWT == "" + assert s.OPENBAO.MINIO.MINIO_SECURE is True def test_empty_nats_seed_and_jwt_allowed(clean_env: pytest.MonkeyPatch) -> None: -- 2.47.2 From a0de203d88c7d57448f1811cdd40de313b07d48a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 23:48:55 +0400 Subject: [PATCH 73/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2022?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 7cdddd9..faab6f0 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -105,7 +105,7 @@ ### Task 22: [5.3] `src/core/nats.py` -- [ ] `src/core/nats.py`: подключение (с nkeys/JWT или без; автопереподключение), `publish(subject, payload, msg_id)` с заголовком `Nats-Msg-Id`, подписка `recognition.result` в queue group `arm-core`; проверка: интеграционный тест против compose-NATS (без JetStream) — публикация и получение сообщения с заголовком; два подписчика в одной группе получают сообщение один раз +- [x] `src/core/nats.py`: подключение (с nkeys/JWT или без; автопереподключение), `publish(subject, payload, msg_id)` с заголовком `Nats-Msg-Id`, подписка `recognition.result` в queue group `arm-core`; проверка: интеграционный тест против compose-NATS (без JetStream) — публикация и получение сообщения с заголовком; два подписчика в одной группе получают сообщение один раз ### Task 23: [5.4] `src/services/recognition_exchange.py` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index ad302c6..f1b7013 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -87,7 +87,7 @@ проверка: тесты зелёные - [x] 5.2 `src/services/audit.py` — `AuditService.record(...)` в текущей сессии; тест: системное событие с `user_id = NULL`, откат транзакции не оставляет записи; статический тест — в `src/` нет `update()/delete()` по `AuditEvent` -- [ ] 5.3 `src/core/nats.py`: подключение (с nkeys/JWT или без; автопереподключение), `publish(subject, payload, msg_id)` +- [x] 5.3 `src/core/nats.py`: подключение (с nkeys/JWT или без; автопереподключение), `publish(subject, payload, msg_id)` с заголовком `Nats-Msg-Id`, подписка `recognition.result` в queue group `arm-core`; проверка: интеграционный тест против compose-NATS (без JetStream) — публикация и получение сообщения с заголовком; два подписчика в одной группе получают сообщение один раз -- 2.47.2 From 2e9697147583fd169e2343237fbe54aacc96d793 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 00:20:06 +0400 Subject: [PATCH 74/97] =?UTF-8?q?feat:=20outbox-=D0=BF=D1=83=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=D1=82=D0=BE=D1=80=20=D0=B8=20=D0=B2=D0=B0?= =?UTF-8?q?=D1=85=D1=82=D1=91=D1=80=20=D1=81=D1=80=D0=BE=D0=BA=D0=BE=D0=B2?= =?UTF-8?q?=20recognition=5Fjob,=20=D1=84=D0=BE=D0=BD=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=B2=20lifespan=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20NATS=5FENABLED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - services/recognition_exchange.py: publish_pending — одно задание на короткую транзакцию (SELECT … LIMIT 1 FOR UPDATE OF recognition_job SKIP LOCKED → публикация → UPDATE → COMMIT): queued → published (request_msg_id = :, request_subject, published_at, reply_deadline — 5 мин по умолчанию, requested_at); published без ack дольше ACK_TIMEOUT → повтор с тем же Nats-Msg-Id и attempt, metrics.republish_count (jsonb ||, прочие ключи сохраняются); после OUTBOX_MAX_REPUBLISH → dlq/NO_ACK; запрос не собирается по контракту → error/INVALID_REQUEST; первая NatsPublishError откатывает задание и прерывает проход; нет соединения — проход пропускается. published с прошедшим reply_deadline оставлен вахтёру. - expire_deadlines — один UPDATE processing/published с reply_deadline < now → timeout/TIMEOUT. - run_publisher_loop / run_watchdog_loop (30 с), ExchangeTasks: запуск в lifespan вместе с NatsConnection, остановка до nats.stop(). audit_event не пишется (R-7). - Тесты: интеграционные против PostgreSQL и NATS (схема запроса, повторы и dlq на инжектируемых часах, ack после первого повтора, вахтёр, ошибка публикации, два конкурентных прохода, циклы), lifespan; фабрика CommittedJobs. Co-Authored-By: Claude Fable 5.1 --- README.md | 4 +- api/src/app.py | 15 +- api/src/services/recognition_exchange.py | 432 +++++++++++++- api/tests/factories.py | 116 +++- api/tests/test_app.py | 77 +++ api/tests/test_recognition_exchange.py | 698 +++++++++++++++++++++++ 6 files changed, 1332 insertions(+), 10 deletions(-) create mode 100644 api/tests/test_recognition_exchange.py diff --git a/README.md b/README.md index 99037f8..7db9d47 100644 --- a/README.md +++ b/README.md @@ -211,8 +211,8 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб | `OPENBAO__SMB__SMB_PATH` | — (обязательно) | заглушка SMB | | `OPENBAO__SMB__SMB_LOGIN` | — (обязательно) | заглушка SMB | | `OPENBAO__SMB__SMB_PASSWORD` | — (обязательно) | заглушка SMB | -| `NATS_ENABLED` | `false` | подключение к NATS и обмен через `recognition_job`; без него `/healthcheck` показывает `nats=off` | -| `OUTBOX_POLL_SECONDS` | `5` | период опроса outbox | +| `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` | | `AUTH_DEV_ISSUER` | `false` | dev-эмитент токенов: работает **только** при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true` | diff --git a/api/src/app.py b/api/src/app.py index ba70527..980c82f 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -5,7 +5,8 @@ NATS без JetStream, подключение в фоне при NATS_ENABLED=tr group `arm-core`), SMB (горячие папки ИС Сканирование), middleware (gzip, proxy headers, CORS, РАИСА при DEBUG=False), healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA. Недоступность S3 или NATS при старте не роняет и не задерживает приложение: ошибка логируется, healthcheck показывает -состояние. Outbox-паблишер и вахтёр сроков — задача 23 (docs/context/03-architecture.md). +состояние. При NATS_ENABLED=true lifespan запускает фоновые задачи обмена `recognition_job` — outbox-публикатор и +вахтёр сроков (`src/services/recognition_exchange.py`, design D-6) — и останавливает их до закрытия NATS. """ import os @@ -28,7 +29,8 @@ from src.core.clients import S3Storage from src.core.config import get_settings from src.core.exception_handlers import register_exception_handlers from src.core.nats import NatsConnection -from src.services.recognition_exchange import handle_message +from src.db.db import async_session_maker +from src.services.recognition_exchange import ExchangeTasks, handle_message settings = get_settings() @@ -102,10 +104,16 @@ def _create_nats() -> NatsConnection | None: return NatsConnection(settings.OPENBAO.NATS, on_connected=_subscribe_results) +def _start_exchange(nats: NatsConnection) -> ExchangeTasks: + """Фоновые задачи обмена: публикатор outbox и вахтёр сроков (только вместе с NATS, т.е. при NATS_ENABLED=true).""" + return ExchangeTasks.start(async_session_maker, nats, settings) + + @asynccontextmanager async def lifespan(app: FastAPI): app.state.storage = None app.state.nats = None + app.state.exchange = None try: smbclient.register_session( @@ -119,12 +127,15 @@ async def lifespan(app: FastAPI): app.state.nats = _create_nats() if app.state.nats is not None: await app.state.nats.start() # подключение в фоне: старт приложения не ждёт NATS + app.state.exchange = _start_exchange(app.state.nats) # публикатор сам ждёт соединения logger.info("Приложение запущено") yield finally: logger.info("Приложение закрывается") smbclient.reset_connection_cache() + if app.state.exchange is not None: # до закрытия NATS: публикатор не должен писать в закрытое соединение + await app.state.exchange.stop() if app.state.nats is not None: await app.state.nats.stop() if app.state.storage: diff --git a/api/src/services/recognition_exchange.py b/api/src/services/recognition_exchange.py index 3ddb740..59372e7 100644 --- a/api/src/services/recognition_exchange.py +++ b/api/src/services/recognition_exchange.py @@ -1,12 +1,436 @@ -"""Обмен с сервисами распознавания через `recognition_job` (design D-6; docs/context/03-architecture.md). +"""Обмен с сервисами распознавания через `recognition_job` (design D-6; spec recognition-job-exchange; +docs/context/03-architecture.md, «Контракт обмена через NATS»). -Пока здесь только заглушка обработчика `recognition.result`: подписка в `src/core/nats.py` уже вызывает её. -Полноценная обработка (ack `processing`, результат, дедуп по `result_msg_id`) — задача 24 (рулинг R-12, design #23); -`publish_pending()` и `expire_deadlines()` — задача 23. +Outbox (задача 23): +- `publish_pending()` — один проход публикатора: `queued` → первая публикация → `published`; `published` без ack дольше + `ACK_TIMEOUT_SECONDS` → повторная публикация с тем же `Nats-Msg-Id` и тем же `attempt` (счётчик — + `metrics.republish_count`); после `OUTBOX_MAX_REPUBLISH` повторов → `dlq`, `error_code = NO_ACK`. `attempt` не растёт: + обработка не начиналась. Replay (`attempt + 1`) — вне Среза 0. +- `expire_deadlines()` — вахтёр: `processing`/`published` с `reply_deadline < now` → `timeout`, `error_code = TIMEOUT`. +- `run_publisher_loop()` / `run_watchdog_loop()` — фоновые циклы; `ExchangeTasks` запускает их в lifespan при + `NATS_ENABLED=true` и останавливает до закрытия NATS. + +Гарантия доставки — at-least-once за счёт повторов без ack, а не за счёт `flush`: успешный `NatsConnection.publish` +означает лишь, что сервер ответил на PING после отправки, но не что принял именно это сообщение (ошибка прав, например, +приходит асинхронно в `error_cb`). Сбой commit после успешной публикации тоже даёт повтор того же сообщения. Поэтому +сервисы распознавания идемпотентны по `job_id` + `attempt` (`Nats-Msg-Id`). + +Модель транзакций публикатора: одно задание — одна короткая транзакция (`SELECT … LIMIT 1 FOR UPDATE OF recognition_job +SKIP LOCKED` → публикация → `UPDATE` → `COMMIT`). Блокировка строки держится на время одной публикации, а не пачки; +параллельный проход (второй инстанс ядра) пропускает заблокированную строку и не публикует её повторно, а после commit +строка уже не подходит под условие выборки. + +Обмен не пишет `audit_event` (рулинг R-7, design #20). + +Inbox (`handle_message`) — пока заглушка: подписка в `src/core/nats.py` уже вызывает её; полноценная обработка +(ack `processing`, результат, дедуп по `result_msg_id`) — задача 24 (рулинг R-12, design #23). """ +import asyncio +import json +import uuid +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Any, Protocol + from loguru import logger from nats.aio.msg import Msg +from pydantic import ValidationError +from sqlalchemy import CursorResult, and_, case, func, literal, or_, select, update +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.contract import CONTRACT_VERSION, RecognitionRequest, S3Ref, request_msg_id, request_subject +from src.core.config import Settings +from src.core.nats import NatsPublishError +from src.models.enums import RecognitionPipeline, RecognitionStatus +from src.models.files import RecognitionJob, S3Object, StoredFile + +WATCHDOG_PERIOD_SECONDS = 30 # период вахтёра сроков (spec «Таймаут ожидания результата») +PUBLISH_BATCH_SIZE = 50 # заданий за один проход публикатора (design D-6, LIMIT 50) +# Срок ответа сервиса, если задание создано без `reply_deadline`: OCR — 5 мин (DBML note), парсер — так же +DEFAULT_REPLY_TIMEOUT: dict[RecognitionPipeline, timedelta] = { + RecognitionPipeline.tabular: timedelta(minutes=5), + RecognitionPipeline.image: timedelta(minutes=5), +} +REPUBLISH_COUNT_KEY = "republish_count" # счётчик повторов в `recognition_job.metrics` (design #6) + +ERROR_NO_ACK = "NO_ACK" +ERROR_TIMEOUT = "TIMEOUT" +ERROR_INVALID_REQUEST = "INVALID_REQUEST" +ERROR_MESSAGE_MAX_LENGTH = 2000 + +SleepFn = Callable[[float], Awaitable[None]] +Clock = Callable[[], datetime] + + +class RequestPublisher(Protocol): + """То, что публикатору нужно от соединения NATS (`src.core.nats.NatsConnection`).""" + + @property + def is_connected(self) -> bool: ... + + async def publish(self, subject: str, payload: bytes, msg_id: str) -> None: ... + + +@dataclass +class PublishReport: + """Итог одного прохода публикатора.""" + + published: int = 0 # первая публикация: queued → published + republished: int = 0 # повторная публикация без ack + dlq: int = 0 # повторы исчерпаны: published → dlq + invalid: int = 0 # запрос не собирается по контракту: → error / INVALID_REQUEST + failed: int = 0 # ошибка публикации (проход прерван) + skipped: bool = False # нет соединения с NATS: проход не выполнялся + + @property + def total(self) -> int: + """Сколько заданий изменено за проход.""" + return self.published + self.republished + self.dlq + self.invalid + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +def _republish_count(metrics: Any) -> int: + """`metrics.republish_count` (нет поля / `metrics` NULL / не число — 0).""" + if isinstance(metrics, dict): + value = metrics.get(REPUBLISH_COUNT_KEY) + if isinstance(value, int) and not isinstance(value, bool): + return value + return 0 + + +def _candidate_stmt(now: datetime, ack_cutoff: datetime) -> Any: + """Следующее задание прохода: `queued` или `published` без ack дольше таймаута (срок ответа ещё не прошёл — + иначе задание за вахтёром). Строка задания блокируется, занятые другим проходом пропускаются.""" + job = RecognitionJob + awaiting_ack = and_( + job.status == RecognitionStatus.published, + or_(job.published_at.is_(None), job.published_at < ack_cutoff), + or_(job.reply_deadline.is_(None), job.reply_deadline >= now), + ) + return ( + select( + job.id, + job.status, + job.pipeline, + job.attempt, + job.reply_deadline, + job.requested_at, + job.metrics, + job.request_msg_id, + StoredFile.id.label("file_id"), + StoredFile.file_type, + StoredFile.original_name, + S3Object.bucket, + S3Object.key, + ) + .join(StoredFile, StoredFile.id == job.file_id) + .join(S3Object, S3Object.id == StoredFile.s3_object_id) + .where(or_(job.status == RecognitionStatus.queued, awaiting_ack)) + .order_by(job.requested_at.asc().nulls_first(), job.id) + .limit(1) + .with_for_update(of=job, skip_locked=True) + ) + + +def _job_update(job_id: uuid.UUID, expected: RecognitionStatus) -> Any: + """`UPDATE recognition_job` только из ожидаемого статуса (страховка от гонки с обработчиком результата).""" + return ( + update(RecognitionJob) + .where(RecognitionJob.id == job_id, RecognitionJob.status == expected) + .execution_options(synchronize_session=False) + ) + + +def _build_request(row: Any, reply_deadline: datetime) -> RecognitionRequest: + """Запрос `recognition-request.schema.json` из `recognition_job ⋈ stored_file ⋈ s3_object`.""" + return RecognitionRequest( + schema_version=CONTRACT_VERSION, + job_id=row.id, + file_id=row.file_id, + pipeline=row.pipeline.value, + file_type=row.file_type.value, + original_name=row.original_name, + s3=S3Ref(bucket=row.bucket, key=row.key), + reply_deadline=reply_deadline, + attempt=row.attempt, + ) + + +def _short(text: str) -> str: + return text if len(text) <= ERROR_MESSAGE_MAX_LENGTH else text[: ERROR_MESSAGE_MAX_LENGTH - 1] + "…" + + +async def _process_one( # noqa: PLR0913 - один шаг прохода: все параметры прохода + session: AsyncSession, + nats: RequestPublisher, + report: PublishReport, + *, + clock: Clock, + ack_cutoff: datetime, + pass_started: datetime, + max_republish: int, +) -> bool: + """Обработать одно задание в транзакции `session`; `False` — подходящих заданий больше нет.""" + row = (await session.execute(_candidate_stmt(pass_started, ack_cutoff))).one_or_none() + if row is None: + return False + now = clock() + first = row.status is RecognitionStatus.queued + + if not first and _republish_count(row.metrics) >= max_republish: + await session.execute( + _job_update(row.id, RecognitionStatus.published).values( + status=RecognitionStatus.dlq, + error_code=ERROR_NO_ACK, + error_message=( + f"Сервис распознавания не подтвердил получение запроса (ack) после {max_republish} " + "повторных публикаций" + ), + finished_at=now, + ) + ) + report.dlq += 1 + logger.warning( + "Запрос распознавания без ack: задание переведено в dlq. error_category=no_ack, job_id={job_id}, " + "republish_count={count}", + job_id=row.id, + count=_republish_count(row.metrics), + ) + return True + + reply_deadline = row.reply_deadline or now + DEFAULT_REPLY_TIMEOUT[row.pipeline] + try: + request = _build_request(row, reply_deadline) + subject = request_subject(row.pipeline.value) + except (ValidationError, ValueError) as exc: + await session.execute( + _job_update(row.id, row.status).values( + status=RecognitionStatus.error, + error_code=ERROR_INVALID_REQUEST, + error_message=_short(f"Запрос распознавания не соответствует контракту: {exc}"), + finished_at=now, + ) + ) + report.invalid += 1 + logger.error( + "Запрос распознавания не собран по контракту, задание переведено в error. " + "error_category=invalid_request, job_id={job_id}, error={error}", + job_id=row.id, + error=str(exc), + ) + return True + + # первая публикация — id из текущего attempt; повтор — сохранённый id (тот же Nats-Msg-Id) + msg_id = request_msg_id(row.id, row.attempt) if first else row.request_msg_id or request_msg_id(row.id, row.attempt) + payload = json.dumps(request.to_wire(), ensure_ascii=False).encode("utf-8") + await nats.publish(subject, payload, msg_id) # NatsPublishError → откат транзакции у вызывающего + + if first: + await session.execute( + _job_update(row.id, RecognitionStatus.queued).values( + status=RecognitionStatus.published, + published_at=now, + request_msg_id=msg_id, + request_subject=subject, + reply_deadline=reply_deadline, + requested_at=row.requested_at or now, + ) + ) + report.published += 1 + logger.info( + "Запрос распознавания опубликован. job_id={job_id}, subject={subject}, msg_id={msg_id}", + job_id=row.id, + subject=subject, + msg_id=msg_id, + ) + else: + count = _republish_count(row.metrics) + 1 + # jsonb `||`: прочие ключи metrics сохраняются; не объект (SQL NULL или JSON null — так ORM пишет None) → {} + is_object = func.jsonb_typeof(RecognitionJob.metrics) == "object" + base = case((is_object, RecognitionJob.metrics), else_=literal({}, JSONB)) + metrics = base.op("||", return_type=JSONB)(literal({REPUBLISH_COUNT_KEY: count}, JSONB)) + await session.execute( + _job_update(row.id, RecognitionStatus.published).values( + published_at=now, reply_deadline=reply_deadline, metrics=metrics + ) + ) + report.republished += 1 + logger.warning( + "Нет ack от сервиса распознавания, запрос опубликован повторно. job_id={job_id}, subject={subject}, " + "msg_id={msg_id}, republish_count={count}", + job_id=row.id, + subject=subject, + msg_id=msg_id, + count=count, + ) + return True + + +async def publish_pending( # noqa: PLR0913 - параметры прохода именованные: настройки и подмены для тестов + session_factory: async_sessionmaker[AsyncSession], + nats: RequestPublisher, + *, + ack_timeout: timedelta, + max_republish: int, + batch_size: int = PUBLISH_BATCH_SIZE, + now: Clock | None = None, +) -> PublishReport: + """Один проход outbox-публикатора (spec «Outbox публикует задания из `queued`», «Запрос без ack публикуется + повторно, затем — `dlq`»; design D-6). + + До `batch_size` заданий, каждое — в своей транзакции (см. docstring модуля). Нет соединения с NATS — проход + пропускается (задания остаются как есть). Первая `NatsPublishError` откатывает транзакцию задания (оно остаётся + `queued` / `published` без изменений) и прерывает проход: следующий проход повторит публикацию. + """ + clock = now or _utcnow + report = PublishReport() + if not nats.is_connected: + report.skipped = True + return report + pass_started = clock() + ack_cutoff = pass_started - ack_timeout + for _ in range(batch_size): + try: + async with session_factory() as session, session.begin(): + found = await _process_one( + session, + nats, + report, + clock=clock, + ack_cutoff=ack_cutoff, + pass_started=pass_started, + max_republish=max_republish, + ) + except NatsPublishError as exc: + report.failed += 1 + logger.warning( + "Публикация запроса распознавания не удалась, повтор в следующем проходе. error_category=nats_error, " + "error={error}", + error=str(exc), + ) + break + if not found: + break + return report + + +async def expire_deadlines(session_factory: async_sessionmaker[AsyncSession], *, now: Clock | None = None) -> int: + """Вахтёр сроков (spec «Таймаут ожидания результата»): `processing`/`published` с `reply_deadline < now` → + `timeout`, `error_code = TIMEOUT`, `finished_at = now`. Один атомарный `UPDATE` с условием по статусу: результат, + успевший перевести задание в финальный статус, не перетирается. Возвращает число заданий.""" + moment = (now or _utcnow)() + stmt = ( + update(RecognitionJob) + .where( + RecognitionJob.status.in_([RecognitionStatus.processing, RecognitionStatus.published]), + RecognitionJob.reply_deadline < moment, + ) + .values( + status=RecognitionStatus.timeout, + error_code=ERROR_TIMEOUT, + error_message="Сервис распознавания не прислал результат до срока ответа (reply_deadline)", + finished_at=moment, + ) + .execution_options(synchronize_session=False) + ) + async with session_factory() as session, session.begin(): + result = await session.execute(stmt) + count = result.rowcount if isinstance(result, CursorResult) else 0 + if count: + logger.warning("Задания распознавания переведены в timeout по сроку ответа. count={count}", count=count) + return count + + +async def run_publisher_loop( # noqa: PLR0913 - настройки цикла именованные + session_factory: async_sessionmaker[AsyncSession], + nats: RequestPublisher, + *, + poll_seconds: float, + ack_timeout: timedelta, + max_republish: int, + sleep: SleepFn = asyncio.sleep, +) -> None: + """Бесконечный цикл публикатора с периодом `poll_seconds`; ошибка прохода логируется, цикл продолжается.""" + while True: + try: + await publish_pending(session_factory, nats, ack_timeout=ack_timeout, max_republish=max_republish) + except Exception as exc: + logger.exception( + "Ошибка прохода outbox-публикатора. error_category=outbox_error, error_type={error_type}, " + "error={error}", + error_type=type(exc).__name__, + error=repr(exc), + ) + await sleep(poll_seconds) + + +async def run_watchdog_loop( + session_factory: async_sessionmaker[AsyncSession], + *, + period_seconds: float = WATCHDOG_PERIOD_SECONDS, + sleep: SleepFn = asyncio.sleep, +) -> None: + """Бесконечный цикл вахтёра сроков с периодом `period_seconds`; ошибка прохода логируется, цикл продолжается.""" + while True: + try: + await expire_deadlines(session_factory) + except Exception as exc: + logger.exception( + "Ошибка прохода вахтёра сроков. error_category=watchdog_error, error_type={error_type}, error={error}", + error_type=type(exc).__name__, + error=repr(exc), + ) + await sleep(period_seconds) + + +class ExchangeTasks: + """Фоновые задачи обмена в lifespan (design D-6): публикатор и вахтёр. Запускаются только при `NATS_ENABLED=true` + (вызывающий код создаёт их вместе с `NatsConnection`) и останавливаются до закрытия NATS.""" + + def __init__(self, tasks: Sequence[asyncio.Task[None]]) -> None: + self.tasks = tuple(tasks) + + @classmethod + def start( + cls, session_factory: async_sessionmaker[AsyncSession], nats: RequestPublisher, settings: Settings + ) -> "ExchangeTasks": + publisher = asyncio.create_task( + run_publisher_loop( + session_factory, + nats, + poll_seconds=settings.OUTBOX_POLL_SECONDS, + ack_timeout=timedelta(seconds=settings.ACK_TIMEOUT_SECONDS), + max_republish=settings.OUTBOX_MAX_REPUBLISH, + ), + name="recognition-outbox-publisher", + ) + watchdog = asyncio.create_task( + run_watchdog_loop(session_factory, period_seconds=WATCHDOG_PERIOD_SECONDS), + name="recognition-deadline-watchdog", + ) + logger.info( + "Фоновые задачи обмена запущены: публикатор (период {poll} с), вахтёр сроков (период {watchdog} с)", + poll=settings.OUTBOX_POLL_SECONDS, + watchdog=WATCHDOG_PERIOD_SECONDS, + ) + return cls([publisher, watchdog]) + + async def stop(self) -> None: + """Отменяет задачи и дожидается их завершения; исключения наружу не выходят.""" + for task in self.tasks: + task.cancel() + results = await asyncio.gather(*self.tasks, return_exceptions=True) + for task, result in zip(self.tasks, results, strict=True): + if isinstance(result, Exception): # CancelledError — штатная остановка, не Exception + logger.opt(exception=result).error( + "Фоновая задача обмена завершилась с ошибкой. task={name}", name=task.get_name() + ) async def handle_message(msg: Msg) -> None: diff --git a/api/tests/factories.py b/api/tests/factories.py index 150a585..e338b4f 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -1,11 +1,18 @@ -"""Фабрики тестовых данных для БД-тестов: минимальные цепочки FK поверх сессии (`flush`, без `commit`).""" +"""Фабрики тестовых данных для БД-тестов. + +- `make_osv_entry` — минимальная цепочка FK поверх сессии (`flush`, без `commit`): откатывается фикстурой `db_session`. +- `CommittedJobs` — задания распознавания, ЗАФИКСИРОВАННЫЕ в БД (`commit`): для фоновых процессов обмена, которые + работают в собственных транзакциях и не видят чужую незафиксированную транзакцию. Всё созданное удаляется + `cleanup()` в порядке FK. +""" import datetime import decimal import uuid from typing import Any -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.models.directories import LegalEntity from src.models.enums import ( @@ -20,6 +27,8 @@ from src.models.enums import ( from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry +DEFAULT_FILE_TYPES = {RecognitionPipeline.tabular: FileType.XLSX, RecognitionPipeline.image: FileType.PDF} + async def make_osv_entry(session: AsyncSession, **overrides: Any) -> OsvEntry: """Строка ОСВ с минимальной цепочкой FK (ЮЛ, s3_object, stored_file, recognition_job, document); `flush`. @@ -69,3 +78,106 @@ async def make_osv_entry(session: AsyncSession, **overrides: Any) -> OsvEntry: session.add(entry) await session.flush() return entry + + +class CommittedJobs: + """Задания `recognition_job` с цепочкой `legal_entity` + `s3_object` + `stored_file`, зафиксированные в БД. + + Каждый `make_queued_job` — отдельная транзакция с `commit`; созданные id запоминаются, `cleanup()` удаляет их + в порядке FK (задание → файл → объект S3 и ЮЛ). Вызывать `cleanup()` в teardown фикстуры — даже при падении теста. + """ + + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._session_factory = session_factory + self.job_ids: list[uuid.UUID] = [] + self._file_ids: list[uuid.UUID] = [] + self._s3_ids: list[uuid.UUID] = [] + self._entity_ids: list[uuid.UUID] = [] + + async def make_queued_job( # noqa: PLR0913 - каждое поле задания задаётся тестом явно + self, + *, + pipeline: RecognitionPipeline = RecognitionPipeline.tabular, + file_type: FileType | None = None, + original_name: str | None = "osv.xlsx", + status: RecognitionStatus = RecognitionStatus.queued, + reply_deadline: datetime.datetime | None = None, + published_at: datetime.datetime | None = None, + request_msg_id: str | None = None, + metrics: Any = None, + attempt: int = 1, + ) -> uuid.UUID: + """Задание (по умолчанию `queued`, tabular, XLSX) со своей цепочкой FK; возвращает `recognition_job.id`.""" + now = datetime.datetime.now(datetime.UTC) + async with self._session_factory() as session, session.begin(): + entity = LegalEntity(inn=str(uuid.uuid4().int)[:12], name="ООО Тест обмена", valid_from=now) + s3 = S3Object( + bucket_role=S3BucketRole.uploads, + bucket="arm-ka", + key=f"uploads/{uuid.uuid4()}/{original_name or 'file'}", + created_at=now, + ) + session.add_all([entity, s3]) + await session.flush() + self._entity_ids.append(entity.id) + self._s3_ids.append(s3.id) + file = StoredFile( + legal_entity_id=entity.id, + file_type=file_type or DEFAULT_FILE_TYPES[pipeline], + original_name=original_name, + s3_object_id=s3.id, + status=FileStatus.UPLOADED, + ) + session.add(file) + await session.flush() + self._file_ids.append(file.id) + job = RecognitionJob( + file_id=file.id, + pipeline=pipeline, + request_subject=f"recognition.{pipeline.value}.request", + schema_version="1.0", + status=status, + attempt=attempt, + reply_deadline=reply_deadline, + published_at=published_at, + request_msg_id=request_msg_id, + metrics=metrics, + ) + session.add(job) + await session.flush() + self.job_ids.append(job.id) + return job.id + + async def s3_key(self, job_id: uuid.UUID) -> str: + """Ключ S3 исходного файла задания.""" + async with self._session_factory() as session: + job = await session.get(RecognitionJob, job_id) + assert job is not None + file = await session.get(StoredFile, job.file_id) + assert file is not None + s3 = await session.get(S3Object, file.s3_object_id) + assert s3 is not None + return s3.key + + async def get(self, job_id: uuid.UUID) -> RecognitionJob: + """Свежее состояние задания из БД (новая сессия).""" + async with self._session_factory() as session: + job = await session.get(RecognitionJob, job_id) + assert job is not None + return job + + async def cleanup(self) -> None: + """Удаляет всё созданное в порядке FK.""" + async with self._session_factory() as session, session.begin(): + if self.job_ids: + await session.execute(delete(RecognitionJob).where(RecognitionJob.id.in_(self.job_ids))) + if self._file_ids: + await session.execute(delete(StoredFile).where(StoredFile.id.in_(self._file_ids))) + if self._s3_ids: + await session.execute(delete(S3Object).where(S3Object.id.in_(self._s3_ids))) + if self._entity_ids: + await session.execute(delete(LegalEntity).where(LegalEntity.id.in_(self._entity_ids))) + self.job_ids.clear() + self._file_ids.clear() + self._s3_ids.clear() + self._entity_ids.clear() diff --git a/api/tests/test_app.py b/api/tests/test_app.py index 5e5ff58..1755540 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -14,6 +14,7 @@ import pytest from src.api.router import router from src.core.nats import NatsConnection +from src.services import recognition_exchange # src/__init__.py реэкспортирует объект app под именем модуля, поэтому модуль берём через importlib app_module = importlib.import_module("src.app") @@ -93,6 +94,24 @@ def _no_smb(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(app_module.smbclient, "reset_connection_cache", lambda: None) +@pytest.fixture(autouse=True) +def exchange_loops(monkeypatch: pytest.MonkeyPatch) -> list[str]: + """Циклы публикатора и вахтёра подменены ожиданием: быстрые тесты lifespan не ходят в БД.""" + started: list[str] = [] + + async def publisher(session_factory: Any, nats: Any, **kwargs: Any) -> None: + started.append("publisher") + await asyncio.Event().wait() + + async def watchdog(session_factory: Any, **kwargs: Any) -> None: + started.append("watchdog") + await asyncio.Event().wait() + + monkeypatch.setattr(recognition_exchange, "run_publisher_loop", publisher) + monkeypatch.setattr(recognition_exchange, "run_watchdog_loop", watchdog) + return started + + @pytest.mark.usefixtures("_no_smb") async def test_lifespan_starts_when_s3_and_nats_unavailable(monkeypatch: pytest.MonkeyPatch): async def unavailable() -> None: @@ -198,3 +217,61 @@ async def test_connect_s3_passes_secure_flag(monkeypatch: pytest.MonkeyPatch, se assert storage is not None assert created["secure"] is secure + + +class _RecordingNats(_FakeNats): + """Запоминает, остановлены ли фоновые задачи обмена к моменту `stop()` соединения NATS.""" + + def __init__(self) -> None: + super().__init__() + self.exchange_done_at_stop: bool | None = None + + async def stop(self) -> None: + exchange = app_module.app.state.exchange + self.exchange_done_at_stop = all(task.done() for task in exchange.tasks) + await super().stop() + + +@pytest.mark.usefixtures("_no_smb") +async def test_lifespan_starts_exchange_tasks_and_stops_them_before_nats( + monkeypatch: pytest.MonkeyPatch, exchange_loops: list[str] +): + """NATS_ENABLED=true: публикатор и вахтёр — две фоновые задачи; остановка — до `nats.stop()`.""" + + async def unavailable() -> None: + return None + + fake_nats = _RecordingNats() + monkeypatch.setattr(app_module, "_connect_s3", unavailable) + monkeypatch.setattr(app_module, "_create_nats", lambda: fake_nats) + + async with app_module.lifespan(app_module.app): + exchange = app_module.app.state.exchange + assert exchange is not None + assert len(exchange.tasks) == 2 # noqa: PLR2004 - публикатор и вахтёр + await asyncio.sleep(0) + assert sorted(exchange_loops) == ["publisher", "watchdog"] + assert not any(task.done() for task in exchange.tasks) + + assert fake_nats.exchange_done_at_stop is True + assert fake_nats.stopped + + +@pytest.mark.usefixtures("_no_smb") +async def test_lifespan_without_nats_starts_no_exchange_tasks( + monkeypatch: pytest.MonkeyPatch, exchange_loops: list[str] +): + """NATS_ENABLED=false (`_create_nats` → None): фоновые задачи обмена не запускаются.""" + + async def unavailable() -> None: + return None + + monkeypatch.setattr(app_module, "_connect_s3", unavailable) + monkeypatch.setattr(app_module.settings, "NATS_ENABLED", False) + tasks_before = asyncio.all_tasks() + + async with app_module.lifespan(app_module.app): + assert app_module.app.state.exchange is None + await asyncio.sleep(0) + assert exchange_loops == [] + assert asyncio.all_tasks() - tasks_before == set() diff --git a/api/tests/test_recognition_exchange.py b/api/tests/test_recognition_exchange.py new file mode 100644 index 0000000..48430fd --- /dev/null +++ b/api/tests/test_recognition_exchange.py @@ -0,0 +1,698 @@ +"""Outbox-публикатор и вахтёр сроков `recognition_job` (spec recognition-job-exchange: «Outbox публикует задания из +`queued`», «Запрос без ack публикуется повторно, затем — `dlq`», «Таймаут ожидания результата»; design D-6). + +Интеграционные тесты работают против реального PostgreSQL (БД сессии pytest) и NATS из `.env.test`. Публикатор и +вахтёр ведут собственные транзакции, поэтому данные фиксируются реально (`CommittedJobs`) и удаляются в teardown. +Subject'ы запросов общие (`recognition.*.request`): тестовый подписчик фильтрует сообщения по `job_id` своих заданий. +Время в тестах повторов — инжектируемые часы `now`, без реальных ожиданий таймаутов. +""" + +import asyncio +import json +import time +import uuid +from collections.abc import AsyncIterator, Callable +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any, get_args + +import pytest +import pytest_asyncio +from jsonschema import Draft202012Validator, FormatChecker +from nats.aio.client import Client as NATS +from nats.aio.msg import Msg +from sqlalchemy import update +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from src.contract import NATS_MSG_ID_HEADER, RecognitionRequest +from src.contract.request import FileType as ContractFileType +from src.core.config import get_settings +from src.core.nats import NatsConnection, NatsPublishError +from src.models.enums import FileType, RecognitionPipeline, RecognitionStatus +from src.models.files import RecognitionJob +from src.services import recognition_exchange as rx +from src.services.recognition_exchange import ( + WATCHDOG_PERIOD_SECONDS, + ExchangeTasks, + expire_deadlines, + publish_pending, + run_publisher_loop, + run_watchdog_loop, +) +from tests.factories import CommittedJobs + +CONTRACTS_DIR = Path(__file__).resolve().parents[1] / "contracts" +REQUEST_SCHEMA = json.loads((CONTRACTS_DIR / "recognition-request.schema.json").read_text(encoding="utf-8")) +REQUEST_VALIDATOR = Draft202012Validator(REQUEST_SCHEMA, format_checker=FormatChecker()) +REQUEST_SUBJECTS = ("recognition.tabular.request", "recognition.image.request") + +ACK = timedelta(seconds=30) +MAX_REPUBLISH = 3 +READY_TIMEOUT = 5.0 +CONCURRENT_JOBS = 20 +MIN_LOOP_PASSES = 3 # цикл пережил несколько ошибок прохода подряд +EXCHANGE_TASKS = 2 # публикатор и вахтёр + + +# --- инфраструктура --------------------------------------------------------------------------------------------- + + +class Clock: + """Инжектируемые часы: `now` для публикатора и вахтёра, `advance` — сдвиг без реального ожидания.""" + + def __init__(self) -> None: + self.current = datetime.now(UTC) + + def __call__(self) -> datetime: + return self.current + + def advance(self, delta: timedelta) -> None: + self.current += delta + + +class RequestSubscriber: + """Тестовый подписчик на subject'ы запросов: «голый» клиент nats-py, сообщения фильтруются по `job_id`.""" + + def __init__(self) -> None: + self.messages: list[Msg] = [] + self._nc = NATS() + + async def start(self) -> None: + url = get_settings().OPENBAO.NATS.NATS_URL + try: + await self._nc.connect(url, connect_timeout=2, allow_reconnect=False) + except Exception as exc: + pytest.fail(f"NATS недоступен ({url}, .env.test): {type(exc).__name__}", pytrace=False) + + async def collect(msg: Msg) -> None: + self.messages.append(msg) + + for subject in REQUEST_SUBJECTS: + await self._nc.subscribe(subject, cb=collect) + await self._nc.flush() + + async def stop(self) -> None: + await self._nc.close() + + def for_job(self, job_id: uuid.UUID) -> list[Msg]: + return [m for m in self.messages if json.loads(m.data).get("job_id") == str(job_id)] + + async def settle(self) -> None: + """Всё, что сервер принял до этого момента, доставлено подписчику (PING/PONG после сообщений) и обработано.""" + await self._nc.flush() + await asyncio.sleep(0.1) + + async def wait_for(self, job_id: uuid.UUID, count: int, timeout: float = 10.0) -> list[Msg]: + deadline = time.monotonic() + timeout + while len(self.for_job(job_id)) < count: + if time.monotonic() > deadline: + raise AssertionError(f"за {timeout} с получено {len(self.for_job(job_id))} из {count} сообщений") + await asyncio.sleep(0.02) + return self.for_job(job_id) + + +class FakeNats: + """Подставной NATS: состояние соединения и счётчик публикаций; первые `fail_first` публикаций — ошибка.""" + + def __init__(self, *, connected: bool = True, fail_first: int = 0, delegate: Any = None, delay: float = 0) -> None: + self.is_connected_value = connected + self.fail_first = fail_first + self.delegate = delegate + self.delay = delay + self.calls: list[tuple[str, str]] = [] + + @property + def is_connected(self) -> bool: + return self.is_connected_value + + async def publish(self, subject: str, payload: bytes, msg_id: str) -> None: + self.calls.append((subject, msg_id)) + if len(self.calls) <= self.fail_first: + raise NatsPublishError(f"подставная ошибка публикации {msg_id}") + if self.delay: + await asyncio.sleep(self.delay) + if self.delegate is not None: + await self.delegate.publish(subject, payload, msg_id) + + +@pytest.fixture +def session_factory(db_engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: + return async_sessionmaker(db_engine, expire_on_commit=False) + + +@pytest_asyncio.fixture +async def jobs(session_factory: async_sessionmaker[AsyncSession]) -> AsyncIterator[CommittedJobs]: + committed = CommittedJobs(session_factory) + try: + yield committed + finally: + await committed.cleanup() + + +@pytest_asyncio.fixture +async def nats() -> AsyncIterator[NatsConnection]: + conn = NatsConnection(get_settings().OPENBAO.NATS) + await conn.start() + try: + if not await conn.wait_ready(timeout=READY_TIMEOUT): + pytest.fail(f"NATS недоступен ({get_settings().OPENBAO.NATS.NATS_URL}, .env.test)", pytrace=False) + yield conn + finally: + await conn.stop() + + +@pytest_asyncio.fixture +async def subscriber() -> AsyncIterator[RequestSubscriber]: + sub = RequestSubscriber() + await sub.start() + try: + yield sub + finally: + await sub.stop() + + +async def _set_status( + session_factory: async_sessionmaker[AsyncSession], job_id: uuid.UUID, status: RecognitionStatus +) -> None: + async with session_factory() as session, session.begin(): + await session.execute(update(RecognitionJob).where(RecognitionJob.id == job_id).values(status=status)) + + +def _publish( + session_factory: async_sessionmaker[AsyncSession], nats: Any, now: Callable[[], datetime] | None = None +) -> Any: + return publish_pending(session_factory, nats, ack_timeout=ACK, max_republish=MAX_REPUBLISH, now=now) + + +# --- юнит: соответствие enum, константы ------------------------------------------------------------------------ + + +def test_orm_file_type_matches_contract_enum(): + """`stored_file.file_type` (DBML) и `file_type` схемы запроса — одно множество значений.""" + assert {m.value for m in FileType} == set(get_args(ContractFileType)) + assert set(REQUEST_SCHEMA["properties"]["file_type"]["enum"]) == {m.value for m in FileType} + + +def test_orm_pipeline_matches_request_subjects(): + assert {m.value for m in RecognitionPipeline} == {"tabular", "image"} + + +def test_watchdog_period_is_30_seconds(): + assert WATCHDOG_PERIOD_SECONDS == 30 # noqa: PLR2004 - значение из spec + + +# --- публикация ------------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +@pytest.mark.parametrize( + ("pipeline", "file_type", "subject"), + [ + (RecognitionPipeline.tabular, FileType.XLSX, "recognition.tabular.request"), + (RecognitionPipeline.image, FileType.PDF, "recognition.image.request"), + ], +) +async def test_publish_request_to_service( # noqa: PLR0913,PLR0917 - фикстуры и параметры + session_factory: async_sessionmaker[AsyncSession], + jobs: CommittedJobs, + nats: NatsConnection, + subscriber: RequestSubscriber, + pipeline: RecognitionPipeline, + file_type: FileType, + subject: str, +): + """Сценарий «Публикация запроса парсеру»: сообщение по схеме, `attempt = 1`, строка `published`.""" + job_id = await jobs.make_queued_job(pipeline=pipeline, file_type=file_type, original_name="ОСВ 60.xlsx") + before = datetime.now(UTC) + + report = await _publish(session_factory, nats) + + assert report.published == 1 + (msg,) = await subscriber.wait_for(job_id, 1) + body = json.loads(msg.data) + errors = [e.message for e in REQUEST_VALIDATOR.iter_errors(body)] + assert errors == [] + request = RecognitionRequest.model_validate_json(msg.data) + job = await jobs.get(job_id) + assert msg.subject == subject + assert msg.headers == {NATS_MSG_ID_HEADER: f"{job_id}:1"} + assert request.job_id == job_id + assert request.file_id == job.file_id + assert request.pipeline == pipeline.value + assert request.file_type == file_type.value + assert request.original_name == "ОСВ 60.xlsx" + assert request.s3.bucket == "arm-ka" + assert request.s3.key == await jobs.s3_key(job_id) + assert request.attempt == 1 + assert request.schema_version == "1.0" + # reply_deadline не был задан: вычислен при первой публикации (5 мин) и сохранён в задание + assert job.reply_deadline is not None + assert request.reply_deadline == job.reply_deadline + assert timedelta(minutes=4) < job.reply_deadline - before < timedelta(minutes=6) + + assert job.status is RecognitionStatus.published + assert job.request_msg_id == f"{job_id}:1" + assert job.request_subject == subject + assert job.published_at is not None and job.published_at >= before + assert job.requested_at is not None + assert job.attempt == 1 + + +@pytest.mark.integration +async def test_publish_keeps_existing_reply_deadline( + session_factory: async_sessionmaker[AsyncSession], + jobs: CommittedJobs, + nats: NatsConnection, + subscriber: RequestSubscriber, +): + deadline = datetime.now(UTC).replace(microsecond=0) + timedelta(hours=2) + job_id = await jobs.make_queued_job(reply_deadline=deadline) + + await _publish(session_factory, nats) + + (msg,) = await subscriber.wait_for(job_id, 1) + assert RecognitionRequest.model_validate_json(msg.data).reply_deadline == deadline + assert (await jobs.get(job_id)).reply_deadline == deadline + + +@pytest.mark.integration +async def test_first_publication_msg_id_follows_attempt( + session_factory: async_sessionmaker[AsyncSession], + jobs: CommittedJobs, + nats: NatsConnection, + subscriber: RequestSubscriber, +): + """`queued` с `attempt = 2` и устаревшим `request_msg_id` (будущий replay): id и тело — по текущему attempt.""" + job_id = await jobs.make_queued_job(attempt=2, request_msg_id=f"stale-{uuid.uuid4()}") + + await _publish(session_factory, nats) + + (msg,) = await subscriber.wait_for(job_id, 1) + assert msg.headers == {NATS_MSG_ID_HEADER: f"{job_id}:2"} + assert RecognitionRequest.model_validate_json(msg.data).attempt == 2 # noqa: PLR2004 - attempt задания + job = await jobs.get(job_id) + assert (job.request_msg_id, job.attempt) == (f"{job_id}:2", 2) + + +@pytest.mark.integration +async def test_invalid_request_marks_job_error_and_pass_continues( + session_factory: async_sessionmaker[AsyncSession], + jobs: CommittedJobs, + nats: NatsConnection, + subscriber: RequestSubscriber, +): + """Запрос не собирается по схеме (нет `original_name`) — задание `error/INVALID_REQUEST`, проход идёт дальше.""" + broken = await jobs.make_queued_job(original_name=None) + good = await jobs.make_queued_job() + + report = await _publish(session_factory, nats) + + assert (report.invalid, report.published) == (1, 1) + await subscriber.wait_for(good, 1) + await subscriber.settle() + assert subscriber.for_job(broken) == [] + job = await jobs.get(broken) + assert job.status is RecognitionStatus.error + assert job.error_code == "INVALID_REQUEST" + assert job.error_message and "original_name" in job.error_message + assert job.finished_at is not None + assert job.request_msg_id is None + + +# --- повторы без ack и dlq --------------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_republish_without_ack_then_dlq( + session_factory: async_sessionmaker[AsyncSession], + jobs: CommittedJobs, + nats: NatsConnection, + subscriber: RequestSubscriber, +): + """Сценарий «Сервис недоступен»: 1 + 3 публикации с одним `Nats-Msg-Id`, затем `dlq / NO_ACK`.""" + clock = Clock() + job_id = await jobs.make_queued_job(reply_deadline=clock() + timedelta(hours=1), metrics={"note": "сохранить"}) + + assert (await _publish(session_factory, nats, clock)).published == 1 + first = await jobs.get(job_id) + # раньше ACK_TIMEOUT повтора нет + clock.advance(ACK - timedelta(seconds=1)) + assert (await _publish(session_factory, nats, clock)).republished == 0 + + for n in range(1, MAX_REPUBLISH + 1): + clock.advance(timedelta(seconds=2)) # с последней публикации прошло больше ACK_TIMEOUT + report = await _publish(session_factory, nats, clock) + assert report.republished == 1 + job = await jobs.get(job_id) + assert job.metrics == {"note": "сохранить", "republish_count": n} + assert job.published_at == clock() + assert job.status is RecognitionStatus.published + clock.advance(ACK - timedelta(seconds=1)) + + messages = await subscriber.wait_for(job_id, 1 + MAX_REPUBLISH) + assert {m.headers[NATS_MSG_ID_HEADER] for m in messages} == {f"{job_id}:1"} + assert len({m.data for m in messages}) == 1 # то же тело: тот же attempt и reply_deadline + + clock.advance(timedelta(seconds=2)) + report = await _publish(session_factory, nats, clock) + assert (report.dlq, report.republished, report.published) == (1, 0, 0) + job = await jobs.get(job_id) + assert job.status is RecognitionStatus.dlq + assert job.error_code == "NO_ACK" + assert job.error_message + assert job.finished_at == clock() + assert job.request_msg_id == first.request_msg_id == f"{job_id}:1" + assert job.attempt == 1 + assert job.metrics == {"note": "сохранить", "republish_count": MAX_REPUBLISH} + + clock.advance(ACK * 2) + assert (await _publish(session_factory, nats, clock)).total == 0 + await subscriber.settle() + assert len(subscriber.for_job(job_id)) == 1 + MAX_REPUBLISH + + +@pytest.mark.integration +async def test_ack_after_first_republish_stops_republishing( + session_factory: async_sessionmaker[AsyncSession], + jobs: CommittedJobs, + nats: NatsConnection, + subscriber: RequestSubscriber, +): + """Сценарий «Сервис появился после первого повтора»: после ack (`processing`) повторов нет.""" + clock = Clock() + job_id = await jobs.make_queued_job(reply_deadline=clock() + timedelta(hours=1)) + await _publish(session_factory, nats, clock) + clock.advance(ACK + timedelta(seconds=1)) + assert (await _publish(session_factory, nats, clock)).republished == 1 + await subscriber.wait_for(job_id, 2) + + await _set_status(session_factory, job_id, RecognitionStatus.processing) # имитация ack + for _ in range(MAX_REPUBLISH + 1): + clock.advance(ACK + timedelta(seconds=1)) + assert (await _publish(session_factory, nats, clock)).total == 0 + + await subscriber.settle() + assert len(subscriber.for_job(job_id)) == 2 # noqa: PLR2004 - первая публикация и один повтор + job = await jobs.get(job_id) + assert job.status is RecognitionStatus.processing + assert job.metrics == {"republish_count": 1} + + +@pytest.mark.integration +async def test_publisher_skips_published_job_with_passed_deadline( + session_factory: async_sessionmaker[AsyncSession], + jobs: CommittedJobs, + nats: NatsConnection, + subscriber: RequestSubscriber, +): + """`published` с прошедшим `reply_deadline` не републикуется и не уходит в dlq — его забирает вахтёр.""" + now = datetime.now(UTC) + job_id = await jobs.make_queued_job( + status=RecognitionStatus.published, + published_at=now - timedelta(hours=1), + reply_deadline=now - timedelta(minutes=1), + request_msg_id=f"{uuid.uuid4()}:1", + metrics={"republish_count": MAX_REPUBLISH}, + ) + + assert (await _publish(session_factory, nats)).total == 0 + await subscriber.settle() + assert subscriber.for_job(job_id) == [] + assert (await jobs.get(job_id)).status is RecognitionStatus.published + + assert await expire_deadlines(session_factory) == 1 + job = await jobs.get(job_id) + assert (job.status, job.error_code) == (RecognitionStatus.timeout, "TIMEOUT") + + +# --- NATS недоступен --------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_pass_skipped_when_nats_disconnected( + session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs +): + job_id = await jobs.make_queued_job() + fake = FakeNats(connected=False) + + report = await _publish(session_factory, fake) + + assert report.skipped + assert fake.calls == [] + assert (await jobs.get(job_id)).status is RecognitionStatus.queued + + +@pytest.mark.integration +async def test_publish_error_keeps_queued_and_stops_pass( + session_factory: async_sessionmaker[AsyncSession], + jobs: CommittedJobs, + nats: NatsConnection, + subscriber: RequestSubscriber, +): + """Сценарий «NATS недоступен»: задание остаётся `queued`, проход прерван; следующий проход публикует.""" + first = await jobs.make_queued_job() + second = await jobs.make_queued_job() + fake = FakeNats(fail_first=1) + + report = await _publish(session_factory, fake) + + assert (report.failed, report.published) == (1, 0) + assert len(fake.calls) == 1 # второе задание в этом проходе не пытались публиковать + for job_id in (first, second): + job = await jobs.get(job_id) + assert job.status is RecognitionStatus.queued + assert job.request_msg_id is None + assert job.published_at is None + assert job.reply_deadline is None # откат: срок не сохранён + + report = await _publish(session_factory, nats) + + assert report.published == 2 # noqa: PLR2004 - оба задания + for job_id in (first, second): + await subscriber.wait_for(job_id, 1) + assert (await jobs.get(job_id)).status is RecognitionStatus.published + + +@pytest.mark.integration +async def test_republish_error_keeps_published_state( + session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs, nats: NatsConnection +): + clock = Clock() + job_id = await jobs.make_queued_job(reply_deadline=clock() + timedelta(hours=1)) + await _publish(session_factory, nats, clock) + published_at = (await jobs.get(job_id)).published_at + clock.advance(ACK + timedelta(seconds=1)) + + report = await _publish(session_factory, FakeNats(fail_first=1), clock) + + assert report.failed == 1 + job = await jobs.get(job_id) + assert job.status is RecognitionStatus.published + assert job.published_at == published_at + assert job.metrics is None + + +# --- конкурентность ----------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_concurrent_publishers_publish_each_job_once( + session_factory: async_sessionmaker[AsyncSession], + jobs: CommittedJobs, + nats: NatsConnection, + subscriber: RequestSubscriber, +): + """Два одновременных прохода (SKIP LOCKED): каждое задание опубликовано ровно один раз.""" + job_ids = [await jobs.make_queued_job() for _ in range(CONCURRENT_JOBS)] + # задержка внутри публикации держит блокировку строки, пока второй проход выбирает задания + first, second = FakeNats(delegate=nats, delay=0.02), FakeNats(delegate=nats, delay=0.02) + + reports = await asyncio.gather(_publish(session_factory, first), _publish(session_factory, second)) + + assert sum(r.published for r in reports) == CONCURRENT_JOBS + assert all(r.published > 0 for r in reports) # оба прохода реально работали параллельно + published_ids = [msg_id for fake in (first, second) for _, msg_id in fake.calls] + assert sorted(published_ids) == sorted(f"{job_id}:1" for job_id in job_ids) + await subscriber.settle() + for job_id in job_ids: + assert len(await subscriber.wait_for(job_id, 1)) == 1 + assert (await jobs.get(job_id)).status is RecognitionStatus.published + + +# --- вахтёр ---------------------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +@pytest.mark.parametrize( + ("status", "deadline_offset", "expected"), + [ + (RecognitionStatus.processing, timedelta(minutes=-1), RecognitionStatus.timeout), + (RecognitionStatus.published, timedelta(minutes=-1), RecognitionStatus.timeout), + (RecognitionStatus.processing, timedelta(minutes=5), RecognitionStatus.processing), + (RecognitionStatus.published, timedelta(minutes=5), RecognitionStatus.published), + (RecognitionStatus.processing, None, RecognitionStatus.processing), + (RecognitionStatus.queued, timedelta(minutes=-1), RecognitionStatus.queued), + (RecognitionStatus.done, timedelta(minutes=-1), RecognitionStatus.done), + (RecognitionStatus.dlq, timedelta(minutes=-1), RecognitionStatus.dlq), + ], +) +async def test_expire_deadlines( + session_factory: async_sessionmaker[AsyncSession], + jobs: CommittedJobs, + status: RecognitionStatus, + deadline_offset: timedelta | None, + expected: RecognitionStatus, +): + """Сценарий «Сервис взял, но не ответил»: `processing`/`published` с прошедшим сроком → `timeout`.""" + clock = Clock() + deadline = None if deadline_offset is None else clock() + deadline_offset + job_id = await jobs.make_queued_job(status=status, reply_deadline=deadline) + + expired = await expire_deadlines(session_factory, now=clock) + + job = await jobs.get(job_id) + assert job.status is expected + if expected is RecognitionStatus.timeout: + assert expired == 1 + assert job.error_code == "TIMEOUT" + assert job.error_message + assert job.finished_at == clock() + assert job.attempt == 1 + else: + assert expired == 0 + assert job.error_code is None + assert job.finished_at is None + + +# --- фоновые циклы ----------------------------------------------------------------------------------------------- + + +async def _wait_until(predicate: Callable[[], bool], timeout: float = 2.0) -> None: + deadline = time.monotonic() + timeout + while not predicate(): + if time.monotonic() > deadline: + raise AssertionError("условие не выполнено за отведённое время") + await asyncio.sleep(0.01) + + +async def _cancel(task: asyncio.Task[Any]) -> None: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.mark.integration +async def test_publisher_loop_publishes_and_stops_on_cancel( + session_factory: async_sessionmaker[AsyncSession], + jobs: CommittedJobs, + nats: NatsConnection, + subscriber: RequestSubscriber, +): + job_id = await jobs.make_queued_job() + task = asyncio.create_task( + run_publisher_loop(session_factory, nats, poll_seconds=0.1, ack_timeout=ACK, max_republish=MAX_REPUBLISH) + ) + try: + await subscriber.wait_for(job_id, 1, timeout=2.0) + finally: + await _cancel(task) + assert (await jobs.get(job_id)).status is RecognitionStatus.published + + +@pytest.mark.integration +async def test_watchdog_loop_expires_and_stops_on_cancel( + session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs +): + job_id = await jobs.make_queued_job( + status=RecognitionStatus.processing, reply_deadline=datetime.now(UTC) - timedelta(seconds=1) + ) + task = asyncio.create_task(run_watchdog_loop(session_factory, period_seconds=0.1)) + try: + deadline = time.monotonic() + 2.0 + while (await jobs.get(job_id)).status is not RecognitionStatus.timeout: + assert time.monotonic() < deadline, "вахтёр не перевёл задание в timeout за 2 с" + await asyncio.sleep(0.05) + finally: + await _cancel(task) + + +class RecordingSleep: + """Подмена `sleep` циклов: запоминает паузы и только уступает управление.""" + + def __init__(self) -> None: + self.delays: list[float] = [] + + async def __call__(self, delay: float) -> None: + self.delays.append(delay) + await asyncio.sleep(0) + + +async def test_publisher_loop_survives_pass_errors(monkeypatch: pytest.MonkeyPatch): + calls: list[int] = [] + + async def failing_pass(*args: Any, **kwargs: Any) -> Any: + calls.append(1) + raise RuntimeError("БД недоступна") + + monkeypatch.setattr(rx, "publish_pending", failing_pass) + sleep = RecordingSleep() + task = asyncio.create_task( + run_publisher_loop( + object(), # type: ignore[arg-type] # проход подменён: фабрика сессий не используется + FakeNats(), + poll_seconds=5, + ack_timeout=ACK, + max_republish=MAX_REPUBLISH, + sleep=sleep, + ) + ) + try: + await _wait_until(lambda: len(calls) >= MIN_LOOP_PASSES) + finally: + await _cancel(task) + assert set(sleep.delays) == {5} + + +async def test_watchdog_loop_survives_pass_errors(monkeypatch: pytest.MonkeyPatch): + calls: list[int] = [] + + async def failing_pass(*args: Any, **kwargs: Any) -> int: + calls.append(1) + raise RuntimeError("БД недоступна") + + monkeypatch.setattr(rx, "expire_deadlines", failing_pass) + sleep = RecordingSleep() + task = asyncio.create_task(run_watchdog_loop(object(), sleep=sleep)) # type: ignore[arg-type] + try: + await _wait_until(lambda: len(calls) >= MIN_LOOP_PASSES) + finally: + await _cancel(task) + assert set(sleep.delays) == {WATCHDOG_PERIOD_SECONDS} + + +async def test_exchange_tasks_start_two_loops_and_stop(monkeypatch: pytest.MonkeyPatch): + started: dict[str, dict[str, Any]] = {} + + async def fake_publisher(session_factory: Any, nats: Any, **kwargs: Any) -> None: + started["publisher"] = kwargs + await asyncio.Event().wait() + + async def fake_watchdog(session_factory: Any, **kwargs: Any) -> None: + started["watchdog"] = kwargs + await asyncio.Event().wait() + + monkeypatch.setattr(rx, "run_publisher_loop", fake_publisher) + monkeypatch.setattr(rx, "run_watchdog_loop", fake_watchdog) + settings = get_settings().model_copy( + update={"OUTBOX_POLL_SECONDS": 7, "ACK_TIMEOUT_SECONDS": 11, "OUTBOX_MAX_REPUBLISH": 2} + ) + + exchange = ExchangeTasks.start(object(), FakeNats(), settings) # type: ignore[arg-type] + await _wait_until(lambda: len(started) == EXCHANGE_TASKS) + + assert len(exchange.tasks) == EXCHANGE_TASKS + assert started["publisher"] == {"poll_seconds": 7, "ack_timeout": timedelta(seconds=11), "max_republish": 2} + assert started["watchdog"] == {"period_seconds": WATCHDOG_PERIOD_SECONDS} + await exchange.stop() + assert all(task.done() for task in exchange.tasks) + await exchange.stop() # повторная остановка безопасна -- 2.47.2 From c5a5c0bf29d75620d582061a362155d4bc130a3d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 00:20:12 +0400 Subject: [PATCH 75/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2023?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index faab6f0..de2d2e6 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -109,7 +109,7 @@ ### Task 23: [5.4] `src/services/recognition_exchange.py` -- [ ] `src/services/recognition_exchange.py`: `publish_pending()` (SKIP LOCKED; первая публикация `queued → published`; повторная публикация `published` без ack через `ACK_TIMEOUT_SECONDS` с тем же `Nats-Msg-Id`, `metrics.republish_count`; после `OUTBOX_MAX_REPUBLISH` → `dlq`, `error_code = NO_ACK`), `expire_deadlines()`; фоновые задачи в lifespan при `NATS_ENABLED`; проверка: интеграционный тест — вставка `s3_object` + `stored_file` + `recognition_job(queued)` → сообщение получено тестовым подписчиком и проходит схему → строка `published`; без ack (с укороченными таймаутами) — три повтора с одним `Nats-Msg-Id` и `dlq`; задание с прошедшим `reply_deadline` → `timeout` +- [x] `src/services/recognition_exchange.py`: `publish_pending()` (SKIP LOCKED; первая публикация `queued → published`; повторная публикация `published` без ack через `ACK_TIMEOUT_SECONDS` с тем же `Nats-Msg-Id`, `metrics.republish_count`; после `OUTBOX_MAX_REPUBLISH` → `dlq`, `error_code = NO_ACK`), `expire_deadlines()`; фоновые задачи в lifespan при `NATS_ENABLED`; проверка: интеграционный тест — вставка `s3_object` + `stored_file` + `recognition_job(queued)` → сообщение получено тестовым подписчиком и проходит схему → строка `published`; без ack (с укороченными таймаутами) — три повтора с одним `Nats-Msg-Id` и `dlq`; задание с прошедшим `reply_deadline` → `timeout` ### Task 24: [5.5] `handle_message()` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index f1b7013..e8abc43 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -91,7 +91,7 @@ с заголовком `Nats-Msg-Id`, подписка `recognition.result` в queue group `arm-core`; проверка: интеграционный тест против compose-NATS (без JetStream) — публикация и получение сообщения с заголовком; два подписчика в одной группе получают сообщение один раз -- [ ] 5.4 `src/services/recognition_exchange.py`: `publish_pending()` (SKIP LOCKED; первая публикация `queued → +- [x] 5.4 `src/services/recognition_exchange.py`: `publish_pending()` (SKIP LOCKED; первая публикация `queued → published`; повторная публикация `published` без ack через `ACK_TIMEOUT_SECONDS` с тем же `Nats-Msg-Id`, `metrics.republish_count`; после `OUTBOX_MAX_REPUBLISH` → `dlq`, `error_code = NO_ACK`), `expire_deadlines()`; фоновые задачи в lifespan при `NATS_ENABLED`; проверка: интеграционный тест — вставка `s3_object` + `stored_file` + -- 2.47.2 From 9b81fa8e37f0313f8f40378434aa2b22b46130d4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 00:46:48 +0400 Subject: [PATCH 76/97] =?UTF-8?q?feat:=20inbox=20recognition.result=20?= =?UTF-8?q?=E2=80=94=20ack=20processing,=20=D0=B8=D0=B4=D0=B5=D0=BC=D0=BF?= =?UTF-8?q?=D0=BE=D1=82=D0=B5=D0=BD=D1=82=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D1=91=D0=BC=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D0=B0,=20s3=5Fobject=20=D1=80=D0=B5=D0=B7=D1=83?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D0=B0,=20ResultMapper/NoopMapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handle_message(msg, *, session_factory, mapper, now): разбор конверта по контракту v1.0, одна короткая транзакция с SELECT … FOR UPDATE (ждёт блокировку публикатора/вахтёра); ack published → processing; результат — дедуп по result_msg_id, финальные статусы (R-22) не трогаются, регистрация s3_object (results), слияние метрик с сохранением republish_count. Подписка app.py получает handler с фабрикой сессий и NoopMapper; шаблонный core/nats_callback.py удалён (R-3). audit_event не пишется (R-7). Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 2 +- README.md | 2 +- api/src/app.py | 16 +- api/src/core/nats_callback.py | 19 - api/src/services/recognition_exchange.py | 366 +++++++++- api/tests/factories.py | 28 +- api/tests/test_app.py | 25 + api/tests/test_recognition_inbox.py | 814 +++++++++++++++++++++++ 8 files changed, 1233 insertions(+), 39 deletions(-) delete mode 100644 api/src/core/nats_callback.py create mode 100644 api/tests/test_recognition_inbox.py diff --git a/CLAUDE.md b/CLAUDE.md index 48e2334..7dcf69b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ `pytest`, `ruff check . && ruff format --check .`, `mypy src`. Из `web/`: `npm run start|build`, `npx biome check src`, `npx tsc --noEmit`. Бутстрап — `setup/setup.sh` (pip/npm через Nexus банка). - **Раскладка `api/src/`**: `api/endpoints` → `services` → `repositories` → `models`; DTO — `schemas/`; инфраструктура — - `core/` (config, clients, nats_callback, logger, exception_handlers, auth); `db/` — engine и сессия. Наши добавления: + `core/` (config, clients, nats, logger, exception_handlers, auth); `db/` — engine и сессия. Наши добавления: `domain/` (чистые функции по §§ правил), `api/scripts/` (`check_dbml_vs_orm.py`, seed). - **DI** — ручные фабрики `Depends` в `api/dependencies/`; сессия из `get_async_session`; `storage`/`nats` — из `request.app.state`. - **Настройки** — pydantic-settings, `api/.env`, вложенность `__`, инфраструктура под `OPENBAO__*`; `DEBUG=true` diff --git a/README.md b/README.md index 7db9d47..ce062b9 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ api/ app.py FastAPI-приложение: lifespan (S3, NATS, SMB), /healthcheck, раздача SPA api/endpoints/ эндпоинты (me.py, dev_auth.py); router.py собирает /api/v1 api/dependencies/ зависимости FastAPI (current_user, проверка прав) - core/ config, auth (JWT), clients (S3), nats (подключение, publish, подписка), nats_callback, + core/ config, auth (JWT), clients (S3), nats (подключение, publish, подписка), logger, exception_handlers db/ движок и сессия (схема dkb) models/ ORM-модели, 1:1 с DBML diff --git a/api/src/app.py b/api/src/app.py index 980c82f..c862cef 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -6,12 +6,15 @@ group `arm-core`), SMB (горячие папки ИС Сканирование) healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA. Недоступность S3 или NATS при старте не роняет и не задерживает приложение: ошибка логируется, healthcheck показывает состояние. При NATS_ENABLED=true lifespan запускает фоновые задачи обмена `recognition_job` — outbox-публикатор и -вахтёр сроков (`src/services/recognition_exchange.py`, design D-6) — и останавливает их до закрытия NATS. +вахтёр сроков (`src/services/recognition_exchange.py`, design D-6) — и останавливает их до закрытия NATS. Сообщения +`recognition.result` (ack и результаты сервисов) обрабатывает inbox `handle_message` того же модуля с фабрикой сессий +приложения и `NoopMapper` (маппинг результата в доменные таблицы — Срез 2). """ import os from contextlib import asynccontextmanager from datetime import UTC, datetime +from functools import partial from http import HTTPStatus import smbclient @@ -28,9 +31,9 @@ from src.api.router import API_PREFIX, router from src.core.clients import S3Storage from src.core.config import get_settings from src.core.exception_handlers import register_exception_handlers -from src.core.nats import NatsConnection +from src.core.nats import MessageHandler, NatsConnection from src.db.db import async_session_maker -from src.services.recognition_exchange import ExchangeTasks, handle_message +from src.services.recognition_exchange import ExchangeTasks, NoopMapper, handle_message settings = get_settings() @@ -91,9 +94,14 @@ async def _connect_s3() -> S3Storage | None: return S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET) +def _result_handler() -> MessageHandler: + """Inbox `recognition.result`: `handle_message` с фабрикой сессий приложения и `NoopMapper` (Срез 0).""" + return partial(handle_message, session_factory=async_session_maker, mapper=NoopMapper()) + + async def _subscribe_results(nats: NatsConnection) -> None: """Подписки после первого подключения к NATS: `recognition.result` в queue group `arm-core`.""" - await nats.subscribe_results(handle_message) + await nats.subscribe_results(_result_handler()) def _create_nats() -> NatsConnection | None: diff --git a/api/src/core/nats_callback.py b/api/src/core/nats_callback.py deleted file mode 100644 index e3420b5..0000000 --- a/api/src/core/nats_callback.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Обработчики сообщений NATS. - -Каркас: доменный обработчик удалён. Consumer `recognition.result` (дедуп по recognition_job.result_msg_id, -маппинг JSON из S3 в document/osv_entry/financial_statement_*) реализуется в Срезе 0–2 по -docs/context/03-architecture.md. Для референса — reference-old-arm/api/src/core/nats_callback.py. -""" - -from loguru import logger -from nats.aio.client import Client as NATS - -from src.core.clients import S3Storage - - -def build_result_handler(nc: NATS, storage: S3Storage): - async def handler(msg): - logger.info("NATS message received. subject={subject}, size={size}", subject=msg.subject, size=len(msg.data)) - await msg.ack() - - return handler diff --git a/api/src/services/recognition_exchange.py b/api/src/services/recognition_exchange.py index 59372e7..049dcfe 100644 --- a/api/src/services/recognition_exchange.py +++ b/api/src/services/recognition_exchange.py @@ -22,29 +22,54 @@ SKIP LOCKED` → публикация → `UPDATE` → `COMMIT`). Блокиро Обмен не пишет `audit_event` (рулинг R-7, design #20). -Inbox (`handle_message`) — пока заглушка: подписка в `src/core/nats.py` уже вызывает её; полноценная обработка -(ack `processing`, результат, дедуп по `result_msg_id`) — задача 24 (рулинг R-12, design #23). +Inbox (задача 24, рулинги R-12, R-22): `handle_message()` — обработчик подписки `recognition.result` +(`src/core/nats.py`, queue group `arm-core`). Одно сообщение — одна короткая транзакция без сетевого IO: +`SELECT … FOR UPDATE` строки задания (ЖДЁТ блокировку: ack, пришедший, пока публикатор держит строку до своего commit, +применяется после него, а не теряется) → проверки статуса → изменения → `COMMIT`. +- ack (`status = processing`): `published` → `processing`, `started_at`, `service_*`; повторный ack для `processing` — + тихо игнорируется; ack для другого статуса — warning, задание не меняется. +- результат (`done | needs_check | error`): дубликат определяется ТОЛЬКО по `recognition_job.result_msg_id == + Nats-Msg-Id` (at-least-once) — без изменений; задание в финальном статусе (`FINAL_STATUSES`) — warning, без изменений; + иначе (`queued | published | processing`: потерянный ack не блокирует результат) — регистрация файла результата в + `s3_object` (`bucket_role = results`), обновление задания, `ResultMapper.map_result` (Срез 0 — `NoopMapper`; Срез 2 + заменит маппингом результата в `document` / `osv_entry` / `financial_statement_*`) и commit. +- Гонка с вахтёром (`expire_deadlines` — один `UPDATE` по статусу) решается блокировкой строки: кто первым взял + строку, тот и задаёт финальный статус; второй видит финальный статус и ничего не меняет. +- Невалидное сообщение, неизвестный `job_id`, ошибка БД или mapper'а — лог без повторов; исключения наружу не выходят. + Задание без применённого результата дойдёт до `timeout` (осознанный компромисс D-6). """ import asyncio import json import uuid -from collections.abc import Awaitable, Callable, Sequence +from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import Any, Protocol from loguru import logger -from nats.aio.msg import Msg from pydantic import ValidationError from sqlalchemy import CursorResult, and_, case, func, literal, or_, select, update from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from src.contract import CONTRACT_VERSION, RecognitionRequest, S3Ref, request_msg_id, request_subject +from src.contract import ( + CONTRACT_VERSION, + NATS_MSG_ID_HEADER, + MessageStatus, + Metrics, + RecognitionRequest, + RecognitionResultMessage, + S3Ref, + ack_msg_id, + request_msg_id, + request_subject, + result_msg_id, +) from src.core.config import Settings from src.core.nats import NatsPublishError -from src.models.enums import RecognitionPipeline, RecognitionStatus +from src.models.enums import RecognitionPipeline, RecognitionStatus, S3BucketRole from src.models.files import RecognitionJob, S3Object, StoredFile WATCHDOG_PERIOD_SECONDS = 30 # период вахтёра сроков (spec «Таймаут ожидания результата») @@ -433,6 +458,329 @@ class ExchangeTasks: ) -async def handle_message(msg: Msg) -> None: - """Заглушка: логирует subject и размер полученного сообщения, ничего не меняет.""" - logger.info("Получено сообщение NATS. subject={subject}, size={size}", subject=msg.subject, size=len(msg.data)) +# --- inbox: recognition.result ---------------------------------------------------------------------------------- + +# Финальные статусы для inbox (рулинг R-22, design #34): результат для них не применяется +FINAL_STATUSES: frozenset[RecognitionStatus] = frozenset( + { + RecognitionStatus.done, + RecognitionStatus.needs_check, + RecognitionStatus.error, + RecognitionStatus.timeout, + RecognitionStatus.dlq, + } +) +# Итог сервиса → статус задания +RESULT_STATUSES: Mapping[MessageStatus, RecognitionStatus] = { + MessageStatus.DONE: RecognitionStatus.done, + MessageStatus.NEEDS_CHECK: RecognitionStatus.needs_check, + MessageStatus.ERROR: RecognitionStatus.error, +} +# Счётчики значений из метрик сервиса → одноимённые колонки задания (DBML v5.7) +FIELD_COUNTERS = ("fields_total", "fields_low_confidence", "fields_unrecognized") +LOG_ID_MAX_LENGTH = 64 # job_id невалидного сообщения в логе — не длиннее (тело в лог не пишется) + + +class InboundMessage(Protocol): + """То, что inbox читает из сообщения nats-py (`nats.aio.msg.Msg`).""" + + @property + def data(self) -> bytes: ... + + @property + def headers(self) -> Mapping[str, str] | None: ... + + @property + def subject(self) -> str: ... + + +class ResultMapper(Protocol): + """Точка расширения inbox (design D-6): маппинг принятого результата в доменные таблицы. + + Вызывается в транзакции обработки сообщения, после обновления задания (`job` уже содержит статус и ссылку на + файл результата и может быть уточнено mapper'ом). Исключение откатывает всю транзакцию: задание не меняется и + дойдёт до `timeout`. Сетевой IO внутри — на совести реализации: транзакция держит блокировку строки задания. + Срез 0 — `NoopMapper`; Срез 2 заменит чтением файла результата и записью `document` / `osv_entry` / … + """ + + async def map_result( + self, session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage + ) -> None: ... + + +class NoopMapper: + """Mapper Среза 0: результат только регистрируется (`s3_object`, задание), доменные таблицы не заполняются.""" + + async def map_result(self, session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage) -> None: + logger.debug("Маппинг результата не выполняется (NoopMapper, Срез 0). job_id={job_id}", job_id=message.job_id) + + +class _MapperError(Exception): + """Исключение `ResultMapper.map_result` — отличаем от ошибок БД в логе.""" + + +def header_msg_id(headers: Mapping[str, str] | None) -> str | None: + """Значение заголовка `Nats-Msg-Id` без учёта регистра имени; нет заголовка или пустое значение — `None`.""" + if not headers: + return None + target = NATS_MSG_ID_HEADER.lower() + for name, value in headers.items(): + if name.lower() == target and value: + return value + return None + + +def merge_metrics(existing: Any, incoming: Mapping[str, Any] | None) -> Any: + """Метрики сервиса поверх метрик задания: ключи ядра (`republish_count`) сохраняются, совпадающие — от сервиса. + + `incoming is None` — метрики задания как есть. Не объект (SQL NULL, JSON `null` — так ORM пишет `None`) — как `{}`. + """ + if incoming is None: + return existing + base = dict(existing) if isinstance(existing, dict) else {} + return {**base, **incoming} + + +def _parse_message(msg: InboundMessage) -> RecognitionResultMessage | None: + """Конверт `recognition-result-message` v1.0 или `None` (warning в лог без тела сообщения).""" + try: + data = json.loads(msg.data) + except ValueError: # JSONDecodeError и UnicodeDecodeError + data = None + if not isinstance(data, dict): + logger.warning( + "Сообщение {subject} отклонено: тело не JSON-объект. error_category=invalid_message, size={size}", + subject=msg.subject, + size=len(msg.data), + ) + return None + try: + return RecognitionResultMessage.model_validate(data) + except ValidationError as exc: + raw_job_id = data.get("job_id") + job_id = raw_job_id[:LOG_ID_MAX_LENGTH] if isinstance(raw_job_id, str) else None + errors = "; ".join( + f"{'.'.join(str(part) for part in error['loc']) or ''}: {error['msg']}" + for error in exc.errors(include_url=False, include_input=False, include_context=False) + ) + logger.warning( + "Сообщение {subject} отклонено: не соответствует контракту recognition-result-message. " + "error_category=invalid_message, job_id={job_id}, errors={errors}", + subject=msg.subject, + job_id=job_id, + errors=errors, + ) + return None + + +async def _lock_job(session: AsyncSession, job_id: uuid.UUID) -> RecognitionJob | None: + """Строка задания под `FOR UPDATE` — ЖДЁТ блокировку (не SKIP LOCKED / NOWAIT): ack или результат, пришедший, пока + публикатор или вахтёр держат строку, применяется после их commit и видит их итог.""" + stmt = ( + select(RecognitionJob) + .where(RecognitionJob.id == job_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return (await session.execute(stmt)).scalar_one_or_none() + + +def _apply_ack(job: RecognitionJob, message: RecognitionResultMessage, msg_id: str, now: datetime) -> None: + """Ack (`status = processing`): `published` → `processing`; повтор для `processing` — тихо; иначе — warning.""" + if job.status is RecognitionStatus.published: + job.status = RecognitionStatus.processing + job.started_at = now + job.service_name = message.service.name.value + job.service_version = message.service.version + logger.info( + "Сервис подтвердил получение запроса (ack): задание в processing. job_id={job_id}, service={service}, " + "msg_id={msg_id}", + job_id=job.id, + service=message.service.name.value, + msg_id=msg_id, + ) + elif job.status is RecognitionStatus.processing: + logger.debug("Повторный ack для задания в processing, игнорируется. job_id={job_id}", job_id=job.id) + else: + logger.warning( + "Ack для задания не в статусе published: задание не изменено. error_category=unexpected_ack, " + "job_id={job_id}, status={status}, msg_id={msg_id}", + job_id=job.id, + status=job.status.value, + msg_id=msg_id, + ) + + +async def _register_result_object(session: AsyncSession, message: RecognitionResultMessage, now: datetime) -> uuid.UUID: + """Файл результата в реестре `s3_object` (`bucket_role = results`, `created_by = service.name`). + + Уникальность `(bucket, key)`: строка с этим ключом уже есть — переиспользуется (дубль не вставляется, не падаем). + """ + assert message.result_s3 is not None + bucket, key = message.result_s3.bucket, message.result_s3.key + stmt = ( + pg_insert(S3Object) + .values( + bucket_role=S3BucketRole.results, + bucket=bucket, + key=key, + created_by=message.service.name.value, + created_at=now, + ) + .on_conflict_do_nothing(index_elements=[S3Object.bucket, S3Object.key]) + .returning(S3Object.id) + ) + inserted = (await session.execute(stmt)).scalar_one_or_none() + if inserted is not None: + return inserted + existing = ( + await session.execute(select(S3Object.id).where(S3Object.bucket == bucket, S3Object.key == key)) + ).scalar_one() + logger.warning( + "Файл результата уже зарегистрирован в s3_object, строка переиспользована. job_id={job_id}, bucket={bucket}, " + "key={key}", + job_id=message.job_id, + bucket=bucket, + key=key, + ) + return existing + + +async def _apply_result( + session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage, msg_id: str, now: datetime +) -> bool: + """Результат (`done | needs_check | error`): дубликат и финальный статус — без изменений (`False`); иначе — + регистрация файла результата и обновление задания (`True`, дальше — mapper и commit).""" + if job.result_msg_id == msg_id: + logger.debug( + "Повторная доставка результата (дубликат по result_msg_id), изменений нет. job_id={job_id}, " + "msg_id={msg_id}", + job_id=job.id, + msg_id=msg_id, + ) + return False + if job.status in FINAL_STATUSES: + logger.warning( + "Результат для задания в финальном статусе не применён. error_category=late_result, job_id={job_id}, " + "status={status}, result_status={result_status}, msg_id={msg_id}", + job_id=job.id, + status=job.status.value, + result_status=message.status.value, + msg_id=msg_id, + ) + return False + + result_s3_object_id = await _register_result_object(session, message, now) if message.result_s3 else None + job.status = RESULT_STATUSES[message.status] + job.service_name = message.service.name.value + job.service_version = message.service.version + job.finished_at = now + if message.metrics is not None: + job.metrics = merge_metrics(job.metrics, _metrics_json(message.metrics)) + for name in FIELD_COUNTERS: + value = getattr(message.metrics, name) + if value is not None: + setattr(job, name, value) + job.error_code = message.error.code.value if message.error is not None else None + job.error_message = message.error.message if message.error is not None else None + job.result_schema_version = message.result_schema_version + job.result_s3_object_id = result_s3_object_id + job.result_msg_id = msg_id + return True + + +async def _map_result( + session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage, mapper: ResultMapper +) -> None: + """`mapper.map_result` в транзакции сообщения; его исключение — `_MapperError` (откат всей транзакции).""" + try: + await mapper.map_result(session, job, message) + except Exception as exc: + raise _MapperError(f"ResultMapper.map_result: {type(exc).__name__}") from exc + logger.info( + "Результат распознавания принят. job_id={job_id}, status={status}, service={service}, msg_id={msg_id}, " + "error_code={error_code}", + job_id=job.id, + status=job.status.value, + service=message.service.name.value, + msg_id=job.result_msg_id, + error_code=job.error_code, + ) + + +def _metrics_json(metrics: Metrics) -> dict[str, Any]: + return metrics.model_dump(mode="json", exclude_none=True) + + +async def _apply_message( + session: AsyncSession, message: RecognitionResultMessage, header_id: str | None, mapper: ResultMapper, clock: Clock +) -> None: + job = await _lock_job(session, message.job_id) + if job is None: + logger.warning( + "Сообщение recognition.result для неизвестного задания, не применено. error_category=unknown_job, " + "job_id={job_id}, status={status}", + job_id=message.job_id, + status=message.status.value, + ) + return + if job.file_id != message.file_id: + logger.warning( + "Сообщение recognition.result: file_id не совпадает с заданием, не применено. " + "error_category=file_mismatch, job_id={job_id}, file_id={file_id}, message_file_id={message_file_id}", + job_id=job.id, + file_id=job.file_id, + message_file_id=message.file_id, + ) + return + is_ack = message.status is MessageStatus.PROCESSING + msg_id = header_id + if msg_id is None: + msg_id = ack_msg_id(job.id, job.attempt) if is_ack else result_msg_id(job.id, job.attempt) + logger.warning( + "Сообщение recognition.result без заголовка Nats-Msg-Id: использован производный id. " + "error_category=missing_msg_id, job_id={job_id}, msg_id={msg_id}", + job_id=job.id, + msg_id=msg_id, + ) + now = clock() + if is_ack: + _apply_ack(job, message, msg_id, now) + elif await _apply_result(session, job, message, msg_id, now): + await _map_result(session, job, message, mapper) + + +async def handle_message( + msg: InboundMessage, + *, + session_factory: async_sessionmaker[AsyncSession], + mapper: ResultMapper | None = None, + now: Clock | None = None, +) -> None: + """Обработчик `recognition.result` (spec «Ack сервиса переводит задание в `processing`», «Inbox принимает + результат идемпотентно»; design D-6). Модель транзакции и правила — в docstring модуля. + + Исключения наружу не выходят: невалидное сообщение — warning; ошибка БД или mapper'а — откат транзакции, + `logger.exception`, без повторов (задание дойдёт до `timeout`). `audit_event` не пишется (R-7). + """ + message = _parse_message(msg) + if message is None: + return + try: + async with session_factory() as session, session.begin(): + await _apply_message(session, message, header_msg_id(msg.headers), mapper or NoopMapper(), now or _utcnow) + except _MapperError: + logger.exception( + "Ошибка маппинга результата распознавания, транзакция откачена; задание дойдёт до timeout. " + "error_category=mapper_error, job_id={job_id}", + job_id=message.job_id, + ) + except Exception as exc: + logger.exception( + "Ошибка обработки сообщения {subject}, транзакция откачена (без повторов). error_category=inbox_error, " + "job_id={job_id}, error_type={error_type}, error={error}", + subject=msg.subject, + job_id=message.job_id, + error_type=type(exc).__name__, + error=repr(exc), + ) diff --git a/api/tests/factories.py b/api/tests/factories.py index e338b4f..9362437 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -3,7 +3,7 @@ - `make_osv_entry` — минимальная цепочка FK поверх сессии (`flush`, без `commit`): откатывается фикстурой `db_session`. - `CommittedJobs` — задания распознавания, ЗАФИКСИРОВАННЫЕ в БД (`commit`): для фоновых процессов обмена, которые работают в собственных транзакциях и не видят чужую незафиксированную транзакцию. Всё созданное удаляется - `cleanup()` в порядке FK. + `cleanup()` в порядке FK, включая строки `s3_object` файлов результата, зарегистрированные inbox'ом. """ import datetime @@ -11,7 +11,7 @@ import decimal import uuid from typing import Any -from sqlalchemy import delete +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.models.directories import LegalEntity @@ -92,6 +92,7 @@ class CommittedJobs: self.job_ids: list[uuid.UUID] = [] self._file_ids: list[uuid.UUID] = [] self._s3_ids: list[uuid.UUID] = [] + self._extra_s3_ids: list[uuid.UUID] = [] self._entity_ids: list[uuid.UUID] = [] async def make_queued_job( # noqa: PLR0913 - каждое поле задания задаётся тестом явно @@ -106,6 +107,8 @@ class CommittedJobs: request_msg_id: str | None = None, metrics: Any = None, attempt: int = 1, + result_msg_id: str | None = None, + started_at: datetime.datetime | None = None, ) -> uuid.UUID: """Задание (по умолчанию `queued`, tabular, XLSX) со своей цепочкой FK; возвращает `recognition_job.id`.""" now = datetime.datetime.now(datetime.UTC) @@ -142,6 +145,8 @@ class CommittedJobs: published_at=published_at, request_msg_id=request_msg_id, metrics=metrics, + result_msg_id=result_msg_id, + started_at=started_at, ) session.add(job) await session.flush() @@ -166,18 +171,31 @@ class CommittedJobs: assert job is not None return job + def track_s3_object(self, s3_object_id: uuid.UUID) -> None: + """Запомнить строку `s3_object`, созданную тестом вне фабрики (удаляется в `cleanup()`).""" + self._extra_s3_ids.append(s3_object_id) + async def cleanup(self) -> None: - """Удаляет всё созданное в порядке FK.""" + """Удаляет всё созданное в порядке FK (файлы результата, на которые ссылаются задания, — после заданий).""" async with self._session_factory() as session, session.begin(): + result_s3_ids: list[uuid.UUID] = [] if self.job_ids: + rows = await session.execute( + select(RecognitionJob.result_s3_object_id).where( + RecognitionJob.id.in_(self.job_ids), RecognitionJob.result_s3_object_id.is_not(None) + ) + ) + result_s3_ids = [row[0] for row in rows] await session.execute(delete(RecognitionJob).where(RecognitionJob.id.in_(self.job_ids))) if self._file_ids: await session.execute(delete(StoredFile).where(StoredFile.id.in_(self._file_ids))) - if self._s3_ids: - await session.execute(delete(S3Object).where(S3Object.id.in_(self._s3_ids))) + s3_ids = [*self._s3_ids, *self._extra_s3_ids, *result_s3_ids] + if s3_ids: + await session.execute(delete(S3Object).where(S3Object.id.in_(s3_ids))) if self._entity_ids: await session.execute(delete(LegalEntity).where(LegalEntity.id.in_(self._entity_ids))) self.job_ids.clear() self._file_ids.clear() self._s3_ids.clear() + self._extra_s3_ids.clear() self._entity_ids.clear() diff --git a/api/tests/test_app.py b/api/tests/test_app.py index 1755540..671001b 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -199,6 +199,31 @@ def test_create_nats_enabled_returns_connection_for_results(monkeypatch: pytest. assert not nats.is_connected # создание не подключает: подключение — в start() +async def test_result_subscription_uses_inbox_handler_with_dependencies(): + """Подписка `recognition.result` получает `handle_message` с фабрикой сессий приложения и `NoopMapper`.""" + subscribed: list[Any] = [] + + class _FakeConnection: + async def subscribe_results(self, handler: Any) -> None: + subscribed.append(handler) + + await app_module._subscribe_results(_FakeConnection()) + + (handler,) = subscribed + assert handler.func is recognition_exchange.handle_message + assert handler.args == () + assert set(handler.keywords) == {"session_factory", "mapper"} + assert handler.keywords["session_factory"] is app_module.async_session_maker + assert isinstance(handler.keywords["mapper"], recognition_exchange.NoopMapper) + + +def test_create_nats_subscribes_results_on_connect(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True) + nats = app_module._create_nats() + assert nats is not None + assert nats._on_connected is app_module._subscribe_results + + @pytest.mark.parametrize("secure", [True, False]) async def test_connect_s3_passes_secure_flag(monkeypatch: pytest.MonkeyPatch, secure: bool): created: dict[str, Any] = {} diff --git a/api/tests/test_recognition_inbox.py b/api/tests/test_recognition_inbox.py new file mode 100644 index 0000000..fa8fbfd --- /dev/null +++ b/api/tests/test_recognition_inbox.py @@ -0,0 +1,814 @@ +"""Inbox обмена `recognition_job`: `handle_message()` (spec recognition-job-exchange: «Ack сервиса переводит задание в +`processing`», «Inbox принимает результат идемпотентно»; design D-6; рулинги R-7, R-12, R-22). + +Быстрые тесты (без маркера) — разбор заголовка `Nats-Msg-Id`, слияние метрик, отказ невалидным сообщениям без +обращения к БД, ошибка БД не выходит наружу, шаблонный `nats_callback` удалён. +Интеграционные — `handle_message` с фейковым Msg (`SimpleNamespace(data, headers, subject)`) на реальном PostgreSQL +(БД сессии pytest; данные фиксируются `CommittedJobs` и удаляются в teardown) и полный цикл через реальный NATS +(уникальный result-subject: общий `recognition.result` не трогаем). +""" + +import asyncio +import importlib.util +import json +import time +import uuid +from collections.abc import AsyncIterator, Iterator +from datetime import UTC, datetime, timedelta +from functools import partial +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +import pytest_asyncio +from jsonschema import Draft202012Validator, FormatChecker +from loguru import logger +from nats.aio.client import Client as NATS +from nats.aio.msg import Msg +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine + +from src.contract import NATS_MSG_ID_HEADER, RecognitionResultMessage, ack_msg_id, result_msg_id +from src.core.config import get_settings +from src.core.nats import NatsConnection +from src.models.enums import RecognitionStatus, S3BucketRole +from src.models.files import RecognitionJob, S3Object +from src.services.recognition_exchange import ( + FINAL_STATUSES, + NoopMapper, + expire_deadlines, + handle_message, + header_msg_id, + merge_metrics, + publish_pending, +) +from tests.factories import CommittedJobs + +API_DIR = Path(__file__).resolve().parents[1] +MESSAGE_SCHEMA = json.loads((API_DIR / "contracts" / "recognition-result-message.schema.json").read_text("utf-8")) +MESSAGE_VALIDATOR = Draft202012Validator(MESSAGE_SCHEMA, format_checker=FormatChecker()) + +SERVICE = {"name": "tabular-parser", "version": "0.1.0"} +BUCKET = "arm-ka" +READY_TIMEOUT = 5.0 +RACE_JOBS = 10 +LOCK_HOLD_SECONDS = 0.5 + + +# --- инфраструктура --------------------------------------------------------------------------------------------- + + +@pytest.fixture +def logs() -> Iterator[list[Any]]: + """Записи loguru (уровень DEBUG и выше), выпущенные за время теста.""" + records: list[Any] = [] + handler_id = logger.add(lambda message: records.append(message.record), level="DEBUG") + try: + yield records + finally: + logger.remove(handler_id) + + +def _logged(records: list[Any], fragment: str, level: str | None = None) -> list[Any]: + return [r for r in records if fragment in r["message"] and (level is None or r["level"].name == level)] + + +def _result_key(file_id: uuid.UUID, job_id: uuid.UUID) -> str: + return f"results/{file_id}/{job_id}/result.json" + + +def envelope(job_id: uuid.UUID, file_id: uuid.UUID, status: str, **extra: Any) -> dict[str, Any]: + """Конверт `recognition-result-message` v1.0; для done/needs_check по умолчанию — `result_s3` и версия схемы.""" + body: dict[str, Any] = { + "schema_version": "1.0", + "job_id": str(job_id), + "file_id": str(file_id), + "status": status, + "service": SERVICE, + } + if status in ("done", "needs_check"): + body["result_s3"] = {"bucket": BUCKET, "key": _result_key(file_id, job_id)} + body["result_schema_version"] = "1.0" + body.update(extra) + return body + + +def fake_msg(body: Any, msg_id: str | None, *, header: str = NATS_MSG_ID_HEADER, validate: bool = True) -> Any: + """Msg-подобный объект nats-py: `.data`, `.headers`, `.subject`. Тело по умолчанию проверяется JSON Schema.""" + if validate: + MESSAGE_VALIDATOR.validate(body) + data = body if isinstance(body, bytes) else json.dumps(body, ensure_ascii=False).encode("utf-8") + headers = None if msg_id is None else {header: msg_id} + return SimpleNamespace(data=data, headers=headers, subject="recognition.result") + + +class RecordingMapper: + """Подставной mapper: запоминает вызовы и видимое состояние задания; `fail=True` — исключение; `delay` — пауза + внутри транзакции (строка задания остаётся заблокированной).""" + + def __init__(self, *, fail: bool = False, delay: float = 0) -> None: + self.fail = fail + self.delay = delay + self.calls: list[tuple[uuid.UUID, RecognitionStatus, RecognitionResultMessage]] = [] + + async def map_result(self, session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage) -> None: + self.calls.append((job.id, job.status, message)) + if self.delay: + await asyncio.sleep(self.delay) + if self.fail: + raise RuntimeError("подставная ошибка маппинга") + + +@pytest.fixture +def session_factory(db_engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: + return async_sessionmaker(db_engine, expire_on_commit=False) + + +@pytest_asyncio.fixture +async def jobs(session_factory: async_sessionmaker[AsyncSession]) -> AsyncIterator[CommittedJobs]: + committed = CommittedJobs(session_factory) + try: + yield committed + finally: + await committed.cleanup() + + +async def _row(session_factory: async_sessionmaker[AsyncSession], job_id: uuid.UUID) -> dict[str, Any]: + """Снимок всех колонок строки `recognition_job`.""" + async with session_factory() as session: + row = (await session.execute(select(RecognitionJob.__table__).where(RecognitionJob.id == job_id))).one() + return dict(row._mapping) + + +async def _s3_rows(session_factory: async_sessionmaker[AsyncSession], key: str) -> list[dict[str, Any]]: + async with session_factory() as session: + rows = await session.execute(select(S3Object.__table__).where(S3Object.key == key)) + return [dict(r._mapping) for r in rows] + + +async def _s3_total(session_factory: async_sessionmaker[AsyncSession]) -> int: + async with session_factory() as session: + return int((await session.execute(select(func.count()).select_from(S3Object))).scalar_one()) + + +async def _file_id(jobs: CommittedJobs, job_id: uuid.UUID) -> uuid.UUID: + return (await jobs.get(job_id)).file_id + + +async def _handle(session_factory: async_sessionmaker[AsyncSession], msg: Any, **kwargs: Any) -> None: + await handle_message(msg, session_factory=session_factory, **kwargs) + + +# --- быстрые тесты: заголовок, метрики, разбор ------------------------------------------------------------------ + + +@pytest.mark.parametrize( + ("headers", "expected"), + [ + ({"Nats-Msg-Id": "result:x:1"}, "result:x:1"), + ({"nats-msg-id": "result:x:1"}, "result:x:1"), + ({"NATS-MSG-ID": "ack:x:1", "other": "y"}, "ack:x:1"), + ({"other": "y"}, None), + ({"Nats-Msg-Id": ""}, None), + ({}, None), + (None, None), + ], +) +def test_header_msg_id_is_case_insensitive(headers: dict[str, str] | None, expected: str | None): + assert header_msg_id(headers) == expected + + +@pytest.mark.parametrize( + ("existing", "incoming", "expected"), + [ + ({"republish_count": 2}, {"duration_ms": 5}, {"republish_count": 2, "duration_ms": 5}), + ({"republish_count": 2, "rows": 1}, {"rows": 7}, {"republish_count": 2, "rows": 7}), + (None, {"duration_ms": 5}, {"duration_ms": 5}), # SQL NULL / JSON null (так ORM пишет None) + ([None, {"x": 1}], {"duration_ms": 5}, {"duration_ms": 5}), # не объект — как пустой + ({"republish_count": 1}, None, {"republish_count": 1}), # метрик сервиса нет — без изменений + (None, None, None), + ], +) +def test_merge_metrics_keeps_republish_count(existing: Any, incoming: Any, expected: Any): + assert merge_metrics(existing, incoming) == expected + + +class _UntouchableFactory: + """session_factory, к которому нельзя обращаться: доказывает, что сообщение отвергнуто до БД.""" + + def __call__(self) -> Any: + raise AssertionError("невалидное сообщение не должно открывать сессию БД") + + +_JOB = uuid.UUID("11111111-1111-4111-8111-111111111111") +_FILE = uuid.UUID("22222222-2222-4222-8222-222222222222") + + +@pytest.mark.parametrize( + "data", + [ + b"{not json", + b"\xff\xfe", + b"[1, 2]", + b'"text"', + json.dumps({"schema_version": "1.0", "status": "done"}).encode(), # нет job_id + json.dumps(envelope(_JOB, _FILE, "done", schema_version="2.0")).encode(), # чужой MAJOR + json.dumps({k: v for k, v in envelope(_JOB, _FILE, "done").items() if k != "result_s3"}).encode(), + json.dumps(envelope(_JOB, _FILE, "error")).encode(), # error без поля error + json.dumps(envelope(_JOB, _FILE, "finished")).encode(), # статус вне enum + json.dumps(envelope(_JOB, _FILE, "done", service={"name": "other", "version": "1"})).encode(), + json.dumps({**envelope(_JOB, _FILE, "done"), "job_id": "not-a-uuid"}).encode(), + ], +) +async def test_invalid_message_is_logged_and_does_not_touch_db(data: bytes, logs: list[Any]): + msg = SimpleNamespace(data=data, headers={NATS_MSG_ID_HEADER: "result:x:1"}, subject="recognition.result") + await handle_message(msg, session_factory=_UntouchableFactory()) # type: ignore[arg-type] + rejected = _logged(logs, "отклонено", "WARNING") + assert len(rejected) == 1 + # тело целиком в лог не попадает + assert "result.json" not in rejected[0]["message"] + + +async def test_invalid_message_log_carries_job_id_when_readable(logs: list[Any]): + body = envelope(_JOB, _FILE, "error") # нет обязательного error + msg = fake_msg(body, "result:x:1", validate=False) + await handle_message(msg, session_factory=_UntouchableFactory()) # type: ignore[arg-type] + (record,) = _logged(logs, "отклонено", "WARNING") + assert str(_JOB) in record["message"] + + +async def test_database_error_is_logged_and_not_raised(logs: list[Any]): + """Ошибка БД (сервер недоступен) — `logger.exception`, без повторов, наружу не выходит.""" + engine = create_async_engine("postgresql+asyncpg://nobody:nothing@127.0.0.1:1/none") + try: + factory = async_sessionmaker(engine, expire_on_commit=False) + await handle_message( + fake_msg(envelope(_JOB, _FILE, "processing"), ack_msg_id(_JOB, 1)), session_factory=factory + ) + finally: + await engine.dispose() + (record,) = _logged(logs, "транзакция откачена", "ERROR") + assert record["exception"] is not None + + +async def test_noop_mapper_does_nothing(): + message = RecognitionResultMessage.model_validate(envelope(_JOB, _FILE, "done")) + assert await NoopMapper().map_result(None, None, message) is None # type: ignore[arg-type] + + +def test_final_statuses(): + """R-22 (design #34): финальные для inbox — done, needs_check, error, timeout, dlq.""" + assert {s.value for s in FINAL_STATUSES} == {"done", "needs_check", "error", "timeout", "dlq"} + + +def test_template_nats_callback_removed(): + """R-3 (design #16): шаблонный `core/nats_callback.py` удалён и нигде в `src/` не упоминается.""" + assert importlib.util.find_spec("src.core.nats_callback") is None + offenders = [p for p in (API_DIR / "src").rglob("*.py") if "nats_callback" in p.read_text(encoding="utf-8")] + assert offenders == [] + + +# --- ack: processing --------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_ack_moves_published_job_to_processing(session_factory, jobs: CommittedJobs, logs: list[Any]): + """Сценарий «Сервис подтвердил».""" + job_id = await jobs.make_queued_job(status=RecognitionStatus.published, request_msg_id=f"{uuid.uuid4()}:1") + file_id = await _file_id(jobs, job_id) + before = await _row(session_factory, job_id) + moment = datetime(2026, 10, 4, 12, 0, tzinfo=UTC) + + await _handle( + session_factory, fake_msg(envelope(job_id, file_id, "processing"), ack_msg_id(job_id, 1)), now=lambda: moment + ) + + after = await _row(session_factory, job_id) + assert after["status"] is RecognitionStatus.processing + assert after["started_at"] == moment + assert (after["service_name"], after["service_version"]) == ("tabular-parser", "0.1.0") + changed = {k for k in after if after[k] != before[k]} + assert changed == {"status", "started_at", "service_name", "service_version"} + assert _logged(logs, "ack", "INFO") + + +@pytest.mark.integration +async def test_repeated_ack_changes_nothing(session_factory, jobs: CommittedJobs): + job_id = await jobs.make_queued_job(status=RecognitionStatus.published) + file_id = await _file_id(jobs, job_id) + ack = fake_msg(envelope(job_id, file_id, "processing"), ack_msg_id(job_id, 1)) + await _handle(session_factory, ack) + first = await _row(session_factory, job_id) + + await _handle(session_factory, ack, now=lambda: datetime.now(UTC) + timedelta(minutes=1)) + + assert await _row(session_factory, job_id) == first + + +@pytest.mark.integration +@pytest.mark.parametrize( + "status", + [ + RecognitionStatus.queued, + RecognitionStatus.done, + RecognitionStatus.needs_check, + RecognitionStatus.error, + RecognitionStatus.timeout, + RecognitionStatus.dlq, + ], +) +async def test_ack_for_other_status_is_logged_and_ignored( + session_factory, jobs: CommittedJobs, logs: list[Any], status: RecognitionStatus +): + job_id = await jobs.make_queued_job(status=status) + file_id = await _file_id(jobs, job_id) + before = await _row(session_factory, job_id) + + await _handle(session_factory, fake_msg(envelope(job_id, file_id, "processing"), ack_msg_id(job_id, 1))) + + assert await _row(session_factory, job_id) == before + (record,) = _logged(logs, "задание не изменено", "WARNING") + assert status.value in record["message"] + + +# --- результат --------------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_done_result_registers_s3_object_and_updates_job(session_factory, jobs: CommittedJobs): + """Сценарий «Успешный результат»: s3_object (results), result_msg_id, finished_at, метрики слиты.""" + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing, metrics={"republish_count": 1}) + file_id = await _file_id(jobs, job_id) + moment = datetime(2026, 10, 4, 12, 30, tzinfo=UTC) + metrics = {"duration_ms": 1234, "rows": 50, "fields_total": 300, "fields_low_confidence": 4, + "fields_unrecognized": 2, "warnings": ["лист без шапки"]} # fmt: skip + body = envelope(job_id, file_id, "done", metrics=metrics, result_schema_version="1.1") + before_total = await _s3_total(session_factory) + + await _handle(session_factory, fake_msg(body, result_msg_id(job_id, 1)), now=lambda: moment) + + job = await _row(session_factory, job_id) + (s3,) = await _s3_rows(session_factory, _result_key(file_id, job_id)) + assert await _s3_total(session_factory) == before_total + 1 + assert job["status"] is RecognitionStatus.done + assert job["result_msg_id"] == f"result:{job_id}:1" + assert job["result_s3_object_id"] == s3["id"] + assert job["finished_at"] == moment + assert job["result_schema_version"] == "1.1" + assert (job["service_name"], job["service_version"]) == ("tabular-parser", "0.1.0") + assert job["metrics"] == {"republish_count": 1, **metrics} + assert (job["fields_total"], job["fields_low_confidence"], job["fields_unrecognized"]) == (300, 4, 2) + assert (job["error_code"], job["error_message"]) == (None, None) + assert s3["bucket_role"] is S3BucketRole.results + assert (s3["bucket"], s3["created_by"], s3["created_at"]) == (BUCKET, "tabular-parser", moment) + + +@pytest.mark.integration +async def test_needs_check_result(session_factory, jobs: CommittedJobs): + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) + file_id = await _file_id(jobs, job_id) + + await _handle(session_factory, fake_msg(envelope(job_id, file_id, "needs_check"), result_msg_id(job_id, 1))) + + job = await _row(session_factory, job_id) + assert job["status"] is RecognitionStatus.needs_check + assert job["result_s3_object_id"] is not None + assert job["metrics"] is None # метрик не было ни у задания, ни у сервиса: колонка не трогается + + +@pytest.mark.integration +async def test_redelivery_changes_nothing(session_factory, jobs: CommittedJobs, logs: list[Any]): + """Сценарий «Повторная доставка»: ни recognition_job, ни s3_object не меняются; mapper не вызывается.""" + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) + file_id = await _file_id(jobs, job_id) + msg = fake_msg(envelope(job_id, file_id, "done", metrics={"rows": 3}), result_msg_id(job_id, 1)) + mapper = RecordingMapper() + await _handle(session_factory, msg, mapper=mapper) + job_before = await _row(session_factory, job_id) + s3_before = await _s3_rows(session_factory, _result_key(file_id, job_id)) + total_before = await _s3_total(session_factory) + + await _handle(session_factory, msg, mapper=mapper, now=lambda: datetime.now(UTC) + timedelta(hours=1)) + + assert await _row(session_factory, job_id) == job_before + assert await _s3_rows(session_factory, _result_key(file_id, job_id)) == s3_before + assert await _s3_total(session_factory) == total_before + assert len(mapper.calls) == 1 + assert _logged(logs, "дубликат", "DEBUG") + assert not _logged(logs, "финальном статусе") + + +@pytest.mark.integration +async def test_service_error_without_result_s3(session_factory, jobs: CommittedJobs): + """Сценарий «Ошибка сервиса»: NOT_IMPLEMENTED без result_s3.""" + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) + file_id = await _file_id(jobs, job_id) + total_before = await _s3_total(session_factory) + body = envelope(job_id, file_id, "error", error={"code": "NOT_IMPLEMENTED", "message": "разбор ОСВ — Срез 1"}) + + await _handle(session_factory, fake_msg(body, result_msg_id(job_id, 1))) + + job = await _row(session_factory, job_id) + assert job["status"] is RecognitionStatus.error + assert (job["error_code"], job["error_message"]) == ("NOT_IMPLEMENTED", "разбор ОСВ — Срез 1") + assert job["result_s3_object_id"] is None + assert job["result_msg_id"] == f"result:{job_id}:1" + assert job["finished_at"] is not None + assert job["result_schema_version"] is None + assert await _s3_total(session_factory) == total_before + + +@pytest.mark.integration +@pytest.mark.parametrize( + "status", + [ + RecognitionStatus.timeout, + RecognitionStatus.dlq, + RecognitionStatus.error, + RecognitionStatus.done, + RecognitionStatus.needs_check, + ], +) +async def test_result_for_final_status_is_logged_and_ignored( + session_factory, jobs: CommittedJobs, logs: list[Any], status: RecognitionStatus +): + """Сценарий «Результат пришёл после таймаута» (и прочие финальные статусы, R-22): задание не меняется.""" + other = f"result:{uuid.uuid4()}:9" if status in (RecognitionStatus.done, RecognitionStatus.needs_check) else None + job_id = await jobs.make_queued_job(status=status, result_msg_id=other) + file_id = await _file_id(jobs, job_id) + before = await _row(session_factory, job_id) + total_before = await _s3_total(session_factory) + mapper = RecordingMapper() + + await _handle(session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)), mapper=mapper) + + assert await _row(session_factory, job_id) == before + assert await _s3_total(session_factory) == total_before + assert mapper.calls == [] + (record,) = _logged(logs, "финальном статусе", "WARNING") + assert status.value in record["message"] + + +@pytest.mark.integration +async def test_unknown_job_is_logged_and_creates_nothing(session_factory, logs: list[Any]): + job_id, file_id = uuid.uuid4(), uuid.uuid4() + total_before = await _s3_total(session_factory) + + await _handle(session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1))) + + assert await _s3_total(session_factory) == total_before + assert await _s3_rows(session_factory, _result_key(file_id, job_id)) == [] + (record,) = _logged(logs, "неизвестного задания", "WARNING") + assert str(job_id) in record["message"] + + +@pytest.mark.integration +async def test_file_id_mismatch_is_logged_and_ignored(session_factory, jobs: CommittedJobs, logs: list[Any]): + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) + before = await _row(session_factory, job_id) + total_before = await _s3_total(session_factory) + + await _handle(session_factory, fake_msg(envelope(job_id, uuid.uuid4(), "done"), result_msg_id(job_id, 1))) + + assert await _row(session_factory, job_id) == before + assert await _s3_total(session_factory) == total_before + assert _logged(logs, "file_id", "WARNING") + + +@pytest.mark.integration +@pytest.mark.parametrize("status", [RecognitionStatus.published, RecognitionStatus.queued]) +async def test_result_without_ack_is_accepted(session_factory, jobs: CommittedJobs, status: RecognitionStatus): + """R-22: потерянный ack не блокирует результат; `started_at` остаётся пустым (начало обработки неизвестно).""" + job_id = await jobs.make_queued_job(status=status) + file_id = await _file_id(jobs, job_id) + + await _handle(session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1))) + + job = await _row(session_factory, job_id) + assert job["status"] is RecognitionStatus.done + assert job["started_at"] is None + assert job["service_name"] == "tabular-parser" + + +@pytest.mark.integration +async def test_msg_id_header_case_insensitive(session_factory, jobs: CommittedJobs): + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) + file_id = await _file_id(jobs, job_id) + + await _handle( + session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1), header="nats-msg-id") + ) + + assert (await _row(session_factory, job_id))["result_msg_id"] == f"result:{job_id}:1" + + +@pytest.mark.integration +async def test_missing_msg_id_header_uses_derived_id(session_factory, jobs: CommittedJobs, logs: list[Any]): + """Нет `Nats-Msg-Id` — предупреждение и производный `result::`; повтор — дубликат.""" + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing, attempt=2) + file_id = await _file_id(jobs, job_id) + msg = fake_msg(envelope(job_id, file_id, "done"), None) + + await _handle(session_factory, msg) + job = await _row(session_factory, job_id) + await _handle(session_factory, msg) + + assert job["status"] is RecognitionStatus.done + assert job["result_msg_id"] == f"result:{job_id}:2" + assert await _row(session_factory, job_id) == job + assert len(_logged(logs, "без заголовка Nats-Msg-Id", "WARNING")) == 2 # noqa: PLR2004 - оба сообщения + + +@pytest.mark.integration +async def test_existing_s3_object_is_reused(session_factory, jobs: CommittedJobs): + """Ключ результата уже зарегистрирован — строка переиспользуется, дубль (bucket, key) не вставляется.""" + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) + file_id = await _file_id(jobs, job_id) + async with session_factory() as session, session.begin(): + existing = S3Object( + bucket_role=S3BucketRole.results, + bucket=BUCKET, + key=_result_key(file_id, job_id), + created_by="tabular-parser", + created_at=datetime.now(UTC), + ) + session.add(existing) + await session.flush() + existing_id = existing.id + jobs.track_s3_object(existing_id) + + await _handle(session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1))) + + assert (await _row(session_factory, job_id))["result_s3_object_id"] == existing_id + assert len(await _s3_rows(session_factory, _result_key(file_id, job_id))) == 1 + + +# --- mapper ------------------------------------------------------------------------------------------------------ + + +@pytest.mark.integration +async def test_mapper_sees_job_and_message(session_factory, jobs: CommittedJobs): + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) + file_id = await _file_id(jobs, job_id) + mapper = RecordingMapper() + + await _handle(session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)), mapper=mapper) + + ((seen_job_id, seen_status, message),) = mapper.calls + assert seen_job_id == job_id + assert seen_status is RecognitionStatus.done # mapper видит уже обновлённое задание (может его уточнить) + assert message.job_id == job_id + assert message.result_s3 is not None + assert message.result_s3.key == _result_key(file_id, job_id) + + +@pytest.mark.integration +async def test_mapper_error_rolls_back_whole_transaction(session_factory, jobs: CommittedJobs, logs: list[Any]): + """Исключение mapper'а — откат всего: задание не изменено, s3_object не создан (дойдёт до timeout, D-6).""" + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) + file_id = await _file_id(jobs, job_id) + before = await _row(session_factory, job_id) + total_before = await _s3_total(session_factory) + + msg = fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)) + await _handle(session_factory, msg, mapper=RecordingMapper(fail=True)) + + assert await _row(session_factory, job_id) == before + assert await _s3_total(session_factory) == total_before + (record,) = _logged(logs, "Ошибка маппинга", "ERROR") + assert record["exception"] is not None + + +# --- гонки -------------------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_concurrent_same_message_applied_once(session_factory, jobs: CommittedJobs, logs: list[Any]): + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) + file_id = await _file_id(jobs, job_id) + msg = fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)) + mapper = RecordingMapper(delay=LOCK_HOLD_SECONDS) # первый держит строку, второй ждёт его commit + + await asyncio.gather(*[_handle(session_factory, msg, mapper=mapper) for _ in range(2)]) + + assert len(await _s3_rows(session_factory, _result_key(file_id, job_id))) == 1 + assert (await _row(session_factory, job_id))["result_msg_id"] == f"result:{job_id}:1" + assert len(mapper.calls) == 1 + assert len(_logged(logs, "дубликат", "DEBUG")) == 1 + assert not _logged(logs, "неизвестного задания") + + +class _SlowNats: + """Подставной NATS: публикация «висит» `delay` секунд, пока публикатор держит блокировку строки задания.""" + + def __init__(self, delay: float) -> None: + self.delay = delay + self.entered = asyncio.Event() + + @property + def is_connected(self) -> bool: + return True + + async def publish(self, subject: str, payload: bytes, msg_id: str) -> None: + self.entered.set() + await asyncio.sleep(self.delay) + + +@pytest.mark.integration +async def test_ack_waits_for_publisher_lock(session_factory, jobs: CommittedJobs): + """Ack пришёл, пока публикатор держит строку (до его commit): обработчик ждёт блокировку и применяет ack после.""" + job_id = await jobs.make_queued_job() + file_id = await _file_id(jobs, job_id) + nats = _SlowNats(LOCK_HOLD_SECONDS) + publisher = asyncio.create_task( + publish_pending(session_factory, nats, ack_timeout=timedelta(seconds=30), max_republish=3, batch_size=1) + ) + await asyncio.wait_for(nats.entered.wait(), 5) + started = time.monotonic() + + await _handle(session_factory, fake_msg(envelope(job_id, file_id, "processing"), ack_msg_id(job_id, 1))) + + waited = time.monotonic() - started + report = await publisher + job = await _row(session_factory, job_id) + assert report.published == 1 + assert waited >= LOCK_HOLD_SECONDS / 2 # ждал блокировку, а не увидел queued + assert job["status"] is RecognitionStatus.processing + assert job["published_at"] is not None + assert job["started_at"] is not None + + +@pytest.mark.integration +async def test_result_vs_watchdog_is_consistent(session_factory, jobs: CommittedJobs): + """Результат и вахтёр на заданиях с прошедшим сроком одновременно: каждое — либо done (с s3_object), либо + timeout (без результата), но не смесь. Порядок решает блокировка строки: кто первый, тот и финальный.""" + past = datetime.now(UTC) - timedelta(minutes=1) + job_ids = [ + await jobs.make_queued_job(status=RecognitionStatus.processing, reply_deadline=past) for _ in range(RACE_JOBS) + ] + messages = [ + fake_msg(envelope(job_id, await _file_id(jobs, job_id), "done"), result_msg_id(job_id, 1)) for job_id in job_ids + ] + + await asyncio.gather(expire_deadlines(session_factory), *[_handle(session_factory, m) for m in messages]) + + for job_id in job_ids: + job = await _row(session_factory, job_id) + s3 = await _s3_rows(session_factory, _result_key(job["file_id"], job_id)) + if job["status"] is RecognitionStatus.done: + assert job["error_code"] is None + assert job["result_msg_id"] == f"result:{job_id}:1" + assert [r["id"] for r in s3] == [job["result_s3_object_id"]] + else: + assert job["status"] is RecognitionStatus.timeout + assert job["error_code"] == "TIMEOUT" + assert job["result_msg_id"] is None + assert job["result_s3_object_id"] is None + assert s3 == [] + + +@pytest.mark.integration +async def test_watchdog_waits_for_result_transaction(session_factory, jobs: CommittedJobs): + """Вахтёр запущен, пока обработчик результата держит строку: его UPDATE ждёт commit, перепроверяет статус на новой + версии строки (`done`) и задание не трогает.""" + past = datetime.now(UTC) - timedelta(minutes=1) + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing, reply_deadline=past) + mapper = RecordingMapper(delay=LOCK_HOLD_SECONDS) + msg = fake_msg(envelope(job_id, await _file_id(jobs, job_id), "done"), result_msg_id(job_id, 1)) + handler = asyncio.create_task(_handle(session_factory, msg, mapper=mapper)) + + async def locked() -> None: # обработчик внутри транзакции, строка заблокирована + while not mapper.calls: + await asyncio.sleep(0.01) + + await asyncio.wait_for(locked(), 5) + + expired = await expire_deadlines(session_factory) + await handler + + assert expired == 0 + job = await _row(session_factory, job_id) + assert job["status"] is RecognitionStatus.done + assert job["error_code"] is None + + +@pytest.mark.integration +async def test_watchdog_after_result_and_result_after_watchdog(session_factory, jobs: CommittedJobs): + past = datetime.now(UTC) - timedelta(minutes=1) + first = await jobs.make_queued_job(status=RecognitionStatus.processing, reply_deadline=past) + second = await jobs.make_queued_job(status=RecognitionStatus.processing, reply_deadline=past) + + await _handle( + session_factory, fake_msg(envelope(first, await _file_id(jobs, first), "done"), result_msg_id(first, 1)) + ) + await expire_deadlines(session_factory) + await _handle( + session_factory, fake_msg(envelope(second, await _file_id(jobs, second), "done"), result_msg_id(second, 1)) + ) + + assert (await _row(session_factory, first))["status"] is RecognitionStatus.done + assert (await _row(session_factory, second))["status"] is RecognitionStatus.timeout + + +# --- e2e: реальный NATS ------------------------------------------------------------------------------------------- + + +async def _wait_until(predicate: Any, timeout: float = 10.0) -> None: + deadline = time.monotonic() + timeout + while not await predicate(): + if time.monotonic() > deadline: + raise AssertionError("условие не выполнено за отведённое время") + await asyncio.sleep(0.05) + + +async def _wait_status( + session_factory: async_sessionmaker[AsyncSession], job_id: uuid.UUID, status: RecognitionStatus +) -> dict[str, Any]: + async def reached() -> bool: + return (await _row(session_factory, job_id))["status"] is status + + await _wait_until(reached) + return await _row(session_factory, job_id) + + +class _FakeService: + """«Сервис распознавания» поверх голого nats-py: принимает запросы своего задания, публикует конверты.""" + + def __init__(self, job_id: uuid.UUID, result_subject: str) -> None: + self.job_id = job_id + self.result_subject = result_subject + self.requests: list[Msg] = [] + self._nc = NATS() + + async def start(self, url: str) -> None: + await self._nc.connect(url, connect_timeout=2, allow_reconnect=False) + + async def collect(msg: Msg) -> None: + if json.loads(msg.data).get("job_id") == str(self.job_id): + self.requests.append(msg) + + await self._nc.subscribe("recognition.tabular.request", cb=collect) + await self._nc.flush() + + async def send(self, body: dict[str, Any], msg_id: str) -> None: + MESSAGE_VALIDATOR.validate(body) + await self._nc.publish(self.result_subject, json.dumps(body).encode(), headers={NATS_MSG_ID_HEADER: msg_id}) + await self._nc.flush() + + async def stop(self) -> None: + await self._nc.close() + + +@pytest.mark.integration +async def test_full_cycle_over_real_nats(session_factory, jobs: CommittedJobs, logs: list[Any]): + """queued → published (outbox) → processing (ack) → done + s3_object (результат) → повтор без изменений.""" + settings = get_settings().OPENBAO.NATS + result_subject = f"test.result.{uuid.uuid4().hex}" + handler = partial(handle_message, session_factory=session_factory, mapper=NoopMapper()) + + async def subscribe(conn: NatsConnection) -> None: + await conn.subscribe_results(handler) + + core = NatsConnection(settings, on_connected=subscribe, result_subject=result_subject) + job_id = await jobs.make_queued_job() + file_id = await _file_id(jobs, job_id) + service = _FakeService(job_id, result_subject) + await core.start() + try: + if not await core.wait_ready(timeout=READY_TIMEOUT): + pytest.fail(f"NATS недоступен ({settings.NATS_URL}, .env.test)", pytrace=False) + await service.start(settings.NATS_URL) + + async def published() -> bool: + await publish_pending(session_factory, core, ack_timeout=timedelta(seconds=30), max_republish=3) + return bool(service.requests) + + await _wait_until(published) + await _wait_status(session_factory, job_id, RecognitionStatus.published) + assert service.requests[0].headers == {NATS_MSG_ID_HEADER: f"{job_id}:1"} + + await service.send(envelope(job_id, file_id, "processing"), ack_msg_id(job_id, 1)) + job = await _wait_status(session_factory, job_id, RecognitionStatus.processing) + assert job["started_at"] is not None + assert (job["service_name"], job["service_version"]) == ("tabular-parser", "0.1.0") + + result = envelope(job_id, file_id, "done", metrics={"duration_ms": 10}) + await service.send(result, result_msg_id(job_id, 1)) + job = await _wait_status(session_factory, job_id, RecognitionStatus.done) + (s3,) = await _s3_rows(session_factory, _result_key(file_id, job_id)) + assert (job["result_s3_object_id"], job["result_msg_id"]) == (s3["id"], f"result:{job_id}:1") + assert job["finished_at"] is not None + assert s3["bucket_role"] is S3BucketRole.results + + await service.send(result, result_msg_id(job_id, 1)) + + async def duplicate_seen() -> bool: + return bool(_logged(logs, "дубликат", "DEBUG")) + + await _wait_until(duplicate_seen) + assert await _row(session_factory, job_id) == job + assert await _s3_rows(session_factory, _result_key(file_id, job_id)) == [s3] + finally: + await service.stop() + await core.stop() -- 2.47.2 From 0b4fa3a8191b95884ae17062eb634a559a299243 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 00:46:54 +0400 Subject: [PATCH 77/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2024?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index de2d2e6..2ef2845 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -113,7 +113,7 @@ ### Task 24: [5.5] `handle_message()` -- [ ] `handle_message()`: ack `processing` → `processing`/`started_at`/`service_*`; результат → `ResultMapper`/`NoopMapper` + регистрация `s3_object` + обновление задания; дубликат по `result_msg_id`, неизвестный `job_id`, результат для финального статуса — лог без изменений; проверка: интеграционный тест полного цикла queued → published → processing → done с `s3_object`; повтор не меняет строки; `error NOT_IMPLEMENTED` → `error_code`; `done` после `timeout` игнорируется +- [x] `handle_message()`: ack `processing` → `processing`/`started_at`/`service_*`; результат → `ResultMapper`/`NoopMapper` + регистрация `s3_object` + обновление задания; дубликат по `result_msg_id`, неизвестный `job_id`, результат для финального статуса — лог без изменений; проверка: интеграционный тест полного цикла queued → published → processing → done с `s3_object`; повтор не меняет строки; `error NOT_IMPLEMENTED` → `error_code`; `done` после `timeout` игнорируется ### Task 25: [6.1] `make check-full` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index e8abc43..0ae3e10 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -97,7 +97,7 @@ фоновые задачи в lifespan при `NATS_ENABLED`; проверка: интеграционный тест — вставка `s3_object` + `stored_file` + `recognition_job(queued)` → сообщение получено тестовым подписчиком и проходит схему → строка `published`; без ack (с укороченными таймаутами) — три повтора с одним `Nats-Msg-Id` и `dlq`; задание с прошедшим `reply_deadline` → `timeout` -- [ ] 5.5 `handle_message()`: ack `processing` → `processing`/`started_at`/`service_*`; результат → `ResultMapper`/`NoopMapper` +- [x] 5.5 `handle_message()`: ack `processing` → `processing`/`started_at`/`service_*`; результат → `ResultMapper`/`NoopMapper` + регистрация `s3_object` + обновление задания; дубликат по `result_msg_id`, неизвестный `job_id`, результат для финального статуса — лог без изменений; проверка: интеграционный тест полного цикла queued → published → processing → done с `s3_object`; повтор не меняет строки; `error NOT_IMPLEMENTED` → `error_code`; `done` после `timeout` игнорируется -- 2.47.2 From 81bb79e600c1b86ea51ffabc0db810892801964c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 00:58:57 +0400 Subject: [PATCH 78/97] =?UTF-8?q?feat:=20make=20check-full=20=E2=80=94=20c?= =?UTF-8?q?heck,=20dbml-check,=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=BE=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8B=20=D0=BF=D1=80=D0=BE=D1=82=D0=B8=D0=B2=20com?= =?UTF-8?q?pose,=20docker=20build,=20web;=20.env.test=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=D1=8B=20compose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Цели docker-build, web-check, check-infra (подсказка «make up»); .env.test переведён на 5432/4222/9000 (R-19), test_config и README обновлены. Цикл миграций входит в test-integration и отдельным шагом не дублируется. Co-Authored-By: Claude Fable 5.1 --- Makefile | 25 +++++++++++++++++++---- README.md | 13 ++++++------ api/.env.test | 9 ++++---- api/scripts/check_infra.py | 42 ++++++++++++++++++++++++++++++++++++++ api/tests/test_config.py | 6 +++--- 5 files changed, 78 insertions(+), 17 deletions(-) create mode 100644 api/scripts/check_infra.py diff --git a/Makefile b/Makefile index b0d16b5..6acc5e2 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ COMPOSE_FILE ?= ../docs/dev/docker-compose.yml # DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/ DBML ?= ../../docs/model/data-model-v5.13.dbml -.PHONY: venv lint test test-integration dbml-check migration-cycle check check-full up migrate seed seed-dev +.PHONY: venv lint test test-integration dbml-check migration-cycle check check-infra docker-build web-check check-full up migrate seed seed-dev # Python 3.11 (версия платформы); внутренние пакеты raisa_* вне банка недоступны — пропускаем их venv: @@ -29,14 +29,31 @@ dbml-check: cd api && $(BIN)/python scripts/check_dbml_vs_orm.py $(DBML) # Миграции на временной БД (spec data-model): upgrade head -> downgrade base -> upgrade head, пустой autogenerate, -# схема после миграции = create_all ORM. Подключается в check-full (задача 25) +# схема после миграции = create_all ORM. Входит в test-integration, отдельно запускается для быстрой проверки миграций migration-cycle: cd api && $(BIN)/pytest -m integration tests/test_migrations.py check: lint test -# TODO (задача 25): добавить фронт (lint/test/build web/), проверку миграций с нуля и откат, актуальность OpenAPI -check-full: check dbml-check test-integration +# Быстрая проверка доступности PostgreSQL и NATS из api/.env.test (порты compose); иначе — подсказка поднять инфраструктуру +check-infra: + cd api && $(BIN)/python scripts/check_infra.py + +# Сборка образа из корня репозитория (Dockerfile — бэкенд) +docker-build: + docker build -t arm-core . + +# Фронт: установка зависимостей (raisa-* без Nexus пропускаются как optionalDependencies), biome, типы, сборка +web-check: + cd web && npm install + cd web && npx biome check src + cd web && npx tsc --noEmit + cd web && npm run build + +# Полная проверка перед PR. Порядок: быстрые без БД (check, dbml-check), затем БД/NATS из compose (test-integration), +# затем docker, затем web. Цикл миграций upgrade -> downgrade -> upgrade на чистой БД входит в test-integration +# (tests/test_migrations.py), поэтому отдельным шагом не гоняется: второй запуск только удлинил бы прогон +check-full: check dbml-check check-infra test-integration docker-build web-check up: docker compose -f $(COMPOSE_FILE) up -d diff --git a/README.md b/README.md index ce062b9..68e4fc4 100644 --- a/README.md +++ b/README.md @@ -246,9 +246,11 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб | `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.13.dbml` | | `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate | | `make check` | быстрая проверка без Docker: `lint` + `test` | -| `make check-full` | `check` + `dbml-check` + `test-integration`; выполняется перед PR (нужна поднятая инфраструктура) | +| `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 check-full` пока не включает проверки фронта, OpenAPI и откат миграций отдельным шагом (задача 25 среза). ### Тесты @@ -256,10 +258,9 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб - **Интеграционные** (`make test-integration`) требуют PostgreSQL, тесты обмена через NATS — ещё и NATS. Рабочую БД `armka` они не трогают: на каждую сессию pytest создаётся свежая БД `armcore_pytest_`, мигрируется `alembic` и удаляется в конце. Параметры подключения берутся из `api/.env.test` (`pytest.ini` задаёт `ENV_FILE=.env.test`). - Сейчас этот файл указывает на временную тестовую инфраструктуру (PostgreSQL `55432`, NATS `54222`, MinIO `59000`), - а не на стандартные порты compose: чтобы гонять интеграционные тесты против `make up`, поправьте порты в - `api/.env.test` на `5432`, `4222`, `9000` (не коммитьте это изменение без согласования). То же относится к - `make migration-cycle` и `make check-full`: они тоже читают `.env.test` через `pytest.ini`. + Файл указывает на стандартные порты и учётные данные 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` без каталога пропускаются с понятной причиной. diff --git a/api/.env.test b/api/.env.test index fe6f5cb..0d3b09f 100644 --- a/api/.env.test +++ b/api/.env.test @@ -1,18 +1,19 @@ # Окружение тестов (pytest.ini: ENV_FILE=.env.test). Только тестовые значения, секретов здесь нет. -# Порты — временная тестовая инфраструктура: PostgreSQL 55432, NATS 54222, MinIO 59000. +# Порты и учётные данные — стандартные для docs/dev/docker-compose.yml (PostgreSQL 5432, NATS 4222, MinIO 9000): +# после `make up` тесты работают без правок. Рабочую БД тесты не трогают (создают armcore_pytest_). DEBUG=true -OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:55432/armka +OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:5432/armka -OPENBAO__MINIO__MINIO_URL=localhost:59000 +OPENBAO__MINIO__MINIO_URL=localhost:9000 OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=minioadmin OPENBAO__MINIO__MINIO_BUCKET=arm-ka # false — MinIO по http (docker compose); по умолчанию true (https, как на платформе) OPENBAO__MINIO__MINIO_SECURE=false -OPENBAO__NATS__NATS_URL=nats://localhost:54222 +OPENBAO__NATS__NATS_URL=nats://localhost:4222 OPENBAO__NATS__NATS_SEED= OPENBAO__NATS__NATS_JWT= diff --git a/api/scripts/check_infra.py b/api/scripts/check_infra.py new file mode 100644 index 0000000..839720e --- /dev/null +++ b/api/scripts/check_infra.py @@ -0,0 +1,42 @@ +"""Быстрая проверка доступности PostgreSQL и NATS из `.env.test` (порты docker-compose из docs/dev). + +Нужна `make check-full`, чтобы вместо длинного трейсбека pytest сразу подсказать: «поднимите инфраструктуру: make up». +Только TCP-подключение, без аутентификации; коды: 0 — доступны, 1 — недоступны. +""" + +import socket +import sys +from pathlib import Path +from urllib.parse import urlparse + +from dotenv import dotenv_values + +ENV_FILE = Path(__file__).resolve().parent.parent / ".env.test" +KEYS = ("OPENBAO__DATABASE__DATABASE_URL", "OPENBAO__NATS__NATS_URL") + + +def unreachable(urls: list[str]) -> list[str]: + """Адреса host:port, к которым не удалось подключиться.""" + bad: list[str] = [] + for url in urls: + parsed = urlparse(url) + host, port = parsed.hostname or "localhost", parsed.port or 0 + try: + with socket.create_connection((host, port), timeout=3): + pass + except OSError: + bad.append(f"{host}:{port}") + return bad + + +def main() -> int: + env = dotenv_values(ENV_FILE) + bad = unreachable([env[key] or "" for key in KEYS]) + if bad: + print(f"Инфраструктура недоступна ({', '.join(bad)}): поднимите её командой make up", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/api/tests/test_config.py b/api/tests/test_config.py index c4be000..22f3f05 100644 --- a/api/tests/test_config.py +++ b/api/tests/test_config.py @@ -20,10 +20,10 @@ def clean_env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch: def test_env_test_is_read(clean_env: pytest.MonkeyPatch) -> None: - """.env.test читается: порты временной тестовой инфраструктуры.""" + """.env.test читается: стандартные порты docker-compose из docs/dev.""" s = Settings(_env_file=API_DIR / ".env.test") # type: ignore[call-arg] - assert s.OPENBAO.DATABASE.DATABASE_URL.endswith("localhost:55432/armka") - assert s.OPENBAO.NATS.NATS_URL == "nats://localhost:54222" + assert s.OPENBAO.DATABASE.DATABASE_URL.endswith("localhost:5432/armka") + assert s.OPENBAO.NATS.NATS_URL == "nats://localhost:4222" assert s.AUTH_DEV_ISSUER is True assert s.NATS_ENABLED is False assert s.OPENBAO.MINIO.MINIO_SECURE is False -- 2.47.2 From d4a29748f5f72f23835c0ac0ab87909e27febddd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 00:59:24 +0400 Subject: [PATCH 79/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2025?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 2ef2845..b67a73d 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -117,7 +117,7 @@ ### Task 25: [6.1] `make check-full` -- [ ] `make check-full`: `make check` + `pytest -m integration` + цикл миграций + `check_dbml_vs_orm` + `docker build` + `web`: `npm install`, `npx biome check src`, `npx tsc --noEmit`, `npm run build`; проверка: цель зелёная против compose docs на ветке `feat/1-slice-0-skeleton` +- [x] `make check-full`: `make check` + `pytest -m integration` + цикл миграций + `check_dbml_vs_orm` + `docker build` + `web`: `npm install`, `npx biome check src`, `npx tsc --noEmit`, `npm run build`; проверка: цель зелёная против compose docs на ветке `feat/1-slice-0-skeleton` ### Task 26: [6.2] Прогон DoD diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 0ae3e10..389e955 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -104,7 +104,7 @@ ## 6. Проверки и завершение -- [ ] 6.1 `make check-full`: `make check` + `pytest -m integration` + цикл миграций + `check_dbml_vs_orm` + `docker build` +- [x] 6.1 `make check-full`: `make check` + `pytest -m integration` + цикл миграций + `check_dbml_vs_orm` + `docker build` + `web`: `npm install`, `npx biome check src`, `npx tsc --noEmit`, `npm run build`; проверка: цель зелёная против compose docs на ветке `feat/1-slice-0-skeleton` - [ ] 6.2 Прогон DoD: `make up` (compose docs) → `make migrate` → `make seed-dev` → `make check-full` → `curl /api/v1/me` -- 2.47.2 From 5ce496b94b72a3c7af27f3e8fe5661b5ebad6a98 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 01:09:10 +0400 Subject: [PATCH 80/97] =?UTF-8?q?chore:=20web/package-lock.json=20=D0=B2?= =?UTF-8?q?=20.gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8b6cf20..dc956d8 100644 --- a/.gitignore +++ b/.gitignore @@ -329,3 +329,4 @@ my_project_env .codegraph/ .superpowers/ .bounded +web/package-lock.json -- 2.47.2 From b3f7deae22e24cb2513294f599f7be269cb4ff19 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 01:12:24 +0400 Subject: [PATCH 81/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2026?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index b67a73d..8a00d6e 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -121,7 +121,7 @@ ### Task 26: [6.2] Прогон DoD -- [ ] Прогон DoD: `make up` (compose docs) → `make migrate` → `make seed-dev` → `make check-full` → `curl /api/v1/me` с dev-токеном возвращает права; `openspec validate slice-0-skeleton --strict`; проверка: всё зелёное, вывод `make check-full` вписан в описание PR +- [x] Прогон DoD: `make up` (compose docs) → `make migrate` → `make seed-dev` → `make check-full` → `curl /api/v1/me` с dev-токеном возвращает права; `openspec validate slice-0-skeleton --strict`; проверка: всё зелёное, вывод `make check-full` вписан в описание PR ### Task 27: [6.3] Дополнить `CLAUDE.md` (команды `make`), зафиксировать новые допущения в `../docs diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 389e955..d3a195b 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -107,7 +107,7 @@ - [x] 6.1 `make check-full`: `make check` + `pytest -m integration` + цикл миграций + `check_dbml_vs_orm` + `docker build` + `web`: `npm install`, `npx biome check src`, `npx tsc --noEmit`, `npm run build`; проверка: цель зелёная против compose docs на ветке `feat/1-slice-0-skeleton` -- [ ] 6.2 Прогон DoD: `make up` (compose docs) → `make migrate` → `make seed-dev` → `make check-full` → `curl /api/v1/me` +- [x] 6.2 Прогон DoD: `make up` (compose docs) → `make migrate` → `make seed-dev` → `make check-full` → `curl /api/v1/me` с dev-токеном возвращает права; `openspec validate slice-0-skeleton --strict`; проверка: всё зелёное, вывод `make check-full` вписан в описание PR - [ ] 6.3 Дополнить `CLAUDE.md` (команды `make`), зафиксировать новые допущения в `../docs/context/07-open-questions.md` -- 2.47.2 From 18126eeb8ff244267d47692593e1ddd6a91a492b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 01:16:56 +0400 Subject: [PATCH 82/97] =?UTF-8?q?docs:=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4=D1=8B=20make=20=D0=B8=20=D1=84=D0=B0=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D0=BA=D0=BE=D0=B5=20=D1=83=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D1=81=D1=82=D0=B2=D0=BE=20=D0=B2=20CLAUDE.md,=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D1=83=D0=BB=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20tasks/plan/spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md: цели make из корня, NATS_ENABLED vs DEBUG, обмен recognition_job, аудит, аутентификация, seed, сверка ORM-DBML, тесты; Dockerfile для dev и check-full; skip reason у fixtures. tasks.md/plan: required_document_rule в audit_ex.py (R-4); spec data-model: 43 таблицы модели + alembic_version. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 53 +++++++++++++------ .../plans/2026-10-03-slice-0-skeleton.md | 4 +- .../slice-0-skeleton/specs/data-model/spec.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 7 +-- 4 files changed, 43 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7dcf69b..eab7fa4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,38 +9,57 @@ ## Соглашения шаблона Полный текст — `../docs/context/03-architecture.md`, раздел «Соглашения шаблона команды». Главное для этого репозитория: -- **Команды — из `api/`** (там `.env`, `pytest.ini`, `web/` для SPA): `python run_server.py`, `alembic upgrade head`, - `pytest`, `ruff check . && ruff format --check .`, `mypy src`. Из `web/`: `npm run start|build`, `npx biome check src`, - `npx tsc --noEmit`. Бутстрап — `setup/setup.sh` (pip/npm через Nexus банка). -- **Раскладка `api/src/`**: `api/endpoints` → `services` → `repositories` → `models`; DTO — `schemas/`; инфраструктура — +- **Команды** — `make` из корня репозитория (Makefile в корне, backend-цели внутри делают `cd api`, venv — `api/.venv`): + `venv` (uv, Python 3.11, без `raisa_*`), `lint` (ruff + mypy), `test` (без БД), `test-integration` (PostgreSQL/NATS + из compose, включает цикл миграций), `check` (= lint + test, Docker не нужен), `up` (compose из `../docs/dev/`), + `migrate`, `seed` / `seed-dev` (справочники / + dev-пользователи), `dbml-check` (сверка ORM↔DBML), `migration-cycle` + (только цикл миграций), `check-infra`, `docker-build`, `web-check` (biome + tsc + build), `check-full` (check + + dbml-check + check-infra + test-integration + docker-build + web-check; **перед PR**, вывод — в описание PR). + Вручную — из `api/`: `python run_server.py`, `alembic upgrade head`, `pytest`; из `web/`: `npm run start|build`. + Бутстрап в банке — `setup/setup.sh` (pip/npm через Nexus). +- **Раскладка `api/src/`**: `api/endpoints` (`src/api/`) → `services` → `repositories` → `models`; DTO — `schemas/`; инфраструктура — `core/` (config, clients, nats, logger, exception_handlers, auth); `db/` — engine и сессия. Наши добавления: - `domain/` (чистые функции по §§ правил), `api/scripts/` (`check_dbml_vs_orm.py`, seed). -- **DI** — ручные фабрики `Depends` в `api/dependencies/`; сессия из `get_async_session`; `storage`/`nats` — из `request.app.state`. -- **Настройки** — pydantic-settings, `api/.env`, вложенность `__`, инфраструктура под `OPENBAO__*`; `DEBUG=true` - отключает NATS и middleware РАИСА. Флаги разработки (`AUTH_DEV_ISSUER`) — отдельные поля `Settings`, по умолчанию выключены. + `domain/` (чистые функции по §§ правил; появится в Срезе 3+), `contract/` (модели контракта обмена), `api/scripts/` (`check_dbml_vs_orm.py`, + `seed.py` + `seed_lib/`, `check_infra.py`), данные seed — `api/seed/`. +- **DI** — ручные фабрики `Depends` в `src/api/dependencies/`; сессия из `get_async_session`; `storage`/`nats` — из `request.app.state`. +- **Настройки** — pydantic-settings, `api/.env`, вложенность `__`, инфраструктура под `OPENBAO__*`. Флаги разработки + (`AUTH_DEV_ISSUER`, `DEBUG`) и обмена (`NATS_ENABLED`, по умолчанию выключен) — отдельные поля `Settings`. NATS + управляется только `NATS_ENABLED` (от `DEBUG` не зависит); при `DEBUG=false` шаблонный импорт + `raisa_fastapi_protected_api` в `app.py` условный (пакета нет вне банка — не ошибка); middleware РАИСА в Срезе 0 + не подключается. - **БД** — схема **`dkb`** (MetaData, `search_path`, Alembic); PK `uuid` через `UUIDPrimaryMixin`; ревизии в `api/alembic/versions/`; автогенерация — черновик, финал сверяется с DBML скриптом. - **Ошибки** — `common/exceptions.py` → `core/exception_handlers.py`, ответ `{"detail": …}` по-русски. **Логи** — loguru. -- **Тесты** — pytest-asyncio (auto, session loop), `APP_ENV=test`, `ENV_FILE=.env.test`; БД-тесты — на PostgreSQL из - compose, `domain/` — без БД. +- **Тесты** — pytest-asyncio (auto, session loop), `APP_ENV=test`, `ENV_FILE=.env.test`; маркеры `integration` (нужны + PostgreSQL/NATS) и `fixtures` (реальные данные из `../fixtures`); БД сессии — свежая `armcore_pytest_` с миграциями, + удаляется в конце; `domain/` — без БД. - **Линтеры** — backend `ruff` + `mypy` (`api/pyproject.toml`); frontend `biome` (ширина 140, одинарные кавычки) + `tsc`. - **Frontend** — React 18 + TS strict, rsbuild, antd 6 (основной UI-кит), Redux Toolkit (`store/slices/`), react-router 7; `layouts/` — страницы, `widgets/` — блоки, `components/` — примитивы; env только `REACT_APP_*` через `conf/config.ts`; запросы — `${Config.API_URL}/api/v1/...`; `REACT_APP_ROOT_PATH` = `ROOT_PATH` бэка. - **Сборка** — платформа (Python 3.11) запускает `api/run_server.py` (`SERVER__HOST/PORT`), ресурсы — `raisa_config.yml`; - SPA: `web/dist` → `api/web/`. `Dockerfile` (`python:3.11-slim`) в корне — только для локального compose (убран из - `.gitignore` шаблона). CI в репозитории нет: перед PR — `make check-full`. Без Nexus: `make venv` ставит зависимости - без `raisa_*`, npm-пакеты `raisa-*` — `optionalDependencies`. -- **NATS без JetStream**: подписка queue group `arm-core` на `recognition.result`; ack сервисов — конверт `processing`; - запрос без ack публикуется повторно (30 с × 3) → `dlq`; дедуп результатов — `recognition_job.result_msg_id`. + SPA: `web/dist` → `api/web/`. `Dockerfile` (`python:3.11-slim`) в корне — для dev-compose и `make check-full` + (`docker build`; убран из `.gitignore` шаблона). CI в репозитории нет: перед PR — `make check-full`. Без Nexus: + `make venv` ставит зависимости без `raisa_*`, npm-пакеты `raisa-*` — `optionalDependencies`. +- **NATS без JetStream** (`NATS_ENABLED=true`): `core/nats.py` — подключение в фоне (supervisor повторяет попытки), + подписка queue group `arm-core` на `recognition.result`. Обмен — `services/recognition_exchange.py`: `publish_pending` + (outbox), `expire_deadlines` (вахтёр сроков), `handle_message` (inbox: ack — конверт `processing`, результат); + публикатор и вахтёр — фоновые задачи lifespan. Запрос без ack повторно публикуется (`ACK_TIMEOUT_SECONDS`=30 × + `OUTBOX_MAX_REPUBLISH`=3, счётчик — `recognition_job.metrics.republish_count`) → `dlq`; дедуп — `request_msg_id` / + `result_msg_id`. Статусы задания в Срезе 0 `audit_event` не пишут. +- **Аудит** — `services/audit.py`, единственная точка записи `audit_event` (append-only: только `record`, в текущей транзакции). +- **Аутентификация** — `core/auth.py` (проверка JWT), `src/api/dependencies/auth.py` (`current_user`, `require(code)`); + dev-эмитент (`POST /api/v1/dev/token`) существует только при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true`. +- **Seed** — `python -m scripts.seed [--dev]` (`make seed|seed-dev`), идемпотентен; `--dev` — только для разработки. + **Сверка ORM↔DBML** — `scripts/check_dbml_vs_orm.py` (`make dbml-check`). ## Зависимости от соседей - Контракты: `../docs/contracts/recognition-result.schema.json` (вход из парсера/шлюза), `ex-payload-v1.schema.json`. -- Фикстуры: `../fixtures/` (env `ARM_KA_FIXTURES`); при отсутствии тесты с маркером `fixtures` пропускаются с предупреждением. +- Фикстуры: `../fixtures/` (env `ARM_KA_FIXTURES`); при отсутствии тесты с маркером `fixtures` пропускаются с явной причиной (skip reason). - NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`. ## Правила репозитория -- ORM 1:1 с `../docs/model/data-model-v5.13.dbml`; `api/scripts/check_dbml_vs_orm.py` — обязательная проверка CI. +- ORM 1:1 с `../docs/model/data-model-v5.13.dbml`; `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. - Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring. - Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц. - Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR. diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 8a00d6e..7fcd8b0 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -41,7 +41,7 @@ ### Task 6: [2.2] Части 01–03 -- [x] Части 01–03: `directories.py` (`regional_office`, `company_group(+_history)`, `legal_entity(+_history)`, `legal_entity_alias`, `related_group`, `related_group_member` с partial unique), `access.py` (`app_user`, `role`, `permission`, `role_permission`, `user_role`), `accounting.py` (`chart_of_account`, `elimination_rule`, `elimination_rule_account`, `required_document_rule`); проверка: `Base.metadata.create_all` на тестовой БД без ошибок +- [x] Части 01–03: `directories.py` (`regional_office`, `company_group(+_history)`, `legal_entity(+_history)`, `legal_entity_alias`, `related_group`, `related_group_member` с partial unique), `access.py` (`app_user`, `role`, `permission`, `role_permission`, `user_role`), `accounting.py` (`chart_of_account`, `elimination_rule`, `elimination_rule_account`; `required_document_rule` — в `audit_ex.py`, см. 2.4); проверка: `Base.metadata.create_all` на тестовой БД без ошибок ### Task 7: [2.3] Части 04–06 @@ -49,7 +49,7 @@ ### Task 8: [2.4] Части 07–09 -- [x] Части 07–09: `consolidation.py` (`consolidation`, `_version`, `_status_history`, `_lock` с partial unique, `_member`, `_document`), `kfo.py` (`correction`, `goodwill`, `kfo_result`, `kfo_result_line`, `kfo_result_line_company`, `client_kfo_line`), `audit_ex.py` (`comment`, `audit_event`, `ex_snapshot`, `ex_snapshot_member`, `ex_snapshot_item`); проверка: как 2.2, всего 43 таблицы в `Base.metadata.tables` +- [x] Части 07–09: `consolidation.py` (`consolidation`, `_version`, `_status_history`, `_lock` с partial unique, `_member`, `_document`), `kfo.py` (`correction`, `goodwill`, `kfo_result`, `kfo_result_line`, `kfo_result_line_company`, `client_kfo_line`), `audit_ex.py` (`comment`, `audit_event`, `ex_snapshot`, `ex_snapshot_member`, `ex_snapshot_item`, `required_document_rule`); проверка: как 2.2, всего 43 таблицы в `Base.metadata.tables` ### Task 9: [2.5] `scripts/check_dbml_vs_orm.py` (pydbml diff --git a/openspec/changes/slice-0-skeleton/specs/data-model/spec.md b/openspec/changes/slice-0-skeleton/specs/data-model/spec.md index 70bd2a3..c989226 100644 --- a/openspec/changes/slice-0-skeleton/specs/data-model/spec.md +++ b/openspec/changes/slice-0-skeleton/specs/data-model/spec.md @@ -33,7 +33,7 @@ SHALL проходить без ошибок. #### Scenario: Полный цикл - **WHEN** на пустой БД выполняются `alembic upgrade head`, `alembic downgrade base`, `alembic upgrade head` -- **THEN** все три команды завершаются успешно, после последней в `information_schema.tables` схемы `dkb` 43 таблицы +- **THEN** все три команды завершаются успешно, после последней в `information_schema.tables` схемы `dkb` 43 таблицы модели (+ служебная `alembic_version`) и в `pg_type` 27 enum-типов #### Scenario: Автогенерация ничего не находит diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index d3a195b..184ec4a 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -32,14 +32,15 @@ - [x] 2.2 Части 01–03: `directories.py` (`regional_office`, `company_group(+_history)`, `legal_entity(+_history)`, `legal_entity_alias`, `related_group`, `related_group_member` с partial unique), `access.py` (`app_user`, `role`, `permission`, `role_permission`, `user_role`), `accounting.py` (`chart_of_account`, `elimination_rule`, - `elimination_rule_account`, `required_document_rule`); проверка: `Base.metadata.create_all` на тестовой БД без ошибок + `elimination_rule_account`; `required_document_rule` — в `audit_ex.py`, см. 2.4); проверка: `Base.metadata.create_all` + на тестовой БД без ошибок - [x] 2.3 Части 04–06: `files.py` (`s3_object`, `stored_file`, `document` с `NULLS NOT DISTINCT`, `recognition_job`; FK-цикл через `use_alter`), `osv.py` (`osv_entry`), `statements.py` (`financial_statement_meta/column/line/value`); проверка: как 2.2 - [x] 2.4 Части 07–09: `consolidation.py` (`consolidation`, `_version`, `_status_history`, `_lock` с partial unique, `_member`, `_document`), `kfo.py` (`correction`, `goodwill`, `kfo_result`, `kfo_result_line`, `kfo_result_line_company`, - `client_kfo_line`), `audit_ex.py` (`comment`, `audit_event`, `ex_snapshot`, `ex_snapshot_member`, `ex_snapshot_item`); - проверка: как 2.2, всего 43 таблицы в `Base.metadata.tables` + `client_kfo_line`), `audit_ex.py` (`comment`, `audit_event`, `ex_snapshot`, `ex_snapshot_member`, `ex_snapshot_item`, + `required_document_rule`); проверка: как 2.2, всего 43 таблицы в `Base.metadata.tables` - [x] 2.5 `scripts/check_dbml_vs_orm.py` (pydbml; сравнение таблиц, колонок, типов, nullability, PK/UNIQUE/FK, индексов с notes-шаблонами, enum'ов, версии) + `tests/test_dbml_parity.py`; проверка: 0 расхождений на 2.1–2.4; намеренно добавленная колонка даёт код 1 и понятное сообщение -- 2.47.2 From f7bde3f82f367b7d5464928224a9c7971bb78799 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 01:18:12 +0400 Subject: [PATCH 83/97] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2027?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 7fcd8b0..23f974e 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -125,5 +125,5 @@ ### Task 27: [6.3] Дополнить `CLAUDE.md` (команды `make`), зафиксировать новые допущения в `../docs -- [ ] Дополнить `CLAUDE.md` (команды `make`), зафиксировать новые допущения в `../docs/context/07-open-questions.md` через PR в docs (OQ-46 и всё, что всплыло); проверка: ссылки из README/CLAUDE.md ведут на существующие файлы +- [x] Дополнить `CLAUDE.md` (команды `make`), зафиксировать новые допущения в `../docs/context/07-open-questions.md` через PR в docs (OQ-46 и всё, что всплыло); проверка: ссылки из README/CLAUDE.md ведут на существующие файлы diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 184ec4a..0b6083e 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -111,5 +111,5 @@ - [x] 6.2 Прогон DoD: `make up` (compose docs) → `make migrate` → `make seed-dev` → `make check-full` → `curl /api/v1/me` с dev-токеном возвращает права; `openspec validate slice-0-skeleton --strict`; проверка: всё зелёное, вывод `make check-full` вписан в описание PR -- [ ] 6.3 Дополнить `CLAUDE.md` (команды `make`), зафиксировать новые допущения в `../docs/context/07-open-questions.md` +- [x] 6.3 Дополнить `CLAUDE.md` (команды `make`), зафиксировать новые допущения в `../docs/context/07-open-questions.md` через PR в docs (OQ-46 и всё, что всплыло); проверка: ссылки из README/CLAUDE.md ведут на существующие файлы -- 2.47.2 From a880330f1f9c1bd4886ef961d4b480df41aba5a0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 01:21:59 +0400 Subject: [PATCH 84/97] =?UTF-8?q?docs:=20design.md=20=E2=80=94=20=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=2024=E2=80=9397=20(=D1=80?= =?UTF-8?q?=D1=83=D0=BB=D0=B8=D0=BD=D0=B3=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BB=D0=BB=D0=B5=D1=80=D0=B0=20=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D0=B8=D1=82=D0=B5=D0=BB=D0=B5=D0=B9,=20SDD=20?= =?UTF-8?q?=D1=81=D1=80=D0=B5=D0=B7=200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- openspec/changes/slice-0-skeleton/design.md | 74 +++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index a09890d..c095439 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -179,3 +179,77 @@ Seed содержит синтетические счета 01–99 и заба | 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 | нет | +| 24 | 2026-10-03 | R-9: `MetaData(schema="dkb", naming_convention={ix, uq, ck, fk, pk})` в `models/base.py`; частичные и служебные индексы и слишком длинные FK — с явными `name=` ≤ 63 символов | имена объектов БД должны совпадать между ORM, миграцией 0001 и autogenerate (проверка пустой ревизии); downgrade удаляет по детерминированным именам; use_alter FK требует имени | переименование ограничений = новая ревизия (до первого деплоя бесплатно) | +| 25 | 2026-10-03 | R-14: ядро сверки ORM и DBML живёт в `api/scripts/` (`check_dbml_vs_orm.py`), CLI и тесты используют один код; `tests/dbml_parity.py` — тонкая обёртка | план требует скрипт в `scripts/` и тест; дублировать логику сверки нельзя | перенос файлов | +| 26 | 2026-10-03 | R-15: `CREATE SCHEMA dkb` выполняется только если схемы нет (проверка `pg_namespace`): DO-блок в офлайн-SQL и в ревизии 0001, условие в `env.py` | спека требует «создавать, если нет», а не безусловный `IF NOT EXISTS`; платформа может выдать роли миграций только права на схему | лишняя проверка | +| 27 | 2026-10-03 | R-13: в fix-раунд 1 задачи 6 вместе с Important включены minor T6.2 (нормализация `server_default`/`now()`) и T6.3 (проверка `postgresql_where`) | они блокировали переиспользование общего компаратора `dbml_parity` в задачах 7–9 | чуть шире fix-раунд | +| 28 | 2026-10-03 | R-16: БД сессии тестов — отдельная свежая `armcore_pytest_` на том же сервере (реквизиты из `.env.test`), мигрируется alembic один раз на сессию подпроцессом и удаляется в конце; `armka` тестами не используется; все БД-тесты помечены `integration` | dev-seed в `armka` ломал бы тесты «app_user пуст»; spec quality-checks требует `make check` без PostgreSQL. Отклонение от буквы D-7: URL тот же, имя БД другое | поменять имя БД в одной фикстуре | +| 29 | 2026-10-03 | R-17: `Claims.email` нормализуется `strip().lower()`; пользователь ищется по `func.lower(AppUser.email)`; более одного совпадения — 403 | `app_user.email` UNIQUE регистрозависим, SSO может отдать другой регистр; fail-closed при неоднозначности | два email, различающихся регистром, дадут 403 обоим | +| 30 | 2026-10-03 | R-18: в fix-раунд 1 задачи 16 включено security-hardening: Claims из странных типов (`exp` и др.) → `AuthError`, пустой `sub`, пин `cryptography` в `requirements.txt`, текст ошибки JWKS (содержит `kid`) в лог как `repr(str(exc))[:200]` | 500 вместо 401 и log injection недопустимы; `kid` отдельно недоступен, поэтому вместо `repr(kid)[:64]` — `repr` всего сообщения | чуть шире раунд; лимит 200 — константа `_LOG_TEXT_LIMIT` | +| 31 | 2026-10-03 | R-19: `api/.env.test` в финале указывает на стандартные порты compose (5432/4222/9000), а не на временные контейнеры контроллера (55432/54222/59000); переключено в задаче 25 | `make check-full` обязан идти против compose из `../docs/dev` | правка одного файла | +| 32 | 2026-10-03 | R-20: `Minio(...)` получает `secure` из нового поля `OPENBAO__MINIO__MINIO_SECURE` (bool, по умолчанию True); `.env.example`/`.env.test` — false для http-compose | поведение шаблона и платформы (TLS) сохраняется, локальный http работает без правки кода | одно поле настроек | +| 33 | 2026-10-03 | R-21: в fix-раунд разрешено включать дешёвые minor-замечания на тех же строках (для T21 — T21.1, T21.2, T21.5) | отдельный раунд на однострочные правки дороже самой правки | чуть шире fix-раунд | +| 34 | 2026-10-03 | R-22: финальные статусы inbox — `done, needs_check, error, timeout, dlq`; результат принимается для заданий в `queued`, `published`, `processing` | спека перечисляет done/error/timeout/dlq; `needs_check` — тоже результат сервиса, повтор для него — дубликат; потерянный ack не должен блокировать результат | поправить множество в одном месте | +| 35 | 2026-10-03 | R-23: `web/package-lock.json` добавлен в `.gitignore`; решение о коммите lock оставлено человеку | шаблон lock не содержит, `raisa-*` из Nexus вне банка дали бы иной lock; после web-check файл оставался untracked | web-сборка невоспроизводима по версиям (follow-up T26.2) | +| 36 | 2026-10-03 | T1: mypy strict задан явным списком флагов семейства strict в override для `src.models.*`, `src.domain.*`, а не `strict = true` | в mypy 2.4 `strict = true` внутри `[[tool.mypy.overrides]]` применяется глобально (проверено) | при необходимости настоящего `strict` перечень придётся вести вручную | +| 37 | 2026-10-03 | T1: для `miniopy_async` — override `follow_imports=skip` (Minio = Any) | mypy не видит реэкспорт `Minio` (attr-defined); правка кода или ignore на двух импортах хуже | S3-клиент не типизирован | +| 38 | 2026-10-03 | T1: в `requirements-dev.txt` нижние границы версий вместо точных пинов; dev-файл ставится через `grep -v '^-r'`, чтобы не втягивать `raisa_*` | платформенный `requirements.txt` не менялся; точные пины конфликтовали бы с платформой | mypy/ruff могут обновиться — воспроизводимость слабее | +| 39 | 2026-10-03 | T2: в `app.py` `kp = nkeys.from_seed(...) if NATS_SEED else None` + `assert` в `sig_cb` — временно до задачи 22 | `import src` падал с `ErrInvalidSeed` на пустом seed, проверка задачи была невозможна | при не-DEBUG и пустом seed подключение к NATS падало бы на assert до задачи 22 | +| 40 | 2026-10-03 | T2: `AUTH_*` строковые настройки — `""`, а не `None`; `AUTH_DEV_SECRET` ≥ 32 байт в `.env.example`/`.env.test`, `.env.test` коммитится | единообразие (потребители проверяют truthiness); минимум длины ключа HS256 | нет | +| 41 | 2026-10-03 | T3: `@biomejs/cli-linux-x64-musl` перенесён из devDependencies в optionalDependencies | на macOS `npm install` падал с EBADPLATFORM; на Linux musl пакет по-прежнему ставится | возврат строки снова ломает `npm install` на macOS | +| 42 | 2026-10-03 | T3: `.dockerignore` разигнорирован в `.gitignore` и исключает `docs`, `openspec`, `setup`, `.claude`; `package-lock.json` не коммитится | иначе `.dockerignore` не попадал бы в репозиторий; образу эти каталоги не нужны; шаблон lock не содержит | если сборке понадобится `setup/` — убрать из `.dockerignore` | +| 43 | 2026-10-03 | T4: ошибки учётных данных NATS (неполная пара) логируются, приложение стартует с `nats=down` (`RuntimeError` перехватывается в `_connect_nats`); при неудаче клиент не сохраняется | старт приложения не должен зависеть от NATS | неверная конфигурация видна только в логе и healthcheck | +| 44 | 2026-10-03 | T22: предпосылка T4.2 неверна — при `max_reconnect_attempts=-1` nats-py повторяет и первое подключение бесконечно, `connect()` не возвращается; прежний `await _connect_nats()` вешал старт без NATS (тесты T4 подменяли функцию). Теперь supervisor вызывает `connect` в фоне; backoff 1→30 с только при исключении `connect` | проверено пробой; повторы раз в 2 с (D-6) делает сам nats-py | для экспоненциальной паузы первого подключения пришлось бы править `options` nats-py | +| 45 | 2026-10-03 | T22: иерархия ошибок `NatsError` → `NatsConfigError`, `NatsNotConnectedError`, `NatsPublishError`; невалидный `NATS_SEED` — `config_error` без повторов; `on_connected` повторяется с backoff; `wait_ready(timeout)`; `stop()` делает `drain`; `app.state.nats_subs` удалён; `Minio` закрывает сессию при неудаче bucket_exists | подписка не должна молча отсутствовать; настройки не лечатся повтором | переименования; дополнительное поведение `stop()` | +| 46 | 2026-10-03 | T22: queue group `RESULT_QUEUE_GROUP = "arm-core"` в `src/core/nats.py`; AST-тест «нет JetStream в `src/`» — в юнит-наборе | группа — свойство ядра, не контракта; тест статический и должен идти в `make test` | нет | +| 47 | 2026-10-03 | T5: базовый класс enum — `enum.StrEnum`, имя члена = значению DBML (включая `ЮЛ`/`ИП` с `noqa: PLC2401`); `pg_enum(name)` + публичный реестр `ENUMS`, неизвестное имя — `KeyError` | ruff UP042 требует `StrEnum`; в коде нужна ровно DBML-строка | следствие: `str(member) == value`; смена имён — перенос по коду | +| 48 | 2026-10-03 | T6: составные unique из DBML `Indexes` — `Index(unique=True)` с именем `uq_...`, а не `UniqueConstraint`; в миграции `CREATE UNIQUE INDEX` | DBML-семантика — индекс, partial возможен только индексом, чётность с pydbml | низкая: для FK-ссылок не важно | +| 49 | 2026-10-03 | T6: `sort_order=-1` на `id` миксина; константа `SCHEMA` перенесена в `base.py`; тестовая БД `armcore_scratch_` с `DROP DATABASE ... WITH (FORCE)` (PG13+); `pytest_plugins` с `pg_helpers` | порядок колонок как в DBML (иначе `id` последним) | нет, влияет на порядок колонок в DDL | +| 50 | 2026-10-03 | T7/T8: явные сокращённые имена FK и индексов там, где шаблон R-9 > 63 символов (например `fk_financial_statement_value_column_id_fs_column`, `fk_consolidation_version_previous_version_id`, `uq_related_group_member_group_entity_current`, `uq_consolidation_lock_version_active`); use_alter-сторона цикла — `stored_file.current_recognition_job_id` | лимит идентификатора PostgreSQL; имена берутся из metadata и совпадают в миграции | низкая; при ручном написании миграций использовать те же имена | +| 51 | 2026-10-03 | T7: у `recognition_job.status` нет `server_default` (в DBML default'а нет); jsonb-колонки — `Mapped[Any] с nullable` | бриф приводил `queued` как пример, а не как DBML | добавить default можно только версией DBML | +| 52 | 2026-10-03 | T7/T8: тесты частей 01–06 проверяют подмножество таблиц; полный состав и `EXPECTED_TABLE_COUNT = 43` — в тесте последней части, привязанном к `load_dbml().tables` | иначе тест ломался бы с каждой следующей частью | нет | +| 53 | 2026-10-03 | T6/T8: `required_document_rule` — в `audit_ex.py` (R-4); формулировки tasks.md 2.2/2.4 и плана Task 8 остаются устаревшими | текст пунктов расходился с design D-1 | нет; формулировку поправит контроллер | +| 54 | 2026-10-03 | T9: `check_dbml_vs_orm` — код возврата 2 при отсутствии версии в первой строке DBML или неразбираемом файле; версия ищется только в первой строке; ручной разбор аргументов без argparse | ошибка использования (непригодный вход) отличается от расхождения (код 1); сообщения по-русски | смена кода в одной ветке `run_check` | +| 55 | 2026-10-03 | T9: правила сверки — табличный UNIQUE = unique index; любой CHECK в ORM = расхождение; явный NO ACTION равен отсутствию действия, RESTRICT нет; индексы группируются по набору колонок со сравнением свойств (`unique`, `WHERE`, `NULLS NOT DISTINCT`); порядок колонок — по общим колонкам; проверяется схема `dkb` | DBML-семантика; лишняя колонка не должна давать второй шумный дифф | ложные расхождения при иной трактовке | +| 56 | 2026-10-03 | T9: fallback `ENV_FILE` = абсолютный путь `api/.env.example` (только если `ENV_FILE` не задан и в cwd нет `.env`); `dbml-check` сразу включён в `check-full` | скрипт работает из любого cwd; D-8 требует DBML в `check-full` | нет | +| 57 | 2026-10-03 | T10: URL в `env.py` — `-x dburl` > `sqlalchemy.url` вызывающего кода (не заглушка `driver://`) > настройки; `ALEMBIC_DATABASE_URL` отвергнута | тесты и скрипты переопределяют БД без подмены env-файла; третий источник конфигурации не нужен | правка одной функции | +| 58 | 2026-10-03 | T10: онлайн `CREATE SCHEMA` — отдельной закоммиченной транзакцией до миграций; alembic в тестах — подпроцессом (`python -m alembic -x dburl=...`) | иначе соединение во внешней autobegin-транзакции; тест проверяет CLI-путь и не ломает логирование pytest | ~1 с на вызов; осиротевшая пустая схема безвредна | +| 59 | 2026-10-03 | T10: сверх брифа — тест «каталог после миграции == каталог `create_all` ORM» и офлайн-тест `--sql`; в ревизии сохранены `op.f(...)`; `downgrade` не дропает индексы отдельно; per-file-ignore `PLR0915` для `alembic/versions/*` | `compare_metadata` не сравнивает partial WHERE, NULLS NOT DISTINCT и порядок enum; `op.f` защищает от повторного применения naming_convention | связность тестовых модулей (импорт `_check_*`); список `TABLES` в ревизии дублирует `create_table` | +| 60 | 2026-10-03 | T11: `run_alembic` перенесён в `pg_helpers`; фабрика `tests/factories.py` (`make_osv_entry`), старые тесты 04–06 не переписаны; недоступность PG — `pytest.fail(pytrace=False)` на любое исключение CREATE DATABASE | не дублировать код; понятное сообщение вместо трейсбэка | неверные учётные данные тоже выдадут «PostgreSQL недоступен» (с текстом ошибки) | +| 61 | 2026-10-03 | T12: seed — пакет `scripts/seed_lib/` (4 файла), реэкспорт публичного API из `scripts.seed`; параметр CLI `--seed-dir`; три счётчика отчёта (вставлено, обновлено, без изменений) | модуль после форматирования превысил 400 строк; `--seed-dir` нужен автотесту CLI | только структура файлов | +| 62 | 2026-10-03 | T12: `role_permissions.yaml` — словарь «роль → список кодов прав», неизвестные поля отклоняются, код права по шаблону `_` или `EXPORT`; права, убранные из yaml, не отзываются; существующие `role_permission` не обновляются (`granted_by`/`granted_at` не перезаписываются) | ловит опечатки; не затирать выдачи, сделанные администратором в UI (D-4) | для синхронизации потребуется явное решение; строгая валидация может отклонить будущие коды | +| 63 | 2026-10-03 | T13: субсчета плана счетов — строго по ссылкам §4/§6/чек-листа (+ родители); названия части субсчетов (66/67 .06/.22/.24/.26, 76.07.x, 76.08/.28/.38) выведены из роли в §4, а не из справочника 1С; `kind` субсчёта = kind родителя | D-9: все субсчета, на которые ссылаются; полного плана клиента нет | несуществующий субсчёт не резолвится до дозагрузки (ARCH-004); шахматка §6 не покажет дебетовое сальдо по 66.xx; неточные названия — правка CSV | +| 64 | 2026-10-03 | T13: валидатор CSV плана счетов строже задания — analytic-часть `[1-9]\d?`, счёт 00 отвергается, kind подсубсчёта в рантайме не сверяется с родителем (только тестом на файле репозитория); забалансовые 001–011 допускают субсчёт; порядок вставки — по глубине кода с предгенерацией uuid4 | администратор может загрузить свой CSV с иным kind | несогласованный kind в чужом CSV не ловится | +| 65 | 2026-10-03 | T14: составные статьи правил хранятся строкой `1450/1550` и `1310/1350` в `article_2_line` | «первая статья + comment» теряет информацию; comment не машиночитаем | потребитель, ждущий один код, получит составной — надо сплитить по `/` | +| 66 | 2026-10-03 | T14: правило 1 — сторона А `03, 76.07.9, 01` (01 резервный включён наравне с 03); сторона Б — `76.07.1`, исключения `76.07.5` и `76.07.9` на стороне 2; счёт без субсчёта (76, 66, 67, 80, 83) — маска «счёт и все субсчета» | буквальное чтение §4; как в spec-сценарии «Правило № 6» | если «резервный» означает приоритет, а не членство — реализовать в алгоритме; алгоритм, трактующий excluded только как потомка, проигнорирует вычитание | +| 67 | 2026-10-03 | T14: yaml правил — `statement_type`/`version_no`/`valid_from` на уровне файла (один файл = одна версия), `valid_to` не задаётся; чек-лист EXTENDED перечислен целиком (27), без наследования от BASIC; upsert без удалений (убранные счета и правила остаются в БД) | явность и отсутствие логики наследования; удаление — только вручную или миграцией | версия 2 потребует нового файла и расширения загрузчика; правка BASIC требует правки двух блоков; дрейф БД от файла | +| 68 | 2026-10-03 | T14: валидатор строже задания — отклоняет код счёта не строкой, «включён и исключён» на одной стороне, пустую сторону, неизвестные поля; общие помощники чтения вынесены в `seed_lib/readers.py` | защита от опечаток в справочниках; нет циклического импорта и файла > 400 строк | ужесточение может отклонить нестандартные записи | +| 69 | 2026-10-03 | T15: dev-РФ дописываются в `SeedData.regional_offices` (dev-файл не может объявить ГО или дублировать имя); у dev-пользователя ровно одна роль, `user_role` только добавляется; `business_segment = DKB`; `created_at`/`updated_at` — явно `now()`; защита `--dev` от прода не вводилась (только ASSUMPTION в docstring/help) | существующий upsert по `name` работает без изменений; триггера в БД нет; так велела инструкция задачи | снятая в файле роль остаётся назначенной; `--dev` на проде не заблокирован | +| 70 | 2026-10-03 | T16: `verify` — async (JWKS по сети через `to_thread`), уточнение D-5; сбой JWKS (сеть, неизвестный kid) → `AuthError` (401), деталь только в warning | требование задания; блокирующий вызов в event loop недопустим | задача 17 обязана `await`; недоступность SSO скрыта за 401 — мониторить по логу | +| 71 | 2026-10-03 | T16: при включённом dev-режиме (оба флага) — только dev-проверяющий, JWKS игнорируется; `AUTH_DEV_ISSUER=true` при `DEBUG=false` — dev не создаётся, warning, JWKS строится если задан | композитный проверяющий не нужен; dev-эмитент не должен работать вне DEBUG | нет | +| 72 | 2026-10-03 | T16: алгоритм статического ключа выводится из типа ключа (RSA → RS256, EC P-256 → ES256); иной тип и невалидный PEM → `ValueError`; заданные одновременно `AUTH_JWKS_URL` и `AUTH_PUBLIC_KEY` → `ValueError` | ключ одного типа не должен принимать токены другого алгоритма; fail-fast вместо молчаливого приоритета | ключи иных типов не поддержаны | +| 73 | 2026-10-03 | T16: `aud` и `iss` не проверяются, если `AUTH_AUDIENCE`/`AUTH_ISSUER` пусты; email из claim — `strip()`; `noqa: PLR0913` на `JwksVerifier.__init__` | иначе PyJWT отвергает любой токен с `aud`; параметры РАИСА неизвестны до первого деплоя | пустая настройка = проверка отключена (предложение OQ: обязательный `aud`) | +| 74 | 2026-10-03 | T17: `current_user` — единая зависимость, возвращает `Principal` (user, regional_office, roles, permissions); цепочка `verified_claims` (без БД) → `current_user`, поэтому 401 не открывает сессию БД; `require(code)` валидирует только непустоту кода | 401 не должен ходить в БД; отдельная `current_principal` избыточна | нет | +| 75 | 2026-10-03 | T17: роль с `is_active=false` не даёт прав и не попадает в `roles`; неактивный пользователь — 403 «учётная запись отключена»; филиал на активность не проверяется; схема `Bearer` без учёта регистра, ровно два токена | DBML `role.is_active`; спека молчит | если нужно иначе — права отключённой роли вернутся | +| 76 | 2026-10-03 | T17: `get_token_verifier` кэширует и результат `None` (ничего не настроено); `RuntimeError` конфигурации `build_verifier` не перехватывается (500, fail-fast); выборка пользователей с `LIMIT 2` и `ORDER BY id` | настройки читаются один раз на приложение; ошибка конфигурации не должна маскироваться | смена настроек требует перезапуска | +| 77 | 2026-10-03 | T18: dev-эндпоинт выпуска токена — «не найден или отключён» → 404 одним текстом; неоднозначный email → 409 без выпуска токена; `DevTokenRequest.email` — `str` (strip, lower), а не `EmailStr`; секрет < 32 байт → `RuntimeError`, пустой → `ValueError` | не раскрывать существование учётки; `email-validator` отвергает домен `.local` у dev-пользователей | зависимость `email-validator` в requirements не используется | +| 78 | 2026-10-03 | T18: исправлен шаблонный обработчик 422 (новая константа статуса, `jsonable_encoder(exc.errors())`); SPA-заглушка отдаёт 404 для любого пути под `/api/v1`; `MeResponse.email = user.email or ""` | `ValueError` в `ctx` ломал ответ 500-кой; спека требует 404 для выключенного dev-маршрута (было 405 или index.html) | GET несуществующего пути под `/api/v1` теперь 404, а не SPA (желаемо) | +| 79 | 2026-10-03 | T19: README описывает трейсбэк `ClientConnectorSSLError` к MinIO как штатный для dev и следует коду (NATS не зависит от DEBUG, middleware РАИСА не подключён), а не CLAUDE.md репозитория | правка `app.py` вне задачи; CLAUDE.md arm-core расходится с кодом | предложение `SERVER__HOST=127.0.0.1` в README осталось deferred | +| 80 | 2026-10-03 | T20: модели контракта принимают лишние поля (`extra="ignore"`), `strict=True`; `result_schema_version` по схеме допускает любой MAJOR (`^\d+\.\d+$`), в отличие от `schema_version` (`^1\.\d+$`) | схемы не содержат `additionalProperties: false`; модель строже схемы ломает обратно совместимый MINOR; схема возможно небрежна (вынесено контроллеру docs) | лишние поля теряются при round-trip; при нужде сквозного проброса — `extra="allow"` | +| 81 | 2026-10-03 | T20: деньги — `Decimal` (допускает null), вход строкой по `^-?\d+(\.\d{1,2})?$` (fullmatch), `null` или `Decimal`; `float`, `int`, `bool` отвергаются; UUID только каноническая запись 8-4-4-4-12; `integer` строгий (`5.0` отвергается, хотя jsonschema считает целым) | деньги не должны становиться float; модель не мягче схемы | отличие от jsonschema в сторону строгости (хвостовой `\n`, `5.0`) не покрыто тестами | +| 82 | 2026-10-03 | T20: «необязательное, но без null» поля (`result_s3`, `error` в конверте, `metrics`, `header_variant`) отвергают явный `null` (`OptionalNotNull`); `reply_deadline` — aware обязателен, приводится к UTC и сериализуется `...Z`; условные правила только по схеме; добавлены `ack_msg_id`/`result_msg_id` | модель не должна быть мягче схемы; `rfc3339-validator` не проверяет формат | модель строже валидатора на naive date-time | +| 83 | 2026-10-03 | T21: Decimal в jsonb аудита — строка формата `f` без экспоненты; собственный сериализатор `to_jsonable` вместо `jsonable_encoder` (тот даёт float); неизвестные типы → `TypeError`; float допустим, NaN/Inf → `ValueError`; `object_type` из пробелов пуст | деньги не должны становиться float | расхождение формата со старыми записями, если решат иначе | +| 84 | 2026-10-03 | T21: сканер удаления `AuditEvent` — best-effort, только внутримодульное отслеживание имён; тестовое событие — `RECOGNIZED_DATA_EDIT` / `AuditSection.RECOGNITION`; `noqa: PLR0913` на `record` (14 полей по спеке) | в `src/` нет обращений к `AuditEvent` вне `models/` и `services/audit.py`; в `audit_action` нет действия для статусов задания (R-7) | межмодульное удаление сканер не поймает; DB-level append-only — O-8, follow-up | +| 85 | 2026-10-03 | T23: невалидный запрос (сейчас `stored_file.original_name IS NULL`) → `status=error`, `error_code=INVALID_REQUEST`, `finished_at`, а не «лог и пропуск» | иначе задание выбиралось бы первым в каждом проходе (ORDER BY `requested_at` NULLS FIRST) и 50 битых блокировали бы outbox; `INVALID_REQUEST` — код ядра, в enum контракта его нет | починка и переотправка — ручной сброс в `queued`; замена строковой константы | +| 86 | 2026-10-03 | T23: `reply_deadline` по умолчанию = момент первой публикации + 5 мин для обоих pipeline, не меняется при повторах; `queued` с прошедшим сроком публикуется как есть; `published` с прошедшим сроком остаётся вахтёру | DBML note: OCR — 5 мин, для парсера значения нет; 3 повтора по 30 с (~2 мин) укладываются до dlq | константа `DEFAULT_REPLY_TIMEOUT`; одна лишняя публикация просроченного задания | +| 87 | 2026-10-03 | T23: время — часы приложения (инжектируемые `now`), не `now()` БД; проход при `not nats.is_connected` пропускается целиком, включая переводы в `dlq`; первая публикация берёт `Nats-Msg-Id` из текущего `attempt`, повтор — сохранённый `request_msg_id`; `published_at = NULL` в `published` считается давним; цикл — сначала проход, потом пауза | детерминированные тесты; без NATS нельзя отличить «сервис молчит» от «мы не можем публиковать»; готовность к replay | расхождение часов приложения и БД не учитывается | +| 88 | 2026-10-03 | T23: `ExchangeTasks` (`start`/`stop`) хранится в `app.state.exchange`, запуск привязан к созданию `NatsConnection`; фабрика `CommittedJobs` копит id для teardown | запуск ⇔ `NATS_ENABLED=true` | нет | +| 89 | 2026-10-03 | T24: нет заголовка `Nats-Msg-Id` → warning и производный id `result::` (для ack — `ack:...`), а не отказ | выбор из брифа; сообщение не должно теряться | результат другой попытки без заголовка дедуплицируется по id текущей попытки | +| 90 | 2026-10-03 | T24: `file_id` конверта ≠ `recognition_job.file_id` → warning `file_mismatch`, сообщение не применяется | спека ищет задание только по `job_id`; регистрировать чужой файл результата опаснее, чем ждать timeout | законно иной `file_id` (по контракту не должен) — результат теряется до timeout | +| 91 | 2026-10-03 | T24: порядок в транзакции результата — s3_object → обновление задания → mapper → commit; исключение mapper'а откатывает всё | mapper видит итоговое состояние задания и может его уточнить (Срез 2, например понизить до needs_check) | Срез 2 должен знать, что `job` уже обновлён | +| 92 | 2026-10-03 | T24: результат без ack (`queued`/`published`) принимается, `started_at` остаётся NULL; `error_code`/`error_message` перезаписываются из конверта, `metrics` и `fields_*` — только если есть в конверте | момент начала обработки неизвестен, подставлять `finished_at` было бы выдумкой | отчёты по длительности должны учитывать NULL | +| 93 | 2026-10-03 | T24: ключ s3_object (bucket, key) совпал с существующим → переиспользование и warning (`ON CONFLICT DO NOTHING RETURNING`), роль не проверяется, `content_type`/`size_bytes`/`sha256` не заполняются; `needs_check` не принимается поверх финального `needs_check`; `handle_message` ловит `Exception` (не только `SQLAlchemyError`), `CancelledError` пробрасывается | S3 в Срезе 0 не читается; asyncpg бросает не-SQLAlchemy исключения при недоступной БД | роль существующей строки может не совпасть | +| 94 | 2026-10-03 | T24: уровни логов — WARNING для невалидного, неизвестного job, file_mismatch, ack не в published, результата для финального статуса и отсутствия заголовка; DEBUG для повторного ack и дубликата; в лог невалидного сообщения только `job_id` (≤ 64) и `loc: msg` без входных значений; упоминания `nats_callback` убраны из README и CLAUDE.md arm-core | не логировать входные данные; модуль удалён (R-3) | нет | +| 95 | 2026-10-03 | T25: цикл миграций не гоняется отдельным шагом `check-full` (входит в `test-integration`; цель `migration-cycle` — для ручного запуска); `check-infra` — скрипт `api/scripts/check_infra.py` перед `test-integration`, читает адреса из `.env.test`; изменён `test_config.py` (порты) | дубль добавил бы ~10 с; сообщение «поднимите compose командой make up» | цикл миграций не виден отдельной строкой в выводе `check-full` | +| 96 | 2026-10-03 | T26: живой обмен показан скриптом на ORM (не SQL), скрипт лежит рядом с отчётом и не добавлен в репозиторий; `result.json` в MinIO не загружался (NoopMapper только регистрирует `s3_object`); `make check-full` прогнан с `api/.env`, файл удалён | корректные enum и FK; содержимое результата Срез 0 не читает | Срез 2 дополнит демо загрузкой; демо-данные уничтожены `down -v` | +| 97 | 2026-10-04 | T27: OQ-46 и OQ-47 уже есть в реестре — вместо новых номеров предложены правки статусов; новые вопросы — `OQ-?? (предл. N)`, 15 штук; в CLAUDE.md `domain/` помечена «появится в Срезе 3+»; «обязательная проверка CI» заменена на `make dbml-check` (входит в `check-full`); рекомендации (триггер, `NULLS NOT DISTINCT`, обязательный `aud`) — предложения, а не принятые решения | дубли нарушали бы реестр; каталога `api/src/domain/` в Срезе 0 нет; CI в Gitea нет | перенумерация при переносе в docs; правка двух строк и одной строки CLAUDE.md | -- 2.47.2 From 2b1fdab294abb8d57506cae934075603c6ea195f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 01:23:18 +0400 Subject: [PATCH 85/97] =?UTF-8?q?docs:=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D1=8B=20=D0=BE=D0=B1=D0=BC=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B8=20ex-payload=20=D0=B2=20CLAUDE.md,=20=D1=83=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D1=91=D0=BD=20docstring=20app.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Строка «Контракты» перечисляет три реальные схемы recognition-* и копии в api/contracts/; ex-payload-v1 помечен как будущий (Срез 7). В раскладке — src/api/endpoints. В docstring app.py убрано утверждение, что middleware РАИСА подключается при DEBUG=False (пакет лишь условно импортируется). Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 6 ++++-- api/src/app.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index eab7fa4..f30f84b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ dbml-check + check-infra + test-integration + docker-build + web-check; **перед PR**, вывод — в описание PR). Вручную — из `api/`: `python run_server.py`, `alembic upgrade head`, `pytest`; из `web/`: `npm run start|build`. Бутстрап в банке — `setup/setup.sh` (pip/npm через Nexus). -- **Раскладка `api/src/`**: `api/endpoints` (`src/api/`) → `services` → `repositories` → `models`; DTO — `schemas/`; инфраструктура — +- **Раскладка `api/src/`**: `src/api/endpoints` → `services` → `repositories` → `models`; DTO — `schemas/`; инфраструктура — `core/` (config, clients, nats, logger, exception_handlers, auth); `db/` — engine и сессия. Наши добавления: `domain/` (чистые функции по §§ правил; появится в Срезе 3+), `contract/` (модели контракта обмена), `api/scripts/` (`check_dbml_vs_orm.py`, `seed.py` + `seed_lib/`, `check_infra.py`), данные seed — `api/seed/`. @@ -54,7 +54,9 @@ **Сверка ORM↔DBML** — `scripts/check_dbml_vs_orm.py` (`make dbml-check`). ## Зависимости от соседей -- Контракты: `../docs/contracts/recognition-result.schema.json` (вход из парсера/шлюза), `ex-payload-v1.schema.json`. +- Контракты обмена: `../docs/contracts/recognition-request.schema.json`, `recognition-result-message.schema.json`, + `recognition-result.schema.json`; в репозитории — байт-идентичные копии в `api/contracts/` (сверяются тестами, + `tests/test_contract.py`). `ex-payload-v1.schema.json` (payload ЕХ) появится в Срезе 7 (`../docs/context/09-delivery-plan.md`). - Фикстуры: `../fixtures/` (env `ARM_KA_FIXTURES`); при отсутствии тесты с маркером `fixtures` пропускаются с явной причиной (skip reason). - NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`. diff --git a/api/src/app.py b/api/src/app.py index c862cef..450000f 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -2,8 +2,8 @@ Что здесь есть: настройки, подключение S3 (MinIO; `MINIO_SECURE` — https или http), NATS (`src/core/nats.py`: core NATS без JetStream, подключение в фоне при NATS_ENABLED=true независимо от DEBUG, подписка `recognition.result` в queue -group `arm-core`), SMB (горячие папки ИС Сканирование), middleware (gzip, proxy headers, CORS, РАИСА при DEBUG=False), -healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA. +group `arm-core`), SMB (горячие папки ИС Сканирование), middleware (gzip, proxy headers, CORS; при DEBUG=False пакет РАИСА лишь условно +импортируется, middleware не подключается), healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA. Недоступность S3 или NATS при старте не роняет и не задерживает приложение: ошибка логируется, healthcheck показывает состояние. При NATS_ENABLED=true lifespan запускает фоновые задачи обмена `recognition_job` — outbox-публикатор и вахтёр сроков (`src/services/recognition_exchange.py`, design D-6) — и останавливает их до закрытия NATS. Сообщения -- 2.47.2 From ac394bb4cee2e38bacb29982210198878499261f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 01:23:43 +0400 Subject: [PATCH 86/97] =?UTF-8?q?docs:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=20=D0=B2=20docstr?= =?UTF-8?q?ing=20app.py=20(E501)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/src/app.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/src/app.py b/api/src/app.py index 450000f..2c78acf 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -2,8 +2,9 @@ Что здесь есть: настройки, подключение S3 (MinIO; `MINIO_SECURE` — https или http), NATS (`src/core/nats.py`: core NATS без JetStream, подключение в фоне при NATS_ENABLED=true независимо от DEBUG, подписка `recognition.result` в queue -group `arm-core`), SMB (горячие папки ИС Сканирование), middleware (gzip, proxy headers, CORS; при DEBUG=False пакет РАИСА лишь условно -импортируется, middleware не подключается), healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA. +group `arm-core`), SMB (горячие папки ИС Сканирование), middleware (gzip, proxy headers, CORS; при DEBUG=False пакет +РАИСА лишь условно импортируется, middleware не подключается), healthcheck (uptime, version, nats: ok|down|off), раздача +собранного SPA. Недоступность S3 или NATS при старте не роняет и не задерживает приложение: ошибка логируется, healthcheck показывает состояние. При NATS_ENABLED=true lifespan запускает фоновые задачи обмена `recognition_job` — outbox-публикатор и вахтёр сроков (`src/services/recognition_exchange.py`, design D-6) — и останавливает их до закрытия NATS. Сообщения -- 2.47.2 From 8d7d014391b7413b9b1e54572237739ba5e1f19b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 01:49:26 +0400 Subject: [PATCH 87/97] =?UTF-8?q?fix:=20nullable=20jsonb=20=D0=BF=D0=B8?= =?UTF-8?q?=D1=88=D0=B5=D1=82=20SQL=20NULL=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=20jsonb=20null=20(I-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Все nullable jsonb-колонки ORM (audit_event.before_value/after_value, ex_snapshot_item.payload, recognition_job.group_schema/metrics, osv_entry.original_values) объявлены через nullable_jsonb() = JSONB(none_as_null=True): Python None сохраняется как SQL NULL, IS NULL находит строку. DDL не меняется; сверка с DBML и миграции зелёные. Обходы для не-объекта в metrics (CASE по jsonb_typeof в публикаторе, merge_metrics в inbox) оставлены: строки до исправления и сырой SQL могут содержать jsonb null. Тесты: SQL NULL после AuditService.record без before/after, metrics фабрики None -> SQL NULL, повтор публикации и слияние метрик для SQL NULL и jsonb null, перечень колонок с флагом. Co-Authored-By: Claude Fable 5.1 --- api/src/models/audit_ex.py | 10 +++---- api/src/models/base.py | 11 +++++++- api/src/models/files.py | 8 +++--- api/src/models/osv.py | 6 ++-- api/src/services/recognition_exchange.py | 6 ++-- api/tests/factories.py | 17 +++++++++++- api/tests/test_audit_service.py | 14 ++++++++++ api/tests/test_dbml_parity.py | 1 + api/tests/test_models_part_07_09.py | 30 +++++++++++++++++++- api/tests/test_recognition_exchange.py | 29 +++++++++++++++++++- api/tests/test_recognition_inbox.py | 35 ++++++++++++++++++++++-- 11 files changed, 147 insertions(+), 20 deletions(-) diff --git a/api/src/models/audit_ex.py b/api/src/models/audit_ex.py index 04af415..27c1915 100644 --- a/api/src/models/audit_ex.py +++ b/api/src/models/audit_ex.py @@ -10,10 +10,10 @@ import uuid from typing import Any from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Integer, SmallInteger, String, Text -from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column -from .base import Base, UUIDPrimaryMixin +from .base import Base, UUIDPrimaryMixin, nullable_jsonb from .enums import ( AuditAction, AuditSection, @@ -58,8 +58,8 @@ class AuditEvent(UUIDPrimaryMixin, Base): UUID(as_uuid=True), ForeignKey("consolidation_version.id") ) description: Mapped[str | None] = mapped_column(Text) - before_value: Mapped[Any | None] = mapped_column(JSONB) - after_value: Mapped[Any | None] = mapped_column(JSONB) + before_value: Mapped[Any | None] = mapped_column(nullable_jsonb()) + after_value: Mapped[Any | None] = mapped_column(nullable_jsonb()) comment: Mapped[str | None] = mapped_column(Text) result: Mapped[str | None] = mapped_column(String) changed_field: Mapped[str | None] = mapped_column(String) @@ -132,7 +132,7 @@ class ExSnapshotItem(UUIDPrimaryMixin, Base): item_type: Mapped[str] = mapped_column(String, nullable=False) legal_entity_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("legal_entity.id")) document_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("document.id")) - payload: Mapped[Any | None] = mapped_column(JSONB) + payload: Mapped[Any | None] = mapped_column(nullable_jsonb()) payload_schema_version: Mapped[str | None] = mapped_column(String) diff --git a/api/src/models/base.py b/api/src/models/base.py index 16839bc..875f3d9 100644 --- a/api/src/models/base.py +++ b/api/src/models/base.py @@ -3,7 +3,7 @@ import uuid from sqlalchemy import MetaData -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, mapped_column #: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.13). @@ -21,6 +21,15 @@ NAMING_CONVENTION = { } +def nullable_jsonb() -> JSONB: + """Тип nullable-колонки jsonb: Python `None` пишется как SQL NULL, а не jsonb `null` (`none_as_null=True`). + + Без флага SQLAlchemy сохраняет `None` как jsonb `null`, и `IS NULL` в SQL такую строку не находит. DDL не + меняется (тип колонки — `jsonb`). Записать именно jsonb `null` можно явно значением `sqlalchemy.JSON.NULL`. + """ + return JSONB(none_as_null=True) # type: ignore[no-untyped-call] # у JSONB.__init__ нет аннотаций + + class Base(DeclarativeBase): metadata = MetaData(schema=SCHEMA, naming_convention=NAMING_CONVENTION) diff --git a/api/src/models/files.py b/api/src/models/files.py index 43798b1..84ff62f 100644 --- a/api/src/models/files.py +++ b/api/src/models/files.py @@ -10,10 +10,10 @@ import uuid from typing import Any from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, SmallInteger, String, Text, text -from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column -from .base import Base, UUIDPrimaryMixin +from .base import Base, UUIDPrimaryMixin, nullable_jsonb from .enums import ( DocumentCheckResult, DocumentStatus, @@ -150,8 +150,8 @@ class RecognitionJob(UUIDPrimaryMixin, Base): error_message: Mapped[str | None] = mapped_column(Text) result_s3_object_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("s3_object.id")) result_schema_version: Mapped[str | None] = mapped_column(String) - group_schema: Mapped[Any | None] = mapped_column(JSONB) - metrics: Mapped[Any | None] = mapped_column(JSONB) + group_schema: Mapped[Any | None] = mapped_column(nullable_jsonb()) + metrics: Mapped[Any | None] = mapped_column(nullable_jsonb()) fields_total: Mapped[int | None] = mapped_column(Integer) fields_low_confidence: Mapped[int | None] = mapped_column(Integer) fields_unrecognized: Mapped[int | None] = mapped_column(Integer) diff --git a/api/src/models/osv.py b/api/src/models/osv.py index 697afdc..3986b4f 100644 --- a/api/src/models/osv.py +++ b/api/src/models/osv.py @@ -10,10 +10,10 @@ import uuid from typing import Any from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, Numeric, SmallInteger, String, text -from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column -from .base import Base, UUIDPrimaryMixin +from .base import Base, UUIDPrimaryMixin, nullable_jsonb from .enums import OsvDataType, OsvGroupType, pg_enum @@ -53,7 +53,7 @@ class OsvEntry(UUIDPrimaryMixin, Base): closing_debit: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) closing_credit: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) is_modified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) - original_values: Mapped[Any | None] = mapped_column(JSONB) + original_values: Mapped[Any | None] = mapped_column(nullable_jsonb()) modified_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) modified_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) is_excisable: Mapped[bool | None] = mapped_column(Boolean) diff --git a/api/src/services/recognition_exchange.py b/api/src/services/recognition_exchange.py index 049dcfe..a26e4c0 100644 --- a/api/src/services/recognition_exchange.py +++ b/api/src/services/recognition_exchange.py @@ -276,7 +276,8 @@ async def _process_one( # noqa: PLR0913 - один шаг прохода: вс ) else: count = _republish_count(row.metrics) + 1 - # jsonb `||`: прочие ключи metrics сохраняются; не объект (SQL NULL или JSON null — так ORM пишет None) → {} + # jsonb `||`: прочие ключи metrics сохраняются; не объект (SQL NULL или jsonb null — строки, записанные до + # JSONB(none_as_null=True) или сырым SQL) → {} is_object = func.jsonb_typeof(RecognitionJob.metrics) == "object" base = case((is_object, RecognitionJob.metrics), else_=literal({}, JSONB)) metrics = base.op("||", return_type=JSONB)(literal({REPUBLISH_COUNT_KEY: count}, JSONB)) @@ -533,7 +534,8 @@ def header_msg_id(headers: Mapping[str, str] | None) -> str | None: def merge_metrics(existing: Any, incoming: Mapping[str, Any] | None) -> Any: """Метрики сервиса поверх метрик задания: ключи ядра (`republish_count`) сохраняются, совпадающие — от сервиса. - `incoming is None` — метрики задания как есть. Не объект (SQL NULL, JSON `null` — так ORM пишет `None`) — как `{}`. + `incoming is None` — метрики задания как есть. Не объект (SQL NULL; jsonb `null` в строках, записанных до + `JSONB(none_as_null=True)` или сырым SQL) — как `{}`. """ if incoming is None: return existing diff --git a/api/tests/factories.py b/api/tests/factories.py index 9362437..949edbf 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -11,7 +11,7 @@ import decimal import uuid from typing import Any -from sqlalchemy import delete, select +from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.models.directories import LegalEntity @@ -171,6 +171,21 @@ class CommittedJobs: assert job is not None return job + async def metrics_sql_state(self, job_id: uuid.UUID) -> tuple[bool, str | None]: + """Как хранится `recognition_job.metrics` на уровне SQL: (`IS NULL`, `jsonb_typeof`). + + SQL NULL — `(True, None)`; jsonb `null` — `(False, "null")`; объект — `(False, "object")`. + """ + async with self._session_factory() as session: + row = ( + await session.execute( + select(RecognitionJob.metrics.is_(None), func.jsonb_typeof(RecognitionJob.metrics)).where( + RecognitionJob.id == job_id + ) + ) + ).one() + return bool(row[0]), row[1] + def track_s3_object(self, s3_object_id: uuid.UUID) -> None: """Запомнить строку `s3_object`, созданную тестом вне фабрики (удаляется в `cleanup()`).""" self._extra_s3_ids.append(s3_object_id) diff --git a/api/tests/test_audit_service.py b/api/tests/test_audit_service.py index bfe9ac6..a829a6f 100644 --- a/api/tests/test_audit_service.py +++ b/api/tests/test_audit_service.py @@ -440,3 +440,17 @@ async def test_record_does_not_commit_and_external_rollback_discards(db_session: await db_session.rollback() # внешний откат (в фикстуре — к SAVEPOINT) уничтожает запись assert await _count_events(db_session, object_id) == 0 + + +@pytest.mark.integration +async def test_absent_before_after_stored_as_sql_null(db_session: AsyncSession) -> None: + """Без before/after в БД — SQL NULL, а не jsonb `null` (`JSONB(none_as_null=True)`, финальное ревью I-1).""" + event = await AuditService(db_session).record( + action=AuditAction.LOGIN, section=AuditSection.SESSION, object_type="app_user", object_id=uuid.uuid4() + ) + is_null = ( + await db_session.execute( + select(AuditEvent.before_value.is_(None), AuditEvent.after_value.is_(None)).where(AuditEvent.id == event.id) + ) + ).one() + assert tuple(is_null) == (True, True) diff --git a/api/tests/test_dbml_parity.py b/api/tests/test_dbml_parity.py index 39a40b8..49aa0d0 100644 --- a/api/tests/test_dbml_parity.py +++ b/api/tests/test_dbml_parity.py @@ -91,6 +91,7 @@ def _dbml_enum(name: str) -> object: (Numeric(9, 4), "numeric(7,4)", False), (Integer(), "numeric(18,2)", False), (JSONB(), "jsonb", True), + (JSONB(none_as_null=True), "jsonb", True), # SQL NULL вместо JSON null — DDL тот же (JSON(), "jsonb", False), (BigInteger(), "bigint", True), (Integer(), "bigint", False), diff --git a/api/tests/test_models_part_07_09.py b/api/tests/test_models_part_07_09.py index c5b7b83..c6f75de 100644 --- a/api/tests/test_models_part_07_09.py +++ b/api/tests/test_models_part_07_09.py @@ -3,7 +3,8 @@ import datetime import pytest -from sqlalchemy import inspect, text +from sqlalchemy import Column, inspect, text +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine @@ -257,3 +258,30 @@ async def _check_active_lock_is_unique(session: AsyncSession) -> None: ) ).one() assert tuple(counts) == (1, 1) + + +NULLABLE_JSONB_COLUMNS = [ + column + for table in Base.metadata.sorted_tables + for column in table.columns + if isinstance(column.type, JSONB) and column.nullable +] + + +def test_all_nullable_jsonb_columns_are_listed() -> None: + """Перечень nullable jsonb-колонок ORM (новая колонка попадёт под проверку ниже автоматически).""" + assert {f"{c.table.name}.{c.name}" for c in NULLABLE_JSONB_COLUMNS} == { + "audit_event.before_value", + "audit_event.after_value", + "ex_snapshot_item.payload", + "recognition_job.group_schema", + "recognition_job.metrics", + "osv_entry.original_values", + } + + +@pytest.mark.parametrize("column", NULLABLE_JSONB_COLUMNS, ids=lambda c: f"{c.table.name}.{c.name}") +def test_nullable_jsonb_writes_sql_null(column: Column[object]) -> None: + """Python `None` в nullable jsonb пишется как SQL NULL, а не jsonb `null` (финальное ревью I-1).""" + assert isinstance(column.type, JSONB) + assert column.type.none_as_null is True diff --git a/api/tests/test_recognition_exchange.py b/api/tests/test_recognition_exchange.py index 48430fd..1deb48e 100644 --- a/api/tests/test_recognition_exchange.py +++ b/api/tests/test_recognition_exchange.py @@ -21,7 +21,7 @@ import pytest_asyncio from jsonschema import Draft202012Validator, FormatChecker from nats.aio.client import Client as NATS from nats.aio.msg import Msg -from sqlalchemy import update +from sqlalchemy import JSON, update from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from src.contract import NATS_MSG_ID_HEADER, RecognitionRequest @@ -371,6 +371,33 @@ async def test_republish_without_ack_then_dlq( assert len(subscriber.for_job(job_id)) == 1 + MAX_REPUBLISH +@pytest.mark.integration +@pytest.mark.parametrize( + ("stored", "expected_state"), + [ + (None, (True, None)), # Python None → SQL NULL (JSONB(none_as_null=True), финальное ревью I-1) + (JSON.NULL, (False, "null")), # jsonb null: строки, записанные до исправления или сырым SQL + ], + ids=["sql_null", "json_null"], +) +async def test_republish_counter_on_null_metrics( + session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs, stored: Any, expected_state: Any +): + """Обход для не-объекта в `metrics` (`jsonb_typeof` CASE) работает и для SQL NULL, и для jsonb null.""" + clock = Clock() + job_id = await jobs.make_queued_job(reply_deadline=clock() + timedelta(hours=1), metrics=stored) + assert await jobs.metrics_sql_state(job_id) == expected_state + fake = FakeNats() + + assert (await _publish(session_factory, fake, clock)).published == 1 + assert await jobs.metrics_sql_state(job_id) == expected_state # первая публикация metrics не трогает + clock.advance(ACK + timedelta(seconds=1)) + assert (await _publish(session_factory, fake, clock)).republished == 1 + + assert (await jobs.get(job_id)).metrics == {"republish_count": 1} + assert await jobs.metrics_sql_state(job_id) == (False, "object") + + @pytest.mark.integration async def test_ack_after_first_republish_stops_republishing( session_factory: async_sessionmaker[AsyncSession], diff --git a/api/tests/test_recognition_inbox.py b/api/tests/test_recognition_inbox.py index fa8fbfd..093e1df 100644 --- a/api/tests/test_recognition_inbox.py +++ b/api/tests/test_recognition_inbox.py @@ -26,7 +26,7 @@ from jsonschema import Draft202012Validator, FormatChecker from loguru import logger from nats.aio.client import Client as NATS from nats.aio.msg import Msg -from sqlalchemy import func, select +from sqlalchemy import JSON, func, select from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine from src.contract import NATS_MSG_ID_HEADER, RecognitionResultMessage, ack_msg_id, result_msg_id @@ -184,7 +184,7 @@ def test_header_msg_id_is_case_insensitive(headers: dict[str, str] | None, expec [ ({"republish_count": 2}, {"duration_ms": 5}, {"republish_count": 2, "duration_ms": 5}), ({"republish_count": 2, "rows": 1}, {"rows": 7}, {"republish_count": 2, "rows": 7}), - (None, {"duration_ms": 5}, {"duration_ms": 5}), # SQL NULL / JSON null (так ORM пишет None) + (None, {"duration_ms": 5}, {"duration_ms": 5}), # SQL NULL / jsonb null (старые строки, сырой SQL) ([None, {"x": 1}], {"duration_ms": 5}, {"duration_ms": 5}), # не объект — как пустой ({"republish_count": 1}, None, {"republish_count": 1}), # метрик сервиса нет — без изменений (None, None, None), @@ -377,6 +377,37 @@ async def test_needs_check_result(session_factory, jobs: CommittedJobs): assert job["metrics"] is None # метрик не было ни у задания, ни у сервиса: колонка не трогается +@pytest.mark.integration +@pytest.mark.parametrize( + ("stored", "expected_state"), + [(None, (True, None)), (JSON.NULL, (False, "null"))], + ids=["sql_null", "json_null"], +) +async def test_result_metrics_merge_over_null_metrics( + session_factory, jobs: CommittedJobs, stored: Any, expected_state: Any +): + """Слияние метрик в inbox (Python `merge_metrics`) работает и для SQL NULL, и для jsonb null (финальное ревью I-1); + результат без метрик колонку не трогает: SQL NULL остаётся SQL NULL.""" + with_metrics = await jobs.make_queued_job(status=RecognitionStatus.processing, metrics=stored) + without_metrics = await jobs.make_queued_job(status=RecognitionStatus.processing, metrics=stored) + assert await jobs.metrics_sql_state(with_metrics) == expected_state + + file_id = await _file_id(jobs, with_metrics) + body = envelope(with_metrics, file_id, "done", metrics={"duration_ms": 10}) + await _handle(session_factory, fake_msg(body, result_msg_id(with_metrics, 1))) + file_id = await _file_id(jobs, without_metrics) + await _handle( + session_factory, fake_msg(envelope(without_metrics, file_id, "done"), result_msg_id(without_metrics, 1)) + ) + + job = await _row(session_factory, with_metrics) + assert job["status"] is RecognitionStatus.done + assert job["metrics"] == {"duration_ms": 10} + assert await jobs.metrics_sql_state(with_metrics) == (False, "object") + assert (await _row(session_factory, without_metrics))["status"] is RecognitionStatus.done + assert await jobs.metrics_sql_state(without_metrics) == expected_state + + @pytest.mark.integration async def test_redelivery_changes_nothing(session_factory, jobs: CommittedJobs, logs: list[Any]): """Сценарий «Повторная доставка»: ни recognition_job, ни s3_object не меняются; mapper не вызывается.""" -- 2.47.2 From 6a6ffb54f4d9a4c08ac2a34a6ad0c5ca6e718674 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 01:50:57 +0400 Subject: [PATCH 88/97] =?UTF-8?q?fix:=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=91=D0=94=20=D0=B8=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20JWT=20?= =?UTF-8?q?=D0=BF=D0=B8=D1=88=D1=83=D1=82=D1=81=D1=8F=20=D0=B2=20=D0=BB?= =?UTF-8?q?=D0=BE=D0=B3=20(I-2,=20T16.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обработчики IntegrityError (409) и SQLAlchemyError (500) молча отдавали ответ. Теперь каждая такая ошибка — одна запись ERROR: путь и метод запроса, классы ошибок SQLAlchemy и драйвера, SQLSTATE, SQL-текст с плейсхолдерами (усечён до 200 символов) и стек вызовов. Текст исключения в лог не идёт: str() ошибки asyncpg/SQLAlchemy содержит параметры запроса и DETAIL PostgreSQL со значениями ключей (email, ИНН). Ответы прежние. JwksVerifier.verify: TypeError/ValueError, превращаемые в AuthError, теперь пишут warning в том же санированном виде repr(str(exc))[:200]. Co-Authored-By: Claude Fable 5.1 --- api/src/core/auth.py | 4 +- api/src/core/exception_handlers.py | 38 +++++++++- api/tests/test_auth_core.py | 19 +++++ api/tests/test_exception_handlers.py | 106 +++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 api/tests/test_exception_handlers.py diff --git a/api/src/core/auth.py b/api/src/core/auth.py index 19af572..c1c7682 100644 --- a/api/src/core/auth.py +++ b/api/src/core/auth.py @@ -240,7 +240,9 @@ class JwksVerifier: except jwt.PyJWTError as exc: raise _to_auth_error(exc) from exc except (TypeError, ValueError) as exc: - # страховка: несовместимые ключ и алгоритм не должны превращаться в 500 + # страховка: несовместимые ключ и алгоритм не должны превращаться в 500; причина — в лог (repr экранирует + # переводы строк, усечение ограничивает размер), клиенту — общее сообщение + logger.warning("Ключ проверки подписи не подошёл к токену JWT: {}", repr(str(exc))[:_LOG_TEXT_LIMIT]) raise AuthError("недействительный токен") from exc return _claims_from_payload(payload, self._email_claim) diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index 42a585b..6ede971 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -1,8 +1,38 @@ +"""Обработчики исключений приложения: единый формат ответа `{"detail": ...}`. + +Ошибки БД (`IntegrityError` → 409, прочие `SQLAlchemyError` → 500) пишутся в лог одной записью уровня ERROR без +текста исключения: `str()` ошибки SQLAlchemy/asyncpg содержит параметры запроса и DETAIL PostgreSQL со значениями +ключей (email, ИНН) — это персональные данные. В лог идут путь и метод запроса, классы ошибок, SQLSTATE, текст SQL +с плейсхолдерами (без значений, усечён) и стек вызовов без сообщения исключения. +""" + +import traceback + from fastapi import FastAPI, HTTPException, Request, status from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse -from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from loguru import logger +from sqlalchemy.exc import DBAPIError, IntegrityError, SQLAlchemyError + +LOG_STATEMENT_LIMIT = 200 # символов SQL-текста (с плейсхолдерами) в записи лога + + +def log_db_error(request: Request, exc: SQLAlchemyError, message: str) -> None: + """Одна запись ERROR об ошибке БД при обработке запроса — без параметров запроса и текста ошибки драйвера.""" + orig = exc.orig if isinstance(exc, DBAPIError) else None + statement = exc.statement if isinstance(exc, DBAPIError) else None + logger.error( + message + " path={path}, method={method}, error_type={error_type}, db_error_type={db_error_type}, " + "sqlstate={sqlstate}, statement={statement}\n{stack}", + path=request.url.path, + method=request.method, + error_type=type(exc).__name__, + db_error_type=type(orig).__name__ if orig is not None else None, + sqlstate=getattr(orig, "sqlstate", None), + statement=repr(statement[:LOG_STATEMENT_LIMIT]) if statement else None, + stack="".join(traceback.format_tb(exc.__traceback__)).rstrip(), + ) def register_exception_handlers(app: FastAPI) -> None: @@ -15,14 +45,16 @@ def register_exception_handlers(app: FastAPI) -> None: ) @app.exception_handler(IntegrityError) - async def integrity_error_handler(_: Request, exc: IntegrityError): + async def integrity_error_handler(request: Request, exc: IntegrityError): + log_db_error(request, exc, "Нарушение целостности данных в БД при обработке запроса.") return JSONResponse( status_code=status.HTTP_409_CONFLICT, content={"detail": "нарушение целостности данных в БД"}, ) @app.exception_handler(SQLAlchemyError) - async def sqlalchemy_error_handler(_: Request, exc: SQLAlchemyError): + async def sqlalchemy_error_handler(request: Request, exc: SQLAlchemyError): + log_db_error(request, exc, "Ошибка базы данных при обработке запроса.") return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"detail": "ошибка базы данных"}, diff --git a/api/tests/test_auth_core.py b/api/tests/test_auth_core.py index 5df00f9..1911f89 100644 --- a/api/tests/test_auth_core.py +++ b/api/tests/test_auth_core.py @@ -697,3 +697,22 @@ async def test_dev_token_rejected_when_debug_off(monkeypatch: pytest.MonkeyPatch assert verifier is not None with pytest.raises(AuthError): await verifier.verify(token) + + +class _BrokenKeyClient: + """Источник ключей, на котором проверка падает `ValueError` с переводом строки и длинным текстом (T16.9).""" + + def get_signing_key_from_jwt(self, token: str) -> jwt.PyJWK: + raise ValueError("несовместимый ключ\nпод-строка " + "x" * 1000) + + +async def test_jwks_type_or_value_error_is_logged_sanitized(log_messages: list[str]) -> None: + """TypeError/ValueError при проверке — AuthError и одна warning-запись: без сырых переводов строк, усечённая.""" + verifier = JwksVerifier(jwk_client=_BrokenKeyClient()) + with pytest.raises(AuthError, match="недействительный токен"): + await verifier.verify(jwt.encode(_payload(), DEV_SECRET, "HS256")) + (message,) = log_messages + assert "Ключ проверки подписи не подошёл" in message + assert "\n" not in message + assert "\\n" in message # перевод строки экранирован repr + assert len(message) <= LOG_LINE_MAX diff --git a/api/tests/test_exception_handlers.py b/api/tests/test_exception_handlers.py new file mode 100644 index 0000000..b52f736 --- /dev/null +++ b/api/tests/test_exception_handlers.py @@ -0,0 +1,106 @@ +"""Обработчики ошибок БД (`src/core/exception_handlers.py`, финальное ревью I-2): ответ прежний, в лог — одна запись +ERROR без параметров запроса и DETAIL PostgreSQL (персональные данные).""" + +from collections.abc import Iterator +from http import HTTPStatus +from typing import Any + +import httpx +import pytest +from fastapi import FastAPI +from loguru import logger +from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError + +from src.core.exception_handlers import LOG_STATEMENT_LIMIT, register_exception_handlers + +SECRET = "ivan.petrov@rshb.ru" + + +class _DriverError(Exception): + """Ошибка драйвера: как у asyncpg, текст содержит DETAIL со значением ключа, есть `sqlstate`.""" + + sqlstate = "23505" + + +def _app() -> FastAPI: + app = FastAPI() + register_exception_handlers(app) + + @app.get("/integrity") + async def integrity() -> None: + raise IntegrityError( + "INSERT INTO dkb.app_user (email) VALUES ($1)", + (SECRET,), + _DriverError(f"duplicate key\nDETAIL: Key (email)=({SECRET}) already exists."), + ) + + @app.get("/operational") + async def operational() -> None: + raise OperationalError("SELECT " + "x" * 1000 + " WHERE email = $1", (SECRET,), ConnectionError(SECRET)) + + @app.get("/generic") + async def generic() -> None: + raise SQLAlchemyError(f"ошибка без DBAPI: {SECRET}") + + return app + + +@pytest.fixture +def records() -> Iterator[list[Any]]: + """Записи loguru уровня DEBUG и выше за время теста (временный sink).""" + collected: list[Any] = [] + handler_id = logger.add(lambda message: collected.append(message.record), level="DEBUG") + try: + yield collected + finally: + logger.remove(handler_id) + + +async def _get(path: str) -> httpx.Response: + transport = httpx.ASGITransport(app=_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + return await client.get(path) + + +@pytest.mark.parametrize( + ("path", "status", "detail", "error_type"), + [ + ("/integrity", HTTPStatus.CONFLICT, "нарушение целостности данных в БД", "IntegrityError"), + ("/operational", HTTPStatus.INTERNAL_SERVER_ERROR, "ошибка базы данных", "OperationalError"), + ("/generic", HTTPStatus.INTERNAL_SERVER_ERROR, "ошибка базы данных", "SQLAlchemyError"), + ], +) +async def test_db_error_logged_once_without_pii( + records: list[Any], path: str, status: HTTPStatus, detail: str, error_type: str +) -> None: + response = await _get(path) + + assert response.status_code == status + assert response.json() == {"detail": detail} + errors = [r for r in records if r["level"].no >= logger.level("ERROR").no] + assert len(errors) == 1 + (record,) = errors + assert record["level"].name == "ERROR" + message = record["message"] + assert f"path={path}" in message + assert "method=GET" in message + assert f"error_type={error_type}" in message + assert record["exception"] is None # текст исключения (параметры, DETAIL) в лог не попадает + assert SECRET not in message + assert "test_exception_handlers.py" in message # стек вызовов — есть + + +async def test_integrity_error_log_has_driver_class_sqlstate_and_statement(records: list[Any]) -> None: + await _get("/integrity") + (record,) = [r for r in records if r["level"].name == "ERROR"] + assert "db_error_type=_DriverError" in record["message"] + assert "sqlstate=23505" in record["message"] + assert "statement='INSERT INTO dkb.app_user (email) VALUES ($1)'" in record["message"] + + +async def test_long_statement_is_truncated(records: list[Any]) -> None: + await _get("/operational") + (record,) = [r for r in records if r["level"].name == "ERROR"] + statement = record["extra"]["statement"] + assert len(statement) <= LOG_STATEMENT_LIMIT + 2 # repr добавляет кавычки + assert "WHERE email" not in statement -- 2.47.2 From 3d31c1eac5d3700b8780aa1b2c45970ad296c09b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 01:53:23 +0400 Subject: [PATCH 89/97] =?UTF-8?q?fix:=20SMB=20=D0=BD=D0=B5=D0=BE=D0=B1?= =?UTF-8?q?=D1=8F=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=BD,=20=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B0=20lifespan=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BD=D0=B5=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8?= =?UTF-8?q?=D0=BC=D1=8B=D0=BC=20=D1=88=D0=B0=D0=B3=D0=B0=D0=BC=20(I-3,=20T?= =?UTF-8?q?4.5,=20M-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OPENBAO__SMB__* необязательны (по умолчанию пусто); при пустом SMB_HOST сессия SMB не регистрируется (DEBUG «SMB не настроен»), в .env.example и .env.test SMB выключен — в логе dev-запуска больше нет трейсбэка SMB. - Задан SMB_HOST: register_session и reset_connection_cache вызываются через asyncio.to_thread и не блокируют цикл событий. - Остановка: задачи обмена -> NATS -> S3 -> SMB -> engine.dispose(); каждый шаг в своём try/except с логом step=..., ошибка одного шага не отменяет следующие. Пул соединений БД закрывается последним (M-3). - README: «что в логе при запуске», таблица настроек SMB, порядок остановки. Co-Authored-By: Claude Fable 5.1 --- README.md | 30 ++++----- api/.env.example | 10 +-- api/.env.test | 9 +-- api/src/app.py | 71 ++++++++++++++++----- api/src/core/config.py | 13 ++-- api/tests/test_app.py | 133 +++++++++++++++++++++++++++++++++++++++ api/tests/test_config.py | 10 +-- 7 files changed, 226 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 68e4fc4..d37515e 100644 --- a/README.md +++ b/README.md @@ -155,21 +155,23 @@ Dev-пользователи (`api/seed/dev/users.yaml`, создаются `mak ### Что вы увидите в логе при запуске -Старт занимает около секунды; в логе один трейсбэк — это ожидаемо в dev, приложение при этом работает: - -- Трейсбэк SMB: `Ошибка при регистрации сессии SMB ... Failed to connect to 'localhost:445': Connection refused`. - Настройки `OPENBAO__SMB__*` — заглушки, SMB в Срезе 0 не используется; игнорируйте. Пока на `localhost:445` ничего - не слушает, отказ приходит сразу (старт не задерживается); если порт отвечает, но молчит, ожидание ограничено - тайм-аутом `smbclient` (60 с). +Старт занимает около секунды; трейсбэков в логе нет. 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`). -Недоступность SMB, S3 или NATS при старте приложение не роняет и не задерживает: ошибка логируется, состояние NATS -видно в `/healthcheck`. К NATS ядро подключается в фоне и повторяет попытки само (`nats=down`, пока связи нет; warning -в логе — на первую ошибку и дальше не чаще раза в минуту); после разрыва nats-py переподключается и восстанавливает -подписку. +Недоступность S3 или NATS при старте приложение не роняет и не задерживает (недоступный SMB, если он настроен, — не +роняет): ошибка логируется, состояние NATS видно в `/healthcheck`. К NATS ядро подключается в фоне и повторяет попытки +само (`nats=down`, пока связи нет; warning в логе — на первую ошибку и дальше не чаще раза в минуту); после разрыва +nats-py переподключается и восстанавливает подписку. + +При остановке ресурсы закрываются по шагам: фоновые задачи обмена → NATS → S3 → SMB → пул соединений БД. Ошибка +одного шага логируется (`Ошибка при остановке приложения. step=...`) и не отменяет следующие. Предупреждение «Включён dev-эмитент токенов … только для разработки» появляется не при старте, а при первом запросе к защищённому эндпоинту (`/api/v1/me`): проверяющий токены создаётся лениво (штатно, см. «Безопасность @@ -207,10 +209,10 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб | `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 не используется, блок обязателен) | -| `OPENBAO__SMB__SMB_PATH` | — (обязательно) | заглушка SMB | -| `OPENBAO__SMB__SMB_LOGIN` | — (обязательно) | заглушка SMB | -| `OPENBAO__SMB__SMB_PASSWORD` | — (обязательно) | заглушка SMB | +| `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 сервиса распознавания до повторной публикации | diff --git a/api/.env.example b/api/.env.example index 952c90f..2bd9534 100644 --- a/api/.env.example +++ b/api/.env.example @@ -19,11 +19,11 @@ OPENBAO__NATS__NATS_URL=nats://localhost:4222 OPENBAO__NATS__NATS_SEED= OPENBAO__NATS__NATS_JWT= -# SMB — заглушки (в Срезе 0 не используется) -OPENBAO__SMB__SMB_HOST=localhost -OPENBAO__SMB__SMB_PATH=share -OPENBAO__SMB__SMB_LOGIN=user -OPENBAO__SMB__SMB_PASSWORD=password +# SMB необязателен (ядру в Срезе 0 не нужен): пустой SMB_HOST — сессия SMB не регистрируется +OPENBAO__SMB__SMB_HOST= +OPENBAO__SMB__SMB_PATH= +OPENBAO__SMB__SMB_LOGIN= +OPENBAO__SMB__SMB_PASSWORD= # Аутентификация. Dev-эмитент работает только при AUTH_DEV_ISSUER=true И DEBUG=true. AUTH_DEV_ISSUER=true diff --git a/api/.env.test b/api/.env.test index 0d3b09f..623e56d 100644 --- a/api/.env.test +++ b/api/.env.test @@ -17,10 +17,11 @@ OPENBAO__NATS__NATS_URL=nats://localhost:4222 OPENBAO__NATS__NATS_SEED= OPENBAO__NATS__NATS_JWT= -OPENBAO__SMB__SMB_HOST=localhost -OPENBAO__SMB__SMB_PATH=share -OPENBAO__SMB__SMB_LOGIN=user -OPENBAO__SMB__SMB_PASSWORD=password +# SMB необязателен: пустой SMB_HOST — сессия SMB не регистрируется +OPENBAO__SMB__SMB_HOST= +OPENBAO__SMB__SMB_PATH= +OPENBAO__SMB__SMB_LOGIN= +OPENBAO__SMB__SMB_PASSWORD= AUTH_DEV_ISSUER=true AUTH_DEV_SECRET=test-only-secret-not-for-production-0123456789 diff --git a/api/src/app.py b/api/src/app.py index 2c78acf..623a0a2 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -2,17 +2,21 @@ Что здесь есть: настройки, подключение S3 (MinIO; `MINIO_SECURE` — https или http), NATS (`src/core/nats.py`: core NATS без JetStream, подключение в фоне при NATS_ENABLED=true независимо от DEBUG, подписка `recognition.result` в queue -group `arm-core`), SMB (горячие папки ИС Сканирование), middleware (gzip, proxy headers, CORS; при DEBUG=False пакет -РАИСА лишь условно импортируется, middleware не подключается), healthcheck (uptime, version, nats: ok|down|off), раздача -собранного SPA. +group `arm-core`), SMB (горячие папки ИС Сканирование; необязателен — при пустом `SMB_HOST` не подключается), +middleware (gzip, proxy headers, CORS; при DEBUG=False пакет РАИСА лишь условно импортируется, middleware не +подключается), healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA. Недоступность S3 или NATS при старте не роняет и не задерживает приложение: ошибка логируется, healthcheck показывает состояние. При NATS_ENABLED=true lifespan запускает фоновые задачи обмена `recognition_job` — outbox-публикатор и вахтёр сроков (`src/services/recognition_exchange.py`, design D-6) — и останавливает их до закрытия NATS. Сообщения `recognition.result` (ack и результаты сервисов) обрабатывает inbox `handle_message` того же модуля с фабрикой сессий приложения и `NoopMapper` (маппинг результата в доменные таблицы — Срез 2). +Остановка: фоновые задачи обмена → NATS → S3 → SMB → пул соединений БД; каждый шаг независим — ошибка одного +логируется и не отменяет остальные. """ +import asyncio import os +from collections.abc import Awaitable, Callable from contextlib import asynccontextmanager from datetime import UTC, datetime from functools import partial @@ -33,7 +37,7 @@ from src.core.clients import S3Storage from src.core.config import get_settings from src.core.exception_handlers import register_exception_handlers from src.core.nats import MessageHandler, NatsConnection -from src.db.db import async_session_maker +from src.db.db import async_session_maker, engine from src.services.recognition_exchange import ExchangeTasks, NoopMapper, handle_message settings = get_settings() @@ -118,18 +122,57 @@ def _start_exchange(nats: NatsConnection) -> ExchangeTasks: return ExchangeTasks.start(async_session_maker, nats, settings) +def _smb_configured() -> bool: + return bool(settings.OPENBAO.SMB.SMB_HOST) + + +async def _register_smb() -> None: + """Сессия SMB (шаблон) — только если задан `SMB_HOST`; вызов блокирующий, поэтому в отдельном потоке.""" + smb = settings.OPENBAO.SMB + if not _smb_configured(): + logger.debug("SMB не настроен (OPENBAO__SMB__SMB_HOST пуст): сессия SMB не регистрируется") + return + try: + await asyncio.to_thread(smbclient.register_session, smb.SMB_HOST, smb.SMB_LOGIN, smb.SMB_PASSWORD) + except Exception: + logger.exception("Ошибка при регистрации сессии SMB") + + +async def _reset_smb() -> None: + """Закрывает кэш соединений SMB (блокирующий вызов — в отдельном потоке); без SMB ничего не делает.""" + if _smb_configured(): + await asyncio.to_thread(smbclient.reset_connection_cache) + + +async def _cleanup_step(name: str, action: Callable[[], Awaitable[object]]) -> None: + """Один шаг остановки: исключение логируется и не мешает следующим шагам.""" + try: + await action() + except Exception: + logger.exception("Ошибка при остановке приложения. step={step}", step=name) + + +async def _shutdown(app: FastAPI) -> None: + """Остановка по шагам: задачи обмена (до NATS: публикатор не пишет в закрытое соединение) → NATS → S3 → SMB → + пул соединений БД (последним: задачи обмена и inbox к этому моменту остановлены).""" + exchange, nats, storage = app.state.exchange, app.state.nats, app.state.storage + if exchange is not None: + await _cleanup_step("exchange", exchange.stop) + if nats is not None: + await _cleanup_step("nats", nats.stop) + if storage: + await _cleanup_step("s3", storage.client.close_session) + await _cleanup_step("smb", _reset_smb) + await _cleanup_step("db", engine.dispose) + + @asynccontextmanager async def lifespan(app: FastAPI): app.state.storage = None app.state.nats = None app.state.exchange = None - try: - smbclient.register_session( - settings.OPENBAO.SMB.SMB_HOST, settings.OPENBAO.SMB.SMB_LOGIN, settings.OPENBAO.SMB.SMB_PASSWORD - ) - except Exception: - logger.exception("Ошибка при регистрации сессии SMB") + await _register_smb() try: app.state.storage = await _connect_s3() @@ -142,13 +185,7 @@ async def lifespan(app: FastAPI): yield finally: logger.info("Приложение закрывается") - smbclient.reset_connection_cache() - if app.state.exchange is not None: # до закрытия NATS: публикатор не должен писать в закрытое соединение - await app.state.exchange.stop() - if app.state.nats is not None: - await app.state.nats.stop() - if app.state.storage: - await app.state.storage.client.close_session() + await _shutdown(app) app = FastAPI(title="АРМ КА", version=VERSION, root_path=settings.ROOT_PATH, lifespan=lifespan) diff --git a/api/src/core/config.py b/api/src/core/config.py index 52604b7..360182b 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -22,17 +22,20 @@ class NATSSettings(BaseSettings): class SMBSettings(BaseSettings): - SMB_HOST: str - SMB_PATH: str - SMB_LOGIN: str - SMB_PASSWORD: str + """SMB (горячие папки ИС Сканирование, шаблон). Необязательны: ядру в Срезе 0 SMB не нужен; пустой `SMB_HOST` — + сессия SMB не регистрируется.""" + + SMB_HOST: str = "" + SMB_PATH: str = "" + SMB_LOGIN: str = "" + SMB_PASSWORD: str = "" class OpenBAOSettings(BaseSettings): DATABASE: DBSettings MINIO: MinioSettings NATS: NATSSettings - SMB: SMBSettings + SMB: SMBSettings = SMBSettings() # все поля необязательны (см. SMBSettings) class ServerSettings(BaseSettings): diff --git a/api/tests/test_app.py b/api/tests/test_app.py index 671001b..3060c60 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -5,12 +5,14 @@ import asyncio import importlib +import threading from http import HTTPStatus from types import SimpleNamespace from typing import Any import httpx import pytest +from loguru import logger from src.api.router import router from src.core.nats import NatsConnection @@ -94,6 +96,27 @@ def _no_smb(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(app_module.smbclient, "reset_connection_cache", lambda: None) +class _FakeEngine: + """Подмена движка БД приложения: lifespan вызывает `dispose()` последним шагом остановки (финальное ревью M-3).""" + + def __init__(self, events: list[str] | None = None) -> None: + self.disposed = 0 + self.events = events + + async def dispose(self) -> None: + self.disposed += 1 + if self.events is not None: + self.events.append("db") + + +@pytest.fixture(autouse=True) +def fake_engine(monkeypatch: pytest.MonkeyPatch) -> _FakeEngine: + """Lifespan в быстрых тестах не трогает пул реального движка БД.""" + engine = _FakeEngine() + monkeypatch.setattr(app_module, "engine", engine) + return engine + + @pytest.fixture(autouse=True) def exchange_loops(monkeypatch: pytest.MonkeyPatch) -> list[str]: """Циклы публикатора и вахтёра подменены ожиданием: быстрые тесты lifespan не ходят в БД.""" @@ -300,3 +323,113 @@ async def test_lifespan_without_nats_starts_no_exchange_tasks( await asyncio.sleep(0) assert exchange_loops == [] assert asyncio.all_tasks() - tasks_before == set() + + +# --- SMB необязателен, остановка по независимым шагам (финальное ревью I-3, T4.5, M-3) ------------------------------- + + +async def _unavailable_s3() -> None: + return None + + +def test_env_files_leave_smb_off(): + """В `.env.test` (и, значит, в тестах) SMB выключен: пустой SMB_HOST.""" + assert app_module.settings.OPENBAO.SMB.SMB_HOST == "" + + +async def test_lifespan_skips_smb_when_host_empty(monkeypatch: pytest.MonkeyPatch, fake_engine: _FakeEngine): + def forbidden(*args: Any, **kwargs: Any) -> None: + raise AssertionError("SMB не настроен — вызова быть не должно") + + records: list[Any] = [] + handler_id = logger.add(lambda message: records.append(message.record), level="DEBUG") + monkeypatch.setattr(app_module.settings.OPENBAO.SMB, "SMB_HOST", "") + monkeypatch.setattr(app_module.smbclient, "register_session", forbidden) + monkeypatch.setattr(app_module.smbclient, "reset_connection_cache", forbidden) + monkeypatch.setattr(app_module, "_connect_s3", _unavailable_s3) + monkeypatch.setattr(app_module, "_create_nats", lambda: None) + try: + async with app_module.lifespan(app_module.app): + pass + finally: + logger.remove(handler_id) + + assert [r for r in records if "SMB не настроен" in r["message"] and r["level"].name == "DEBUG"] + assert not [r for r in records if r["level"].name == "ERROR"] + assert fake_engine.disposed == 1 + + +async def test_lifespan_registers_smb_in_worker_thread_when_configured(monkeypatch: pytest.MonkeyPatch): + """Задан SMB_HOST: `register_session` и `reset_connection_cache` — вне потока цикла событий (`to_thread`).""" + loop_thread = threading.get_ident() + calls: list[tuple[str, Any, bool]] = [] + + def register_session(*args: Any, **kwargs: Any) -> None: + calls.append(("register", args, threading.get_ident() != loop_thread)) + + def reset_connection_cache() -> None: + calls.append(("reset", (), threading.get_ident() != loop_thread)) + + smb = app_module.settings.OPENBAO.SMB + monkeypatch.setattr(smb, "SMB_HOST", "smb.local") + monkeypatch.setattr(smb, "SMB_LOGIN", "user") + monkeypatch.setattr(smb, "SMB_PASSWORD", "password") + monkeypatch.setattr(app_module.smbclient, "register_session", register_session) + monkeypatch.setattr(app_module.smbclient, "reset_connection_cache", reset_connection_cache) + monkeypatch.setattr(app_module, "_connect_s3", _unavailable_s3) + monkeypatch.setattr(app_module, "_create_nats", lambda: None) + + async with app_module.lifespan(app_module.app): + assert calls == [("register", ("smb.local", "user", "password"), True)] + + assert calls[-1] == ("reset", (), True) + + +class _FailingNats(_FakeNats): + def __init__(self, events: list[str]) -> None: + super().__init__() + self.events = events + + async def stop(self) -> None: + self.events.append("nats") + raise RuntimeError("подставная ошибка остановки NATS") + + +class _FailingS3Client: + def __init__(self, events: list[str]) -> None: + self.events = events + + async def close_session(self) -> None: + self.events.append("s3") + raise RuntimeError("подставная ошибка закрытия S3") + + +async def test_shutdown_steps_are_independent_and_engine_disposed_last( + monkeypatch: pytest.MonkeyPatch, exchange_loops: list[str] +): + """Ошибки `nats.stop()` и закрытия S3 не отменяют сброс SMB и `engine.dispose()`; порядок шагов сохраняется.""" + events: list[str] = [] + monkeypatch.setattr(app_module, "engine", _FakeEngine(events)) + monkeypatch.setattr(app_module.settings.OPENBAO.SMB, "SMB_HOST", "smb.local") + monkeypatch.setattr(app_module.smbclient, "register_session", lambda *args, **kwargs: None) + monkeypatch.setattr(app_module.smbclient, "reset_connection_cache", lambda: events.append("smb")) + storage = SimpleNamespace(client=_FailingS3Client(events)) + + async def connect_s3() -> SimpleNamespace: + return storage + + monkeypatch.setattr(app_module, "_connect_s3", connect_s3) + monkeypatch.setattr(app_module, "_create_nats", lambda: _FailingNats(events)) + records: list[Any] = [] + handler_id = logger.add(lambda message: records.append(message.record), level="ERROR") + try: + async with app_module.lifespan(app_module.app): + exchange = app_module.app.state.exchange + assert exchange is not None + finally: + logger.remove(handler_id) + + assert all(task.done() for task in exchange.tasks) + assert events == ["nats", "s3", "smb", "db"] + failed_steps = sorted(r["extra"].get("step") for r in records) + assert failed_steps == ["nats", "s3"] diff --git a/api/tests/test_config.py b/api/tests/test_config.py index 22f3f05..907f606 100644 --- a/api/tests/test_config.py +++ b/api/tests/test_config.py @@ -27,10 +27,11 @@ def test_env_test_is_read(clean_env: pytest.MonkeyPatch) -> None: assert s.AUTH_DEV_ISSUER is True assert s.NATS_ENABLED is False assert s.OPENBAO.MINIO.MINIO_SECURE is False + assert s.OPENBAO.SMB.SMB_HOST == "" # SMB выключен: в логе запуска нет трейсбэка SMB def test_defaults(clean_env: pytest.MonkeyPatch) -> None: - """Значения по умолчанию классов настроек: env-файла нет, заданы только обязательные OPENBAO__*.""" + """Значения по умолчанию классов настроек: env-файла нет, заданы только обязательные OPENBAO__* (SMB не нужен).""" required = { "OPENBAO__DATABASE__DATABASE_URL": "postgresql+asyncpg://u:p@localhost:5432/d", "OPENBAO__MINIO__MINIO_URL": "localhost:9000", @@ -38,10 +39,6 @@ def test_defaults(clean_env: pytest.MonkeyPatch) -> None: "OPENBAO__MINIO__MINIO_ADMIN_PASSWORD": "password", "OPENBAO__MINIO__MINIO_BUCKET": "bucket", "OPENBAO__NATS__NATS_URL": "nats://localhost:4222", - "OPENBAO__SMB__SMB_HOST": "host", - "OPENBAO__SMB__SMB_PATH": "path", - "OPENBAO__SMB__SMB_LOGIN": "login", - "OPENBAO__SMB__SMB_PASSWORD": "password", } for key, value in required.items(): clean_env.setenv(key, value) @@ -59,6 +56,8 @@ def test_defaults(clean_env: pytest.MonkeyPatch) -> None: assert s.OPENBAO.NATS.NATS_SEED == "" assert s.OPENBAO.NATS.NATS_JWT == "" assert s.OPENBAO.MINIO.MINIO_SECURE is True + smb = s.OPENBAO.SMB + assert (smb.SMB_HOST, smb.SMB_PATH, smb.SMB_LOGIN, smb.SMB_PASSWORD) == ("", "", "", "") def test_empty_nats_seed_and_jwt_allowed(clean_env: pytest.MonkeyPatch) -> None: @@ -67,3 +66,4 @@ def test_empty_nats_seed_and_jwt_allowed(clean_env: pytest.MonkeyPatch) -> None: assert s.OPENBAO.NATS.NATS_SEED == "" assert s.OPENBAO.NATS.NATS_JWT == "" assert s.NATS_ENABLED is True + assert s.OPENBAO.SMB.SMB_HOST == "" -- 2.47.2 From 7a6a7fb36b9718dfa8a2ec8a757f573620616637 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 01:53:48 +0400 Subject: [PATCH 90/97] =?UTF-8?q?fix:=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=20=D0=BE=D0=B1=D0=BC=D0=B5=D0=BD=D0=B0=20PositiveI?= =?UTF-8?q?nt/NonNegativeInt=20(M-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OUTBOX_POLL_SECONDS и ACK_TIMEOUT_SECONDS — PositiveInt, OUTBOX_MAX_REPUBLISH — NonNegativeInt: 0 и отрицательные значения периода и таймаута, отрицательное число повторов отвергаются при старте. Значения по умолчанию прежние. Co-Authored-By: Claude Fable 5.1 --- api/src/core/config.py | 10 +++++++--- api/tests/test_config.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/api/src/core/config.py b/api/src/core/config.py index 360182b..47f093d 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -1,5 +1,6 @@ import os +from pydantic import NonNegativeInt, PositiveInt from pydantic_settings import BaseSettings, SettingsConfigDict @@ -68,9 +69,12 @@ class Settings(BaseSettings): # NATS и outbox/inbox обмена recognition_job NATS_ENABLED: bool = False - OUTBOX_POLL_SECONDS: int = 5 # период опроса outbox - ACK_TIMEOUT_SECONDS: int = 30 # ожидание ack сервиса распознавания до повторной публикации - OUTBOX_MAX_REPUBLISH: int = 3 # число повторных публикаций до перевода в dlq + # 0 и отрицательные значения отвергаются при старте: период 0 — горячий цикл, ack-таймаут 0 — повтор каждый проход + OUTBOX_POLL_SECONDS: PositiveInt = 5 # период опроса outbox + ACK_TIMEOUT_SECONDS: PositiveInt = 30 # ожидание ack сервиса распознавания до повторной публикации + OUTBOX_MAX_REPUBLISH: NonNegativeInt = ( + 3 # число повторных публикаций до перевода в dlq (0 — сразу dlq без повторов) + ) model_config = SettingsConfigDict( extra="ignore", diff --git a/api/tests/test_config.py b/api/tests/test_config.py index 907f606..f413f61 100644 --- a/api/tests/test_config.py +++ b/api/tests/test_config.py @@ -4,6 +4,7 @@ import os from pathlib import Path import pytest +from pydantic import ValidationError from src.core.config import Settings @@ -67,3 +68,40 @@ def test_empty_nats_seed_and_jwt_allowed(clean_env: pytest.MonkeyPatch) -> None: assert s.OPENBAO.NATS.NATS_JWT == "" assert s.NATS_ENABLED is True assert s.OPENBAO.SMB.SMB_HOST == "" + + +REQUIRED_ENV = { + "OPENBAO__DATABASE__DATABASE_URL": "postgresql+asyncpg://u:p@localhost:5432/d", + "OPENBAO__MINIO__MINIO_URL": "localhost:9000", + "OPENBAO__MINIO__MINIO_ADMIN_LOGIN": "login", + "OPENBAO__MINIO__MINIO_ADMIN_PASSWORD": "password", + "OPENBAO__MINIO__MINIO_BUCKET": "bucket", + "OPENBAO__NATS__NATS_URL": "nats://localhost:4222", +} + + +@pytest.mark.parametrize( + ("key", "value"), + [ + ("OUTBOX_POLL_SECONDS", "0"), + ("OUTBOX_POLL_SECONDS", "-1"), + ("ACK_TIMEOUT_SECONDS", "0"), + ("ACK_TIMEOUT_SECONDS", "-5"), + ("OUTBOX_MAX_REPUBLISH", "-1"), + ], +) +def test_exchange_settings_reject_invalid(clean_env: pytest.MonkeyPatch, key: str, value: str) -> None: + """Период опроса и ack-таймаут — строго положительные, число повторов — неотрицательное (финальное ревью M-4).""" + for name, required in REQUIRED_ENV.items(): + clean_env.setenv(name, required) + clean_env.setenv(key, value) + with pytest.raises(ValidationError, match=key): + Settings(_env_file=None) # type: ignore[call-arg] + + +def test_zero_republish_allowed(clean_env: pytest.MonkeyPatch) -> None: + """OUTBOX_MAX_REPUBLISH=0 допустим: без повторов, сразу dlq после ack-таймаута.""" + for name, required in REQUIRED_ENV.items(): + clean_env.setenv(name, required) + clean_env.setenv("OUTBOX_MAX_REPUBLISH", "0") + assert Settings(_env_file=None).OUTBOX_MAX_REPUBLISH == 0 # type: ignore[call-arg] -- 2.47.2 From 87816ef6c98d3804ade28c24c90d49ef61dd5426 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 01:54:30 +0400 Subject: [PATCH 91/97] =?UTF-8?q?fix:=20job=5Fid=20=D0=BD=D0=B5=D0=B2?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B4=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=81=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=B8=D1=88?= =?UTF-8?q?=D0=B5=D1=82=D1=81=D1=8F=20=D0=B2=20=D0=BB=D0=BE=D0=B3=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20repr=20(M-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit job_id из сообщения, не прошедшего контракт, — данные отправителя; без экранирования перевод строки в нём позволял подделать строку лога. Теперь repr(job_id[:64]), как в рулинге R-18 для kid токена. Co-Authored-By: Claude Fable 5.1 --- api/src/services/recognition_exchange.py | 3 ++- api/tests/test_recognition_inbox.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/api/src/services/recognition_exchange.py b/api/src/services/recognition_exchange.py index a26e4c0..8ca7f2e 100644 --- a/api/src/services/recognition_exchange.py +++ b/api/src/services/recognition_exchange.py @@ -560,7 +560,8 @@ def _parse_message(msg: InboundMessage) -> RecognitionResultMessage | None: return RecognitionResultMessage.model_validate(data) except ValidationError as exc: raw_job_id = data.get("job_id") - job_id = raw_job_id[:LOG_ID_MAX_LENGTH] if isinstance(raw_job_id, str) else None + # job_id невалидного сообщения — данные отправителя: repr экранирует переводы строк и управляющие символы + job_id = repr(raw_job_id[:LOG_ID_MAX_LENGTH]) if isinstance(raw_job_id, str) else None errors = "; ".join( f"{'.'.join(str(part) for part in error['loc']) or ''}: {error['msg']}" for error in exc.errors(include_url=False, include_input=False, include_context=False) diff --git a/api/tests/test_recognition_inbox.py b/api/tests/test_recognition_inbox.py index 093e1df..e3d69c3 100644 --- a/api/tests/test_recognition_inbox.py +++ b/api/tests/test_recognition_inbox.py @@ -238,6 +238,18 @@ async def test_invalid_message_log_carries_job_id_when_readable(logs: list[Any]) assert str(_JOB) in record["message"] +async def test_invalid_message_job_id_is_escaped_in_log(logs: list[Any]): + """job_id невалидного сообщения с переводом строки попадает в лог через repr: подделать строку лога нельзя (M-7).""" + forged = "not-a-uuid\n2026-10-04 | INFO | подделанная запись\r\x1b[31m" + body = {**envelope(_JOB, _FILE, "done"), "job_id": forged} + msg = fake_msg(body, "result:x:1", validate=False) + await handle_message(msg, session_factory=_UntouchableFactory()) # type: ignore[arg-type] + (record,) = _logged(logs, "отклонено", "WARNING") + message = record["message"] + assert "\n" not in message and "\r" not in message and "\x1b" not in message + assert repr(forged) in message + + async def test_database_error_is_logged_and_not_raised(logs: list[Any]): """Ошибка БД (сервер недоступен) — `logger.exception`, без повторов, наружу не выходит.""" engine = create_async_engine("postgresql+asyncpg://nobody:nothing@127.0.0.1:1/none") -- 2.47.2 From feaac9cb9ac9789e0064f80423243cb685d2190c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 01:55:22 +0400 Subject: [PATCH 92/97] =?UTF-8?q?test:=20CREATE/DROP=20DATABASE=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20=D1=81=D0=BB=D1=83=D0=B6=D0=B5=D0=B1?= =?UTF-8?q?=D0=BD=D1=83=D1=8E=20=D0=91=D0=94=20postgres=20(M-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Команды уровня кластера в тестах (создание и удаление временных БД, роль в тесте предвыданной схемы) подключались к рабочей БД armka. Теперь — к служебной БД postgres того же сервера (service_url): тестам не нужна существующая armka. Проверено прогоном с несуществующей БД в OPENBAO__DATABASE__DATABASE_URL. Co-Authored-By: Claude Fable 5.1 --- api/tests/pg_helpers.py | 20 +++++++++++++++----- api/tests/test_migrations.py | 11 +++++++++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/api/tests/pg_helpers.py b/api/tests/pg_helpers.py index 8138471..b7c3c5c 100644 --- a/api/tests/pg_helpers.py +++ b/api/tests/pg_helpers.py @@ -1,7 +1,9 @@ """Помощники интеграционных тестов с PostgreSQL: временная БД внутри того же сервера, что и `OPENBAO__DATABASE`. Фикстура `scratch_db_url` создаёт пустую БД `armcore_scratch_`, отдаёт её SQLAlchemy-URL (драйвер asyncpg) и -в конце удаляет. Рабочую БД (`armka`) тесты не трогают. Подключается плагином в `conftest.py`. +в конце удаляет. Рабочую БД (`armka`) тесты не трогают и от её существования не зависят: `CREATE/DROP DATABASE` и +прочие команды уровня кластера идут через служебную БД `postgres` того же сервера (`service_url`). Подключается +плагином в `conftest.py`. """ import asyncio @@ -25,6 +27,15 @@ def base_url() -> URL: return make_url(settings.OPENBAO.DATABASE.DATABASE_URL) +#: Служебная БД PostgreSQL, которая есть на любом сервере: к ней подключаются для команд уровня кластера. +SERVICE_DATABASE = "postgres" + + +def service_url() -> URL: + """SQLAlchemy-URL служебной БД `postgres` на сервере из настроек (те же хост, порт и учётные данные).""" + return base_url().set(database=SERVICE_DATABASE) + + def _asyncpg_dsn(url: URL) -> str: return url.set(drivername="postgresql").render_as_string(hide_password=False) @@ -54,18 +65,17 @@ async def alembic_async(url: str, *args: str) -> str: async def create_scratch_db(name: str) -> URL: """Создать пустую БД `name` на сервере рабочей БД; вернуть SQLAlchemy-URL на неё.""" - url = base_url() - conn = await asyncpg.connect(_asyncpg_dsn(url)) + conn = await asyncpg.connect(_asyncpg_dsn(service_url())) try: await conn.execute(f'CREATE DATABASE "{name}"') finally: await conn.close() - return url.set(database=name) + return base_url().set(database=name) async def drop_scratch_db(name: str) -> None: """Удалить БД `name` (закрывая оставшиеся подключения).""" - conn = await asyncpg.connect(_asyncpg_dsn(base_url())) + conn = await asyncpg.connect(_asyncpg_dsn(service_url())) try: await conn.execute(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)') finally: diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index d33289f..18b1692 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -18,7 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, c from src.models import Base from src.models.enums import ENUMS from tests.pg_helpers import alembic_async as alembic -from tests.pg_helpers import base_url, create_scratch_db, drop_scratch_db, run_alembic +from tests.pg_helpers import base_url, create_scratch_db, drop_scratch_db, run_alembic, service_url from tests.test_models_part_01_03 import _check_related_group_member_uniqueness from tests.test_models_part_04_06 import _check_nulls_not_distinct, _make_file_with_job from tests.test_models_part_07_09 import ( @@ -263,7 +263,7 @@ async def test_upgrade_into_preprovisioned_schema_without_database_create(scratc password = uuid.uuid4().hex scratch_url = make_url(scratch_db_url) role_url = scratch_url.set(username=role, password=password).render_as_string(hide_password=False) - server = create_async_engine(base_url(), isolation_level="AUTOCOMMIT") + server = create_async_engine(service_url(), isolation_level="AUTOCOMMIT") scratch = create_async_engine(scratch_db_url, isolation_level="AUTOCOMMIT") role_created = False try: @@ -308,3 +308,10 @@ def test_offline_sql_creates_schema_before_version_table() -> None: assert schema_at < output.index("CREATE TABLE dkb.alembic_version") assert schema_at < output.index("CREATE TYPE dkb.entity_type AS ENUM ('ЮЛ', 'ИП')") assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{HEAD}')" in output + + +def test_cluster_commands_use_service_database() -> None: + """CREATE/DROP DATABASE и роли — через служебную БД `postgres`, а не рабочую `armka` (финальное ревью M-9).""" + assert service_url().database == "postgres" + base = base_url() + assert (service_url().host, service_url().port, service_url().username) == (base.host, base.port, base.username) -- 2.47.2 From 8bab9659887153147c2eebe243315b3143994f51 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 01:55:59 +0400 Subject: [PATCH 93/97] =?UTF-8?q?fix:=20dev-=D0=B7=D0=B0=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=20=D1=81=D0=BB=D1=83=D1=88=D0=B0=D0=B5=D1=82=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20127.0.0.1=20(M-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В api/.env.example задан SERVER__HOST=127.0.0.1 (и SERVER__PORT=8000): при DEBUG и AUTH_DEV_ISSUER эндпоинт dev-токенов выдаёт токен любому, кто знает email. Значение приложения по умолчанию (0.0.0.0) не меняется. .env.test — те же ключи; новый тест сверяет набор ключей .env.example и .env.test. README: рекомендация в быстром старте и строка таблицы. Co-Authored-By: Claude Fable 5.1 --- README.md | 6 ++++-- api/.env.example | 5 +++++ api/.env.test | 4 ++++ api/tests/test_config.py | 20 +++++++++++++++++++- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d37515e..2765b75 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,9 @@ dev-пользователей. Seed идемпотентен: повторны cd api && .venv/bin/python run_server.py ``` -Сервер слушает `0.0.0.0:8000` (`SERVER__HOST`, `SERVER__PORT`). Дальнейшие команды — из другого терминала. +Сервер слушает `127.0.0.1:8000` (`SERVER__HOST`, `SERVER__PORT` из `api/.env`). Для dev-запуска оставляйте loopback: +при `DEBUG=true` и `AUTH_DEV_ISSUER=true` эндпоинт dev-токенов выдаёт токен любому, кто знает email пользователя, — +не открывайте его в сеть. Дальнейшие команды — из другого терминала. ### 6. Проверка @@ -197,7 +199,7 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб |---|---|---| | `ENV_FILE` | `.env` | файл окружения (относительно `api/`) | | `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов; при `false` пакеты РАИСА подгружаются условно | -| `SERVER__HOST`, `SERVER__PORT` | `0.0.0.0`, `8000` | адрес и порт сервера (`run_server.py`) | +| `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` | diff --git a/api/.env.example b/api/.env.example index 2bd9534..97311dc 100644 --- a/api/.env.example +++ b/api/.env.example @@ -3,6 +3,11 @@ DEBUG=true +# Dev-запуск слушает только loopback: при DEBUG и AUTH_DEV_ISSUER эндпоинт dev-токенов выдаёт токен любому, кто знает +# email пользователя. По умолчанию приложения (без этой строки) — 0.0.0.0, как в контейнере платформы. +SERVER__HOST=127.0.0.1 +SERVER__PORT=8000 + # PostgreSQL (драйвер asyncpg) OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:5432/armka diff --git a/api/.env.test b/api/.env.test index 623e56d..8801f1d 100644 --- a/api/.env.test +++ b/api/.env.test @@ -4,6 +4,10 @@ DEBUG=true +# Как в .env.example (тесты сервер не запускают) +SERVER__HOST=127.0.0.1 +SERVER__PORT=8000 + OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:5432/armka OPENBAO__MINIO__MINIO_URL=localhost:9000 diff --git a/api/tests/test_config.py b/api/tests/test_config.py index f413f61..f06f7c6 100644 --- a/api/tests/test_config.py +++ b/api/tests/test_config.py @@ -15,7 +15,7 @@ API_DIR = Path(__file__).resolve().parent.parent def clean_env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch: """Убирает из окружения процесса переменные, которые перебили бы значения из env-файла.""" for key in list(os.environ): - if key.startswith(("OPENBAO__", "AUTH_", "OUTBOX_", "ACK_")) or key in {"NATS_ENABLED", "DEBUG"}: + if key.startswith(("OPENBAO__", "AUTH_", "OUTBOX_", "ACK_", "SERVER__")) or key in {"NATS_ENABLED", "DEBUG"}: monkeypatch.delenv(key) return monkeypatch @@ -57,6 +57,7 @@ def test_defaults(clean_env: pytest.MonkeyPatch) -> None: assert s.OPENBAO.NATS.NATS_SEED == "" assert s.OPENBAO.NATS.NATS_JWT == "" assert s.OPENBAO.MINIO.MINIO_SECURE is True + assert (s.server.HOST, s.server.PORT) == ("0.0.0.0", 8000) # noqa: S104 - по умолчанию, как в контейнере smb = s.OPENBAO.SMB assert (smb.SMB_HOST, smb.SMB_PATH, smb.SMB_LOGIN, smb.SMB_PASSWORD) == ("", "", "", "") @@ -68,6 +69,7 @@ def test_empty_nats_seed_and_jwt_allowed(clean_env: pytest.MonkeyPatch) -> None: assert s.OPENBAO.NATS.NATS_JWT == "" assert s.NATS_ENABLED is True assert s.OPENBAO.SMB.SMB_HOST == "" + assert (s.server.HOST, s.server.PORT) == ("127.0.0.1", 8000) # dev-запуск — только loopback (M-11) REQUIRED_ENV = { @@ -105,3 +107,19 @@ def test_zero_republish_allowed(clean_env: pytest.MonkeyPatch) -> None: clean_env.setenv(name, required) clean_env.setenv("OUTBOX_MAX_REPUBLISH", "0") assert Settings(_env_file=None).OUTBOX_MAX_REPUBLISH == 0 # type: ignore[call-arg] + + +def _env_keys(name: str) -> set[str]: + lines = (API_DIR / name).read_text(encoding="utf-8").splitlines() + return {line.split("=", 1)[0] for line in lines if line.strip() and not line.lstrip().startswith("#")} + + +def test_env_example_and_env_test_have_same_keys() -> None: + """`.env.example` и `.env.test` перечисляют одни и те же переменные: новая настройка не забывается ни в одном.""" + assert _env_keys(".env.example") == _env_keys(".env.test") + + +def test_env_example_listens_on_loopback(clean_env: pytest.MonkeyPatch) -> None: + """Dev-запуск по `.env.example` слушает только 127.0.0.1 (финальное ревью M-11).""" + s = Settings(_env_file=API_DIR / ".env.example") # type: ignore[call-arg] + assert (s.server.HOST, s.server.PORT) == ("127.0.0.1", 8000) -- 2.47.2 From cce92212d2783fd6c82003e634bd3c9a014b4930 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 01:56:53 +0400 Subject: [PATCH 94/97] =?UTF-8?q?test:=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD=20?= =?UTF-8?q?--capture=3Dno=20=D0=B8=D0=B7=20addopts=20pytest=20(M-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вывод loguru из негативных тестов больше не засоряет прогон: pytest перехватывает stderr и показывает его только для упавших тестов (секция «Captured stderr call»). Ни один тест не зависел от отключённого перехвата; полный прогон зелёный. Co-Authored-By: Claude Fable 5.1 --- api/pytest.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/api/pytest.ini b/api/pytest.ini index e6f0417..12c269f 100644 --- a/api/pytest.ini +++ b/api/pytest.ini @@ -14,7 +14,6 @@ addopts = --tb=short --strict-markers --verbose - --capture=no env = APP_ENV=test ENV_FILE=.env.test -- 2.47.2 From 75045af72380f6be39492b044017ee673a9efb85 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 01:59:58 +0400 Subject: [PATCH 95/97] =?UTF-8?q?test:=20=D0=BE=D0=B1=D0=BC=D0=B5=D0=BD=20?= =?UTF-8?q?=D0=B2=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC=20lif?= =?UTF-8?q?espan=20=D0=B8=20=D0=B4=D0=B2=D0=B0=20=D0=B8=D0=BD=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D1=81=D0=B0=20=D1=8F=D0=B4=D1=80=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B0=20NATS=20(I-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/test_exchange_lifespan_nats.py (integration, NATS и PostgreSQL из .env.test, уникальный subject результатов, queue group arm-core): - настоящий lifespan при NATS_ENABLED=true: /healthcheck -> nats=ok, подписка видна в мониторинге NATS (/connz) с qgroup arm-core, публикатор lifespan переводит задание в published, inbox принимает ack и результат; остановка гасит задачи обмена до nats.stop(), закрывает пул БД и не оставляет висящих задач; - два NatsConnection с настоящим handle_message: результат обрабатывает ровно один инстанс, задание и s3_object меняются один раз; повторная доставка того же сообщения ничего не меняет. Co-Authored-By: Claude Fable 5.1 --- api/tests/test_exchange_lifespan_nats.py | 339 +++++++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 api/tests/test_exchange_lifespan_nats.py diff --git a/api/tests/test_exchange_lifespan_nats.py b/api/tests/test_exchange_lifespan_nats.py new file mode 100644 index 0000000..133a365 --- /dev/null +++ b/api/tests/test_exchange_lifespan_nats.py @@ -0,0 +1,339 @@ +"""Обмен `recognition_job` в реальном lifespan и два инстанса ядра (финальное ревью I-7; spec recognition-job-exchange, +«Соединение с NATS по шаблону, без JetStream», «Inbox принимает результат идемпотентно»; design D-6). + +Интеграционные тесты против NATS и PostgreSQL из `.env.test` (БД сессии pytest; задания фиксируются `CommittedJobs` +и удаляются в teardown). Subject результатов — уникальный на тест (`NatsConnection(result_subject=...)`): общий +`recognition.result` не трогаем, queue group — боевая `arm-core`. S3 не нужен (`_connect_s3` → None), SMB выключен +(`.env.test`), пул движка БД приложения подменён (lifespan закрывает его последним шагом). Ожидания — с условием и +ограничением по времени, без «голых» пауз. +""" + +import asyncio +import importlib +import json +import time +import uuid +from collections.abc import AsyncIterator, Awaitable, Callable +from datetime import UTC, datetime, timedelta +from functools import partial +from typing import Any +from urllib.parse import urlsplit + +import httpx +import pytest +import pytest_asyncio +from nats.aio.client import Client as NATS +from nats.aio.msg import Msg +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from src.contract import NATS_MSG_ID_HEADER, ack_msg_id, result_msg_id +from src.core.config import get_settings +from src.core.nats import RESULT_QUEUE_GROUP, NatsConnection +from src.models.enums import RecognitionStatus, S3BucketRole +from src.models.files import RecognitionJob, S3Object +from src.services.recognition_exchange import NoopMapper, handle_message +from tests.factories import CommittedJobs +from tests.test_recognition_inbox import MESSAGE_VALIDATOR, envelope + +pytestmark = pytest.mark.integration + +# src/__init__.py реэкспортирует объект app под именем модуля, поэтому модуль берём через importlib +app_module = importlib.import_module("src.app") + +READY_TIMEOUT = 5.0 +WAIT_TIMEOUT = 10.0 +NATS_MONITOR_PORT = 8222 # мониторинг NATS из docs/dev/docker-compose.yml +EXCHANGE_TASKS = 2 # публикатор и вахтёр + + +# --- инфраструктура --------------------------------------------------------------------------------------------- + + +async def _wait_until(predicate: Callable[[], Awaitable[bool]], what: str, timeout: float = WAIT_TIMEOUT) -> None: + deadline = time.monotonic() + timeout + while not await predicate(): + if time.monotonic() > deadline: + raise AssertionError(f"за {timeout} с не выполнено: {what}") + await asyncio.sleep(0.05) + + +@pytest.fixture +def session_factory(db_engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: + return async_sessionmaker(db_engine, expire_on_commit=False) + + +@pytest_asyncio.fixture +async def jobs(session_factory: async_sessionmaker[AsyncSession]) -> AsyncIterator[CommittedJobs]: + committed = CommittedJobs(session_factory) + try: + yield committed + finally: + await committed.cleanup() + + +@pytest.fixture +def result_subject() -> str: + return f"test.result.{uuid.uuid4().hex}" + + +class _Service: + """«Сервис распознавания» на голом nats-py: публикует конверты `recognition-result-message` в subject ядра.""" + + def __init__(self, result_subject: str) -> None: + self.result_subject = result_subject + self.nc = NATS() + + async def start(self) -> None: + url = get_settings().OPENBAO.NATS.NATS_URL + try: + await self.nc.connect(url, connect_timeout=2, allow_reconnect=False) + except Exception as exc: + pytest.fail(f"NATS недоступен ({url}, .env.test): {type(exc).__name__}", pytrace=False) + + async def send(self, body: dict[str, Any], msg_id: str) -> None: + MESSAGE_VALIDATOR.validate(body) + payload = json.dumps(body, ensure_ascii=False).encode("utf-8") + await self.nc.publish(self.result_subject, payload, headers={NATS_MSG_ID_HEADER: msg_id}) + await self.nc.flush() + + async def stop(self) -> None: + await self.nc.close() + + +@pytest_asyncio.fixture +async def service(result_subject: str) -> AsyncIterator[_Service]: + svc = _Service(result_subject) + await svc.start() + try: + yield svc + finally: + await svc.stop() + + +async def _row(session_factory: async_sessionmaker[AsyncSession], job_id: uuid.UUID) -> dict[str, Any]: + async with session_factory() as session: + row = (await session.execute(select(RecognitionJob.__table__).where(RecognitionJob.id == job_id))).one() + return dict(row._mapping) + + +async def _s3_rows(session_factory: async_sessionmaker[AsyncSession], key: str) -> list[dict[str, Any]]: + async with session_factory() as session: + rows = await session.execute(select(S3Object.__table__).where(S3Object.key == key)) + return [dict(r._mapping) for r in rows] + + +async def _status_is( + session_factory: async_sessionmaker[AsyncSession], job_id: uuid.UUID, status: RecognitionStatus +) -> bool: + return (await _row(session_factory, job_id))["status"] is status + + +def _result_key(file_id: uuid.UUID, job_id: uuid.UUID) -> str: + return f"results/{file_id}/{job_id}/result.json" + + +async def _queue_subscriptions(subject: str) -> list[dict[str, Any]] | None: + """Подписки на `subject` по мониторингу NATS (`/connz?subs=detail`); `None` — мониторинг недоступен.""" + host = urlsplit(get_settings().OPENBAO.NATS.NATS_URL).hostname or "localhost" + url = f"http://{host}:{NATS_MONITOR_PORT}/connz" + try: + async with httpx.AsyncClient(timeout=2.0) as client: + response = await client.get(url, params={"subs": "detail", "limit": 1024}) + except httpx.HTTPError: + return None + if response.status_code != httpx.codes.OK: + return None + return [ + sub + for conn in response.json().get("connections", []) + for sub in conn.get("subscriptions_list_detail") or [] + if sub.get("subject") == subject + ] + + +class _EngineStub: + """Подмена движка БД приложения: lifespan вызывает `dispose()` последним шагом остановки.""" + + def __init__(self) -> None: + self.disposed = 0 + + async def dispose(self) -> None: + self.disposed += 1 + + +# --- (1) реальный lifespan ---------------------------------------------------------------------------------------- + + +async def test_real_lifespan_runs_exchange_over_real_nats( # noqa: PLR0915 - один сквозной сценарий жизненного цикла + monkeypatch: pytest.MonkeyPatch, + session_factory: async_sessionmaker[AsyncSession], + jobs: CommittedJobs, + service: _Service, + result_subject: str, +): + """NATS_ENABLED=true: `/healthcheck` → `nats=ok`, подписка в queue group `arm-core` активна, публикатор и inbox + работают с БД; остановка гасит задачи обмена ДО NATS и не оставляет висящих задач.""" + + async def no_s3() -> None: + return None + + engine = _EngineStub() + monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True) + monkeypatch.setattr(app_module.settings, "OUTBOX_POLL_SECONDS", 1) # публикатор не ждёт 5 с между проходами + monkeypatch.setattr(app_module.settings.OPENBAO.SMB, "SMB_HOST", "") + monkeypatch.setattr(app_module, "_connect_s3", no_s3) + monkeypatch.setattr(app_module, "async_session_maker", session_factory) + monkeypatch.setattr(app_module, "engine", engine) + # Настоящий `_create_nats` и подписка `_subscribe_results`; отличается только subject результатов + monkeypatch.setattr(app_module, "NatsConnection", partial(NatsConnection, result_subject=result_subject)) + + job_id = await jobs.make_queued_job(reply_deadline=datetime.now(UTC) + timedelta(hours=1)) + file_id = (await jobs.get(job_id)).file_id + tasks_before = asyncio.all_tasks() + exchange_done_at_nats_stop: list[bool] = [] + + async with app_module.lifespan(app_module.app): + app = app_module.app + nats: NatsConnection = app.state.nats + exchange = app.state.exchange + assert isinstance(nats, NatsConnection) + assert nats.result_subject == result_subject + assert nats.queue == RESULT_QUEUE_GROUP == "arm-core" + assert exchange is not None + assert len(exchange.tasks) == EXCHANGE_TASKS + + if not await nats.wait_ready(timeout=READY_TIMEOUT): # подключение и подписка выполнены + pytest.fail(f"NATS недоступен ({get_settings().OPENBAO.NATS.NATS_URL}, .env.test)", pytrace=False) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + health = (await client.get("/healthcheck")).json() + assert health["nats"] == "ok" + + subscriptions = await _queue_subscriptions(result_subject) + if subscriptions is not None: # мониторинг NATS доступен: подписка ядра видна серверу в queue group + assert [sub.get("qgroup") for sub in subscriptions] == ["arm-core"] + assert not any(task.done() for task in exchange.tasks) + + # публикатор lifespan: queued → published + await _wait_until( + partial(_status_is, session_factory, job_id, RecognitionStatus.published), "публикация задания" + ) + # inbox lifespan (подписка на result_subject): ack → processing, результат → done + await service.send(envelope(job_id, file_id, "processing"), ack_msg_id(job_id, 1)) + await _wait_until(partial(_status_is, session_factory, job_id, RecognitionStatus.processing), "ack") + await service.send(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)) + await _wait_until(partial(_status_is, session_factory, job_id, RecognitionStatus.done), "результат") + assert not any(task.done() for task in exchange.tasks) + + original_stop = nats.stop + + async def recording_stop() -> None: + exchange_done_at_nats_stop.append(all(task.done() for task in exchange.tasks)) + await original_stop() + + monkeypatch.setattr(nats, "stop", recording_stop) + + assert exchange_done_at_nats_stop == [True] # задачи обмена остановлены до NATS + assert all(task.done() for task in exchange.tasks) + assert not nats.is_connected + assert engine.disposed == 1 + + current = asyncio.current_task() + + async def no_leftover_tasks() -> bool: + return not {t for t in asyncio.all_tasks() - tasks_before if t is not current and not t.done()} + + await _wait_until(no_leftover_tasks, "все задачи lifespan завершены", timeout=READY_TIMEOUT) + job = await _row(session_factory, job_id) + (s3,) = await _s3_rows(session_factory, _result_key(file_id, job_id)) + assert (job["result_s3_object_id"], job["result_msg_id"]) == (s3["id"], f"result:{job_id}:1") + assert s3["bucket_role"] is S3BucketRole.results + + +# --- (2) два инстанса ядра в одной queue group ---------------------------------------------------------------------- + + +class _CountingHandler: + """Обёртка над настоящим `handle_message`: считает вызовы инстанса и незавершённые обработки.""" + + def __init__(self, handler: Callable[[Msg], Awaitable[None]]) -> None: + self._handler = handler + self.calls = 0 + self.in_flight = 0 + + async def __call__(self, msg: Msg) -> None: + self.calls += 1 + self.in_flight += 1 + try: + await self._handler(msg) + finally: + self.in_flight -= 1 + + +async def test_two_core_instances_handle_result_once( + session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs, service: _Service, result_subject: str +): + """Два `NatsConnection` в queue group `arm-core` на одном subject: результат обрабатывает ровно один инстанс, + задание меняется один раз; повторная доставка того же сообщения ничего не меняет.""" + settings = get_settings().OPENBAO.NATS + handlers = [ + _CountingHandler(partial(handle_message, session_factory=session_factory, mapper=NoopMapper())) + for _ in range(2) + ] + + def subscriber(handler: _CountingHandler) -> Callable[[NatsConnection], Awaitable[None]]: + async def subscribe(conn: NatsConnection) -> None: + await conn.subscribe_results(handler) + + return subscribe + + cores = [NatsConnection(settings, on_connected=subscriber(h), result_subject=result_subject) for h in handlers] + job_id = await jobs.make_queued_job( + status=RecognitionStatus.published, + published_at=datetime.now(UTC), + reply_deadline=datetime.now(UTC) + timedelta(hours=1), + ) + file_id = (await jobs.get(job_id)).file_id + key = _result_key(file_id, job_id) + result = envelope(job_id, file_id, "done", metrics={"duration_ms": 7}) + + async def settled() -> bool: + """Сервер доставил всё отправленное обоим инстансам (PING/PONG), и их обработчики завершились.""" + for core in cores: + await core._client.flush(timeout=2) + return all(h.in_flight == 0 for h in handlers) + + for core in cores: + await core.start() + try: + for core in cores: + if not await core.wait_ready(timeout=READY_TIMEOUT): + pytest.fail(f"NATS недоступен ({settings.NATS_URL}, .env.test)", pytrace=False) + subscriptions = await _queue_subscriptions(result_subject) + if subscriptions is not None: + assert [sub.get("qgroup") for sub in subscriptions] == ["arm-core", "arm-core"] + + await service.send(result, result_msg_id(job_id, 1)) + await _wait_until(partial(_status_is, session_factory, job_id, RecognitionStatus.done), "результат") + await _wait_until(settled, "обработка доставленных сообщений") + + assert sorted(h.calls for h in handlers) == [0, 1] # ровно один инстанс получил сообщение + job = await _row(session_factory, job_id) + s3_rows = await _s3_rows(session_factory, key) + assert len(s3_rows) == 1 + assert (job["result_msg_id"], job["result_s3_object_id"]) == (f"result:{job_id}:1", s3_rows[0]["id"]) + assert job["metrics"] == {"duration_ms": 7} + + # повторная доставка того же сообщения (at-least-once): снова один инстанс, без изменений + await service.send(result, result_msg_id(job_id, 1)) + + async def redelivered() -> bool: + return sum(h.calls for h in handlers) == 2 and await settled() # noqa: PLR2004 - две доставки + + await _wait_until(redelivered, "повторная доставка обработана") + assert await _row(session_factory, job_id) == job + assert await _s3_rows(session_factory, key) == s3_rows + finally: + for core in cores: + await core.stop() -- 2.47.2 From db3c1e5141681c6c2f978c987a91a8cd7b466327 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:01:41 +0400 Subject: [PATCH 96/97] =?UTF-8?q?docs:=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE=D1=82=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D1=8B=D1=85=20=D0=B2=D0=BE=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B2=20change=20slice-0-skeleton=20(I-5,=20R-2?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openspec/changes/slice-0-skeleton/open-questions-proposal.md — носитель 15 предложенных вопросов, правок статусов OQ-46/OQ-47 и кандидатов в DBML v5.14 для docs-PR (открывает владелец ../docs); архивируется вместе с change. Добавлены пункты финального ревью: аудит LOGIN/LOGIN_FAILED, CORS * и доверенные прокси, открытые /docs и /openapi.json, статус stored_file из inbox, срок ответа tabular, append-only на уровне БД, последовательный callback NATS и длинный маппер, проверка ключа results/{file_id}/{job_id}/ до чтения результата. Co-Authored-By: Claude Fable 5.1 --- .../open-questions-proposal.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 openspec/changes/slice-0-skeleton/open-questions-proposal.md diff --git a/openspec/changes/slice-0-skeleton/open-questions-proposal.md b/openspec/changes/slice-0-skeleton/open-questions-proposal.md new file mode 100644 index 0000000..9650979 --- /dev/null +++ b/openspec/changes/slice-0-skeleton/open-questions-proposal.md @@ -0,0 +1,64 @@ +> Назначение: предложения для `docs/context/07-open-questions.md` и DBML v5.14; docs-PR открывает владелец `../docs`. +> Дата: 2026-10-04 (срез 0, ветка `feat/1-slice-0-skeleton`; файл архивируется вместе с change `slice-0-skeleton`, рулинг R-26). +> Номера `OQ-??` присваиваются там, в реестре `07-open-questions.md` (подряд с OQ-48), а не здесь. + +# Предложения для `../docs/context/07-open-questions.md` (Срез 0, arm-core) — для PR в docs (R-8) + +Формат строк — как в таблице `07-open-questions.md`: `| ID | Вопрос | Откуда | Влияет на | Статус / допущение |`. +Занятые номера: OQ-01…OQ-47 (последний — OQ-47). Новые строки ниже помечены `OQ-?? (предл. N)`; при переносе — +нумеровать подряд с OQ-48 в порядке предложений. Ничего в `../docs` не правилось. + +## A. Правки существующих строк + +| ID | Вопрос | Откуда | Влияет на | Статус / допущение | +|---|---|---|---|---| +| OQ-46 (правка статуса) | Обязательны ли все ОСВ расширенного подхода (02, 03, 08, 44, 26, 91, 51, 52, 59, 80, 83, 86, 97, 001, 008, 009, 011) при `approach = EXTENDED`, или часть — «при наличии»? | ТЗ 5.1; `required_document_rule.is_mandatory` | срез 0 (seed), срез 3 (чек-лист) | **допущение принято и применено**: все 27 строк EXTENDED в seed (`api/seed/core/required_document_rules.yaml`, маркер `ASSUMPTION(OQ-46)`) — `is_mandatory = true`; смена — правка одного yaml; BASIC — 10 строк, все обязательные | +| OQ-47 (дополнение) | Источник полного плана счетов 1С для seed `chart_of_account`: в фикстурах файла нет, «план счетов из бандла макета» не передан. **Дополнение**: названия нестандартных субсчетов (66/67 `.06/.22/.24/.26`, 76.07.x, 76.08/.28/.38) в seed выведены по их роли в §4 правил, а не взяты из справочника 1С | `06-test-data.md` §6, `fixtures/README.md`; `api/seed/core/chart_of_accounts.csv` | срез 0 (seed), срез 1 (справочник счетов) | допущение: План счетов 94н (01–99, 001–011) + типовые субсчета 1С БП 3.0, упомянутые в §4/§6 (122 строки: 62 синтетических 94н, 11 забалансовых, 49 субсчетов/подсубсчетов); названия-заглушки заменить по справочнику 1С клиента; остальное добавляет администратор (ARCH-004) | + +## B. Новые вопросы + +| ID | Вопрос | Откуда | Влияет на | Статус / допущение | +|---|---|---|---|---| +| OQ-?? (предл. 1) | Правило элиминации № 1 (§4), сторона Б «76.07.1 − 76.07.5 − 76.07.9»: 76.07.5 и 76.07.9 не потомки 76.07.1, поэтому «вычитание» чисто информативно. Так и задумано? | `05-domain-rules.md` §4 (правило 1); seed `elimination_rules_v1.yaml` (T14.1) | срез 5 (пары ВГО) | допущение: исключения 76.07.5/76.07.9 на стороне Б хранятся как `is_excluded` и не влияют на сумму (76.07.1 их не содержит); алгоритм на них не опирается; подтвердить с методологами | +| OQ-?? (предл. 2) | Правило № 1, сторона А: резервный счёт 01 лежит в одном списке с 03 и 76.07.9 — приоритета нет. Как выбирать «03, а если у А нет ОСВ 03 — 01»? Что значит «результат в строку ППА» (см. OQ-11)? | §4 (правило 1: «01 — резервный»); seed (T14.2) | срез 5–6 | допущение: в seed `accounts: ["03", "76.07.9", "01"]` без порядка; fallback «берём 01, если нет ОСВ 03» — в `comment` правила; логику приоритета реализует срез 5, до решения — не реализуется | +| OQ-?? (предл. 3) | Составные коды статей КФО `1450/1550` (правило 1), `1310/1350` (правило 7, гудвилл) хранятся в `elimination_rule.article_2_line` одной строкой с «/». Как их потреблять: делить по «/» и подбирать ту строку, что есть в ББ Б, или хранить две строки? | §4 (правила 1, 7); seed (T14.3) | срез 5–6; DBML (note колонки) | допущение: хранится как в §4, потребитель делит по «/»; предложение — зафиксировать это в note `article_2_line` в DBML v5.14 | +| OQ-?? (предл. 4) | `required_document_rule`: уникальный индекс `(approach, document_type, account_id)` при nullable `account_id` допускает дубли строк для не-ОСВ документов (NULL в PostgreSQL различны). Добавить `NULLS NOT DISTINCT`? | DBML v5.13 (строка `Indexes { (approach, document_type, account_id) [unique] }`); ORM следует DBML буквально (T8.3) | DBML v5.14, срез 3 (чек-лист) | допущение: ORM/миграция 1:1 с DBML (без `NULLS NOT DISTINCT`); предложение v5.14: `[unique, note: 'NULLS NOT DISTINCT']`, как у `document` | +| OQ-?? (предл. 5) | Контракт v1.0, `recognition-result-message`: `schema_version` принимает только `1.x` (`^1\.\d+$`), а `result_schema_version` — любой MAJOR (`^\d+\.\d+$`). Намеренно (файл результата версионируется отдельно) или небрежность? | `docs/contracts/recognition-result-message.schema.json` (T20.7) | контракт v1.x, срез 2 (inbox/маппинг) | допущение: ядро повторяет схему буквально (любой MAJOR проходит валидацию); проверку совместимости MAJOR результата делает маппинг Среза 2; уточнить у владельца контракта | +| OQ-?? (предл. 6) | Контракт v1.0: схемы нигде не задают `additionalProperties: false` (кроме `columns` ОСВ). Считать ли неизвестные поля допустимыми расширениями? | `docs/contracts/*.schema.json` (T20) | контракт v1.x, все три сервиса | допущение: модели ядра `extra = "ignore"` (строже схемы быть нельзя — иначе ядро отвергнет совместимого пира); изменение — через MINOR контракта | +| OQ-?? (предл. 7) | Параметры JWT РАИСА неизвестны: JWKS URL / публичный ключ, `iss`, `aud`, имя claim с email. Что в продуктивном контуре? Сделать проверку `aud`/`iss` обязательной в проде? | ТЗ 8.1, `03-architecture.md` (SSO РАИСА); `core/auth.py` (T16.2) | срез 1+ (прод-конфигурация) | допущение: настраиваются `AUTH_JWKS_URL`/`AUTH_PUBLIC_KEY` (взаимоисключающие), `AUTH_ISSUER`, `AUTH_AUDIENCE`, `AUTH_EMAIL_CLAIM` (по умолчанию `email`); при пустых `AUTH_AUDIENCE`/`AUTH_ISSUER` `aud`/`iss` не проверяются; предложение: в проде сделать `aud` обязательным и предупреждать при старте, если пусто | +| OQ-?? (предл. 8) | В enum `audit_action` нет действия для смены статуса задания распознавания (`recognition_job`). Добавлять ли (DBML v5.14) и нужен ли аудит обмена вообще? | DBML v5.13 `audit_action`; решение R-7 (design #20) | DBML v5.14, срез 2 (обмен) | допущение: Срез 0 не пишет `audit_event` из обмена `recognition_job`; сервис аудита (`services/audit.py`) готов и покрыт тестами; если нужно — действие в v5.14 и вызовы в `recognition_exchange.py` | +| OQ-?? (предл. 9) | Append-only аудит обеспечен только на уровне кода (единственная точка записи, статический тест на UPDATE/DELETE по `AuditEvent`). Нужна ли защита на уровне БД (триггер или `REVOKE UPDATE, DELETE` для роли приложения)? | инвариант «Append-only аудит» (`../CLAUDE.md`); `services/audit.py` (задача 21) | DBML/миграции, платформа (права БД) | допущение: только код + тесты; решение по триггеру/правам — с командой платформы до прода | +| OQ-?? (предл. 10) | Счётчик повторных публикаций запроса (`ACK_TIMEOUT_SECONDS` × `OUTBOX_MAX_REPUBLISH`) хранится в `recognition_job.metrics.republish_count` (jsonb), колонки в DBML нет. Нужна ли колонка (v5.14)? | `services/recognition_exchange.py` (design #6, T23) | DBML v5.14, срез replay | допущение: в `metrics.republish_count`; `attempt` не растёт при повторе; при будущем replay счётчик и `reply_deadline` надо сбрасывать (T23.1) | +| OQ-?? (предл. 11) | Семантика сроков и ошибок обмена: (а) `reply_deadline` по умолчанию = `now + 5 мин` для обоих pipeline (`tabular`, `image`), если у задания NULL; (б) при невозможности собрать запрос по контракту задание уходит в `error` / `INVALID_REQUEST`; (в) при `ACK_TIMEOUT × (MAX_REPUBLISH+1) ≥ 5 мин` статус `dlq` недостижим (раньше сработает `timeout`). Верно ли? | DBML `reply_deadline` note («OCR — 5 мин»); `recognition_exchange.py` (T23.4) | срез 2–3, настройка таймаутов | допущение: как описано (дефолты 30 с × 3 укладываются в 5 мин); сроки по pipeline уточнить с командами парсера и шлюза | +| OQ-?? (предл. 12) | Email пользователя сопоставляется с `app_user.email` по `lower(email)`; при двух записях, различающихся регистром, — 403 (fail-closed). Добавить `CHECK`/уникальный индекс по `lower(email)` в DBML? | `core/auth.py`, `api/dependencies/auth.py` (R-17, design #29) | DBML v5.14, срез 1 (админ-экран) | допущение: уникальность `email` — регистрозависимая (как в DBML); регистронезависимая уникальность — на v5.14 | +| OQ-?? (предл. 13) | Seed `--dev` не защищён флагом `DEBUG`: создаёт dev-пользователей (в т.ч. `ADMIN`) на любой БД, на которую указывает `.env`, и перезаписывает существующего `app_user` с совпадающим email. Нужна ли защита? | `scripts/seed.py` (T15.2, T15.4) | срез 0 (процесс), прод-развёртывание | допущение: спека не требует защиты, `--dev` помечен «только для разработки»; предложение — отказ при `DEBUG=false` или явный флаг подтверждения | +| OQ-?? (предл. 14) | Неверный метод на существующий API-маршрут (например `POST /api/v1/me`) отдаёт 404 вместо 405: SPA-монтирование на `/` перехватывает частичное совпадение, а `/api/*` в нём принудительно 404. Приемлемо? | `app.py` (`SPAStaticFiles`, T18.2) | API-контракт (клиенты, фронт) | допущение: оставлено как есть (честный 404 для несуществующих путей важнее 405); при необходимости — отдельная правка | +| OQ-?? (предл. 15) | Решение по `web/package-lock.json` и уязвимостям: шаблон lock не содержит, `raisa-*` из Nexus — lock вне банка отличался бы; в Срезе 0 lock в `.gitignore` (R-23, design #35). `npm install` сообщает 6 high (npm audit) в зависимостях шаблона web. Коммитить ли lock; кто обновляет зависимости? | `web/` (T25.1, `task-26-report.md` C1) | платформа/фронт, до прода | допущение: lock не в репозитории, зависимости не менялись; решение — с платформенной командой (Nexus, политика audit) | + +## B2. Новые вопросы из финального ревью ветки (2026-10-04) + +| ID | Вопрос | Откуда | Влияет на | Статус / допущение | +|---|---|---|---|---| +| OQ-?? (предл. 16) | Аудит `LOGIN` / `LOGIN_FAILED` (действия есть в `audit_action`) для JWT-аутентификации: в каком срезе и что считать «входом» — первый запрос с токеном, каждый запрос, отказ проверки токена? | DBML `audit_action`; `core/auth.py`, `api/dependencies/auth.py` (финальное ревью) | срез 1 (вход, админ-экран), аудит | допущение: в Срезе 0 вход не аудируется; сервис аудита готов | +| OQ-?? (предл. 17) | Шаблонные CORS `allow_origins=["*"]` + `allow_credentials=True` и `ProxyHeadersMiddleware(trusted_hosts=["*"])`: в проде любой клиент может подставить `X-Forwarded-For`, и `audit_event.ip_address` недостоверен. Какие origins и доверенные прокси на платформе? | `app.py` (шаблон команды); финальное ревью | прод-конфигурация, достоверность аудита | допущение: оставлено как в шаблоне; перечни origins и доверенных прокси — с платформенной командой до прода | +| OQ-?? (предл. 18) | `/docs` и `/openapi.json` FastAPI открыты без аутентификации, в том числе в проде. Закрывать их (выключать при `DEBUG=false`) или оставить? | `app.py` (шаблон); финальное ревью | прод-конфигурация, безопасность | допущение: открыты, как в шаблоне; решение — с платформой/ИБ | +| OQ-?? (предл. 19) | Inbox не обновляет `stored_file.status` (`UPLOADED → PROCESSING → PROCESSED / ERROR`) и `stored_file.current_recognition_job_id` по результату задания. Кто и когда это делает в Срезе 2 — inbox или маппер, в той же транзакции? | DBML `stored_file`, `file_status`; `recognition_exchange.py` (финальное ревью) | срез 2 (загрузка и маппинг) | допущение: в Срезе 0 файл остаётся `UPLOADED`; решение — при проектировании маппера Среза 2 | +| OQ-?? (предл. 11, дополнение) | Срок ответа по умолчанию 5 мин для `tabular` взят по аналогии с OCR: в DBML значение есть только для OCR («OCR — 5 мин»), для парсера — нет. Какой срок у `tabular-parser`? | DBML `recognition_job.reply_deadline` note; `DEFAULT_REPLY_TIMEOUT` в `recognition_exchange.py` (финальное ревью) | срез 2, настройка таймаутов | допущение: 5 мин для обоих pipeline (см. предл. 11) | +| OQ-?? (предл. 9, дополнение) | Append-only аудита на уровне БД — финальное ревью повторно подняло вопрос; см. предл. 9 (триггер или `REVOKE UPDATE, DELETE` для роли приложения) | инвариант «Append-only аудит»; финальное ревью | миграции, права БД | допущение: как в предл. 9 — только код + тесты | +| OQ-?? (предл. 20) | Обработчик `recognition.result` выполняется последовательно в callback подписки NATS; длинный маппер Среза 2 задержит следующие сообщения, ack сервиса не успеет за `ACK_TIMEOUT_SECONDS` (30 с) — ложный republish, затем `dlq`. Предложение: развести быстрый inbox (только фиксация сообщения и статуса) и отдельный цикл маппинга результата | `core/nats.py` (`subscribe_results`), `recognition_exchange.py` (`handle_message`, `ResultMapper`); финальное ревью | срез 2 (маппинг), архитектура обмена (D-6) | допущение: в Срезе 0 `NoopMapper` — проблема не проявляется; решение — до реализации маппера Среза 2 | +| OQ-?? (предл. 21) | Перед чтением файла результата маппер должен проверять, что ключ начинается с `results/{file_id}/{job_id}/`, а бакет — бакет роли `results` (иначе сервис может «подсунуть» чужой объект). Закрепить в контракте и `03-architecture.md`? | контракт `recognition-result-message` (`result_s3`); `_register_result_object` в `recognition_exchange.py`; финальное ревью | срез 2 (маппинг), контракт v1.x | допущение: в Срезе 0 результат только регистрируется в `s3_object`, не читается; проверку реализует маппер Среза 2 | + +## C. Кандидаты в DBML v5.14 (сводка из вопросов выше) + +- `required_document_rule`: уникальный индекс `(approach, document_type, account_id)` — `NULLS NOT DISTINCT` (предл. 4). +- `audit_action`: действие для смены статуса `recognition_job` (предл. 8). +- `recognition_job`: колонка счётчика повторных публикаций вместо `metrics.republish_count` (предл. 10). +- `app_user`: уникальность по `lower(email)` (предл. 12). +- `elimination_rule.article_2_line`: note о составных кодах «1450/1550», «1310/1350» (предл. 3). +- OQ-45 (уже в реестре): `NU` в `osv_data_type` — Срез 0 не затрагивает. + +## D. Заметки для спек/контрактов (не OQ) + +- `contracts/recognition-result-message.schema.json`: `result_schema_version` vs `schema_version` (предл. 5), нет `additionalProperties: false` (предл. 6). +- `03-architecture.md` / контракт: ключ файла результата — только под `results/{file_id}/{job_id}/` в бакете роли `results`, проверка до чтения (предл. 21). +- `docs/dev/` (compose): тома общие для всех worktree, имя проекта `arm-ka`. -- 2.47.2 From 477f03787246ac26881e9841b25d17919f8bf6f2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:01:41 +0400 Subject: [PATCH 97/97] =?UTF-8?q?docs:=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B9=20=C2=AB=D0=A1=D0=B8=D1=81=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B5?= =?UTF-8?q?=C2=BB=20=D0=BF=D0=BE=D0=B4=20R-7,=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=2098=E2=80=93105=20=D0=B2=20design.md=20(I-4?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit audit-trail: WHEN сценария «Системное событие» — «системный процесс (без пользователя) вызывает сервис аудита»; NOTE: обмен через recognition_job в Срезе 0 аудит не пишет (R-7, design #20). design.md: R-25, R-26 и решения fix-волны финального ревью (№100–105). openspec validate --strict проходит. Co-Authored-By: Claude Fable 5.1 --- openspec/changes/slice-0-skeleton/design.md | 8 ++++++++ .../changes/slice-0-skeleton/specs/audit-trail/spec.md | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index c095439..59b18d4 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -253,3 +253,11 @@ Seed содержит синтетические счета 01–99 и заба | 95 | 2026-10-03 | T25: цикл миграций не гоняется отдельным шагом `check-full` (входит в `test-integration`; цель `migration-cycle` — для ручного запуска); `check-infra` — скрипт `api/scripts/check_infra.py` перед `test-integration`, читает адреса из `.env.test`; изменён `test_config.py` (порты) | дубль добавил бы ~10 с; сообщение «поднимите compose командой make up» | цикл миграций не виден отдельной строкой в выводе `check-full` | | 96 | 2026-10-03 | T26: живой обмен показан скриптом на ORM (не SQL), скрипт лежит рядом с отчётом и не добавлен в репозиторий; `result.json` в MinIO не загружался (NoopMapper только регистрирует `s3_object`); `make check-full` прогнан с `api/.env`, файл удалён | корректные enum и FK; содержимое результата Срез 0 не читает | Срез 2 дополнит демо загрузкой; демо-данные уничтожены `down -v` | | 97 | 2026-10-04 | T27: OQ-46 и OQ-47 уже есть в реестре — вместо новых номеров предложены правки статусов; новые вопросы — `OQ-?? (предл. N)`, 15 штук; в CLAUDE.md `domain/` помечена «появится в Срезе 3+»; «обязательная проверка CI» заменена на `make dbml-check` (входит в `check-full`); рекомендации (триггер, `NULLS NOT DISTINCT`, обязательный `aud`) — предложения, а не принятые решения | дубли нарушали бы реестр; каталога `api/src/domain/` в Срезе 0 нет; CI в Gitea нет | перенумерация при переносе в docs; правка двух строк и одной строки CLAUDE.md | +| 98 | 2026-10-04 | R-25: сценарий audit-trail «Системное событие» переформулирован под R-7: WHEN — «системный процесс (без пользователя) вызывает сервис аудита» (THEN прежний); под требованием — NOTE, что обмен через recognition_job в Срезе 0 аудит не пишет | прежний WHEN («consumer результатов фиксирует изменение задания») противоречил рулингу R-7 (design #20), спека и код расходились | при появлении аудита обмена в Срезе 2+ сценарий придётся вернуть к конкретному источнику | +| 99 | 2026-10-04 | R-26: `open-questions-proposal.md` хранится в `openspec/changes/slice-0-skeleton/` | DoD требует занести открытые вопросы; `../docs` ведёт другой контроллер; файл архивируется вместе с change и не теряется вместе с ledger | вопросы не попадут в реестр `07-open-questions.md`, если владелец `../docs` не откроет docs-PR | +| 100 | 2026-10-04 | FIX-wave (I-1): тип nullable jsonb — функция `nullable_jsonb()` в `models/base.py` (`JSONB(none_as_null=True)`), сверка ORM↔DBML не менялась (тип определяется по классу), добавлены самотест компаратора и тест-перечень всех nullable jsonb-колонок с флагом | `JSONB.__init__` без аннотаций — mypy strict запрещает вызов в моделях; одна точка вместо шести копий | новая jsonb-колонка без `nullable_jsonb()` — тест-перечень упадёт и подскажет | +| 101 | 2026-10-04 | FIX-wave (I-2): ошибка БД в обработчиках 409/500 логируется одной записью `logger.error` без текста исключения (не `logger.exception`): путь, метод, классы ошибок SQLAlchemy и драйвера, SQLSTATE, SQL с плейсхолдерами (≤ 200 символов), стек без сообщения | проверено: `str()` ошибки asyncpg содержит параметры запроса и DETAIL со значениями ключей (email, ИНН) — персональные данные в логе | при разборе инцидента значения ключей придётся восстанавливать по SQLSTATE и стеку | +| 102 | 2026-10-04 | FIX-wave (I-3): `SMB: SMBSettings = SMBSettings()` (по образцу `ServerSettings` шаблона); `reset_connection_cache` тоже только при заданном `SMB_HOST` и через `asyncio.to_thread`; порядок остановки — задачи обмена → NATS → S3 → SMB → `engine.dispose()`; старт ждёт регистрацию SMB (в потоке), а не уводит её в фон | без SMB сбрасывать нечего; блокирующие вызовы smbclient не должны держать цикл событий; фоновая регистрация усложнила бы остановку | настроенный, но молчащий SMB задержит старт до тайм-аута smbclient (60 с) | +| 103 | 2026-10-04 | FIX-wave (I-5): пункты финального ревью (д) срок ответа tabular и (е) append-only на уровне БД записаны как дополнения к предл. 11 и 9, а не новыми номерами; новые вопросы — предл. 16–21 в разделе B2 | реестр не должен получать дубли | при переносе в docs дополнения надо слить с исходными строками | +| 104 | 2026-10-04 | FIX-wave (I-7): в тесте реального lifespan подменяется `app_module.NatsConnection` на `partial(NatsConnection, result_subject=<уникальный>)` — настоящие `_create_nats` и `_subscribe_results` выполняются; движок БД — заглушка; `OUTBOX_POLL_SECONDS=1`; queue group проверяется по мониторингу NATS (`/connz?subs=detail`, порт 8222) только если он доступен, изменение в БД проверяется всегда | общий `recognition.result` в compose не трогаем; реальный пул БД сессии pytest закрывать нельзя | без мониторинга (другой порт) queue group подтверждается только косвенно — тестом двух инстансов | +| 105 | 2026-10-04 | FIX-wave (M-9, M-11, M-4): команды уровня кластера в тестах (включая `CREATE ROLE` в тесте предвыданной схемы) — через служебную БД `postgres`; в `.env.example` и `.env.test` добавлены `SERVER__HOST=127.0.0.1` и `SERVER__PORT=8000`, новый тест сверяет набор ключей двух файлов (раньше такого теста не было); `OUTBOX_MAX_REPUBLISH=0` допустим (сразу `dlq` после ack-таймаута) | тестам не нужна существующая `armka`; ключи двух env-файлов расходились бы незаметно; 0 повторов — осмысленная настройка | нет | diff --git a/openspec/changes/slice-0-skeleton/specs/audit-trail/spec.md b/openspec/changes/slice-0-skeleton/specs/audit-trail/spec.md index e2c3b84..85d0b8a 100644 --- a/openspec/changes/slice-0-skeleton/specs/audit-trail/spec.md +++ b/openspec/changes/slice-0-skeleton/specs/audit-trail/spec.md @@ -13,8 +13,10 @@ `description`, `before_value`, `after_value` (jsonb), `comment`, `result`, `changed_field`, `ip_address`, `user_agent`; `occurred_at` SHALL проставляться сервером (UTC). Запись SHALL выполняться в той же транзакции, что и изменение данных. +NOTE: В Срезе 0 обмен через recognition_job аудит не пишет (рулинг R-7, design #20); сервис готов к использованию Срезом 2+. + #### Scenario: Системное событие -- **WHEN** consumer результатов фиксирует изменение задания и вызывает аудит без пользователя +- **WHEN** системный процесс (без пользователя) вызывает сервис аудита - **THEN** в `audit_event` появляется строка с `user_id = NULL`, `before_value`/`after_value` содержат старый и новый статус #### Scenario: Откат транзакции -- 2.47.2