Andrey Epifancev 9c57f680af Архив OpenSpec elimination-pairs-rule-totals (PR #175)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:02:24 +04:00

214 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# data-model Specification
## Purpose
Схема базы данных ядра совпадает с текущей моделью данных DBML (v5.18) один в один: таблицы, колонки, типы, enum'ы, ключи и
индексы; миграции применяются к пустой базе и полностью откатываются; расхождение между ORM и DBML невозможно пропустить.
## Requirements
### Requirement: Схема БД равна DBML v5.21
ORM-метаданные SHALL содержать ровно 44 таблицы и 33 enum'а из `../docs/model/data-model-v5.21.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
`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`; `osv_data_type` SHALL содержать `NU` (после `BU`).
Enum `counterparty_match_kind` SHALL содержать `INN`, `NAME`, `ALIAS`, `MANUAL`, `PROPAGATED`; колонка
`osv_entry.counterparty_match_kind` (nullable) SHALL быть NULL тогда и только тогда, когда `counterparty_id` NULL
(проверяет ядро при записи). `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. Колонка `kpp` (`varchar`, nullable, без индексов и уникальности; уникален только `inn`) SHALL быть
последней в `legal_entity` и `legal_entity_history`; формат и запрет КПП у ИП проверяет ядро. Миграция `0005_dbml_v5_17`
SHALL добавлять обе колонки (существующие записи — `kpp IS NULL`), откат — удалять.
Колонка `stored_file.consolidation_id` (`uuid`, nullable, FK → `consolidation.id`; v5.18) SHALL быть последней в
`stored_file`; индекс `ix_stored_file_legal_entity_id_consolidation_id (legal_entity_id, consolidation_id)`. Миграция
`0006_dbml_v5_18` SHALL добавлять колонку, FK и индекс и заполнять колонку по `consolidation_document`: файлу с документом,
привязанным к версии, — консолидация этой версии; при привязках к версиям разных консолидаций — самый ранний период
(год, затем квартал); файлы без привязок остаются `NULL`; откат SHALL удалять индекс и колонку. Поля, отсутствующие в
DBML, MUST NOT существовать.
Схема v5.19 (RSHB/arm-ka-docs#116) SHALL добавлять: таблицу `document_issue` и enum'ы `statement_line_status`, `statement_form_variant`, `document_issue_kind` и прочие enum'ы DBML v5.19; колонки `document.file_ordinal` (уникальна в паре `(stored_file_id, file_ordinal)`), `recognition_job.statements_version`, `related_group.crm_id` (`varchar`, nullable, уникальна), в `financial_statement_meta` — `form_variant`, `needs_review_fields`, `header_source`, `organization_name_original_value`, `organization_name_modified`; в `financial_statement_column` — `period_kind`, `label_confidence`; в `financial_statement_line` — `row_index`, `source_ref`, `line_status`, `line_code_text`, `parent_row_index`, `needs_review`, `is_confirmed`; в `financial_statement_value` — `source_text`, `needs_review`, `is_confirmed` (с `confirmed_by/at`). Уникальность `(meta_id, line_code)` снимается, добавляется частичная уникальность `(meta_id, source_ref)` WHERE `source_ref IS NOT NULL`; уникальность документа `(stored_file_id, document_type, osv_account_code)` становится частичной (только для типов, кроме `BALANCE` / `INCOME`). Состав и порядок колонок — буквально по DBML v5.19.
Схема v5.20 SHALL добавлять в конец `document` колонки `is_modified` (`boolean NOT NULL DEFAULT false`), `original_values`
(`jsonb`, nullable), `modified_by` (`uuid`, nullable, FK → `app_user.id`) и `modified_at` (`timestamptz`, nullable); схема v5.21 SHALL
добавлять значение `FILE_REPROCESS` в enum `audit_action`. Состав и порядок колонок — буквально по DBML v5.21.
#### Scenario: Сверка ORM с DBML
- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.21.dbml` (`make dbml-check`)
- **THEN** вывод пуст и код возврата 0
#### Scenario: Лишняя колонка в ORM
- **WHEN** в ORM добавлена колонка, которой нет в DBML (или удалена существующая)
- **THEN** скрипт печатает `таблица.колонка: отсутствует в DBML` (или `отсутствует в ORM`) и возвращает код 1
#### Scenario: Расхождение типа или nullability
- **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` нарушает уникальный индекс
#### Scenario: Строка налогового учёта
- **WHEN** в `osv_entry` вставляется строка с `data_type = 'NU'`
- **THEN** вставка проходит
#### Scenario: Backfill при миграции
- **WHEN** до миграции 0004 есть строки с `counterparty_id`, найденным по ИНН, по имени и по алиасу, и строка без `counterparty_id`
- **THEN** после `alembic upgrade head` у них `counterparty_match_kind` = `INN`, `NAME`, `ALIAS` и NULL соответственно;
`alembic downgrade 0003` удаляет колонку и enum
#### Scenario: Backfill консолидации файла при миграции 0006
- **WHEN** до миграции 0006 есть файл `A` с документом, привязанным к версии консолидации 2025 Q2 и к версии консолидации
2025 Q1, и файл `B` без привязок
- **THEN** после `alembic upgrade head` у `A` `consolidation_id` = консолидация 2025 Q1, у `B` — NULL; колонка последняя в
`stored_file`; `alembic downgrade 0005_dbml_v5_17` удаляет индекс и колонку, повторный `upgrade` их возвращает
#### Scenario: Сверка с DBML v5.19
- **WHEN** выполняется `make dbml-check` по `../docs/model/data-model-v5.21.dbml`
- **THEN** таблицы, колонки, enum'ы, ключи и индексы ORM совпадают с DBML без расхождений
### Requirement: Миграции применяются с нуля и откатываются
Цепочка ревизий `0001_dbml_v5_13` → `0002_dbml_v5_14` → `0003_dbml_v5_15` → `0004_dbml_v5_16` → `0005_dbml_v5_17` →
`0006_dbml_v5_18` 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`,
`ix_stored_file_current_recognition_job_id` (DBML v5.15 п. 4); `downgrade` до `base` SHALL удалять всё созданное (новые значения enum — через
пересоздание типа), оставляя пустую схему; повторный `upgrade` после `downgrade` SHALL проходить без ошибок.
#### Scenario: Полный цикл
- **WHEN** на пустой БД выполняются `alembic upgrade head`, `alembic downgrade base`, `alembic upgrade head`
- **THEN** все три команды завершаются успешно, после последней в `information_schema.tables` схемы `DB_SCHEMA` 43 таблицы модели (+ служебная `alembic_version`)
и в `pg_type` 28 enum-типов
#### 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 (`DBML_VERSION = "5.23"`); скрипт сверки SHALL сравнивать её с версией в шапке файла
DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна.
#### Scenario: Новая версия DBML без обновления ORM
- **WHEN** в `docs/model/` появляется `data-model-v5.24.dbml`, а константа в ORM — `5.23`
- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц
### Requirement: Деньги и проценты — точные типы
Все денежные колонки SHALL быть `numeric(18,2)`, проценты — `numeric(7,4)`/`numeric(9,4)`, уверенность — `numeric(5,2)`;
ORM SHALL отдавать их как `Decimal`, `float` в моделях MUST NOT встречаться.
#### Scenario: Чтение денежной колонки
- **WHEN** из `osv_entry` читается строка со `closing_debit = 106311116.46`
- **THEN** значение имеет тип `Decimal` и равно `Decimal("106311116.46")`
### Requirement: Миграция DBML v5.19
Одна миграция Alembic SHALL приводить схему к DBML v5.19 и **только** добавлять колонки, таблицу, enum'ы и индексы и
заполнять значения по умолчанию (Р-24); §7 по существующим формам миграция MUST NOT считать, импорта копии
`arm_statements` в ревизии нет. Перед выкладкой база очищается от форм ББ/ОФР (требование «Очистка форм перед
выкладкой»), но миграция SHALL проходить и на непустой базе (стенды разработчиков, база, очищенная не полностью):
`row_index` — `NULL` у строк с `line_code_original_value IS NULL` (созданных правкой), у остальных — по возрастанию
`line_code` внутри формы с 0; `line_status = ok`, `needs_review` строк — `false`, `needs_review` значений — **НЕ**
`values_ok` (`values_ok` — правлено, или `is_recognized` не `false` и `confidence` равна 100 или пуста), `source_text =
string_value`, `is_confirmed = false`, `period_kind` — `absolute` при `period_date`, иначе `none`, `form_variant =
null`, `header_source` — из `*_original_value` и `inn_confidence`, `organization_name_original_value =
organization_name`, `document.file_ordinal` — по `(created_at, id)` внутри файла с 0 (у всех документов),
`related_group.crm_id` — `null`; `document_issue` — пуст. **Корректность нарушений у форм, оставшихся с до-миграционных
времён, не гарантируется:** у них нет записей `document_issue`, поэтому до первой правки экран не показывает нарушений
§7, а `POST …/confirm` не отвечает `CHECKS_NOT_PASSED`; первый PATCH пересчитывает форму общим кодом. Это цена Р-24; на
контуре её снимает очистка. Сверка `check_dbml_vs_orm.py` — против `data-model-v5.19.dbml`.
Откат (`downgrade`) SHALL быть гарантирован на пустой БД и на данных, которые схема v5.18 может представить без потери
первичных данных и ручного ввода. `downgrade` SHALL останавливаться с ошибкой, называющей причину и число записей, и
ничего не менять, если есть: две строки с одним кодом в форме, строка с `line_code = null`, две формы одного типа в файле,
непустой `related_group.crm_id`, `organization_name_modified = true` (исходное наименование хранится только в новой
колонке). Остальные новые колонки и таблица `document_issue` — производные и сигнальные данные v5.19 (`needs_review`,
`source_text`, `header_source`, `is_confirmed` с `confirmed_by/at`, `period_kind`, `label_confidence`, `form_variant`,
`needs_review_fields`, `file_ordinal`, `statements_version`, `document_issue`): `downgrade` удаляет их и пишет в журнал
миграции, сколько записей с непустыми значениями удалено (подтверждения аналитика остаются в `audit_event`). Это не
«молчаливое удаление»: потеря названа в журнале и в этом требовании.
#### Scenario: Цикл миграций
- **WHEN** `alembic upgrade head` → `downgrade base` → `upgrade head` на чистой БД
- **THEN** все три шага проходят, сверка ORM с DBML v5.19 — без расхождений
#### Scenario: Миграция на непустой базе
- **WHEN** на стенде есть форма ББ, загруженная до v5.19, с несошедшимся `1600 = 1700`, и выполнена миграция
- **THEN** миграция проходит: у строк `row_index` по коду, `line_status = ok`, у документа `file_ordinal`, записей
`document_issue` нет — нарушение до первой правки не показано (корректность не гарантируется); после любого PATCH форма
пересчитана, и запись `RELATION_FAILED` появляется
#### Scenario: Строка правки до миграции
- **WHEN** до миграции у формы есть строка 1250, созданная правкой (`line_code_original_value = null`), с суммой
- **THEN** после миграции у неё `row_index = null`, `origin = template`; строки источника пронумерованы с 0
#### Scenario: Откат на данных v2.0
- **WHEN** в БД есть форма с двумя строками 2410, выполняется `downgrade` ниже v5.19
- **THEN** откат останавливается с ошибкой о повторе кода; данные не изменены
#### Scenario: Откат после подтверждений
- **WHEN** в БД нет несовместимых данных, но у ячеек есть `is_confirmed = true` и записи `document_issue`
- **THEN** `downgrade` проходит, удаляет колонки и таблицу и пишет в журнал число удалённых подтверждений и записей
### Requirement: Миграция DBML v5.20
Одна добавочная миграция после 0007_dbml_v5_19 SHALL добавить четыре колонки document по DBML v5.20; существующие записи получают is_modified = false и остальные поля NULL. Миграция MUST NOT менять документы, их строки, подтверждения и привязки. Downgrade SHALL до любых изменений проверить отсутствие document.is_modified = true и при наличии остановиться с ошибкой и числом записей.
#### Scenario: Обновление существующих ОСВ
- **WHEN** миграция применяется к базе с подтверждёнными ОСВ РБ
- **THEN** добавлены четыре поля, данные ОСВ, id, file_ordinal, подтверждения и привязки сохранены; сверка с DBML v5.20 проходит
#### Scenario: Защита от потери исправлений
- **WHEN** хотя бы один документ имеет is_modified = true и запрошен downgrade до v5.19
- **THEN** откат прекращается до DDL с причиной и числом записей, схема и данные не меняются
#### Scenario: Пустая база и нестандартная схема
- **WHEN** выполняется upgrade head, downgrade base, upgrade head на пустой базе с настроенным DB_SCHEMA
- **THEN** цикл успешен, ORM соответствует DBML v5.20, autogenerate пуст
### Requirement: Миграция DBML v5.21 и действие аудита повторной обработки
Отдельная миграция v5.21 после миграции v5.20 SHALL добавить только FILE_REPROCESS в enum audit_action
по DBML v5.21. Миграция SHALL сохранять все существующие значения и события. Downgrade MUST NOT удалять либо
переписывать audit_event; при наличии FILE_REPROCESS он SHALL завершаться ошибкой до изменения схемы.
#### Scenario: Схема после дополнения модели
- **WHEN** миграция v5.21 применена к БД v5.20
- **THEN** событие нового типа записывается, прежние события читаются без изменений, сверка DBML успешна
#### Scenario: Откат после ручного повтора
- **WHEN** существует audit_event с FILE_REPROCESS и выполняется downgrade с v5.21 к v5.20
- **THEN** ошибка с причиной и количеством несовместимых событий до DDL, события и схема сохранены
#### Scenario: Независимый откат enum
- **WHEN** в БД v5.21 нет FILE_REPROCESS и выполняется downgrade к v5.20
- **THEN** удалено только новое значение enum; четыре поля document, исходные значения и привязки сохранены
### Requirement: Миграция DBML v5.23 — версия правил элиминации обязательна
Миграция `0010_dbml_v5_23` после `0009_dbml_v5_21` SHALL заполнить пустой `consolidation_version.elimination_rules_version`
номером версии правил, действовавшей на московскую дату `created_at` версии (пустой `created_at` — текущая дата), а если
такой нет — последней открытой версии (`valid_to IS NULL`), затем сделать колонку NOT NULL. Если после заполнения
остаются пустые значения (правил нет вовсе), миграция SHALL завершиться ошибкой с числом таких версий до изменения схемы.
Откат SHALL снимать только NOT NULL, значения сохраняются. v5.22 структуру БД не меняла, отдельной миграции у неё нет.
#### Scenario: Заполнение по дате создания
- **WHEN** правила v1 действуют до 30.06.2026, v2 — с 01.07.2026, версии созданы 01.03.2026, 30.06.2026 21:30 UTC и без даты
- **THEN** после `upgrade head` у них 1, 2 и 2; колонка NOT NULL; `downgrade 0009_dbml_v5_21` снимает NOT NULL, значения остаются
#### Scenario: Версии без правил
- **WHEN** в БД есть версия консолидации и нет ни одного правила элиминации
- **THEN** `upgrade head` падает с ошибкой «миграция v5.23 невозможна…», ревизия остаётся `0009_dbml_v5_21`