openspec: архив change slice-1-directories #7
@ -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` отсутствуют
|
||||
|
||||
@ -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: Деньги и проценты — точные типы
|
||||
|
||||
124
openspec/specs/directories-api/spec.md
Normal file
124
openspec/specs/directories-api/spec.md
Normal file
@ -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** ЮЛ остаётся в прежней ГК, строк истории и аудита нет
|
||||
69
openspec/specs/directory-import/spec.md
Normal file
69
openspec/specs/directory-import/spec.md
Normal file
@ -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 ГК файла одна целиком состоит из строк-дублей и не создаётся)
|
||||
38
openspec/specs/directory-policy/spec.md
Normal file
38
openspec/specs/directory-policy/spec.md
Normal file
@ -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, и она видна создателю
|
||||
69
openspec/specs/users-admin/spec.md
Normal file
69
openspec/specs/users-admin/spec.md
Normal file
@ -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/*`
|
||||
Loading…
x
Reference in New Issue
Block a user