From db7df33517bfa244aa6224cae16cf09e32ca1703 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 19:44:37 +0400 Subject: [PATCH] =?UTF-8?q?chore(openspec):=20=D0=B0=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D0=B2=20change=20slice-0-skeleton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Срез 0 реализован и влит (PR #3), change перенесён в openspec/changes/archive, спеки — в openspec/specs. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- .../.openspec.yaml | 0 .../2026-10-04-slice-0-skeleton}/design.md | 0 .../open-questions-proposal.md | 0 .../2026-10-04-slice-0-skeleton}/proposal.md | 0 .../specs/audit-trail/spec.md | 0 .../specs/authn-authz/spec.md | 0 .../specs/data-model/spec.md | 0 .../specs/recognition-job-exchange/spec.md | 0 .../specs/reference-data-seed/spec.md | 0 .../2026-10-04-slice-0-skeleton}/tasks.md | 0 openspec/specs/audit-trail/spec.md | 31 ++++++ openspec/specs/authn-authz/spec.md | 66 ++++++++++++ openspec/specs/data-model/spec.md | 56 ++++++++++ .../specs/recognition-job-exchange/spec.md | 100 ++++++++++++++++++ openspec/specs/reference-data-seed/spec.md | 58 ++++++++++ 15 files changed, 311 insertions(+) rename openspec/changes/{slice-0-skeleton => archive/2026-10-04-slice-0-skeleton}/.openspec.yaml (100%) rename openspec/changes/{slice-0-skeleton => archive/2026-10-04-slice-0-skeleton}/design.md (100%) rename openspec/changes/{slice-0-skeleton => archive/2026-10-04-slice-0-skeleton}/open-questions-proposal.md (100%) rename openspec/changes/{slice-0-skeleton => archive/2026-10-04-slice-0-skeleton}/proposal.md (100%) rename openspec/changes/{slice-0-skeleton => archive/2026-10-04-slice-0-skeleton}/specs/audit-trail/spec.md (100%) rename openspec/changes/{slice-0-skeleton => archive/2026-10-04-slice-0-skeleton}/specs/authn-authz/spec.md (100%) rename openspec/changes/{slice-0-skeleton => archive/2026-10-04-slice-0-skeleton}/specs/data-model/spec.md (100%) rename openspec/changes/{slice-0-skeleton => archive/2026-10-04-slice-0-skeleton}/specs/recognition-job-exchange/spec.md (100%) rename openspec/changes/{slice-0-skeleton => archive/2026-10-04-slice-0-skeleton}/specs/reference-data-seed/spec.md (100%) rename openspec/changes/{slice-0-skeleton => archive/2026-10-04-slice-0-skeleton}/tasks.md (100%) create mode 100644 openspec/specs/audit-trail/spec.md create mode 100644 openspec/specs/authn-authz/spec.md create mode 100644 openspec/specs/data-model/spec.md create mode 100644 openspec/specs/recognition-job-exchange/spec.md create mode 100644 openspec/specs/reference-data-seed/spec.md diff --git a/openspec/changes/slice-0-skeleton/.openspec.yaml b/openspec/changes/archive/2026-10-04-slice-0-skeleton/.openspec.yaml similarity index 100% rename from openspec/changes/slice-0-skeleton/.openspec.yaml rename to openspec/changes/archive/2026-10-04-slice-0-skeleton/.openspec.yaml diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/archive/2026-10-04-slice-0-skeleton/design.md similarity index 100% rename from openspec/changes/slice-0-skeleton/design.md rename to openspec/changes/archive/2026-10-04-slice-0-skeleton/design.md diff --git a/openspec/changes/slice-0-skeleton/open-questions-proposal.md b/openspec/changes/archive/2026-10-04-slice-0-skeleton/open-questions-proposal.md similarity index 100% rename from openspec/changes/slice-0-skeleton/open-questions-proposal.md rename to openspec/changes/archive/2026-10-04-slice-0-skeleton/open-questions-proposal.md diff --git a/openspec/changes/slice-0-skeleton/proposal.md b/openspec/changes/archive/2026-10-04-slice-0-skeleton/proposal.md similarity index 100% rename from openspec/changes/slice-0-skeleton/proposal.md rename to openspec/changes/archive/2026-10-04-slice-0-skeleton/proposal.md diff --git a/openspec/changes/slice-0-skeleton/specs/audit-trail/spec.md b/openspec/changes/archive/2026-10-04-slice-0-skeleton/specs/audit-trail/spec.md similarity index 100% rename from openspec/changes/slice-0-skeleton/specs/audit-trail/spec.md rename to openspec/changes/archive/2026-10-04-slice-0-skeleton/specs/audit-trail/spec.md diff --git a/openspec/changes/slice-0-skeleton/specs/authn-authz/spec.md b/openspec/changes/archive/2026-10-04-slice-0-skeleton/specs/authn-authz/spec.md similarity index 100% rename from openspec/changes/slice-0-skeleton/specs/authn-authz/spec.md rename to openspec/changes/archive/2026-10-04-slice-0-skeleton/specs/authn-authz/spec.md diff --git a/openspec/changes/slice-0-skeleton/specs/data-model/spec.md b/openspec/changes/archive/2026-10-04-slice-0-skeleton/specs/data-model/spec.md similarity index 100% rename from openspec/changes/slice-0-skeleton/specs/data-model/spec.md rename to openspec/changes/archive/2026-10-04-slice-0-skeleton/specs/data-model/spec.md diff --git a/openspec/changes/slice-0-skeleton/specs/recognition-job-exchange/spec.md b/openspec/changes/archive/2026-10-04-slice-0-skeleton/specs/recognition-job-exchange/spec.md similarity index 100% rename from openspec/changes/slice-0-skeleton/specs/recognition-job-exchange/spec.md rename to openspec/changes/archive/2026-10-04-slice-0-skeleton/specs/recognition-job-exchange/spec.md diff --git a/openspec/changes/slice-0-skeleton/specs/reference-data-seed/spec.md b/openspec/changes/archive/2026-10-04-slice-0-skeleton/specs/reference-data-seed/spec.md similarity index 100% rename from openspec/changes/slice-0-skeleton/specs/reference-data-seed/spec.md rename to openspec/changes/archive/2026-10-04-slice-0-skeleton/specs/reference-data-seed/spec.md diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/archive/2026-10-04-slice-0-skeleton/tasks.md similarity index 100% rename from openspec/changes/slice-0-skeleton/tasks.md rename to openspec/changes/archive/2026-10-04-slice-0-skeleton/tasks.md diff --git a/openspec/specs/audit-trail/spec.md b/openspec/specs/audit-trail/spec.md new file mode 100644 index 0000000..b943bc1 --- /dev/null +++ b/openspec/specs/audit-trail/spec.md @@ -0,0 +1,31 @@ +# audit-trail Specification + +## Purpose +Все действия из enum `audit_action` пишутся в `audit_event` одним способом — через сервис аудита; журнал только +дополняется, записи неизменяемы (ТЗ 8.2). + +## 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 выполняться в той же транзакции, что и изменение данных. + +NOTE: В Срезе 0 обмен через recognition_job аудит не пишет (рулинг R-7, design #20); сервис готов к использованию Срезом 2+. + +#### Scenario: Системное событие +- **WHEN** системный процесс (без пользователя) вызывает сервис аудита +- **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/specs/authn-authz/spec.md b/openspec/specs/authn-authz/spec.md new file mode 100644 index 0000000..6d6c6ec --- /dev/null +++ b/openspec/specs/authn-authz/spec.md @@ -0,0 +1,66 @@ +# authn-authz Specification + +## Purpose +Вход в ядро по JWT внешнего SSO (РАИСА): пользователь находится по email в `app_user`, его эффективные права — объединение +прав его ролей из БД; любой защищённый маршрут объявляет требуемое право одной зависимостью. Для разработки — эмитент +токенов за флагом. + +## 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/specs/data-model/spec.md b/openspec/specs/data-model/spec.md new file mode 100644 index 0000000..bd4a33e --- /dev/null +++ b/openspec/specs/data-model/spec.md @@ -0,0 +1,56 @@ +# data-model Specification + +## Purpose +Схема базы данных ядра совпадает с моделью данных DBML v5.13 один в один: таблицы, колонки, типы, enum'ы, ключи и +индексы; миграции применяются к пустой базе и полностью откатываются; расхождение между ORM и DBML невозможно пропустить. + +## 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 таблицы модели (+ служебная `alembic_version`) + и в `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/specs/recognition-job-exchange/spec.md b/openspec/specs/recognition-job-exchange/spec.md new file mode 100644 index 0000000..2050a6c --- /dev/null +++ b/openspec/specs/recognition-job-exchange/spec.md @@ -0,0 +1,100 @@ +# recognition-job-exchange Specification + +## Purpose +Единственная точка обмена ядра с сервисами распознавания — таблица `recognition_job` (DECISION-4): outbox публикует +запросы в NATS, inbox принимает подтверждения и результаты идемпотентно, потеря сообщений без JetStream компенсируется +повторной публикацией и таймаутами, файл результата регистрируется в `s3_object`. + +## 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/specs/reference-data-seed/spec.md b/openspec/specs/reference-data-seed/spec.md new file mode 100644 index 0000000..d4b9d92 --- /dev/null +++ b/openspec/specs/reference-data-seed/spec.md @@ -0,0 +1,58 @@ +# reference-data-seed Specification + +## Purpose +Справочники продукта (роли, права и их матрица, план счетов, правила элиминации, состав обязательных документов) и +dev-данные загружаются из файлов репозитория одной идемпотентной командой; повторный запуск ничего не дублирует. + +## 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` содержит только Головной офис -- 2.47.2