diff --git a/openspec/changes/slice-1-directories/.openspec.yaml b/openspec/changes/archive/2026-10-05-slice-1-directories/.openspec.yaml similarity index 100% rename from openspec/changes/slice-1-directories/.openspec.yaml rename to openspec/changes/archive/2026-10-05-slice-1-directories/.openspec.yaml diff --git a/openspec/changes/slice-1-directories/design.md b/openspec/changes/archive/2026-10-05-slice-1-directories/design.md similarity index 100% rename from openspec/changes/slice-1-directories/design.md rename to openspec/changes/archive/2026-10-05-slice-1-directories/design.md diff --git a/openspec/changes/slice-1-directories/proposal.md b/openspec/changes/archive/2026-10-05-slice-1-directories/proposal.md similarity index 100% rename from openspec/changes/slice-1-directories/proposal.md rename to openspec/changes/archive/2026-10-05-slice-1-directories/proposal.md diff --git a/openspec/changes/slice-1-directories/specs/audit-trail/spec.md b/openspec/changes/archive/2026-10-05-slice-1-directories/specs/audit-trail/spec.md similarity index 100% rename from openspec/changes/slice-1-directories/specs/audit-trail/spec.md rename to openspec/changes/archive/2026-10-05-slice-1-directories/specs/audit-trail/spec.md diff --git a/openspec/changes/slice-1-directories/specs/data-model/spec.md b/openspec/changes/archive/2026-10-05-slice-1-directories/specs/data-model/spec.md similarity index 100% rename from openspec/changes/slice-1-directories/specs/data-model/spec.md rename to openspec/changes/archive/2026-10-05-slice-1-directories/specs/data-model/spec.md diff --git a/openspec/changes/slice-1-directories/specs/directories-api/spec.md b/openspec/changes/archive/2026-10-05-slice-1-directories/specs/directories-api/spec.md similarity index 100% rename from openspec/changes/slice-1-directories/specs/directories-api/spec.md rename to openspec/changes/archive/2026-10-05-slice-1-directories/specs/directories-api/spec.md diff --git a/openspec/changes/slice-1-directories/specs/directory-import/spec.md b/openspec/changes/archive/2026-10-05-slice-1-directories/specs/directory-import/spec.md similarity index 100% rename from openspec/changes/slice-1-directories/specs/directory-import/spec.md rename to openspec/changes/archive/2026-10-05-slice-1-directories/specs/directory-import/spec.md diff --git a/openspec/changes/slice-1-directories/specs/directory-policy/spec.md b/openspec/changes/archive/2026-10-05-slice-1-directories/specs/directory-policy/spec.md similarity index 100% rename from openspec/changes/slice-1-directories/specs/directory-policy/spec.md rename to openspec/changes/archive/2026-10-05-slice-1-directories/specs/directory-policy/spec.md diff --git a/openspec/changes/slice-1-directories/specs/users-admin/spec.md b/openspec/changes/archive/2026-10-05-slice-1-directories/specs/users-admin/spec.md similarity index 100% rename from openspec/changes/slice-1-directories/specs/users-admin/spec.md rename to openspec/changes/archive/2026-10-05-slice-1-directories/specs/users-admin/spec.md diff --git a/openspec/changes/slice-1-directories/tasks.md b/openspec/changes/archive/2026-10-05-slice-1-directories/tasks.md similarity index 100% rename from openspec/changes/slice-1-directories/tasks.md rename to openspec/changes/archive/2026-10-05-slice-1-directories/tasks.md diff --git a/openspec/specs/audit-trail/spec.md b/openspec/specs/audit-trail/spec.md index b943bc1..74ab0e0 100644 --- a/openspec/specs/audit-trail/spec.md +++ b/openspec/specs/audit-trail/spec.md @@ -29,3 +29,29 @@ ORM-модель `audit_event` MUST NOT использоваться для `UPD #### Scenario: Попытка изменить запись - **WHEN** код вызывает обновление строки `audit_event` через сервис аудита - **THEN** такого метода нет (ошибка на этапе типизации/теста), запись остаётся прежней + +### Requirement: События справочников, импорта и администрирования +Каждая мутация справочников, импорт и каждое действие администратора SHALL писать `audit_event` через сервис аудита в +той же транзакции с `user_id` инициатора, `ip_address` и `user_agent` запроса: + +| Действие | `action` | `section` | `object_type` | +|---|---|---|---| +| создание/изменение/архив ГК | `GROUP_CHANGE` | `DIRECTORIES` | `company_group` | +| смена филиала ГК | `ASSIGNMENT_CHANGE` | `DIRECTORIES` | `company_group` | +| создание/изменение/перевод/закрытие ЮЛ, алиасы | `LEGAL_ENTITY_CHANGE` | `DIRECTORIES` | `legal_entity` / `legal_entity_alias` | +| ГСК/ГСЗ и их состав | `MEMBERSHIP_CHANGE` | `DIRECTORIES` | `related_group` / `related_group_member` | +| импорт выгрузки | `DIRECTORY_IMPORT` | `DIRECTORIES` | `directory_import` | +| пользователь создан/изменён/деактивирован | `USER_CHANGE` | `USERS` | `app_user` | +| роли пользователя | `USER_ROLE_CHANGE` | `USERS` | `app_user` | +| права роли | `ROLE_PERMISSION_CHANGE` | `ACCESS` | `role` | + +`before_value`/`after_value` SHALL содержать изменённые поля (для списков — весь набор до и после); `changed_field` — +имя поля при изменении одного поля. + +#### Scenario: Изменение имени ГК +- **WHEN** имя ГК меняется с «А» на «Б» +- **THEN** в `audit_event` строка `GROUP_CHANGE` с `changed_field = "name"`, `before_value = {"name": "А"}`, `after_value = {"name": "Б"}`, `user_id` инициатора + +#### Scenario: Откат мутации справочника +- **WHEN** транзакция перевода ЮЛ откатывается +- **THEN** строки `LEGAL_ENTITY_CHANGE` и `MEMBERSHIP_CHANGE` в `audit_event` отсутствуют diff --git a/openspec/specs/data-model/spec.md b/openspec/specs/data-model/spec.md index bd4a33e..f5c1d5c 100644 --- a/openspec/specs/data-model/spec.md +++ b/openspec/specs/data-model/spec.md @@ -6,15 +6,17 @@ ## Requirements -### Requirement: Схема БД равна DBML v5.13 -ORM-метаданные SHALL содержать ровно 43 таблицы и 27 enum'ов из `../docs/model/data-model-v5.13.dbml` в схеме `dkb`; +### Requirement: Схема БД равна DBML v5.14 +ORM-метаданные SHALL содержать ровно 43 таблицы и 27 enum'ов из `../docs/model/data-model-v5.14.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 существовать. +`(related_group_id, legal_entity_id)` и `(legal_entity_id, kind)` WHERE `is_current`, `consolidation_lock` WHERE +`released_at IS NULL`) и `NULLS NOT DISTINCT` на `document (stored_file_id, document_type, osv_account_code)`. +`related_group_member.kind` SHALL быть NOT NULL и равняться `related_group.kind` группы (проверяет ядро при вставке). +`audit_action` SHALL содержать `LEGAL_ENTITY_CHANGE` и `USER_CHANGE`. Поля, отсутствующие в DBML, MUST NOT существовать. #### Scenario: Сверка ORM с DBML -- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.13.dbml` +- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.14.dbml` - **THEN** вывод пуст и код возврата 0 #### Scenario: Лишняя колонка в ORM @@ -25,26 +27,36 @@ WHERE `is_current`, `consolidation_lock` WHERE `released_at IS NULL`) и `NULLS - **WHEN** колонка `osv_entry.opening_debit` объявлена в ORM как `Numeric(18, 4)` или `NOT NULL` - **THEN** скрипт сообщает `ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL` и возвращает код 1 +#### Scenario: ЮЛ одновременно в ГСК и ГСЗ +- **WHEN** для одного `legal_entity_id` вставлены две текущие строки `related_group_member` с `kind = gsk` и `kind = gsz` +- **THEN** обе вставки проходят; третья текущая строка с `kind = gsk` нарушает уникальный индекс + ### Requirement: Миграции применяются с нуля и откатываются -Единственная ревизия `0001` SHALL создавать схему `dkb` (если нет), все enum'ы, таблицы, ограничения и индексы на пустой -базе PostgreSQL 16 (требуется ≥ 15); `downgrade` SHALL удалять всё созданное, оставляя пустую схему; повторный `upgrade` после `downgrade` -SHALL проходить без ошибок. +Цепочка ревизий `0001_dbml_v5_13` → `0002_dbml_v5_14` SHALL создавать схему `dkb` (если нет), все enum'ы, таблицы, +ограничения, индексы и последовательности кодов (`company_group_code_seq`, `related_group_code_seq`) на пустой базе +PostgreSQL 16 (требуется ≥ 15); `downgrade` до `base` SHALL удалять всё созданное (включая новые значения `audit_action` +через пересоздание типа), оставляя пустую схему; повторный `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 downgrade 0001_dbml_v5_13` +- **THEN** колонка `related_group_member.kind` отсутствует, индекс `(legal_entity_id) WHERE is_current` восстановлен, + `audit_action` не содержит `LEGAL_ENTITY_CHANGE`/`USER_CHANGE` + #### Scenario: Автогенерация ничего не находит - **WHEN** после `upgrade head` выполняется `alembic revision --autogenerate` на той же БД - **THEN** сгенерированная ревизия не содержит операций (пустые `upgrade`/`downgrade`) ### Requirement: Версия DBML зафиксирована в коде -Пакет моделей SHALL объявлять константу версии DBML (`5.13`); скрипт сверки SHALL сравнивать её с версией в шапке файла +Пакет моделей SHALL объявлять константу версии DBML (`5.14`); скрипт сверки SHALL сравнивать её с версией в шапке файла DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна. #### Scenario: Новая версия DBML без обновления ORM -- **WHEN** в `docs/model/` появляется `data-model-v5.14.dbml`, а константа в ORM — `5.13` +- **WHEN** в `docs/model/` появляется `data-model-v5.15.dbml`, а константа в ORM — `5.14` - **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц ### Requirement: Деньги и проценты — точные типы diff --git a/openspec/specs/directories-api/spec.md b/openspec/specs/directories-api/spec.md new file mode 100644 index 0000000..ab75633 --- /dev/null +++ b/openspec/specs/directories-api/spec.md @@ -0,0 +1,124 @@ +# directories-api Specification + +## Purpose +API справочников ядра: группы компаний (ГК), юридические лица и ИП с алиасами и историей, группы связанных компаний и +заёмщиков (ГСК/ГСЗ) с составом, филиалы. Все изменения идут через сервисы с историей (SCD4/SCD2) и записью в `audit_event`; +имена полей — как в DBML v5.14. + +## Requirements + +### Requirement: Филиалы доступны для чтения +`GET /api/v1/directories/regional-offices` SHALL возвращать список `regional_office` (`id`, `name`, `is_head_office`, `is_active`) +любому пользователю с правом `DIRECTORY_VIEW`; создание и изменение филиалов через API в Срезе 1 не предоставляется +(seed, импорт). + +#### Scenario: Список филиалов +- **WHEN** пользователь с `DIRECTORY_VIEW` запрашивает список после `seed --dev` +- **THEN** ответ 200 содержит Головной офис с `is_head_office = true` и два dev-филиала + +### Requirement: Группа компаний — создание, чтение, изменение, архив, история +`POST /api/v1/directories/company-groups` (право `DIRECTORY_EDIT`) SHALL создавать ГК с полями `name` (обязательно), +`crm_id` (необязательно, уникально), `base_regional_office_id` (необязательно), генерируя `code` вида `GK-NNNNNN` и +`valid_from = now`, с записью `audit_event(GROUP_CHANGE)`. `PATCH …/{id}` SHALL изменять `name`, `crm_id`, +`base_regional_office_id`, сохраняя снимок прежних значений в `company_group_history` (`valid_from` прежний, +`valid_to = now`) и обновляя `valid_from`; смена филиала пишет `ASSIGNMENT_CHANGE`, остальное — `GROUP_CHANGE`. +`POST …/{id}/archive` и `…/unarchive` SHALL переключать `is_archived`. `GET …` SHALL отдавать список с поиском по +`name`/`crm_id`/`code` (подстрока без регистра), фильтром `is_archived`, пагинацией `{items, total, limit, offset}` +(`limit` ≤ 200) и счётчиком `legal_entities_count`; `GET …/{id}` — карточку; `GET …/{id}/history` — снимки по убыванию `valid_from`. +Удаление ГК API NOT предоставляет. + +#### Scenario: Создание и код +- **WHEN** создаются две ГК подряд +- **THEN** их `code` различаются, имеют вид `GK-` и шесть цифр, `valid_from` заполнен, в `audit_event` две записи `GROUP_CHANGE` с `after_value` + +#### Scenario: Перезакрепление за филиалом +- **WHEN** у ГК меняется `base_regional_office_id` +- **THEN** в `company_group_history` появляется строка с прежним филиалом и `valid_to`, у ГК новый `valid_from`, записан `audit_event(ASSIGNMENT_CHANGE)` с `before_value`/`after_value` + +#### Scenario: Дубликат crm_id +- **WHEN** создаётся ГК с `crm_id`, который уже есть +- **THEN** ответ 409 `{"detail": {"code": "CRM_ID_EXISTS", "message": …}}`, записи нет + +### Requirement: Юридическое лицо / ИП — создание, чтение, изменение +`POST /api/v1/directories/legal-entities` (`DIRECTORY_EDIT`) SHALL создавать запись с `inn` (ЮЛ — ровно 10 цифр, ИП — 12; +иначе 422 `INVALID_INN`), `entity_type` (`ЮЛ` | `ИП`), `name`, `company_group_id` (NULL = монозаёмщик), `regional_office_id` +(по умолчанию — филиал ГК), `crm_id`; `inn` уникален (409 `INN_EXISTS`). `PATCH …/{id}` SHALL менять `name`, `entity_type`, +`crm_id`, `regional_office_id` со снимком в `legal_entity_history(event_type = UPDATE)` и `audit_event(LEGAL_ENTITY_CHANGE)`. +`GET …` SHALL давать список с фильтрами `company_group_id`, `outside_group` (`company_group_id IS NULL`), `inn` +(точно), `q` (подстрока имени/ИНН), `closed` (по `close_date`), пагинацией; `GET …/{id}` — карточку с текущими +членствами ГСК/ГСЗ и алиасами; `GET …/{id}/history` — снимки. + +#### Scenario: Неверная длина ИНН +- **WHEN** создаётся ИП с ИНН из 10 цифр +- **THEN** ответ 422 с кодом `INVALID_INN`, запись не создана + +#### Scenario: Монозаёмщик +- **WHEN** ЮЛ создано без `company_group_id` +- **THEN** оно отдаётся в списке с `outside_group = true` и видно пользователям филиала `regional_office_id` + +### Requirement: Перевод ЮЛ в другую ГК и вывод из состава +`POST /api/v1/directories/legal-entities/{id}/move` (`DIRECTORY_EDIT`; тело `company_group_id`, `effective_date` ≥ сегодня) +SHALL сменить ГК, записать `legal_entity_history(event_type = MOVE_TO_GROUP, effective_date)`, закрыть все текущие +членства ЮЛ в ГСК/ГСЗ прежней ГК (`valid_to = now`, `is_current = false`) и записать `LEGAL_ENTITY_CHANGE` и +`MEMBERSHIP_CHANGE`. `POST …/{id}/withdraw` (тело `event`: `CLOSE` | `REMOVE_FROM_GROUP`, `effective_date`, `comment`) +SHALL установить `company_group_id = NULL`, при `CLOSE` — ещё `close_date = effective_date`, закрыть членства, записать +историю и аудит. `effective_date` раньше сегодняшнего дня → 422. + +#### Scenario: Перевод закрывает членства +- **WHEN** ЮЛ состоит в ГСК старой ГК и переводится в новую ГК +- **THEN** членство получает `is_current = false` и `valid_to`, ЮЛ имеет новую `company_group_id`, в истории строка `MOVE_TO_GROUP` с датой перевода + +#### Scenario: Закрытие +- **WHEN** выполнен `withdraw` с `event = CLOSE` +- **THEN** `close_date` заполнен, `company_group_id = NULL`, ЮЛ отдаётся в списке с `closed = true`, в истории `CLOSE` + +### Requirement: Алиасы ЮЛ +`POST /api/v1/directories/legal-entities/{id}/aliases` (`DIRECTORY_EDIT`; тело `alias`) SHALL сохранять `alias` как есть и +`normalized_alias` по §3 (нижний регистр, trim, без пунктуации и кавычек, без ОПФ ООО/АО/ПАО/ЗАО/ИП, схлопнутые пробелы); +повтор для того же ЮЛ — 409 `ALIAS_EXISTS`; такой же `normalized_alias` у другого ЮЛ — 409 `ALIAS_TAKEN` с указанием +`legal_entity_id` владельца. `DELETE …/aliases/{alias_id}` SHALL удалять алиас. Оба пишут `LEGAL_ENTITY_CHANGE`. + +#### Scenario: Нормализация +- **WHEN** добавляется алиас `ООО «Пекарня Счастливочная»` +- **THEN** `normalized_alias = "пекарня счастливочная"`, повторное добавление `Пекарня Счастливочная, ООО` → 409 `ALIAS_EXISTS` + +### Requirement: ГСК и ГСЗ — группы и состав +`POST /api/v1/directories/company-groups/{gk_id}/related-groups` (`DIRECTORY_EDIT`; тело `kind` ∈ {gsk, gsz}, `name`, +`created_date`) SHALL создавать `related_group` с `code` `GSK-NNNNNN`/`GSZ-NNNNNN`; `PATCH …/related-groups/{id}` — менять +`name`, `created_date`; `DELETE …/related-groups/{id}` (`DIRECTORY_DELETE`) SHALL удалять только группу без текущих +членов (иначе 409 `GROUP_NOT_EMPTY`). `POST …/related-groups/{id}/members` (тело `legal_entity_id`) SHALL добавлять текущее +членство с `kind` группы, `valid_from = now`, `is_current = true`; ЮЛ MUST принадлежать той же ГК (иначе 422 +`DIFFERENT_COMPANY_GROUP`); если у ЮЛ уже есть текущее членство в группе того же вида — 409 `MEMBER_OF_OTHER_GROUP` +(с `related_group_id`); повтор в той же группе — 409 `MEMBER_EXISTS`. `DELETE …/members/{legal_entity_id}` SHALL закрывать +членство (`valid_to`, `is_current = false`). Все операции пишут `MEMBERSHIP_CHANGE`. `GET …/related-groups` и +`GET …/related-groups/{id}` SHALL отдавать группы с числом текущих членов и списком членов. + +#### Scenario: Одновременно в ГСК и ГСЗ +- **WHEN** ЮЛ добавлено в ГСК своей ГК, затем в ГСЗ той же ГК +- **THEN** обе операции успешны, у ЮЛ два текущих членства с `kind = gsk` и `kind = gsz` + +#### Scenario: Вторая ГСК запрещена +- **WHEN** ЮЛ с текущим членством в ГСК A добавляют в ГСК B той же ГК +- **THEN** ответ 409 `MEMBER_OF_OTHER_GROUP` с идентификатором ГСК A; данные не меняются + +#### Scenario: Повторное включение после исключения +- **WHEN** членство закрыто, затем ЮЛ снова добавляют в ту же группу +- **THEN** появляется новая строка `related_group_member` с `is_current = true`, старая остаётся с `valid_to` + +#### Scenario: Удаление непустой группы +- **WHEN** удаляется ГСК с текущими членами +- **THEN** ответ 409 `GROUP_NOT_EMPTY` + +### Requirement: Права и аудит на всех маршрутах справочников +Каждый маршрут SHALL требовать соответствующее право (`DIRECTORY_VIEW` для чтения, `DIRECTORY_EDIT` для изменений, +`DIRECTORY_DELETE` для удаления ГСК/ГСЗ и алиасов) и применять политику видимости (`directory-policy`); каждая мутация +SHALL выполняться в одной транзакции с записью `audit_event` (`user_id` инициатора, `before_value`/`after_value`, +`ip_address`, `user_agent`), откат транзакции отменяет и аудит. + +#### Scenario: Нет права +- **WHEN** пользователь с ролью `EXPERT` (только `DIRECTORY_VIEW`) создаёт ГК +- **THEN** ответ 403, записи и аудита нет + +#### Scenario: Ошибка внутри мутации +- **WHEN** при переводе ЮЛ закрытие членства завершается ошибкой БД +- **THEN** ЮЛ остаётся в прежней ГК, строк истории и аудита нет diff --git a/openspec/specs/directory-import/spec.md b/openspec/specs/directory-import/spec.md new file mode 100644 index 0000000..d307259 --- /dev/null +++ b/openspec/specs/directory-import/spec.md @@ -0,0 +1,69 @@ +# directory-import Specification + +## Purpose +Импорт справочников ГК / ЮЛ / ГСК / ГСЗ / филиалов из выгрузки ЕЦП CRM (xlsx или csv) по правилам §13 +`05-domain-rules.md`: построчные решения с кодами, одна транзакция, `dry_run`, стратегия дублей, отчёт в ответе, +в `audit_event(DIRECTORY_IMPORT)` и файл в S3. + +## Requirements + +### Requirement: Приём файла и формат +`POST /api/v1/directories/import` (multipart: `file`; поля `dry_run` (по умолчанию `true`), `on_existing` ∈ {`skip`, `update`}, +по умолчанию `skip`; право `DIRECTORY_ADMIN`) SHALL принимать xlsx (первый лист) или csv (UTF-8, разделитель `;` или `,`) +размером до 20 МБ, сопоставлять заголовки по нормализованному тексту (`ИНН`, `iD ГК`, `Базовый РФ ГК в CRM`, `Группа +компаний`, `Организация`, `ГСК`, `ГСЗ`, `Тип`; порядок и лишние колонки не важны). Отсутствие любой из обязательных колонок +(`ИНН`, `iD ГК`, `Группа компаний`, `Организация`, `Тип`) → 422 `MISSING_COLUMNS` с перечнем; нечитаемый файл → 422 +`UNSUPPORTED_FILE`; превышение размера → 413. + +#### Scenario: Реальная выгрузка принята +- **WHEN** загружается `fixtures/directories/ecp-export-russkiy-biskvit.xlsx` с `dry_run = true` +- **THEN** ответ 200 с отчётом: `rows = 21`, ошибок файла нет + +#### Scenario: Нет колонки «Тип» +- **WHEN** в csv отсутствует колонка `Тип` +- **THEN** ответ 422 `MISSING_COLUMNS`, в отчёте перечислена недостающая колонка, в БД ничего не изменено + +### Requirement: Построчные правила и коды отчёта +Для каждой строки импорт SHALL применять §13.2 и присваивать ровно один итоговый код из перечня: `UNSUPPORTED_TYPE`, +`INVALID_INN`, `DUPLICATE_IN_FILE`, `LE_IN_OTHER_GROUP`, `GK_NAME_CONFLICT` (строка пропущена) или `LE_CREATED`, `LE_UPDATED`, +`LE_EXISTS` (строка применена), плюс сопутствующие события `RF_CREATED`, `GK_CREATED`, `GK_UPDATED`, `GK_EXISTS`, +`GSK_CREATED`, `GSZ_CREATED`, `MEMBER_ADDED`, `UNKNOWN_FLAG`. Тип вне {ЮЛ, ИП} (ASSUMPTION(OQ-87)), ИНН не 10/12 цифр +(ASSUMPTION(OQ-88)) и ИНН, уже встречавшийся в файле, пропускаются. Филиал по имени создаётся при отсутствии +(ASSUMPTION(OQ-89)). ЮЛ, существующее в другой ГК, не переносится (ASSUMPTION(OQ-90)). Флаги «входит в ГСК»/«входит в ГСЗ» +создают не более одной ГСК и одной ГСЗ на ГК с именем ГК (ASSUMPTION(OQ-91)). Отсутствие флага членство NOT закрывает. + +#### Scenario: Срез «Русский Бисквит», пустая база +- **WHEN** импорт `ecp-export-russkiy-biskvit.xlsx` с `dry_run = false`, `on_existing = skip` на базе после `seed` +- **THEN** создана 1 ГК (`crm_id = 1-AZFL6BQ`, имя «Русский Бисквит»), 1 филиал «Санкт-Петербургский РФ» (`RF_CREATED`), + 20 ЮЛ/ИП (`LE_CREATED`: 18 ЮЛ и 2 ИП), 1 ГСК с именем ГК и двумя членами (АО «Русский бисквит», ООО «Пекарня Счастливочная»), 0 ГСЗ; + строка типа `ФЛ` (идёт после ИП с тем же ИНН) → `UNSUPPORTED_TYPE` (тип проверяется первым, `DUPLICATE_IN_FILE` в этом файле не возникает) + +#### Scenario: Повторный импорт идемпотентен +- **WHEN** тот же файл импортируется второй раз с `on_existing = skip` +- **THEN** все применённые строки получают `LE_EXISTS`/`GK_EXISTS`, число строк в таблицах не меняется + +#### Scenario: Обновление имени +- **WHEN** в файле изменено имя существующего ЮЛ и импорт идёт с `on_existing = update` +- **THEN** имя обновлено, в `legal_entity_history` снимок `UPDATE`, код строки `LE_UPDATED` + +#### Scenario: ЮЛ в другой ГК +- **WHEN** ИНН уже состоит в другой ГК +- **THEN** строка пропущена с `LE_IN_OTHER_GROUP`, ЮЛ не переведено, импорт продолжается + +### Requirement: Транзакция, dry_run, след +Импорт SHALL выполняться в одной транзакции: ошибки строк не прерывают его, при `dry_run = true` транзакция откатывается, +а отчёт идентичен боевому. При `dry_run = false` SHALL быть записан `audit_event(action = DIRECTORY_IMPORT, +section = DIRECTORIES, object_type = 'directory_import', after_value = отчёт, description = имя файла)` и файл сохранён в S3 +`uploads/imports/{audit_event_id}/{original_name}` с регистрацией в `s3_object` (`bucket_role = uploads`, +`created_by = user:{id}`); при `dry_run` ни аудита, ни файла. Отчёт SHALL содержать счётчики (`rows`, `groups_created`, +`groups_updated`, `groups_skipped`, `entities_created`, `entities_updated`, `entities_skipped`, `memberships_created`, +`offices_created`, `errors`) и построчный список `{row, inn, code, message}`. + +#### Scenario: dry_run не оставляет следов +- **WHEN** импорт выполнен с `dry_run = true` +- **THEN** число строк в `company_group`, `legal_entity`, `regional_office`, `audit_event`, `s3_object` не изменилось, отчёт возвращён + +#### Scenario: Полная выгрузка +- **WHEN** `docs/source/biq/ecp-export-lagal-entity.xlsx` импортируется с `dry_run = true` (маркер `fixtures`, `ARM_KA_DOCS`) +- **THEN** ответ 200 за ≤ 60 с, `rows = 8400`, в отчёте есть коды `UNSUPPORTED_TYPE` (≥ 500), `INVALID_INN`, `DUPLICATE_IN_FILE`, + `RF_CREATED` = 53, `groups_created` = 344 (из 345 ГК файла одна целиком состоит из строк-дублей и не создаётся) diff --git a/openspec/specs/directory-policy/spec.md b/openspec/specs/directory-policy/spec.md new file mode 100644 index 0000000..f44892d --- /dev/null +++ b/openspec/specs/directory-policy/spec.md @@ -0,0 +1,38 @@ +# directory-policy Specification + +## Purpose +Политика видимости справочников поверх прав: что именно из ГК/ЮЛ/ГСК видит пользователь, определяется данными о его +филиале, а не кодом роли (§10.1 `05-domain-rules.md`). + +## Requirements + +### Requirement: Область видимости вычисляется из филиала пользователя +Для каждого запроса ядро SHALL вычислять область видимости из `Principal`: если `regional_office.is_head_office = true` — +вся система; иначе — ГК с `company_group.base_regional_office_id = app_user.regional_office_id`, их ЮЛ и ГСК/ГСЗ, а также +монозаёмщики с `legal_entity.regional_office_id = app_user.regional_office_id`. Код роли (`role.code`) MUST NOT участвовать +в вычислении; `business_segment` в Срезе 1 не используется (ASSUMPTION(OQ-86)). + +#### Scenario: Пользователь филиала видит только своё +- **WHEN** есть ГК А (филиал X) и ГК Б (филиал Y), пользователь `KA_RF` филиала X запрашивает список ГК +- **THEN** в ответе только ГК А, `total = 1` + +#### Scenario: Пользователь ГО видит всё +- **WHEN** пользователь с `is_head_office = true` запрашивает список ГК +- **THEN** в ответе обе ГК + +### Requirement: Объект вне видимости неотличим от несуществующего +Чтение, изменение и любые операции над ГК/ЮЛ/ГСК вне области видимости SHALL возвращать 404 с тем же телом, что и для +несуществующего идентификатора; 403 для этого случая MUST NOT использоваться. + +#### Scenario: Чужая ГК по id +- **WHEN** пользователь филиала X запрашивает `GET /directories/company-groups/{id}` ГК Б +- **THEN** ответ 404, текст совпадает с ответом на случайный `uuid` + +### Requirement: Политика применяется до прав на изменение +Проверка права (`require`) и проверка видимости SHALL выполняться обе; порядок: право → видимость → операция. Создание ГК +пользователем филиала SHALL привязывать её к его филиалу, если `base_regional_office_id` не указан; указание чужого филиала +пользователем филиала → 422 `OFFICE_NOT_ALLOWED`. + +#### Scenario: Создание ГК из филиала +- **WHEN** пользователь филиала X с `DIRECTORY_EDIT` создаёт ГК без филиала +- **THEN** у ГК `base_regional_office_id` = X, и она видна создателю diff --git a/openspec/specs/users-admin/spec.md b/openspec/specs/users-admin/spec.md new file mode 100644 index 0000000..bde6843 --- /dev/null +++ b/openspec/specs/users-admin/spec.md @@ -0,0 +1,69 @@ +# users-admin Specification + +## Purpose +Администрирование доступа: пользователи, назначение ролей и матрица «роль × право» как данные (`role_permission`), с +аудитом; минимальный экран администратора во фронте. + +## Requirements + +### Requirement: Пользователи — список, создание, изменение, деактивация +Маршруты `/api/v1/admin/users` (право `USERS_ADMIN`): `GET` — список с ролями и филиалом (поиск по email/ФИО, фильтр +`is_active`, пагинация); `POST` — создание с обязательными `email` (уникален без учёта регистра → 409 `EMAIL_EXISTS`), +`full_name`, `regional_office_id`, необязательными `employee_no`, `business_segment`, `roles` (коды); `PATCH …/{id}` — +`full_name`, `employee_no`, `regional_office_id`, `business_segment`, `is_active`. Удаление MUST NOT предоставляться. +`external_id` через API не задаётся. Каждая мутация пишет `audit_event(USER_CHANGE, section = USERS)`. + +#### Scenario: Создание с ролью +- **WHEN** администратор создаёт пользователя с `roles = ["KA_RF"]` +- **THEN** появляются `app_user` и `user_role`, `GET /me` от его имени (dev-токен) возвращает права `KA_RF` + +#### Scenario: Email в другом регистре +- **WHEN** создаётся пользователь с email, отличающимся от существующего только регистром +- **THEN** ответ 409 `EMAIL_EXISTS` + +### Requirement: Назначение ролей +`PUT /api/v1/admin/users/{id}/roles` (тело: список кодов) SHALL заменять набор ролей пользователя: отсутствующие в наборе +назначения удаляются, новые добавляются; неизвестный или неактивный код роли → 422; пишется `audit_event(USER_ROLE_CHANGE, +before_value = прежний набор, after_value = новый)`. Замена набора ролей MUST NOT оставить систему без активного пользователя с +`USERS_ADMIN` (409 `LAST_ADMIN`): администратор не может снять с себя последнюю роль, дающую `USERS_ADMIN`, если он — +единственный активный обладатель этого права; при наличии другого активного администратора снятие у себя разрешено +(решение №34). + +#### Scenario: Замена набора ролей +- **WHEN** у пользователя роли `[KA_RF]`, администратор ставит `[KA_GO, EXPERT]` +- **THEN** `user_role` содержит ровно две новые строки, в аудите `before_value = ["KA_RF"]`, `after_value = ["KA_GO","EXPERT"]` + +#### Scenario: Последний администратор +- **WHEN** единственный активный пользователь с `USERS_ADMIN` снимает с себя роль `ADMIN` +- **THEN** ответ 409 `LAST_ADMIN`, роли не изменены + +### Requirement: Матрица прав +`GET /api/v1/admin/roles` SHALL отдавать роли с их правами; `GET /api/v1/admin/permissions` — справочник прав (16 кодов, только +чтение; создание прав MUST NOT предоставляться); `PUT /api/v1/admin/roles/{code}/permissions` (тело: список кодов прав) SHALL +заменять набор прав роли и писать `audit_event(ROLE_PERMISSION_CHANGE)` с before/after. Системные роли (`is_system`) +нельзя деактивировать или переименовать; нельзя снять `USERS_ADMIN` с роли, если после этого ни у одного активного +пользователя не останется этого права (409 `LAST_ADMIN`). + +#### Scenario: Изменение матрицы +- **WHEN** у роли `EXPERT` добавляется право `EXPORT` +- **THEN** `role_permission` содержит новую строку, пользователь с ролью `EXPERT` при следующем запросе `/me` видит `EXPORT`, + в аудите `ROLE_PERMISSION_CHANGE` + +#### Scenario: Снятие USERS_ADMIN с единственной роли +- **WHEN** у роли `ADMIN` (единственной с `USERS_ADMIN` у активных пользователей) убирают `USERS_ADMIN` +- **THEN** ответ 409 `LAST_ADMIN` + +### Requirement: Минимальный экран администратора +SPA SHALL содержать маршрут `/admin` (пункт «Система → Пользователи и роли» в боковом меню), доступный при наличии +`USERS_ADMIN` в ответе `/api/v1/me`: таблица пользователей с выбором ролей и формой создания, таблица «роль × право» с +чекбоксами и кнопкой сохранения; запросы идут через общий API-клиент с заголовком `Authorization: Bearer`. В режиме +разработки SHALL быть доступен ввод email для получения dev-токена (`POST /api/v1/dev/token`); в сборке для прода этот +ввод отсутствует. + +#### Scenario: Назначение роли через экран +- **WHEN** администратор на `/admin` выбирает пользователю роль `KA_GO` и сохраняет +- **THEN** вызывается `PUT /admin/users/{id}/roles`, таблица показывает новую роль, ошибка API показывается уведомлением + +#### Scenario: Нет права +- **WHEN** пользователь без `USERS_ADMIN` открывает `/admin` +- **THEN** страница показывает сообщение об отсутствии доступа и не делает запросов к `/admin/*`