Merge pull request 'OpenSpec-спеки по коду: DBML v5.17 (КПП), схема из DB_SCHEMA (#55)' (#56) from feat/55-specs-v517 into main

Reviewed-on: #56
This commit is contained in:
aep 2026-10-07 22:43:46 +03:00
commit 53ac9f19ed
3 changed files with 49 additions and 17 deletions

View File

@ -1,13 +1,15 @@
# data-model Specification
## Purpose
Схема базы данных ядра совпадает с моделью данных DBML v5.13 один в один: таблицы, колонки, типы, enum'ы, ключи и
Схема базы данных ядра совпадает с текущей моделью данных DBML (v5.17) один в один: таблицы, колонки, типы, enum'ы, ключи и
индексы; миграции применяются к пустой базе и полностью откатываются; расхождение между ORM и DBML невозможно пропустить.
## Requirements
### Requirement: Схема БД равна DBML v5.16
ORM-метаданные SHALL содержать ровно 43 таблицы и 28 enum'ов из `../docs/model/data-model-v5.16.dbml` в схеме `dkb`;
### Requirement: Схема БД равна DBML v5.17
ORM-метаданные SHALL содержать ровно 43 таблицы и 28 enum'ов из `../docs/model/data-model-v5.17.dbml` в схеме PostgreSQL
из настройки `DB_SCHEMA` (по умолчанию `dkb`; в контуре — `OPENBAO__APP__DB_SCHEMA`; простой идентификатор
`[a-z_][a-z0-9_]{0,62}`, не начинающийся с `pg_`, иначе приложение не стартует); сверка с DBML идёт в эталонной схеме `dkb`;
имена таблиц, колонок, enum'ов и их значений SHALL совпадать с DBML буквально; nullability, PK, UNIQUE, FK, значения
по умолчанию и индексы SHALL соответствовать DBML, включая частичные уникальные индексы (`related_group_member`
`(related_group_id, legal_entity_id)` и `(legal_entity_id, kind)` WHERE `is_current`, `consolidation_lock` WHERE
@ -19,10 +21,12 @@ Enum `counterparty_match_kind` SHALL содержать `INN`, `NAME`, `ALIAS`,
(проверяет ядро при записи). `app_user.email` SHALL храниться в нижнем регистре. Миграция `0004_dbml_v5_16` SHALL
создавать enum и колонку и заполнять её по уже смапленным строкам (ИНН строки без пробелов = `legal_entity.inn` → `INN`;
`trim(counterparty_name) = legal_entity.name` → `NAME`; остальные с `counterparty_id` → `ALIAS`); откат SHALL удалять
колонку и enum. Поля, отсутствующие в DBML, MUST NOT существовать.
колонку и enum. Колонка `kpp` (`varchar`, nullable, без индексов и уникальности; уникален только `inn`) SHALL быть
последней в `legal_entity` и `legal_entity_history`; формат и запрет КПП у ИП проверяет ядро. Миграция `0005_dbml_v5_17`
SHALL добавлять обе колонки (существующие записи — `kpp IS NULL`), откат — удалять. Поля, отсутствующие в DBML, MUST NOT существовать.
#### Scenario: Сверка ORM с DBML
- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.16.dbml`
- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.17.dbml` (`make dbml-check`)
- **THEN** вывод пуст и код возврата 0
#### Scenario: Лишняя колонка в ORM
@ -47,7 +51,8 @@ Enum `counterparty_match_kind` SHALL содержать `INN`, `NAME`, `ALIAS`,
`alembic downgrade 0003` удаляет колонку и enum
### Requirement: Миграции применяются с нуля и откатываются
Цепочка ревизий `0001_dbml_v5_13` → `0002_dbml_v5_14` → `0003_dbml_v5_15` SHALL создавать схему `dkb` (если нет), все
Цепочка ревизий `0001_dbml_v5_13` → `0002_dbml_v5_14` → `0003_dbml_v5_15` → `0004_dbml_v5_16` → `0005_dbml_v5_17` SHALL
создавать схему из `DB_SCHEMA` (если нет), все
enum'ы, таблицы, ограничения, индексы и последовательности кодов (`company_group_code_seq`, `related_group_code_seq`)
на пустой базе PostgreSQL 16 (требуется ≥ 15); `0003` SHALL добавлять `NU` в `osv_data_type`, приводить
`app_user.email` к нижнему регистру и создавать индексы `ix_osv_entry_parent_entry_id`, `ix_osv_entry_recognition_job_id`,
@ -56,23 +61,32 @@ enum'ы, таблицы, ограничения, индексы и послед
#### Scenario: Полный цикл
- **WHEN** на пустой БД выполняются `alembic upgrade head`, `alembic downgrade base`, `alembic upgrade head`
- **THEN** все три команды завершаются успешно, после последней в `information_schema.tables` схемы `dkb` 43 таблицы модели (+ служебная `alembic_version`)
и в `pg_type` 27 enum-типов
- **THEN** все три команды завершаются успешно, после последней в `information_schema.tables` схемы `DB_SCHEMA` 43 таблицы модели (+ служебная `alembic_version`)
и в `pg_type` 28 enum-типов
#### Scenario: Откат на один шаг
#### Scenario: Откат до v5.14
- **WHEN** после `upgrade head` выполняется `alembic downgrade 0002_dbml_v5_14`
- **THEN** `osv_data_type` не содержит `NU`, индексов v5.15 п. 4 нет; регистр `app_user.email` не возвращается (данные)
#### Scenario: КПП добавляется и откатывается
- **WHEN** на базе с ЮЛ выполняется `alembic upgrade head`, затем `alembic downgrade 0004_dbml_v5_16`
- **THEN** после `upgrade` у `legal_entity` и `legal_entity_history` есть последняя колонка `kpp varchar NULL`, у существующих ЮЛ
`kpp IS NULL`; после `downgrade` колонок нет, повторный `upgrade` их возвращает
#### Scenario: Нестандартная схема
- **WHEN** `DB_SCHEMA = dkb_alt` и на пустой БД выполняется `alembic upgrade head`
- **THEN** таблицы и enum'ы модели создаются в схеме `dkb_alt`, приложение работает с ней; схема `dkb` не создаётся
#### Scenario: Автогенерация ничего не находит
- **WHEN** после `upgrade head` выполняется `alembic revision --autogenerate` на той же БД
- **THEN** сгенерированная ревизия не содержит операций (пустые `upgrade`/`downgrade`)
### Requirement: Версия DBML зафиксирована в коде
Пакет моделей SHALL объявлять константу версии DBML (`5.15`); скрипт сверки SHALL сравнивать её с версией в шапке файла
Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.17"`); скрипт сверки SHALL сравнивать её с версией в шапке файла
DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна.
#### Scenario: Новая версия DBML без обновления ORM
- **WHEN** в `docs/model/` появляется `data-model-v5.16.dbml`, а константа в ORM — `5.15`
- **WHEN** в `docs/model/` появляется `data-model-v5.18.dbml`, а константа в ORM — `5.17`
- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц
### Requirement: Деньги и проценты — точные типы

View File

@ -3,7 +3,7 @@
## Purpose
API справочников ядра: группы компаний (ГК), юридические лица и ИП с алиасами и историей, группы связанных компаний и
заёмщиков (ГСК/ГСЗ) с составом, филиалы. Все изменения идут через сервисы с историей (SCD4/SCD2) и записью в `audit_event`;
имена полей — как в DBML v5.14.
имена полей — как в текущей DBML (v5.17).
## Requirements
@ -42,10 +42,13 @@ API справочников ядра: группы компаний (ГК), ю
### 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)`.
(по умолчанию — филиал ГК), `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}` — карточку с текущими
(точно), `q` (подстрока имени/ИНН/КПП), `closed` (по `close_date`), пагинацией; `GET …/{id}` — карточку с текущими
членствами ГСК/ГСЗ и алиасами; `GET …/{id}/history` — снимки. В списке и карточке SHALL быть `documents_count` — число
документов файлов ЮЛ, кроме файлов `SUPERSEDED`.
@ -53,6 +56,14 @@ API справочников ядра: группы компаний (ГК), ю
- **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`

View File

@ -11,7 +11,8 @@
`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`, `Группа
компаний`, `Организация`, `ГСК`, `ГСЗ`, `Тип`; порядок и лишние колонки не важны). Отсутствие любой из обязательных колонок
компаний`, `Организация`, `ГСК`, `ГСЗ`, `Тип`, `КПП`; порядок и лишние колонки не важны). Колонка `КПП` (v5.17) необязательна:
её отсутствие и пустая ячейка — не ошибка. Отсутствие любой из обязательных колонок
(`ИНН`, `iD ГК`, `Группа компаний`, `Организация`, `Тип`) → 422 `MISSING_COLUMNS` с перечнем; нечитаемый файл → 422
`UNSUPPORTED_FILE`; превышение размера → 413.
@ -25,12 +26,14 @@
### Requirement: Построчные правила и коды отчёта
Для каждой строки импорт SHALL применять §13.2 и присваивать ровно один итоговый код из перечня: `UNSUPPORTED_TYPE`,
`INVALID_INN`, `DUPLICATE_IN_FILE`, `LE_IN_OTHER_GROUP`, `GK_NAME_CONFLICT` (строка пропущена) или `LE_CREATED`, `LE_UPDATED`,
`INVALID_INN`, `INVALID_KPP`, `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 закрывает.
КПП проверяется после ИНН и до дубля в файле по правилам `directories-api` (формат, запрет у ИП) → `INVALID_KPP`.
С `on_existing = update` КПП существующего ЮЛ SHALL меняться только непустым значением из файла: пустая ячейка КПП не стирает.
#### Scenario: Срез «Русский Бисквит», пустая база
- **WHEN** импорт `ecp-export-russkiy-biskvit.xlsx` с `dry_run = false`, `on_existing = skip` на базе после `seed`
@ -46,6 +49,10 @@
- **WHEN** в файле изменено имя существующего ЮЛ и импорт идёт с `on_existing = update`
- **THEN** имя обновлено, в `legal_entity_history` снимок `UPDATE`, код строки `LE_UPDATED`
#### Scenario: КПП у ИП в файле
- **WHEN** у строки с типом `ИП` заполнена колонка `КПП`
- **THEN** строка пропущена с `INVALID_KPP`, импорт продолжается
#### Scenario: ЮЛ в другой ГК
- **WHEN** ИНН уже состоит в другой ГК
- **THEN** строка пропущена с `LE_IN_OTHER_GROUP`, ЮЛ не переведено, импорт продолжается