# directories-api Specification ## Purpose API справочников ядра: группы компаний (ГК), юридические лица и ИП с алиасами и историей, группы связанных компаний и заёмщиков (ГСК/ГСЗ) с составом, филиалы. Все изменения идут через сервисы с историей (SCD4/SCD2) и записью в `audit_event`; имена полей — как в текущей DBML (v5.18). ## 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`, `kpp`; `inn` уникален (409 `INN_EXISTS`). `kpp` (DBML v5.17, `05-domain-rules.md` §13.5) необязателен у ЮЛ и запрещён у ИП; нормализуется (пробелы убираются, буквы — в верхний регистр) и SHALL быть 9 символов `[0-9]{4}[0-9A-Z]{2}[0-9]{3}`, иначе 422 `INVALID_KPP`; пустая строка — «КПП нет»; уникальности у КПП нет. `PATCH …/{id}` SHALL менять `name`, `entity_type`, `crm_id`, `regional_office_id`, `kpp` (`kpp: null` очищает КПП; смена типа на ИП при непустом итоговом КПП — 422 `INVALID_KPP`) со снимком в `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` — снимки. В списке и карточке SHALL быть `documents_count` — число документов файлов ЮЛ, кроме файлов `SUPERSEDED`. Во всех ответах ЮЛ, кроме снимков истории, SHALL быть `company_group_name` — текущее имя ГК `company_group_id` (архивной тоже) или `null` у монозаёмщика; имена набора ЮЛ — одним запросом. Во всех ответах ЮЛ, кроме снимков истории, SHALL быть `memberships` — текущие членства ЮЛ в ГСК/ГСЗ (`related_group_id`, `code`, `kind`, `name`, `valid_from`; по виду и коду группы; закрытые не отдаются; нет — `[]`); членства набора ЮЛ — одним запросом (#109: фильтр ЮЛ по ГСК/ГСЗ в карточке ГК без запроса карточки каждого ЮЛ). Ответ `POST` и `PATCH` — карточка (`LegalEntityDetail`: поля списка + `aliases`); поля формы `aliases`, `gsk_id`, `gsz_id` — требование «Форма ЮЛ одним запросом». #### Scenario: Неверная длина ИНН - **WHEN** создаётся ИП с ИНН из 10 цифр - **THEN** ответ 422 с кодом `INVALID_INN`, запись не создана #### Scenario: КПП у ИП - **WHEN** создаётся ИП с `kpp = "770101001"` или ЮЛ с КПП из 8 символов - **THEN** ответ 422 с кодом `INVALID_KPP`, запись не создана #### Scenario: Очистка КПП - **WHEN** у ЮЛ с КПП выполняется `PATCH` с `{"kpp": null}` - **THEN** `kpp` = NULL, прежнее значение — в снимке `legal_entity_history(event_type = UPDATE)` и в `audit_event` #### Scenario: Монозаёмщик - **WHEN** ЮЛ создано без `company_group_id` - **THEN** оно отдаётся в списке с `outside_group = true` и видно пользователям филиала `regional_office_id` #### Scenario: Членства в списке - **WHEN** ЮЛ ГК состоит в ГСК и ГСЗ, другое ЮЛ той же ГК — только в закрытом членстве ГСК - **THEN** в `GET …?company_group_id=…` у первого `memberships` — ГСК и ГСЗ, у второго — `[]` #### Scenario: Число документов - **WHEN** у монозаёмщика файл с книгой НПЗ (ББ и ОФР) и заменённый файл с одной ОСВ - **THEN** `documents_count = 2` ### 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/legal-entities` и `PATCH …/{id}` SHALL принимать поля формы (макет 51-02, 79): `aliases` (список строк 1–500 символов, не больше 100 элементов; больше — 422 массивом), `gsk_id`, `gsz_id` — и применять их в той же транзакции, что и поля ЮЛ; ошибка на любом шаге откатывает весь запрос. Шаги и порядок ошибок: поля ЮЛ → алиасы → ГСК → ГСЗ. Алиасы и членства SHALL проверяться и аудироваться так же, как в ручках `…/aliases` и `…/related-groups/{id}/members` (`EMPTY_ALIAS`, `ALIAS_TAKEN`, `DIFFERENT_COMPANY_GROUP`, `MEMBERSHIP_CHANGE`, `LEGAL_ENTITY_CHANGE` по алиасу); два элемента `aliases` с одним `normalized_alias` — 409 `ALIAS_EXISTS`. Группа SHALL быть видимой (иначе 404, как несуществующая) и нужного вида: в `gsk_id` — ГСК, в `gsz_id` — ГСЗ (иначе 422 `WRONG_GROUP_KIND`). В `POST` не переданное поле — пустой список / без группы. В `PATCH` не переданное поле SHALL не меняться; `aliases: null` — 422; `aliases` — итоговый набор: алиасы сверяются по `normalized_alias`, совпавшие не трогаются (написание прежнее), недостающие добавляются, лишние удаляются — удаление требует права `DIRECTORY_DELETE` (иначе 403 «недостаточно прав: требуется DIRECTORY_DELETE», ничего не меняется); `gsk_id`/`gsz_id` — итоговая группа вида: `null` закрывает текущее членство вида, та же группа — без изменений, другая — текущее закрывается, новое открывается. Снимок `legal_entity_history(UPDATE)` пишется только при изменении полей ЮЛ. #### Scenario: Создание с алиасами и группами - **WHEN** ЮЛ ГК создаётся с `aliases = ["ООО «Ромашка»", "Ромашка-Юг"]`, `gsk_id` и `gsz_id` групп своей ГК - **THEN** ответ 201 с двумя алиасами и двумя текущими членствами; в аудите — создание ЮЛ, два события по алиасам и два `MEMBERSHIP_CHANGE` #### Scenario: Чужой алиас откатывает создание - **WHEN** в `aliases` есть алиас, закреплённый за другим ЮЛ - **THEN** ответ 409 `ALIAS_TAKEN` с `legal_entity_id` владельца; ни ЮЛ, ни алиасов, ни членств, ни событий аудита #### Scenario: Итоговый набор алиасов - **WHEN** у ЮЛ алиасы `ООО «Альфа»` и `Бета`, а `PATCH` администратора передаёт `aliases = ["альфа", "Гамма"]` - **THEN** `ООО «Альфа»` остаётся с прежним `id` и написанием, `Бета` удалён, `Гамма` добавлен; снимка истории нет #### Scenario: Удаление алиаса без права - **WHEN** пользователь без `DIRECTORY_DELETE` передаёт `aliases` без одного из текущих алиасов - **THEN** ответ 403, ни поля ЮЛ, ни алиасы не меняются #### Scenario: Смена ГСК и выход из ГСЗ - **WHEN** ЮЛ в ГСК A и ГСЗ C; `PATCH` с `gsk_id = B`, `gsz_id = null` - **THEN** членства в A и C закрыты, открыто членство в B; повтор того же тела ничего не меняет ### 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** ЮЛ остаётся в прежней ГК, строк истории и аудита нет ### Requirement: Плоский список счетов для фильтров шахматки `GET /api/v1/directories/accounts` SHALL возвращать пользователю с `DIRECTORY_VIEW` все строки `chart_of_account` одним массивом в естественном порядке кодов. Каждая строка SHALL содержать `id`, `code`, `parent_id`, `name`, `kind` (`A`/`P`/`AP`/null), `row_type`, `is_off_balance`, `is_debit`, `is_credit`; корневые счета имеют `parent_id = null`. `is_debit = true` для `A` и `AP`, `is_credit = true` для `P` и `AP`; при `kind = null` оба признака ложны. #### Scenario: Счета разных уровней - **WHEN** в плане счетов есть счёт, субсчёт и аналитика - **THEN** ответ содержит все три строки без вложенности, а `parent_id` указывает на родительскую строку #### Scenario: Нет права на справочник - **WHEN** пользователь без `DIRECTORY_VIEW` запрашивает список счетов - **THEN** ответ 403 ### Requirement: ГСК и ГСЗ — группы, состав и crm_id `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`; `POST` и `PATCH` группы, меняющие поля группы (в том числе `crm_id`), пишут `GROUP_CHANGE` с `before/after` всех изменённых полей (Р-41). `GET …/related-groups` и `GET …/related-groups/{id}` SHALL отдавать группы с числом текущих членов и списком членов. Тела `POST` и `PATCH` SHALL принимать необязательный `crm_id` («ID ГСК/ГСЗ в CRM» форм 51-03/51-04/77-02) и сохранять его в `related_group.crm_id` (`varchar`, nullable, уникален; пустая строка после обрезки — `null`; явный `null` в `PATCH` очищает); `RelatedGroupOut` и `RelatedGroupDetail` отдают `crm_id`; повтор — 409 `CRM_ID_EXISTS`. Импорт ЕЦП `crm_id` групп не заполняет (OQ-91). #### Scenario: `crm_id` сохраняется и аудируется - **WHEN** ГСК создаётся с `crm_id = " 51-03-0001 "`, затем `PATCH` меняет его на `77-02-0002` - **THEN** создание — 201 с `crm_id = "51-03-0001"`; `PATCH` пишет `GROUP_CHANGE` с `before.crm_id = "51-03-0001"`, `after.crm_id = "77-02-0002"` #### Scenario: Занятый crm_id - **WHEN** создаётся ГСЗ с `crm_id`, который уже есть у другой ГСК или ГСЗ - **THEN** 409 `CRM_ID_EXISTS`, ничего не создано #### 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`