diff --git a/openspec/specs/data-model/spec.md b/openspec/specs/data-model/spec.md index ccdc9b2..44b39f3 100644 --- a/openspec/specs/data-model/spec.md +++ b/openspec/specs/data-model/spec.md @@ -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: Деньги и проценты — точные типы diff --git a/openspec/specs/directories-api/spec.md b/openspec/specs/directories-api/spec.md index ad52e67..c3a1574 100644 --- a/openspec/specs/directories-api/spec.md +++ b/openspec/specs/directories-api/spec.md @@ -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` diff --git a/openspec/specs/directory-import/spec.md b/openspec/specs/directory-import/spec.md index d307259..14d101e 100644 --- a/openspec/specs/directory-import/spec.md +++ b/openspec/specs/directory-import/spec.md @@ -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`, ЮЛ не переведено, импорт продолжается