diff --git a/CLAUDE.md b/CLAUDE.md index 012b3d7..48e2334 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,20 +4,44 @@ ## Что это Ядро: справочники, файлы/документы, маппинг результатов распознавания, вычисления консолидации, маршрут согласования, -ЕХ, аудит, права. **Единственный писатель в PostgreSQL.** Монорепо `backend/` (FastAPI) + `frontend/` (React/TS) по -шаблону команды (из нулевого коммита репозитория `<имя>`, коммит ``). +ЕХ, аудит, права. **Единственный писатель в PostgreSQL.** Монорепо `api/` (FastAPI) + `web/` (React/TS) по шаблону +команды «Шаблон FastAPI» (нулевой коммит `RSHB/DKB_ARMKA@ba172f8`; импорт — `ba92222`, очистка от домена — `8e1f423`). ## Соглашения шаблона - +Полный текст — `../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/`; инфраструктура — + `core/` (config, clients, nats_callback, 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`, по умолчанию выключены. +- **БД** — схема **`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/` — без БД. +- **Линтеры** — 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`. ## Зависимости от соседей - Контракты: `../docs/contracts/recognition-result.schema.json` (вход из парсера/шлюза), `ex-payload-v1.schema.json`. -- Фикстуры: `../fixtures/` (env `ARM_KA_FIXTURES`); в CI — checkout `arm-ka-fixtures`. +- Фикстуры: `../fixtures/` (env `ARM_KA_FIXTURES`); при отсутствии тесты с маркером `fixtures` пропускаются с предупреждением. - NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`. ## Правила репозитория -- ORM 1:1 с `../docs/model/data-model-v5.13.dbml`; `scripts/check_dbml_vs_orm.py` — обязательная проверка CI. +- ORM 1:1 с `../docs/model/data-model-v5.13.dbml`; `api/scripts/check_dbml_vs_orm.py` — обязательная проверка CI. - Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring. -- Вычисляемые объекты консолидации — `backend/app/domain/`, без ORM-таблиц. +- Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц. - Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR. - OpenSpec: `openspec/` здесь; сквозные изменения — change в `../docs`. diff --git a/openspec/changes/slice-0-skeleton/.openspec.yaml b/openspec/changes/slice-0-skeleton/.openspec.yaml new file mode 100644 index 0000000..6a87d2b --- /dev/null +++ b/openspec/changes/slice-0-skeleton/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-03 diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md new file mode 100644 index 0000000..b5834c3 --- /dev/null +++ b/openspec/changes/slice-0-skeleton/design.md @@ -0,0 +1,171 @@ +# Design: Срез 0 — скелет ядра arm-core + +## Context + +См. `proposal.md` — Why. Что задано и не обсуждается здесь: + +- Шаблон команды (`../docs/context/03-architecture.md`, «Соглашения шаблона команды»): раскладка `api/src/`, pydantic-settings + с `OPENBAO__*`, схема `dkb`, Alembic с URL из настроек, DI ручными `Depends`, loguru, обработчики ошибок `{"detail": …}`, + SPA из `api/web/`, `DEBUG=true` отключает NATS/РАИСА. +- DBML v5.13 — источник имён и структур; поля вне DBML не вводятся. +- Контракт v1.0 — родительский change в `docs` (конверты сообщений, ack `processing`, файл результата, `Nats-Msg-Id`). +- Платформа (ответы 03.10.2026): Python **3.11**, PostgreSQL **16**, **NATS без JetStream**, Nexus недоступен до прода, + CI в Gitea нет — пайплайн платформы вне репозитория, параметры JWT РАИСА неизвестны. +- Инварианты CLAUDE.md: права — данные; единственный писатель в БД — ядро; append-only аудит; обмен только через `recognition_job`. + +## Goals / Non-Goals + +**Goals:** рабочая схема БД и миграции; пользователь с правами из БД через JWT; outbox/inbox на core NATS, доказанные +интеграционным тестом (включая потерю запроса и повторную публикацию); `make check-full`, который ловит расхождение +ORM ↔ DBML и невозвратные миграции. + +**Non-Goals:** см. proposal. Дополнительно: не оптимизируем производительность outbox (одна инстанция, 5-секундный +опрос достаточно для Среза 0–3); не проектируем multi-tenant. + +## Decisions + +### D-1. ORM: модуль на часть DBML, enum'ы отдельно, имена — буквально из DBML +`src/models/`: `enums.py` (27 `enum.Enum` + фабрика `pg_enum(name)` → `sqlalchemy.Enum(..., name=…, schema="dkb", +native_enum=True, values_callable=lambda e: [m.value for m in e])`, значения — строки DBML, включая кириллические +`"ЮЛ"/"ИП"`), далее `directories.py` (часть 01), `access.py` (02), `accounting.py` (03), `files.py` (04), +`osv.py` (05), `statements.py` (06), `consolidation.py` (07), `kfo.py` (08), `audit_ex.py` (09 + `required_document_rule`). +Имена классов — CamelCase от имени таблицы (`RegionalOffice`), `__tablename__` — как в DBML. Типы: `uuid` → `UUID(as_uuid=True)`, +`varchar` → `String` (без длины, как в DBML), `text` → `Text`, `numeric(p,s)` → `Numeric(p, s)`, `timestamptz` → +`DateTime(timezone=True)`, `date` → `Date`, `smallint/int/bigint` → `SmallInteger/Integer/BigInteger`, `boolean` → `Boolean`, +`jsonb` → `JSONB`. Nullability и `default` — буквально из DBML (`default` → `server_default`). Константа +`DBML_VERSION = "5.13"` в `models/__init__.py`. *Альтернатива* — генерировать ORM из DBML скриптом: отвергнуто, DBML +содержит notes-семантику (partial unique, NULLS NOT DISTINCT), которую проще выразить руками и проверять скриптом. + +### D-2. Миграция 0001 — одна ревизия, с нуля, откат до пустой схемы +`alembic/versions/0001_dbml_v5_13.py`: `CREATE SCHEMA IF NOT EXISTS dkb` (в `env.py`, до миграций), enum'ы → таблицы → +FK → индексы. Циклическая связь `stored_file.current_recognition_job_id ↔ recognition_job.file_id` — FK с `use_alter=True`. +Частичные уникальные индексы — `Index(..., unique=True, postgresql_where=text("is_current"))`; `document` — +`Index(..., unique=True, postgresql_nulls_not_distinct=True)` (PostgreSQL ≥ 15; на платформе 16). `downgrade()` удаляет +индексы, таблицы, enum'ы (`DROP TYPE`) и оставляет пустую схему. Автогенерация используется как черновик, итог правится +руками и проверяется шагом «upgrade → downgrade → upgrade» и `check_dbml_vs_orm.py` в `make check-full`. + +### D-3. `check_dbml_vs_orm.py` — pydbml + сравнение метаданных, notes читаются по шаблонам +Парсер — библиотека `pydbml` (dev-зависимость). Сравниваются: множество таблиц; для каждой — колонки (имя, тип по таблице +соответствий D-1, nullability, PK, unique, default), FK (целевая таблица.колонка), индексы (колонки, unique), enum'ы (имя и +упорядоченный список значений). Notes интерпретируются только по трём шаблонам: `partial UNIQUE WHERE `, +`NULLS NOT DISTINCT`, `[pk]` на составных индексах; остальное — текст. Вывод — список расхождений `таблица.колонка: ожидалось / +фактически`, код возврата 1 при любом расхождении. Скрипт также запускается как pytest-тест (`tests/test_dbml_parity.py`). +*Если `pydbml` окажется неустановим* — fallback: собственный парсер подмножества DBML (Table/Enum/Indexes/Ref), ~200 строк. + +### D-4. Seed — данные в файлах, upsert по натуральному ключу, разделение prod/dev +`api/seed/core/`: `regional_office.yaml` (только ГО), `roles.yaml`, `permissions.yaml`, `role_permissions.yaml`, +`chart_of_accounts.csv` (code, parent_code, name, kind, row_type, is_off_balance), `elimination_rules_v1.yaml` (правило + +счета сторон + исключения), `required_document_rules.yaml`. `api/seed/dev/`: `regional_offices.yaml` (2 РФ), +`users.yaml` (по одному на роль, email `@armka.local`). `python -m scripts.seed [--dev]` — идемпотентно: upsert по +`code`/`inn`/`email`/`(statement_type, rule_no, version_no)`; удаление не делает. Коды прав — `_` +(`DIRECTORY_EDIT`, `KFO_VERIFY`), при совпадении объекта и действия — одно слово (`EXPORT`). `role_permission.granted_by = NULL` +(системное действие, как в DBML для `*_by`). `elimination_rule.valid_from = 2024-01-01` — покрывает период фикстур. + +### D-5. Аутентификация: `TokenVerifier` + dev-эмитент, РАИСА — каркас до ответа платформы +`src/core/auth.py`: `Claims(subject, email, exp)`; протокол `TokenVerifier.verify(token) -> Claims`; +`DevHs256Verifier` (секрет `AUTH_DEV_SECRET`, включается `AUTH_DEV_ISSUER=true`) и `JwksVerifier` (`AUTH_JWKS_URL` | +`AUTH_PUBLIC_KEY`, `AUTH_ISSUER`, `AUTH_AUDIENCE`, `AUTH_EMAIL_CLAIM` = `email`; алгоритмы RS256/ES256) на `PyJWT`. +Зависимости в `api/dependencies/auth.py`: `current_user` (Bearer → verify → `app_user` по email, `is_active`, иначе 403), +`require(code)` (403, если `code ∉ effective_permissions`). `effective_permissions` — один запрос по `user_role ⋈ +role_permission ⋈ permission`. Dev-эмитент — `POST /api/v1/dev/token {email}` → HS256 JWT на 8 часов; роутер подключается +только при `AUTH_DEV_ISSUER=true` и `DEBUG=true` (в проде невозможен). Шаблонный middleware `raisa_fastapi_protected_api` +остаётся как в шаблоне (импорт при `DEBUG=false`; пакет недоступен вне банка до прода) — он защищает периметр, наш слой +извлекает личность. *Альтернатива* — только middleware РАИСА: отвергнуто, пакет недоступен и его API неизвестен. + +### D-6. Outbox/inbox на core NATS — фоновые задачи в lifespan, ack и повторы на уровне приложения +JetStream на платформе нет: нет durable consumer'ов, серверного дедупа, redelivery, `max_deliver`. Схема: +- `src/core/nats.py`: подключение (пустые `NATS_SEED/NATS_JWT` → без учётных данных; иначе nkeys/JWT как в шаблоне), + `publish(subject, payload, msg_id)` с заголовком `Nats-Msg-Id`, подписка `recognition.result` с queue group `arm-core` + (несколько инстанций ядра получают результат по одному разу). Stream'ов и consumer'ов нет; переподключение — встроенное + в `nats-py` (`max_reconnect_attempts=-1`, `reconnect_time_wait=2`). +- `src/services/recognition_exchange.py`: + - `publish_pending()` — `SELECT … FROM recognition_job WHERE status='queued' OR (status='published' AND published_at < + now() - ACK_TIMEOUT_SECONDS) FOR UPDATE SKIP LOCKED LIMIT 50`; для `queued` — первая публикация (`published`, + `published_at`, `request_msg_id = :`, `request_subject`); для `published` без ack — повторная + публикация с тем же `Nats-Msg-Id` и обновлением `published_at`, счётчик повторов — в `metrics.republish_count` + (jsonb, поле контракта метрик ядра); при `republish_count ≥ OUTBOX_MAX_REPUBLISH` → `status = dlq`, + `error_code = NO_ACK`. `attempt` при этом не растёт — обработка не начиналась. + - `handle_message(msg)` — конверт → pydantic; `status = processing` → задание `published → processing`, `started_at`, + `service_name/version` (повторный ack игнорируется; ack для задания не в `published` — лог); иначе результат: job не + найден → лог; `result_msg_id == Nats-Msg-Id` → дубликат, игнор; иначе `s3_object` (роль `results`, `created_by = + service.name`) → обновление job (`status`, `finished_at`, `metrics`, `error_*`, `result_schema_version`, + `result_s3_object_id`, `result_msg_id`) → commit. Ошибка БД — лог и повтор по следующему сообщению не предусмотрен + (брокер не переотправит): задание дойдёт до `timeout` по `reply_deadline` и будет replay'нуто — осознанный компромисс + без JetStream. + - `expire_deadlines()` — `processing` (и `published`) с `reply_deadline < now()` → `timeout`. + - Период опроса `OUTBOX_POLL_SECONDS=5`, `ACK_TIMEOUT_SECONDS=30`, `OUTBOX_MAX_REPUBLISH=3`, вахтёр — 30 с. +- Запуск — `asyncio.create_task` в lifespan при `NATS_ENABLED=true` (в dev NATS нужен, шаблонный `DEBUG` его выключал — + добавлен явный флаг). Маппинг документов — протокол `ResultMapper` с реализацией `NoopMapper` (Срез 2 заменит). +*Альтернатива* — отдельный процесс-воркер: отвергнуто для Среза 0 (одна инстанция; SKIP LOCKED делает многоинстансность +безопасной позже без изменений). *Если JetStream появится* — `publish` и подписка переезжают на JS API, остальное без изменений. + +### D-7. Тесты — PostgreSQL/NATS/MinIO из compose, маркеры `integration` и `fixtures` +`tests/conftest.py`: engine на `OPENBAO__DATABASE__DATABASE_URL` из `.env.test`; схема создаётся миграциями один раз на +сессию; каждый тест — в транзакции с откатом (`SAVEPOINT`-паттерн SQLAlchemy). `domain/` — без БД. Маркеры: `integration` +(нужны NATS/MinIO), `fixtures` (нужен `ARM_KA_FIXTURES`; пропуск с причиной). SQLite не используется: enum'ы, partial +unique, jsonb, `NULLS NOT DISTINCT`. + +### D-8. Линтеры и проверки: ruff + mypy в `api/pyproject.toml`, `make check` / `make check-full` +`ruff`: `line-length = 120`, правила `E,F,I,UP,B,PL` (подмножество pylint), формат по умолчанию (black-совместимый). +`mypy`: `strict = false` на старте, `check_untyped_defs = true`, плагин pydantic; `src/models` и `src/domain` — `strict = true`. +Шаблон декларировал black/isort/pylint без конфигов; откат — замена файла конфига. CI в Gitea нет: `make check` (без +Docker) и `make check-full` (compose, миграции, DBML, `web`) — по `../docs/dev/checks.md`; `make check-full` перед PR. + +### D-9. План счетов для seed — стандартный 94н + типовые субсчета 1С БП 3.0 +Полный план конкретной конфигурации клиента недоступен (fixtures README: «план счетов из бандла макета» отсутствует). +Seed содержит синтетические счета 01–99 и забалансовые 001–011 (Приказ 94н) с `kind` по плану и все субсчета, на которые +ссылаются §4 (правила), §6 (шахматка) и `required_document_rule`; `row_type` — по глубине кода; `parent_code` — префикс. +Редкие субсчета клиентов добавляет администратор (ARCH-004, v5.6). Проверка: все счета `elimination_rules_v1.yaml` и +`required_document_rules.yaml` резолвятся в `chart_of_account` (OQ-47). + +### D-10. API — префикс `/api/v1`, формат ошибок шаблона +Все новые роутеры под `APIRouter(prefix="/api/v1")` в `api/router.py`; ответы ошибок — `{"detail": …}` по-русски, как +в шаблонных обработчиках; 401 — нет/невалидный токен, 403 — пользователь не найден/неактивен/нет права. + +### D-11. Dev без Nexus: Python 3.11, фильтр `raisa_*`, `optionalDependencies` +`Dockerfile` — `python:3.11-slim`; `make venv` — `python3.11 -m venv` + `grep -v '^raisa_' requirements.txt | pip install -r +/dev/stdin` + `requirements-dev.txt`; `Dockerfile` ставит так же. `requirements.txt` для платформы не меняется. +`web/package.json`: `raisa-ui`, `raisa-fastapi-protected-web` → `optionalDependencies` (с Nexus ставятся, без — пропускаются). + +## Risks / Trade-offs + +- [Без JetStream результат, опубликованный при простое ядра, теряется] → `reply_deadline` → `timeout` → replay (Срез 3+); + для Среза 0 это задокументированный компромисс, покрыт интеграционным тестом «ядро не слушало». +- [Повторная публикация без ack дублирует запрос работающему сервису] → сервисы держат множество `job_id` в работе и + отвечают повторным ack; тот же `Nats-Msg-Id`. +- [Автогенерация Alembic пропускает partial-индексы и enum-изменения] → финальная ревизия пишется руками; шаг + «upgrade → downgrade → upgrade» и сверка с DBML в `make check-full`. +- [`pydbml` не разбирает какую-то конструкцию DBML (кириллические enum-значения в кавычках)] → проверка на первом шаге + задачи 2.5; при провале — собственный парсер (D-3). +- [Dev-эмитент попадёт в прод] → роутер подключается только при `AUTH_DEV_ISSUER=true and DEBUG=true`; тест проверяет, + что при `DEBUG=false` маршрута нет. +- [Шаблон создаёт `settings` при импорте] → тесты всегда идут с `.env.test`; `ENV_FILE` читается в `Settings.model_config`. +- [Нет CI — проверки можно забыть] → `make check-full` в DoD каждой задачи и в описании PR. + +## Migration Plan + +Пустая БД → `alembic upgrade head` → `python -m scripts.seed [--dev]`. Откат — `alembic downgrade base` (все объекты +удаляются). Данных для переноса нет. В проде seed `core` выполняется при каждом деплое (идемпотентен), `--dev` — никогда. + +## Open Questions + +Остались (в родительском change): версия Node, параметры JWT РАИСА (нужны к первому деплою; до этого dev-эмитент), +появится ли JetStream (меняет только `core/nats.py`). Ни один не меняет структуру задач. + +## Decisions made without discussion + +| № | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| 1 | 2026-10-03 | D-1: раскладка ORM по частям DBML, `varchar` → `String` без длины | DBML не задаёт длины; меньше шума в сверке | добавить длины — правка типов в одном месте + миграция | +| 2 | 2026-10-03 | D-2: одна миграция 0001 на весь DBML, FK-цикл через `use_alter` | срез 0 стартует с пустой БД | нет — дальнейшие изменения идут новыми ревизиями | +| 3 | 2026-10-03 | D-3: сверка через `pydbml`, notes по трём шаблонам | писать полный парсер дорого | собственный парсер ~200 строк | +| 4 | 2026-10-03 | D-4: коды прав `_`, `EXPORT` для совпадения; `valid_from` правил = 2024-01-01; seed разделён на core/dev | предсказуемость кодов; фикстуры 2024 г. | переименование seed-данных до первого деплоя бесплатно | +| 5 | 2026-10-03 | D-5: dev-эмитент HS256 за двумя флагами; РАИСА — конфигурируемый JWKS-каркас | пакет РАИСА недоступен, параметры неизвестны | дописать `RaisaVerifier` под реальные claims | +| 6 | 2026-10-03 | D-6: core NATS — queue group `arm-core`, ack `processing`, повторная публикация без ack 30 с × 3 → `dlq` с `error_code = NO_ACK`, счётчик в `metrics.republish_count`, `attempt` не растёт; ошибка БД при приёме результата не ретраится (доходит до `timeout`) | JetStream нет (ответ 03.10); DBML не имеет колонки для счётчика повторов | при JetStream — замена publisher/subscriber; при необходимости — колонка в DBML v5.14 | +| 7 | 2026-10-03 | D-6: `stored_file.status` в Срезе 0 не меняется, только `recognition_job` | жизненный цикл файла — Срез 2 | нет | +| 8 | 2026-10-03 | D-7: тесты только на PostgreSQL 16, транзакция с откатом | SQLite не воспроизводит схему | нет | +| 9 | 2026-10-03 | D-8: ruff + mypy вместо black/isort/pylint из бейджей README; `make check`/`make check-full` вместо CI | один инструмент, black-совместимый формат; CI в Gitea нет | замена конфига; YAML под пайплайн платформы при необходимости | +| 10 | 2026-10-03 | D-9: план счетов — 94н + типовые субсчета 1С, не полный план клиента | полного плана нет; ARCH-004 | дозагрузка CSV администратором или новым seed | +| 11 | 2026-10-03 | ASSUMPTION(OQ-46): все ОСВ расширенного подхода `is_mandatory = true` | иначе EXTENDED ничем не отличается от BASIC в чек-листе | правка одного yaml | +| 12 | 2026-10-03 | `Settings` читает `ENV_FILE`; `requirements-dev.txt` → `-r requirements.txt` + инструменты | шаблонный pytest.ini уже ссылается на `ENV_FILE`; dev-пины конфликтовали с prod | нет | +| 13 | 2026-10-03 | D-11: Python 3.11, фильтр `raisa_*` при dev-установке, npm `raisa-*` → `optionalDependencies` | ответы пользователя: 3.11, Nexus недоступен до прода | вернуть две строки в `dependencies` | diff --git a/openspec/changes/slice-0-skeleton/proposal.md b/openspec/changes/slice-0-skeleton/proposal.md new file mode 100644 index 0000000..0a1c281 --- /dev/null +++ b/openspec/changes/slice-0-skeleton/proposal.md @@ -0,0 +1,83 @@ +# Proposal: Срез 0 — скелет ядра arm-core + +**Срез:** 0 «Скелет» (`../docs/context/09-delivery-plan.md`). **Репозиторий:** arm-core. **Issue:** RSHB/arm-core#1. +**Родительский change:** `../docs/openspec/changes/slice-0-skeleton` (контракт v1.0, compose, проверки качества). +**Соседние:** `../tabular-parser/openspec/changes/slice-0-skeleton`, `../scan-gateway/openspec/changes/slice-0-skeleton`. + +## Why + +Ядро — единственный писатель в PostgreSQL и единственная точка обмена с сервисами распознавания. Пока нет схемы БД по +DBML v5.13, прав как данных и работающего outbox/inbox, ни один следующий срез (справочники, загрузка файлов, парсер) +не может начаться: каждому нужны таблицы, пользователь с правами и канал в NATS. Срез 0 делает всё это один раз, +без бизнес-логики, и доказывает end-to-end тестом на пустых обработчиках. + +## What Changes + +- **Инструменты шаблона**: `api/pyproject.toml` (ruff — формат, совместимый с black, и lint; mypy), `requirements-dev.txt` + переписан как `-r requirements.txt` + dev-инструменты (пины шаблона были рассинхронизированы), `Dockerfile` + (`python:3.11-slim` — версия платформы; строка `Dockerfile` убрана из `.gitignore`), `Makefile` с общими целями + `up / test / test-integration / lint / check / check-full` и своими `venv / migrate / seed`, `.env.example`, `.env.test`; + `Settings` читает путь к env-файлу из `ENV_FILE` (шаблонный `pytest.ini` уже задаёт его, но `config.py` игнорировал). + Без Nexus: `make venv` ставит `requirements.txt` без строк `raisa_*`; в `web/package.json` `raisa-ui` и + `raisa-fastapi-protected-web` → `optionalDependencies`. +- **Модель данных**: ORM 1:1 с `../docs/model/data-model-v5.13.dbml` — 43 таблицы, 27 enum'ов, все FK, уникальные и + частичные индексы (`related_group_member` WHERE is_current, `consolidation_lock` WHERE released_at IS NULL, + `document` NULLS NOT DISTINCT), схема `dkb`. Миграция `0001_dbml_v5_13` создаёт всё с нуля и полностью откатывается. + `api/scripts/check_dbml_vs_orm.py` сравнивает DBML и ORM-метаданные; расхождение — ошибка CI. +- **Seed** (`api/seed/`, идемпотентный `python -m scripts.seed`): `regional_office` (ГО), `role` (7 ролей ТЗ 8.1), + `permission` (16 сочетаний объект × действие из §10), `role_permission` по §10 (ASSUMPTION(OQ-16)), + `chart_of_account` (План счетов 94н + субсчета типовой 1С, упомянутые в §4/§6), `elimination_rule` v1 для ББ (§4, 8 правил) + с `elimination_rule_account`, `required_document_rule` для BASIC и EXTENDED (ASSUMPTION(OQ-46)); `--dev`: примеры РФ и + по одному `app_user` на роль. +- **Аутентификация и права**: `core/auth.py` — проверка JWT (`TokenVerifier`: dev HS256-эмитент за флагом + `AUTH_DEV_ISSUER`; каркас `RaisaVerifier` под JWKS/публичный ключ, параметры — вопрос к человеку), поиск `app_user` + по email, зависимости `current_user` и `require(permission_code)`, `GET /api/v1/me`, `POST /api/v1/dev/token` + (только при включённом флаге). Policy-слой (филиал/ЗО/статус) — не здесь (Срез 1+). +- **Обмен через `recognition_job`** (DECISION-4, **NATS без JetStream**): вендоренные схемы контракта `api/contracts/` + + pydantic-модели `src/contract/`; outbox-паблишер (`queued` → NATS → `published`, `Nats-Msg-Id = :`); + подписка queue group `arm-core` на `recognition.result`: ack сервиса (`status = processing`) → `processing`/`started_at`, + результат → дедуп по `result_msg_id`, обновление статуса/метрик, регистрация файла результата в `s3_object`; + повторная публикация запроса без ack (`ACK_TIMEOUT_SECONDS` = 30, до `OUTBOX_MAX_REPUBLISH` = 3 раз) → затем `dlq`; + вахтёр `reply_deadline` → `timeout`. Маппинг документов — точка расширения, реализуется в Срезе 2. +- **Аудит**: `services/audit.py` — единственный способ записать `audit_event` (append-only, `before/after` jsonb). +- **Проверки качества** (CI в Gitea нет, пайплайн платформы вне репозитория): `make check` — ruff, mypy, pytest без + Docker, идентичность контракта; `make check-full` — плюс интеграционные тесты против compose, миграции + upgrade → downgrade → upgrade, `check_dbml_vs_orm`, сборка образа, `web`: biome, tsc, rsbuild build. +- **Документация репозитория**: `README.md` заменяется описанием сервиса (команды, env, как поднять), `CLAUDE.md` — + раздел «Соглашения шаблона» (уже в ветке). + +## Capabilities + +### New Capabilities +- `data-model`: схема БД = DBML v5.13; миграции применяются с нуля и откатываются; сверка ORM ↔ DBML. +- `reference-data-seed`: идемпотентная загрузка справочников продукта (роли, права, матрица, план счетов, правила + элиминации, чек-лист) и dev-данных. +- `authn-authz`: вход по JWT, пользователь по email, эффективные права, зависимость `require(permission)`, `/api/v1/me`. +- `recognition-job-exchange`: outbox/inbox через `recognition_job`, идемпотентность, таймауты, DLQ, регистрация результата в `s3_object`. +- `audit-trail`: append-only запись событий `audit_event` через единый сервис. + +### Modified Capabilities + + +## Impact + +- **Таблицы DBML v5.13:** все 43 (ORM и миграция); данными заполняются `regional_office`, `role`, `permission`, + `role_permission`, `chart_of_account`, `elimination_rule`, `elimination_rule_account`, `required_document_rule`, + `app_user` (dev), `user_role` (dev); в рантайме Среза 0 изменяются `recognition_job`, `s3_object`, `audit_event`. + Новых полей и таблиц нет. +- **Параграфы правил:** §4 (seed правил элиминации), §9.4 (жизненный цикл `recognition_status`), §10 (матрица прав). +- **Зависимости (новые):** `PyJWT[crypto]`, `pydbml` (dev, сверка DBML), `ruff`, `mypy`, `pytest-cov`, `types-*`; + `nats-py` уже есть (core NATS, заголовки), `miniopy-async` уже есть. +- **Внешние системы:** NATS без JetStream (subjects `recognition.*`), MinIO/S3 (бакет `arm-ka`), PostgreSQL 16 + (`CREATE SCHEMA dkb`, `CREATE TYPE`). +- **Фронт:** код не меняется; `raisa-*` в `package.json` → `optionalDependencies`; проверки `web` входят в `make check-full`. + +## Non-goals + +- Загрузка файлов, `stored_file`/`document` жизненный цикл и маппинг результата в `osv_entry`/`financial_statement_*` (Срез 2). +- CRUD справочников и экраны администратора (Срез 1); policy-слой по филиалу/ЗО/статусу (Срез 1+). +- Реальная проверка JWT РАИСА (нужны параметры — Open Questions родительского change'а); в Срезе 0 — только dev-эмитент + и конфигурируемый каркас. +- Авто-элиминация ОФР (OQ-09) — правила ОФР в seed не входят. +- Полный план счетов конкретного клиента — только стандартный 94н + типовые субсчета 1С; остальное добавляет администратор (ARCH-004). +- Любые экраны фронта. diff --git a/openspec/changes/slice-0-skeleton/specs/audit-trail/spec.md b/openspec/changes/slice-0-skeleton/specs/audit-trail/spec.md new file mode 100644 index 0000000..e2c3b84 --- /dev/null +++ b/openspec/changes/slice-0-skeleton/specs/audit-trail/spec.md @@ -0,0 +1,30 @@ +# Spec Delta: audit-trail + +## Purpose + +Все действия из enum `audit_action` пишутся в `audit_event` одним способом — через сервис аудита; журнал только +дополняется, записи неизменяемы (ТЗ 8.2). + +## ADDED Requirements + +### Requirement: Единая точка записи +Сервис аудита SHALL предоставлять метод записи события с полями `action` (из enum `audit_action`), `section` +(из `audit_section`), `object_type`, `object_id`, `user_id` (NULL для системных действий), `consolidation_version_id`, +`description`, `before_value`, `after_value` (jsonb), `comment`, `result`, `changed_field`, `ip_address`, `user_agent`; +`occurred_at` SHALL проставляться сервером (UTC). Запись SHALL выполняться в той же транзакции, что и изменение данных. + +#### Scenario: Системное событие +- **WHEN** consumer результатов фиксирует изменение задания и вызывает аудит без пользователя +- **THEN** в `audit_event` появляется строка с `user_id = NULL`, `before_value`/`after_value` содержат старый и новый статус + +#### Scenario: Откат транзакции +- **WHEN** изменение данных и запись аудита выполняются в одной транзакции, и она откатывается +- **THEN** строка `audit_event` не сохраняется + +### Requirement: Журнал только дополняется +ORM-модель `audit_event` MUST NOT использоваться для `UPDATE` или `DELETE` в коде ядра; тест SHALL проверять, что в +`src/` нет обращений, изменяющих или удаляющих `audit_event`, а сервис аудита не имеет методов изменения. + +#### Scenario: Попытка изменить запись +- **WHEN** код вызывает обновление строки `audit_event` через сервис аудита +- **THEN** такого метода нет (ошибка на этапе типизации/теста), запись остаётся прежней diff --git a/openspec/changes/slice-0-skeleton/specs/authn-authz/spec.md b/openspec/changes/slice-0-skeleton/specs/authn-authz/spec.md new file mode 100644 index 0000000..27ddd5e --- /dev/null +++ b/openspec/changes/slice-0-skeleton/specs/authn-authz/spec.md @@ -0,0 +1,67 @@ +# Spec Delta: authn-authz + +## Purpose + +Вход в ядро по JWT внешнего SSO (РАИСА): пользователь находится по email в `app_user`, его эффективные права — объединение +прав его ролей из БД; любой защищённый маршрут объявляет требуемое право одной зависимостью. Для разработки — эмитент +токенов за флагом. + +## ADDED Requirements + +### Requirement: Личность из JWT, пользователь из БД +Защищённый маршрут SHALL требовать заголовок `Authorization: Bearer `. Ядро SHALL проверить подпись и срок токена +настроенным проверяющим (dev HS256 при `AUTH_DEV_ISSUER=true`, иначе JWKS/публичный ключ по `AUTH_*`), извлечь email из +claim `AUTH_EMAIL_CLAIM` (по умолчанию `email`) и найти `app_user` с этим `email` и `is_active = true`. Нет или невалидный +токен → 401; пользователь не найден или неактивен → 403. Тело ошибки — `{"detail": "<причина по-русски>"}`. + +#### Scenario: Валидный токен активного пользователя +- **WHEN** запрос несёт корректный токен с `email = ka_rf@armka.local`, и такой активный пользователь есть +- **THEN** маршрут выполняется от имени этого `app_user` + +#### Scenario: Пользователя нет в системе +- **WHEN** токен валиден, но `app_user` с таким email отсутствует +- **THEN** ответ 403, `detail` сообщает, что пользователь не зарегистрирован в АРМ + +#### Scenario: Истёкший или подделанный токен +- **WHEN** срок токена истёк или подпись не проходит +- **THEN** ответ 401, тело `{"detail": …}` + +### Requirement: Эффективные права и зависимость `require` +`effective_permissions(user)` SHALL быть объединением `permission.code` по всем ролям пользователя +(`user_role → role_permission → permission`); код роли в проверках MUST NOT использоваться. Зависимость +`require("")` SHALL возвращать 403, если кода нет в эффективных правах, и MUST NOT обращаться к БД +повторно в рамках одного запроса. + +#### Scenario: Право есть +- **WHEN** пользователь с ролью `KA_GO` вызывает маршрут с `require("KFO_VERIFY")` +- **THEN** маршрут выполняется + +#### Scenario: Права нет +- **WHEN** пользователь с ролью `EXPERT` вызывает маршрут с `require("DOCUMENT_EDIT")` +- **THEN** ответ 403, `detail` называет недостающее право + +#### Scenario: Две роли +- **WHEN** пользователю назначены роли `KA_RF` и `CURATOR_GO` +- **THEN** его эффективные права — объединение прав обеих ролей без дубликатов + +### Requirement: `GET /api/v1/me` +Маршрут SHALL возвращать текущего пользователя: `id`, `email`, `full_name`, `regional_office {id, name, is_head_office}`, +`business_segment`, `roles` (список `code`), `permissions` (отсортированный список `code`). Это единственный маршрут +Среза 0, доступный любому аутентифицированному пользователю без `require`. + +#### Scenario: Ответ для dev-пользователя +- **WHEN** `ka_rf@armka.local` (роль `KA_RF`) запрашивает `/api/v1/me` +- **THEN** ответ 200 содержит `roles = ["KA_RF"]` и `permissions`, равные правам `KA_RF` из матрицы §10 + +### Requirement: Dev-эмитент токенов только в режиме разработки +При `AUTH_DEV_ISSUER=true` **и** `DEBUG=true` ядро SHALL предоставлять `POST /api/v1/dev/token` с телом `{"email"}`, +возвращающий HS256 JWT (срок 8 часов) для существующего активного пользователя. При любом другом сочетании флагов +маршрут MUST NOT существовать (404), а dev-проверяющий MUST NOT принимать токены. + +#### Scenario: Получение dev-токена +- **WHEN** `AUTH_DEV_ISSUER=true`, `DEBUG=true` и запрошен токен для `admin@armka.local` +- **THEN** ответ 200 `{access_token, token_type: "bearer"}`, токен принимается `/api/v1/me` + +#### Scenario: Прод-конфигурация +- **WHEN** `DEBUG=false` +- **THEN** `POST /api/v1/dev/token` возвращает 404, токен, подписанный dev-секретом, отвергается с 401 diff --git a/openspec/changes/slice-0-skeleton/specs/data-model/spec.md b/openspec/changes/slice-0-skeleton/specs/data-model/spec.md new file mode 100644 index 0000000..70bd2a3 --- /dev/null +++ b/openspec/changes/slice-0-skeleton/specs/data-model/spec.md @@ -0,0 +1,57 @@ +# Spec Delta: data-model + +## Purpose + +Схема базы данных ядра совпадает с моделью данных DBML v5.13 один в один: таблицы, колонки, типы, enum'ы, ключи и +индексы; миграции применяются к пустой базе и полностью откатываются; расхождение между ORM и DBML невозможно пропустить. + +## ADDED Requirements + +### Requirement: Схема БД равна DBML v5.13 +ORM-метаданные SHALL содержать ровно 43 таблицы и 27 enum'ов из `../docs/model/data-model-v5.13.dbml` в схеме `dkb`; +имена таблиц, колонок, enum'ов и их значений SHALL совпадать с DBML буквально; nullability, PK, UNIQUE, FK, значения +по умолчанию и индексы SHALL соответствовать DBML, включая частичные уникальные индексы (`related_group_member` +WHERE `is_current`, `consolidation_lock` WHERE `released_at IS NULL`) и `NULLS NOT DISTINCT` на +`document (stored_file_id, document_type, osv_account_code)`. Поля, отсутствующие в DBML, MUST NOT существовать. + +#### Scenario: Сверка ORM с DBML +- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.13.dbml` +- **THEN** вывод пуст и код возврата 0 + +#### Scenario: Лишняя колонка в ORM +- **WHEN** в ORM добавлена колонка, которой нет в DBML (или удалена существующая) +- **THEN** скрипт печатает `таблица.колонка: отсутствует в DBML` (или `отсутствует в ORM`) и возвращает код 1 + +#### Scenario: Расхождение типа или nullability +- **WHEN** колонка `osv_entry.opening_debit` объявлена в ORM как `Numeric(18, 4)` или `NOT NULL` +- **THEN** скрипт сообщает `ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL` и возвращает код 1 + +### Requirement: Миграции применяются с нуля и откатываются +Единственная ревизия `0001` SHALL создавать схему `dkb` (если нет), все enum'ы, таблицы, ограничения и индексы на пустой +базе PostgreSQL 16 (требуется ≥ 15); `downgrade` SHALL удалять всё созданное, оставляя пустую схему; повторный `upgrade` после `downgrade` +SHALL проходить без ошибок. + +#### Scenario: Полный цикл +- **WHEN** на пустой БД выполняются `alembic upgrade head`, `alembic downgrade base`, `alembic upgrade head` +- **THEN** все три команды завершаются успешно, после последней в `information_schema.tables` схемы `dkb` 43 таблицы + и в `pg_type` 27 enum-типов + +#### Scenario: Автогенерация ничего не находит +- **WHEN** после `upgrade head` выполняется `alembic revision --autogenerate` на той же БД +- **THEN** сгенерированная ревизия не содержит операций (пустые `upgrade`/`downgrade`) + +### Requirement: Версия DBML зафиксирована в коде +Пакет моделей SHALL объявлять константу версии DBML (`5.13`); скрипт сверки SHALL сравнивать её с версией в шапке файла +DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна. + +#### Scenario: Новая версия DBML без обновления ORM +- **WHEN** в `docs/model/` появляется `data-model-v5.14.dbml`, а константа в ORM — `5.13` +- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц + +### Requirement: Деньги и проценты — точные типы +Все денежные колонки SHALL быть `numeric(18,2)`, проценты — `numeric(7,4)`/`numeric(9,4)`, уверенность — `numeric(5,2)`; +ORM SHALL отдавать их как `Decimal`, `float` в моделях MUST NOT встречаться. + +#### Scenario: Чтение денежной колонки +- **WHEN** из `osv_entry` читается строка со `closing_debit = 106311116.46` +- **THEN** значение имеет тип `Decimal` и равно `Decimal("106311116.46")` diff --git a/openspec/changes/slice-0-skeleton/specs/recognition-job-exchange/spec.md b/openspec/changes/slice-0-skeleton/specs/recognition-job-exchange/spec.md new file mode 100644 index 0000000..60c61fb --- /dev/null +++ b/openspec/changes/slice-0-skeleton/specs/recognition-job-exchange/spec.md @@ -0,0 +1,101 @@ +# Spec Delta: recognition-job-exchange + +## Purpose + +Единственная точка обмена ядра с сервисами распознавания — таблица `recognition_job` (DECISION-4): outbox публикует +запросы в NATS, inbox принимает подтверждения и результаты идемпотентно, потеря сообщений без JetStream компенсируется +повторной публикацией и таймаутами, файл результата регистрируется в `s3_object`. + +## ADDED Requirements + +### Requirement: Outbox публикует задания из `queued` +Фоновый публикатор SHALL с периодом `OUTBOX_POLL_SECONDS` (по умолчанию 5) выбирать задания `status = queued` +(с блокировкой `FOR UPDATE SKIP LOCKED`), формировать сообщение по `recognition-request.schema.json` v1.0 из +`recognition_job ⋈ stored_file ⋈ s3_object` и публиковать в subject по `pipeline` с заголовком +`Nats-Msg-Id = :`. После успешной публикации задание SHALL получить `status = published`, +`published_at`, `request_msg_id`, `request_subject`; при ошибке публикации задание SHALL остаться `queued`. + +#### Scenario: Публикация запроса парсеру +- **WHEN** в `recognition_job` вставлена строка `queued` с `pipeline = tabular`, связанная со `stored_file` (XLSX) и его `s3_object` +- **THEN** в течение 10 с в `recognition.tabular.request` появляется сообщение, проходящее схему запроса, с `job_id`, + `file_id`, `s3.key` исходника, `reply_deadline`, `attempt = 1`, а строка переходит в `published` с заполненным `request_msg_id` + +#### Scenario: NATS недоступен +- **WHEN** публикация завершается ошибкой соединения +- **THEN** задание остаётся `queued`, ошибка залогирована, следующий цикл повторяет публикацию + +### Requirement: Ack сервиса переводит задание в `processing` +Получив в `recognition.result` конверт со `status = processing` для задания в `published`, ядро SHALL установить +`status = processing`, `started_at`, `service_name`, `service_version`. Повторный ack для задания в `processing` SHALL +игнорироваться; ack для задания в другом статусе SHALL логироваться и не менять задание. + +#### Scenario: Сервис подтвердил +- **WHEN** сервис опубликовал конверт `processing` с `job_id` опубликованного задания +- **THEN** задание имеет `status = processing`, `started_at` заполнен, `service_name = tabular-parser` + +### Requirement: Запрос без ack публикуется повторно, затем — `dlq` +Если задание находится в `published` дольше `ACK_TIMEOUT_SECONDS` (по умолчанию 30) без ack, публикатор SHALL +опубликовать запрос повторно с тем же `Nats-Msg-Id` и тем же `attempt`, обновив `published_at` и увеличив счётчик +`metrics.republish_count`. После `OUTBOX_MAX_REPUBLISH` (по умолчанию 3) повторов без ack задание SHALL получить +`status = dlq`, `error_code = NO_ACK`, `finished_at`. + +#### Scenario: Сервис недоступен +- **WHEN** ни один инстанс `tabular-parser` не подписан, задание опубликовано +- **THEN** запрос публикуется повторно примерно каждые 30 с; после трёх повторов задание имеет `status = dlq`, + `error_code = NO_ACK`, `request_msg_id` не изменился + +#### Scenario: Сервис появился после первого повтора +- **WHEN** сервис подписался между первой и второй публикацией и прислал ack +- **THEN** задание переходит в `processing`, дальнейшие повторы не выполняются + +### Requirement: Inbox принимает результат идемпотентно +Для конверта со `status ∈ {done, needs_check, error}` ядро SHALL валидировать его по +`recognition-result-message.schema.json`, найти задание по `job_id` и: если `result_msg_id` задания уже равен +`Nats-Msg-Id` сообщения — ничего не менять; иначе зарегистрировать файл результата в `s3_object` (`bucket_role = results`, +`created_by = `; только при наличии `result_s3`), обновить задание (`status`, `service_name`, +`service_version`, `finished_at`, `metrics`, `error_code`, `error_message`, `result_schema_version`, `result_s3_object_id`, +`result_msg_id`) и зафиксировать транзакцию. Неизвестный `job_id` SHALL быть залогирован. Результат для задания, уже +находящегося в финальном статусе (`done`, `error`, `timeout`, `dlq`), SHALL логироваться и не применяться. + +#### Scenario: Успешный результат +- **WHEN** сервис публикует конверт `status = done` с `result_s3 = {arm-ka, results///result.json}` и + `Nats-Msg-Id = result::1` +- **THEN** задание получает `status = done`, `result_msg_id = result::1`, `result_s3_object_id` указывает на новую + строку `s3_object` с этим ключом, `finished_at` заполнен + +#### Scenario: Повторная доставка +- **WHEN** то же сообщение получено второй раз +- **THEN** строка `recognition_job` и `s3_object` не меняются + +#### Scenario: Ошибка сервиса +- **WHEN** конверт содержит `status = error`, `error = {NOT_IMPLEMENTED, …}` без `result_s3` +- **THEN** задание получает `status = error`, `error_code = NOT_IMPLEMENTED`, `error_message` из конверта, `result_s3_object_id` пуст + +#### Scenario: Результат пришёл после таймаута +- **WHEN** задание уже `timeout`, и приходит конверт `done` +- **THEN** задание не меняется, событие залогировано + +### Requirement: Таймаут ожидания результата +Фоновый вахтёр SHALL с периодом 30 с переводить задания `processing` и `published` с `reply_deadline < now()` в +`status = timeout`, `error_code = TIMEOUT`, `finished_at = now()`. Replay задания (новая попытка, `attempt + 1`) — вне Среза 0. + +#### Scenario: Сервис взял, но не ответил +- **WHEN** задание в `processing` и `reply_deadline` прошёл без результата +- **THEN** не позднее чем через 30 с задание имеет `status = timeout` + +### Requirement: Соединение с NATS по шаблону, без JetStream +Ядро SHALL подключаться к NATS с nkeys-seed и JWT, если заданы `OPENBAO__NATS__NATS_SEED/NATS_JWT`, и без учётных данных, +если они пусты; подписываться на `recognition.result` в queue group `arm-core`; переподключаться автоматически. +Stream'ы и consumer'ы JetStream MUST NOT требоваться. Фоновые задачи SHALL запускаться при `NATS_ENABLED=true`. + +#### Scenario: Dev-окружение compose +- **WHEN** ядро стартует с `DEBUG=true`, `NATS_ENABLED=true` и пустыми учётными данными против NATS без JetStream +- **THEN** подписка активна, `/healthcheck` отдаёт `nats = ok`, в логах нет ошибок подключения + +#### Scenario: Два инстанса ядра +- **WHEN** запущены два инстанса с `NATS_ENABLED=true` и опубликован один результат +- **THEN** результат обработан ровно одним инстансом (queue group), строка задания изменена один раз + +#### Scenario: NATS выключен +- **WHEN** `NATS_ENABLED=false` +- **THEN** приложение стартует, фоновые задачи не запускаются, `/healthcheck` отвечает 200 с `nats = off` diff --git a/openspec/changes/slice-0-skeleton/specs/reference-data-seed/spec.md b/openspec/changes/slice-0-skeleton/specs/reference-data-seed/spec.md new file mode 100644 index 0000000..b4bb891 --- /dev/null +++ b/openspec/changes/slice-0-skeleton/specs/reference-data-seed/spec.md @@ -0,0 +1,59 @@ +# Spec Delta: reference-data-seed + +## Purpose + +Справочники продукта (роли, права и их матрица, план счетов, правила элиминации, состав обязательных документов) и +dev-данные загружаются из файлов репозитория одной идемпотентной командой; повторный запуск ничего не дублирует. + +## ADDED Requirements + +### Requirement: Идемпотентный seed справочников продукта +Команда `python -m scripts.seed` (из `api/`) SHALL загрузить из `api/seed/core/`: `regional_office` (Головной офис, +`is_head_office = true`), `role` — 7 системных ролей ТЗ 8.1 (`KA_RF`, `KA_GO`, `RKA`, `CLIENT_MANAGER`, `CURATOR_GO`, +`EXPERT`, `ADMIN`, `is_system = true`), `permission` — 16 сочетаний объект × действие из §10 правил с кодами +`_` (`EXPORT` при совпадении), `role_permission` по матрице §10 (ASSUMPTION(OQ-16)), `chart_of_account`, +`elimination_rule` + `elimination_rule_account` версии 1 для ББ (§4), `required_document_rule` для `BASIC` и `EXTENDED`. +Записи SHALL вставляться или обновляться по натуральному ключу; удаление MUST NOT выполняться. + +#### Scenario: Первый запуск на пустой схеме +- **WHEN** после `alembic upgrade head` выполняется `python -m scripts.seed` +- **THEN** в `role` 7 строк, в `permission` 16, в `role_permission` — число строк равно сумме прав по матрице §10, + в `elimination_rule` 8 строк с `statement_type = balance`, `version_no = 1`, в `required_document_rule` для `BASIC` + 10 строк (BALANCE, INCOME, OSV × 01, 58, 60, 62, 66, 67, 76, 90) и для `EXTENDED` 27 строк + +#### Scenario: Повторный запуск +- **WHEN** `python -m scripts.seed` выполняется второй раз без изменения файлов +- **THEN** число строк во всех затронутых таблицах не меняется, команда завершается с кодом 0 + +#### Scenario: Изменение названия роли в файле +- **WHEN** в `roles.yaml` изменено `name` роли `EXPERT` и seed запущен снова +- **THEN** строка `role` с `code = EXPERT` обновлена, новая не создана + +### Requirement: Счета правил и чек-листа существуют в плане счетов +Все коды счетов, на которые ссылаются `elimination_rules_v1.yaml` и `required_document_rules.yaml`, SHALL резолвиться в +`chart_of_account.code` после seed; `elimination_rule_account.is_excluded = true` SHALL быть у исключений вида +«76 кроме 76.07.5, 76.07.9»; `chart_of_account.kind` SHALL быть задан для всех синтетических счетов, `is_off_balance = true` +для 001–011. + +#### Scenario: Правило № 6 (основная пара) +- **WHEN** seed завершён +- **THEN** у правила `rule_no = 6` сторона 1 содержит счета 60.02, 60.22, 60.32, 62.01, 62.03, 62.21, 62.31, 76 и исключения + 76.07.5, 76.07.9; сторона 2 — 60.01, 60.03, 60.21, 60.31, 62.02, 62.22, 62.32, 76 и исключение 76.07.1; + `elimination_mode = min`, `measure = closing`, `side_1 = D` + +#### Scenario: Ссылка на несуществующий счёт +- **WHEN** в файл правил добавлен счёт, которого нет в `chart_of_accounts.csv` +- **THEN** seed падает до записи в БД с сообщением, какой счёт и в каком правиле не найден + +### Requirement: Dev-данные только по явному флагу +`python -m scripts.seed --dev` SHALL дополнительно загрузить из `api/seed/dev/`: примеры региональных филиалов и по одному +`app_user` на каждую системную роль (`@armka.local`, активные, с назначенной ролью через `user_role`). Без флага +`--dev` эти данные MUST NOT загружаться. + +#### Scenario: Dev-пользователи +- **WHEN** выполнен `python -m scripts.seed --dev` +- **THEN** в `app_user` 7 строк с email вида `ka_rf@armka.local`, каждая связана ровно с одной ролью через `user_role` + +#### Scenario: Прод-режим +- **WHEN** выполнен `python -m scripts.seed` без `--dev` +- **THEN** таблица `app_user` пуста, `regional_office` содержит только Головной офис diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md new file mode 100644 index 0000000..d226f3d --- /dev/null +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -0,0 +1,114 @@ +# Tasks: Срез 0 — скелет ядра arm-core + +Репозиторий — arm-core, если не указано иное; все команды — из `api/`. Порядок — по зависимостям. Каждая задача ≤ половины +дня, с указанием файлов и способа проверки. Зависимости от других репозиториев: 5.1 ждёт контракт v1.0 +(`docs` задачи 1.1–1.4); 2.x нужна инфраструктура compose (`docs` 2.1). CI нет — проверка каждой задачи локальная. + +## 1. Инструменты и окружение шаблона + +- [ ] 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`; + проверка: `make venv && make lint` зелёные на текущем каркасе без доступа к Nexus +- [ ] 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 + import settings"` читает тестовые значения +- [ ] 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` собирает +- [ ] 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. Модель данных (spec `data-model`) + +- [ ] 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`, + `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`; + 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, + `_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, индексов с + 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` (черновик + 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`, миграции один раз на сессию, транзакция с откатом на тест, маркеры + `integration`/`fixtures` (skip с причиной при отсутствии `ARM_KA_FIXTURES`); проверка: тест чтения `Decimal` из + `osv_entry.closing_debit` проходит; `make check` зелёный + +## 3. Seed (spec `reference-data-seed`) + +- [ ] 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`) + + субсчета, упомянутые в §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 со счетами сторон и исключениями; + `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`); + `make seed` / `make seed-dev`; проверка: без `--dev` `app_user` пуст; с `--dev` — 7 пользователей по одной роли + +## 4. Аутентификация и права (spec `authn-authz`) + +- [ ] 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` + (`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`, подключается + только при `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, + получение dev-токена, запуск против compose; проверка: новый разработчик проходит README до зелёного `/me` + +## 5. Обмен через `recognition_job` и аудит (spec `recognition-job-exchange`, `audit-trail`) + +- [ ] 5.1 `api/contracts/` — копии трёх схем из `../docs/contracts/` + `examples/`; `src/contract/` — pydantic-модели + запроса, конверта (статусы `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`, + откат транзакции не оставляет записи; статический тест — в `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`; проверка: интеграционный тест + против compose-NATS (без JetStream) — публикация и получение сообщения с заголовком; два подписчика в одной группе + получают сообщение один раз +- [ ] 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` + + `recognition_job(queued)` → сообщение получено тестовым подписчиком и проходит схему → строка `published`; без ack + (с укороченными таймаутами) — три повтора с одним `Nats-Msg-Id` и `dlq`; задание с прошедшим `reply_deadline` → `timeout` +- [ ] 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` игнорируется + +## 6. Проверки и завершение + +- [ ] 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` + с dev-токеном возвращает права; `openspec validate slice-0-skeleton --strict`; проверка: всё зелёное, вывод + `make check-full` вписан в описание PR +- [ ] 6.3 Дополнить `CLAUDE.md` (команды `make`), зафиксировать новые допущения в `../docs/context/07-open-questions.md` + через PR в docs (OQ-46 и всё, что всплыло); проверка: ссылки из README/CLAUDE.md ведут на существующие файлы diff --git a/openspec/config.yaml b/openspec/config.yaml index 348f8bf..0ef971c 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -7,7 +7,7 @@ context: | ../docs/context/05-domain-rules.md → страницы ТЗ в ../docs/source/tz (черновик, старые имена v4). Открытые вопросы — ../docs/context/07-open-questions.md; план срезов — ../docs/context/09-delivery-plan.md. Фикстуры — ../fixtures (реальные данные, не спецификация формата). - Стек: Python 3.12, FastAPI, SQLAlchemy 2 async + Alembic, PostgreSQL, pydantic v2, NATS JetStream, S3 (MinIO), + Стек: Python 3.11, FastAPI, SQLAlchemy 2 async + Alembic, PostgreSQL 16, pydantic v2, NATS без JetStream (ack и повторы — на уровне приложения), S3 (MinIO, miniopy-async), loguru; фронт — React + TypeScript, rsbuild, antd, Redux Toolkit, biome. Язык документов и коммитов — русский, идентификаторы — английские, как в DBML. @@ -20,7 +20,7 @@ rules: - Любое допущение по открытому вопросу помечать ASSUMPTION(OQ-nn) specs: - Сценарии на реальных фикстурах из ../fixtures, синтетика — только для форматов, которых там нет - - Для парсеров: ни одного требования, завязанного на букву колонки, номер строки или имя листа + - "Для парсеров: ни одного требования, завязанного на букву колонки, номер строки или имя листа" design: - Вычисляемые объекты консолидации (сводная ОСВ, пары ВГО, шахматка) — чистые функции в domain/, без ORM-таблиц - Деньги — Decimal / numeric(18,2); единицы измерения (руб. / тыс. руб.) указывать явно @@ -37,4 +37,4 @@ operations: - После изменений прогонять линтеры и тесты репозитория; для web — tsc, biome, rsbuild build archive: guidance: - - Перед архивированием: DoD среза из ../CLAUDE.md выполнен, открытые вопросы занесены в 07-open-questions.md + - "Перед архивированием: DoD среза из ../CLAUDE.md выполнен, открытые вопросы занесены в 07-open-questions.md"