From fd9efb354e155f3de5764548e55487942ea868b5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 10 Oct 2026 10:04:07 +0400 Subject: [PATCH] =?UTF-8?q?openspec:=20=D0=B0=D1=80=D1=85=D0=B8=D0=B2=20co?= =?UTF-8?q?nsole-files,=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B8=20data-model/dire?= =?UTF-8?q?ctories-api=20=E2=80=94=20=D1=82=D0=B5=D0=BA=D1=83=D1=89=D0=B0?= =?UTF-8?q?=D1=8F=20DBML=20v5.18=20(M-5,=20#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../2026-10-10-console-files}/.openspec.yaml | 0 .../2026-10-10-console-files}/design.md | 0 .../2026-10-10-console-files}/proposal.md | 0 .../specs/consolidations-api/spec.md | 0 .../specs/data-model/spec.md | 0 .../specs/documents-api/spec.md | 0 .../specs/primary-files/spec.md | 0 openspec/specs/consolidations-api/spec.md | 4 +- openspec/specs/data-model/spec.md | 30 +++++-- openspec/specs/directories-api/spec.md | 2 +- openspec/specs/documents-api/spec.md | 33 +++++++- openspec/specs/primary-files/spec.md | 83 +++++++++++++++---- 12 files changed, 124 insertions(+), 28 deletions(-) rename openspec/changes/{console-files => archive/2026-10-10-console-files}/.openspec.yaml (100%) rename openspec/changes/{console-files => archive/2026-10-10-console-files}/design.md (100%) rename openspec/changes/{console-files => archive/2026-10-10-console-files}/proposal.md (100%) rename openspec/changes/{console-files => archive/2026-10-10-console-files}/specs/consolidations-api/spec.md (100%) rename openspec/changes/{console-files => archive/2026-10-10-console-files}/specs/data-model/spec.md (100%) rename openspec/changes/{console-files => archive/2026-10-10-console-files}/specs/documents-api/spec.md (100%) rename openspec/changes/{console-files => archive/2026-10-10-console-files}/specs/primary-files/spec.md (100%) diff --git a/openspec/changes/console-files/.openspec.yaml b/openspec/changes/archive/2026-10-10-console-files/.openspec.yaml similarity index 100% rename from openspec/changes/console-files/.openspec.yaml rename to openspec/changes/archive/2026-10-10-console-files/.openspec.yaml diff --git a/openspec/changes/console-files/design.md b/openspec/changes/archive/2026-10-10-console-files/design.md similarity index 100% rename from openspec/changes/console-files/design.md rename to openspec/changes/archive/2026-10-10-console-files/design.md diff --git a/openspec/changes/console-files/proposal.md b/openspec/changes/archive/2026-10-10-console-files/proposal.md similarity index 100% rename from openspec/changes/console-files/proposal.md rename to openspec/changes/archive/2026-10-10-console-files/proposal.md diff --git a/openspec/changes/console-files/specs/consolidations-api/spec.md b/openspec/changes/archive/2026-10-10-console-files/specs/consolidations-api/spec.md similarity index 100% rename from openspec/changes/console-files/specs/consolidations-api/spec.md rename to openspec/changes/archive/2026-10-10-console-files/specs/consolidations-api/spec.md diff --git a/openspec/changes/console-files/specs/data-model/spec.md b/openspec/changes/archive/2026-10-10-console-files/specs/data-model/spec.md similarity index 100% rename from openspec/changes/console-files/specs/data-model/spec.md rename to openspec/changes/archive/2026-10-10-console-files/specs/data-model/spec.md diff --git a/openspec/changes/console-files/specs/documents-api/spec.md b/openspec/changes/archive/2026-10-10-console-files/specs/documents-api/spec.md similarity index 100% rename from openspec/changes/console-files/specs/documents-api/spec.md rename to openspec/changes/archive/2026-10-10-console-files/specs/documents-api/spec.md diff --git a/openspec/changes/console-files/specs/primary-files/spec.md b/openspec/changes/archive/2026-10-10-console-files/specs/primary-files/spec.md similarity index 100% rename from openspec/changes/console-files/specs/primary-files/spec.md rename to openspec/changes/archive/2026-10-10-console-files/specs/primary-files/spec.md diff --git a/openspec/specs/consolidations-api/spec.md b/openspec/specs/consolidations-api/spec.md index 64fcd7b..19331bd 100644 --- a/openspec/specs/consolidations-api/spec.md +++ b/openspec/specs/consolidations-api/spec.md @@ -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` из diff --git a/openspec/specs/data-model/spec.md b/openspec/specs/data-model/spec.md index 44b39f3..dfa5ade 100644 --- a/openspec/specs/data-model/spec.md +++ b/openspec/specs/data-model/spec.md @@ -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: Деньги и проценты — точные типы diff --git a/openspec/specs/directories-api/spec.md b/openspec/specs/directories-api/spec.md index eb6c42b..844f8fc 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.17). +имена полей — как в текущей DBML (v5.18). ## Requirements diff --git a/openspec/specs/documents-api/spec.md b/openspec/specs/documents-api/spec.md index 64aeaf0..952e1ad 100644 --- a/openspec/specs/documents-api/spec.md +++ b/openspec/specs/documents-api/spec.md @@ -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 с одинаковым телом diff --git a/openspec/specs/primary-files/spec.md b/openspec/specs/primary-files/spec.md index 6de8384..0ac3258 100644 --- a/openspec/specs/primary-files/spec.md +++ b/openspec/specs/primary-files/spec.md @@ -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`