openspec: архив console-files, спеки data-model/directories-api — текущая DBML v5.18 (M-5, #96)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-10 10:04:07 +04:00
parent 9be7d7508b
commit fd9efb354e
12 changed files with 124 additions and 28 deletions

View File

@ -141,8 +141,8 @@ MUST NOT изменяться. Ответ — карточка версии.
### Requirement: Статусы Среза 4
Версия SHALL менять статус только через сервис статусов, который пишет `consolidation_status_history(from, to,
changed_by, changed_at, comment)` и `audit_event(CONSOLIDATION_STATUS_CHANGE, before = from, after = to, comment)`.
Автоматические переходы: загрузка файла с `consolidation_version_id` (см. `primary-files`) — `INITIATED →
DOCUMENTS_PROCESSING`; маппинг документа `NEEDS_CHECK`, подходящего версии (см. `osv-result-mapping`) —
Автоматические переходы: загрузка в консолидацию (`POST /consolidations/{id}/legal-entities/{le}/files`, см. `primary-files`) —
`INITIATED → DOCUMENTS_PROCESSING` у каждой версии консолидации «в работе» с ЮЛ в периметре; маппинг документа `NEEDS_CHECK`, подходящего версии (см. `osv-result-mapping`) —
`DOCUMENTS_PROCESSING → DATA_CORRECTION`; подтверждение документа, привязанного к версии (см. `documents-api`) —
`INITIATED` или `DOCUMENTS_PROCESSING → DATA_CORRECTION`. Ручной `POST /consolidations/versions/{id}/status`
(`CONSOLIDATION_EDIT`, policy, своя блокировка) с телом `{to_status, comment}` SHALL допускать только: `CANCELLED` из

View File

@ -1,13 +1,13 @@
# data-model Specification
## Purpose
Схема базы данных ядра совпадает с текущей моделью данных DBML (v5.17) один в один: таблицы, колонки, типы, enum'ы, ключи и
Схема базы данных ядра совпадает с текущей моделью данных DBML (v5.18) один в один: таблицы, колонки, типы, enum'ы, ключи и
индексы; миграции применяются к пустой базе и полностью откатываются; расхождение между ORM и DBML невозможно пропустить.
## Requirements
### Requirement: Схема БД равна DBML v5.17
ORM-метаданные SHALL содержать ровно 43 таблицы и 28 enum'ов из `../docs/model/data-model-v5.17.dbml` в схеме PostgreSQL
### Requirement: Схема БД равна DBML v5.18
ORM-метаданные SHALL содержать ровно 43 таблицы и 28 enum'ов из `../docs/model/data-model-v5.18.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, значения
@ -23,10 +23,16 @@ Enum `counterparty_match_kind` SHALL содержать `INN`, `NAME`, `ALIAS`,
`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`), откат — удалять. Поля, отсутствующие в DBML, MUST NOT существовать.
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 существовать.
#### Scenario: Сверка ORM с DBML
- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.17.dbml` (`make dbml-check`)
- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.18.dbml` (`make dbml-check`)
- **THEN** вывод пуст и код возврата 0
#### Scenario: Лишняя колонка в ORM
@ -50,9 +56,15 @@ SHALL добавлять обе колонки (существующие зап
- **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` их возвращает
### Requirement: Миграции применяются с нуля и откатываются
Цепочка ревизий `0001_dbml_v5_13` → `0002_dbml_v5_14` → `0003_dbml_v5_15` → `0004_dbml_v5_16` → `0005_dbml_v5_17` SHALL
создавать схему из `DB_SCHEMA` (если нет), все
Цепочка ревизий `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`,
@ -82,11 +94,11 @@ enum'ы, таблицы, ограничения, индексы и послед
- **THEN** сгенерированная ревизия не содержит операций (пустые `upgrade`/`downgrade`)
### Requirement: Версия DBML зафиксирована в коде
Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.17"`); скрипт сверки SHALL сравнивать её с версией в шапке файла
Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.18"`); скрипт сверки SHALL сравнивать её с версией в шапке файла
DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна.
#### Scenario: Новая версия DBML без обновления ORM
- **WHEN** в `docs/model/` появляется `data-model-v5.18.dbml`, а константа в ORM — `5.17`
- **WHEN** в `docs/model/` появляется `data-model-v5.19.dbml`, а константа в ORM — `5.18`
- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц
### Requirement: Деньги и проценты — точные типы

View File

@ -3,7 +3,7 @@
## Purpose
API справочников ядра: группы компаний (ГК), юридические лица и ИП с алиасами и историей, группы связанных компаний и
заёмщиков (ГСК/ГСЗ) с составом, филиалы. Все изменения идут через сервисы с историей (SCD4/SCD2) и записью в `audit_event`;
имена полей — как в текущей DBML (v5.17).
имена полей — как в текущей DBML (v5.18).
## Requirements

View File

@ -7,7 +7,8 @@
### Requirement: Файлы организации и карточка файла
`GET /api/v1/legal-entities/{id}/files` (`DOCUMENT_VIEW`, policy; `include_superseded=false` по умолчанию) SHALL
возвращать `Page` файлов ЮЛ по убыванию `uploaded_at`: поля `stored_file`, текущее задание (`status`, `error_code`,
возвращать `Page` файлов ЮЛ по убыванию `uploaded_at`: поля `stored_file` (в том числе `consolidation_id: uuid | null`,
v5.18 — консолидация, в контексте которой файл загружен), текущее задание (`status`, `error_code`,
`error_message`, `mapping_error` (`metrics.mapping_error`: причина `ERROR` файла после неудачного маппинга, когда у задания
`error_code` пуст), `metrics.warnings`, `metrics.mapping_warnings`, `finished_at`), документы файла (`id`, `document_type`,
`osv_account_code`, `period_year`, `period_quarter`, `is_cumulative`, `status`, `period_check`, `counterparty_check`,
@ -21,6 +22,10 @@
- **WHEN** к документу файла добавлены два комментария
- **THEN** в карточке файла у документа `comments_count = 2`
#### Scenario: Консолидация в карточке
- **WHEN** один файл ЮЛ загружен в консолидацию `C`, другой — без контекста
- **THEN** в списке ЮЛ и в `GET /files/{id}` у первого `consolidation_id = C`, у второго `consolidation_id = null`
### Requirement: Документ и строки
`GET /api/v1/documents/{document_id}` (`DOCUMENT_VIEW`, policy по ЮЛ владельца) SHALL отдавать документ, статус,
проверки, `locator`, схему группировок листа из `recognition_job.group_schema` и `file_id`.
@ -243,3 +248,29 @@ SHALL отдавать комментарии по `created_at`: `{id, text, aut
#### Scenario: Кириллица в имени
- **WHEN** запрошен файл `ОСВ_66 за 2024.xls`
- **THEN** `filename*` содержит percent-кодированное исходное имя
### Requirement: Рабочий стол консолидации для ЮЛ
`GET /api/v1/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` (`DOCUMENT_VIEW`; `limit`,
`offset`, `include_superseded=false` по умолчанию; §9.5 п. 11 v5.18) SHALL отвечать 404 `NOT_FOUND` на консолидацию вне
видимости или несуществующую («консолидация не найдена») и на ЮЛ вне видимости, иначе возвращать `Page[FileOut]` —
объединение файлов ЮЛ, у которых `stored_file.consolidation_id = consolidation_id`, и файлов ЮЛ, у которых есть документ
с записью `consolidation_document` к любой версии этой консолидации; порядок и карточка — как у списка файлов ЮЛ;
`SUPERSEDED` — только при `include_superseded=true` (в обоих множествах). Принадлежность ЮЛ к ГК консолидации не
проверяется: чужому ЮЛ соответствует пустой список.
#### Scenario: Стол показывает только файлы консоли
- **WHEN** у ЮЛ три файла: загружен в консолидацию 2025 Q1, загружен в консолидацию 2025 Q2, без контекста
- **THEN** стол 2025 Q1 содержит только первый; стол 2025 Q2 — только второй; `GET /legal-entities/{id}/files` — все три
#### Scenario: Подхваченный файл виден через привязку
- **WHEN** файл с `CONFIRMED` ОСВ за 2025 Q1 загружен без контекста и его документ привязан к версии консолидации 2025 Q2
(подхват при создании версии)
- **THEN** файл есть в столе 2025 Q2, хотя `consolidation_id IS NULL`
#### Scenario: Заменённый файл скрыт по умолчанию
- **WHEN** файл консолидации заменён (`SUPERSEDED`) новым файлом той же консолидации
- **THEN** по умолчанию в столе только новый; с `include_superseded=true` — оба
#### Scenario: Чужая консолидация
- **WHEN** `ka_rf` запрашивает стол консолидации ГК другого филиала и несуществующий id
- **THEN** оба ответа 404 с одинаковым телом

View File

@ -13,23 +13,22 @@
`jpg`); отвергнуть файл больше `UPLOAD_MAX_BYTES` (50 МиБ) или неподдерживаемого типа; вычислить sha256; создать
`s3_object` (`bucket_role = uploads`, ключ `uploads/{file_id}/{job_id}/{безопасное имя}`, `sha256`, `size_bytes`,
`content_type`, `created_by = user:{id}`), положить объект в S3, создать `stored_file` (`status = UPLOADED`,
`version_no = 1`, `uploaded_by`, `uploaded_at`, `content_hash`, `original_name`) и `recognition_job` (`status =
queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg → `image`; `max_attempts` 3 / 1), установить
`stored_file.current_recognition_job_id`, записать `audit_event(FILE_UPLOAD, PRIMARY_DOCUMENTS)`. Ответ SHALL
`version_no = 1`, `uploaded_by`, `uploaded_at`, `content_hash`, `original_name`, `consolidation_id = NULL`) и
`recognition_job` (`status = queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg → `image`;
`max_attempts` 3 / 1), установить `stored_file.current_recognition_job_id`, записать
`audit_event(FILE_UPLOAD, PRIMARY_DOCUMENTS)` с `after_value.consolidation_id = null`. Ответ SHALL
перечислять принятые файлы (`accepted[]` с `file_id`, `job_id`, `name`) и отклонённые (`rejected[]` с `name`, `code`,
`message`); код ответа 201, если принят хотя бы один, иначе 422. Тип документа и период при загрузке MUST NOT
запрашиваться (макет `40`). Без доступного S3 — 503 `STORAGE_UNAVAILABLE`.
Необязательный query-параметр `consolidation_version_id` SHALL проверяться до приёма файлов: версия видима (иначе 404
`NOT_FOUND` с сообщением `VERSION_NOT_FOUND`), статус «в работе» (иначе 409 `VERSION_NOT_EDITABLE`), ЮЛ ∈ периметру (иначе 422
`ENTITY_NOT_IN_PERIMETER`); если принят хотя бы один файл и версия в `INITIATED`, она SHALL перейти в
`DOCUMENTS_PROCESSING` (`changed_by` = загрузивший) в отдельной транзакции после приёма файлов. Блокировка версии
для загрузки MUST NOT требоваться.
Query-параметр `consolidation_version_id` MUST NOT приниматься (удалён в v5.18): загрузка этим маршрутом — без
контекста консолидации и не меняет статусы версий; загрузка в консолидацию — требование «Загрузка файлов в
консолидацию».
#### Scenario: Реальная ОСВ принята и ушла в outbox
- **WHEN** `ka_rf` загружает `rb/2024-FY/ОСВ_66 за 2024.xls` для ЮЛ своего филиала
- **THEN** 201, `accepted[0].file_id` существует; `stored_file.status = UPLOADED`, `file_type = XLS`, `content_hash` =
sha256 файла; `recognition_job.status = queued`, `pipeline = tabular`; объект в S3 по ключу `uploads/{file_id}/{job_id}/ОСВ_66 за 2024.xls`;
публикатор outbox публикует запрос с этим ключом
sha256 файла, `consolidation_id IS NULL`; `recognition_job.status = queued`, `pipeline = tabular`; объект в S3 по ключу
`uploads/{file_id}/{job_id}/ОСВ_66 за 2024.xls`; публикатор outbox публикует запрос с этим ключом
#### Scenario: Смесь принятых и отклонённых
- **WHEN** в одном запросе xlsx, файл `.docx` и файл 51 МиБ
@ -44,12 +43,13 @@ queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg →
- **THEN** 404, объектов и записей нет
#### Scenario: Загрузка в контексте версии
- **WHEN** версия в `INITIATED`, ЮЛ в периметре, загружен один принятый файл с `?consolidation_version_id=`
- **THEN** 201; версия в `DOCUMENTS_PROCESSING`, запись истории с `changed_by` = пользователь
- **WHEN** у ГК ЮЛ есть версия `INITIATED` с этим ЮЛ в периметре, файл загружен маршрутом ЮЛ с query
`consolidation_version_id` (как делал клиент до v5.18)
- **THEN** 201, параметр игнорируется; `stored_file.consolidation_id IS NULL`; версия остаётся `INITIATED`
#### Scenario: ЮЛ вне периметра
- **WHEN** `consolidation_version_id` указан, ЮЛ не в периметре версии
- **THEN** 422 `ENTITY_NOT_IN_PERIMETER`, файлы не приняты
- **WHEN** ЮЛ не входит в периметр ни одной версии, файл загружен маршрутом ЮЛ
- **THEN** 201 — маршрут ЮЛ периметр не проверяет; проверка периметра — только у загрузки в консолидацию
### Requirement: Дедуп по содержимому
Файл с тем же sha256, что у существующего `stored_file` того же ЮЛ со статусом не `SUPERSEDED`, SHALL отклоняться с
@ -77,7 +77,9 @@ queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg →
После успешного маппинга файла ядро SHALL найти другие файлы того же ЮЛ со статусом не `SUPERSEDED`, у которых есть
документ с теми же `document_type`, `osv_account_code`, `period_year`, `period_quarter`, и перевести их в
`SUPERSEDED` (`superseded_by_file_id` = новый файл, `updated_at`); новый файл получает `version_no` = максимум среди
заменённых + 1. Документы и строки заменённых файлов SHALL сохраняться (история), но по умолчанию не показываться.
заменённых + 1. Если у нового файла `consolidation_id IS NULL`, он SHALL получить `consolidation_id` первого по `id`
заменённого файла, у которого он задан (v5.18); файл, загруженный в консолидацию, сохраняет свой `consolidation_id`.
Документы и строки заменённых файлов SHALL сохраняться (история), но по умолчанию не показываться.
Привязки `consolidation_document` документов заменённых файлов к версиям «в работе» SHALL удаляться в той же
транзакции (версии блокируются `FOR UPDATE` по возрастанию `id` после старых файлов); привязки к версиям в других
статусах не трогаются; статус версии не меняется. Аудит `FILE_REPLACE` (`object_id` заменённого, `after_value` с
@ -106,6 +108,15 @@ queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg →
- **THEN** прежний файл — `SUPERSEDED`, привязка ББ снята; в `metrics.mapping_warnings` нового задания одно предупреждение:
имя прежнего файла, `BALANCE 2025 Q3 (CONFIRMED)` и версия «ГК» 2025 Q3, версия 1; ОФР в предупреждении нет
#### Scenario: Новый файл без контекста наследует консолидацию
- **WHEN** прежний файл загружен в консолидацию 2025 Q1 (`consolidation_id = C`), новый файл с тем же документом загружен
маршрутом ЮЛ (`consolidation_id IS NULL`) и смаплен
- **THEN** у нового файла `consolidation_id = C`
#### Scenario: Новый файл с контекстом сохраняет свой
- **WHEN** прежний файл в консолидации 2025 Q1 (`C1`), новый с тем же документом загружен в консолидацию 2025 Q2 (`C2`)
- **THEN** у нового файла `consolidation_id = C2`
### Requirement: Удаление файла
`DELETE /api/v1/files/{file_id}` (`DOCUMENT_EDIT`, policy) SHALL удалить строки ОСВ, документы, задания и `stored_file`,
проставить `s3_object.expires_at = now()` (объект физически не удаляется, OQ-22) и записать `audit_event(FILE_DELETE,
@ -138,3 +149,45 @@ advisory-блокировка привязок ГК → версии по `id`.
#### Scenario: Автоподтверждённый без привязок
- **WHEN** удаляется файл, все документы которого автоподтверждены и ни к одной версии не привязаны
- **THEN** 204; `after_value.unbound_version_ids = []`
### Requirement: Загрузка файлов в консолидацию
`POST /api/v1/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` (multipart `files`, право
`DOCUMENT_EDIT`; §9.5 п. 8 v5.18) SHALL до приёма файлов проверить: консолидация видима (ГК в области пользователя;
чужая и несуществующая — одинаковый 404 `NOT_FOUND` «консолидация не найдена»), ЮЛ видимо (404), у консолидации есть
хотя бы одна версия со статусом «в работе» (`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`; иначе 409
`CONSOLIDATION_NOT_EDITABLE`), ЮЛ — член периметра хотя бы одной такой версии (иначе 422 `ENTITY_NOT_IN_PERIMETER`
объектом `detail`). Приём файлов, дедуп, ответ и коды `rejected[]` — как у маршрута ЮЛ; принятый файл SHALL получить
`stored_file.consolidation_id = consolidation_id`, `FILE_UPLOAD.after_value.consolidation_id` = его строка. Если принят
хотя бы один файл, ядро SHALL в отдельной транзакции после приёма заблокировать `FOR UPDATE ORDER BY id` версии этой
консолидации со статусом `INITIATED` и ЮЛ в периметре и перевести каждую в `DOCUMENTS_PROCESSING` (`changed_by` =
загрузивший, запись `consolidation_status_history` и `CONSOLIDATION_STATUS_CHANGE`); сбой перехода логируется и не
меняет ответ. Блокировка версии (`consolidation_lock`) для загрузки MUST NOT требоваться.
#### Scenario: Загрузка в консолидацию переводит версию
- **WHEN** у консолидации 2024 Q3 версия 1 в `INITIATED`, ЮЛ в периметре, загружен один принятый файл
- **THEN** 201; `stored_file.consolidation_id` = консолидация; версия в `DOCUMENTS_PROCESSING`, история с `changed_by`
= пользователь; `FILE_UPLOAD.after_value.consolidation_id` = id консолидации
#### Scenario: Версия в другом рабочем статусе
- **WHEN** единственная версия консолидации в `DATA_CORRECTION`, загружен принятый файл
- **THEN** 201; статус версии не меняется; файл получил `consolidation_id`
#### Scenario: Нет версии в работе
- **WHEN** единственная версия консолидации `CANCELLED`
- **THEN** 409 `CONSOLIDATION_NOT_EDITABLE`, файлы не приняты, объектов в S3 нет
#### Scenario: ЮЛ вне периметра
- **WHEN** ЮЛ той же ГК не входит в периметр ни одной версии «в работе»
- **THEN** 422 `ENTITY_NOT_IN_PERIMETER`, файлы не приняты
#### Scenario: Чужая и несуществующая консолидация
- **WHEN** `ka_rf` загружает файл в консолидацию ГК другого филиала и в несуществующий id
- **THEN** оба ответа 404 с одинаковым телом (`code = NOT_FOUND`), записей нет
#### Scenario: Все файлы отклонены
- **WHEN** в консолидацию с версией `INITIATED` загружен только `.docx`
- **THEN** 422 телом `UploadResult`, версия остаётся `INITIATED`
#### Scenario: Загрузка не требует блокировки версии
- **WHEN** версию держит другой пользователь (`consolidation_lock`), ЮЛ в периметре
- **THEN** 201; версия `INITIATED` → `DOCUMENTS_PROCESSING`