openspec: change console-files — файлы в контексте консолидации, DBML v5.18 (#96)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-09 23:30:02 +04:00
parent fde860be7c
commit fcfb3f7f4e
6 changed files with 462 additions and 0 deletions

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-09

View File

@ -0,0 +1,105 @@
## Context
Сквозной дизайн — `../docs/openspec/changes/console-files/design.md` (модель, алгоритмы, API, сравнение вариантов
привязки). Здесь — только решения уровня ядра: где лежит код, как считается стол, как переводятся версии, миграция.
Точки опоры в коде: единственная точка создания `stored_file` — `services/primary_files._store`; переход версии после
загрузки — `after_upload_to_version` (отдельная транзакция `session_transaction`, `lock_version FOR UPDATE`, затем
`consolidation_status.transition`); автозамена — `services/result_mapping.supersede_previous` (старые файлы под
`FOR UPDATE ORDER BY id`); карточка файла — `services/documents.file_out`; видимость консолидации —
`repositories/consolidations._visible_consolidations` (ГК в области пользователя).
## Goals / Non-Goals
Goals: см. proposal. Non-goals: см. proposal.
## Decisions
**D-1. Колонка последней.** `StoredFile.consolidation_id` объявляется после `updated_at` в ORM и в DBML v5.18:
`ALTER TABLE … ADD COLUMN` ставит её в конец, а `check_dbml_vs_orm` сверяет порядок (как `kpp` в v5.17). FK —
`fk_stored_file_consolidation_id_consolidation` по соглашению имён 0001; индекс `ix_stored_file_legal_entity_id_consolidation_id`.
**D-2. Backfill в миграции 0006 — одним UPDATE.** Для каждого файла с документом в `consolidation_document` берётся
консолидация версии; при нескольких — самый ранний период (`ORDER BY c.year, c.quarter, c.id`, enum `period_quarter`
упорядочен Q1 < … < Q4 по объявлению):
```sql
UPDATE {S}.stored_file AS sf SET consolidation_id = pick.consolidation_id
FROM (
SELECT DISTINCT ON (d.stored_file_id) d.stored_file_id, c.id AS consolidation_id
FROM {S}.document d
JOIN {S}.consolidation_document cd ON cd.document_id = d.id
JOIN {S}.consolidation_version v ON v.id = cd.consolidation_version_id
JOIN {S}.consolidation c ON c.id = v.consolidation_id
ORDER BY d.stored_file_id, c.year, c.quarter, c.id
) AS pick
WHERE sf.id = pick.stored_file_id
```
Откат — `DROP INDEX`, `DROP COLUMN` (FK уходит вместе с колонкой).
**D-3. Проверки загрузки в консолидацию — до приёма, без блокировок.** `PrimaryFilesService.check_consolidation_for_upload
(consolidation_id, entity) -> Consolidation`: `consolidations_repo.get_consolidation(session, scope, id)` (видимость по ГК,
`None` → 404 `NOT_FOUND` «консолидация не найдена»); `consolidations_repo.versions_of(session, consolidation_id)`; версии
со статусом из `IN_WORK_STATUSES` — пусто → 409 `CONSOLIDATION_NOT_EDITABLE`; `consolidations_repo.members` по каждой из
них — ЮЛ ни в одной → 422 `ENTITY_NOT_IN_PERIMETER`. Как и раньше, `FOR UPDATE` не нужен: статус перечитывается под
блокировкой при переходе.
**D-4. Переход N версий одной отдельной транзакцией.** `after_upload_to_consolidation(consolidation_id, entity_id)`:
в `session_transaction` новый репозиторий `consolidations_repo.lock_versions_of_consolidation(session, consolidation_id,
entity_id, statuses={INITIATED})` — `SELECT … FOR UPDATE ORDER BY id` версий этой консолидации с ЮЛ в периметре;
по каждой `transition(INITIATED → DOCUMENTS_PROCESSING, changed_by = загрузивший)`. Любое исключение логируется и не
меняет ответ (R-10 Среза 4 сохраняется). Advisory-lock ГК не берётся: версии создаются только через `POST
/consolidations`, которое берёт его само, а новая версия не видна до commit — она просто не попадёт в выборку, и её
статус `INITIATED` переведёт следующая загрузка или подтверждение (то же, что сейчас для одной версии).
**D-5. Один цикл приёма для двух маршрутов.** Логика приёма файлов (чтение с лимитом, `upload_one`, сбор
`accepted`/`rejected`, 422 при пустом `accepted`) выносится из `upload.py` в функцию `accept_uploads(service, entity,
files, response, *, consolidation_id) -> UploadResult`; маршрут ЮЛ вызывает её с `None`, маршрут консолидации — с id
после проверок D-3 и затем `after_upload_to_consolidation`. Новый модуль `api/endpoints/files/consolidation_files.py`
(оба маршрута консолидации), подключается в `files/__init__.py`. Проверка права — `DOCUMENT_EDIT`/`DOCUMENT_VIEW`, как
у маршрутов ЮЛ (загружает и смотрит первичку тот же аналитик; `CONSOLIDATION_*` права не требуются).
**D-6. Рабочий стол — один запрос с `OR EXISTS`.** `files_repo.list_desk_files(session, scope, entity_id,
consolidation_id, *, include_superseded, limit, offset)` повторяет `list_files` с условием
`StoredFile.consolidation_id == cid OR EXISTS (document d JOIN consolidation_document cd JOIN consolidation_version v
WHERE d.stored_file_id = StoredFile.id AND v.consolidation_id = cid)`; `count` тем же фильтром; порядок прежний. Сервис
`page_desk(consolidation_id, entity_id, *, include_superseded, page)`: консолидация видима (404), ЮЛ видимо (404),
затем `_cards`. Привязка ЮЛ к ГК консолидации не проверяется: стол ЮЛ чужой ГК просто пуст.
**D-7. `FileOut.consolidation_id`** — из `file.consolidation_id` в `file_out`; `DocumentBrief` не меняется.
**D-8. Наследование при автозамене** — в `supersede_previous` сразу после `new_file.version_no = …`:
`if new_file.consolidation_id is None: new_file.consolidation_id = next((old.consolidation_id for old in locked if
old.consolidation_id is not None), None)`; `locked` уже упорядочен по `id`.
**D-9. Удаление старого контекста версии.** `check_version_for_upload`, `after_upload_to_version` и query
`consolidation_version_id` удаляются вместе с тестами на них (переписываются под консолидацию); код
`VERSION_NOT_EDITABLE` остаётся для правок версии, `VERSION_NOT_FOUND` — для маршрутов версии.
**D-10. Аудит.** `FILE_UPLOAD.after_value` получает `consolidation_id` (строка uuid или `null`); отдельного события
«загрузка в консолидацию» нет; переходы версий пишут `CONSOLIDATION_STATUS_CHANGE` как раньше.
## Risks / Trade-offs
- `OR EXISTS` по `consolidation_document` на большом ЮЛ — подзапрос по индексу `(consolidation_version_id, document_id)`
и `document (stored_file_id)`; объёмы — сотни файлов на ЮЛ, не тысячи.
- Переход нескольких версий в одной транзакции — пока версия одна; порядок `ORDER BY id` совпадает с R-2 Среза 4.
- Backfill «самый ранний период» — эвристика для тестовых стендов; на проде данных до миграции нет.
## Decisions made without discussion
1. 09.10.2026 — **R-1:** права маршрутов консолидации — `DOCUMENT_EDIT`/`DOCUMENT_VIEW`, а не `CONSOLIDATION_*`:
первичку загружает и смотрит тот же пользователь, что и на странице ЮЛ. Цена ошибки: низкая — одна константа в
`dependencies` маршрута.
2. 09.10.2026 — **R-2:** 404 загрузки/стола по консолидации — код `NOT_FOUND` с текстом «консолидация не найдена»
(константа `CONSOLIDATION_NOT_FOUND` в `services/consolidation_guard.py`), в духе правила (7) README. Цена ошибки:
низкая.
3. 09.10.2026 — **R-3:** стол не проверяет принадлежность ЮЛ к ГК консолидации (D-6). Цена ошибки: низкая — пустой
список вместо 422.
4. 09.10.2026 — **R-4:** advisory-lock ГК при переходе версий после загрузки не берётся (D-4). Цена ошибки: низкая —
версия, созданная параллельно с загрузкой, останется `INITIATED` до следующего события, как и сейчас.
5. 09.10.2026 — **R-5:** общий приёмный цикл `accept_uploads` живёт в `endpoints/files/upload.py` и импортируется
`consolidation_files.py` (а не в сервисе): он работает с `UploadFile`/`Response` FastAPI. Цена ошибки: низкая.
6. 09.10.2026 — **R-6:** backfill миграции тестируется сценарием «файл с двумя привязками к версиям разных периодов →
самый ранний»; файл без привязок остаётся `NULL`. Цена ошибки: низкая.

View File

@ -0,0 +1,71 @@
## Why
Панель «Первичные документы» ЮЛ, открытая из версии консолидации, показывает все файлы ЮЛ: `stored_file` не знает,
в какую консолидацию загружен, `GET /legal-entities/{id}/files` не фильтруется, а `consolidation_version_id` при
загрузке служит только переходу статуса версии. Решения владельца 09.10.2026 (RSHB/arm-ka-docs#90, сквозной change
`../docs/openspec/changes/console-files/`, DBML v5.18): файл загружается **в консолидацию** (ГК + период) и образует
её «рабочий стол», общий для всех версий периода; что использует конкретная версия, по-прежнему определяет
`consolidation_document`; загрузка и список в контексте консоли — маршрутами от консолидации.
Срез: доработка Срезов 2c/4 вне плана `09-delivery-plan.md` (запись в разделе «Срез 2d»). Репозиторий — только
`arm-core`; фронт — RSHB/arm-web (свой change), документы — RSHB/arm-ka-docs#90. Issue — RSHB/arm-core#96.
Правила домена: `05-domain-rules.md` §9.1 (переход при загрузке), §9.5 п. 1, 3, 8 и 11 (редакция v5.18 из docs #90).
Таблицы DBML v5.18: `stored_file` (новая колонка `consolidation_id`, индекс `(legal_entity_id, consolidation_id)`),
читаются `consolidation`, `consolidation_version`, `consolidation_member`, `consolidation_document`, `document`.
## What Changes
- **Модель и миграция.** `StoredFile.consolidation_id` (nullable FK → `consolidation`, последняя колонка), индекс
`ix_stored_file_legal_entity_id_consolidation_id`; `DBML_VERSION = "5.18"`, `make dbml-check` по
`../docs/model/data-model-v5.18.dbml`; миграция `0006_dbml_v5_18`: колонка, FK, индекс, backfill по
`consolidation_document` (при привязках к версиям разных периодов — самый ранний), откат — удаление.
- **Загрузка в консолидацию.** `POST /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files`
(`DOCUMENT_EDIT`): консолидация и ЮЛ видимы (404), есть версия «в работе» (409 `CONSOLIDATION_NOT_EDITABLE`), ЮЛ в
периметре хотя бы одной из них (422 `ENTITY_NOT_IN_PERIMETER`); файл получает `consolidation_id`,
`FILE_UPLOAD.after_value.consolidation_id`; после приёма ≥ 1 файла каждая версия консолидации «в работе» с ЮЛ в
периметре и статусом `INITIATED` переходит в `DOCUMENTS_PROCESSING` отдельной транзакцией.
- **Загрузка без контекста.** `POST /legal-entities/{id}/files`: query `consolidation_version_id` удалён, коды
`VERSION_NOT_FOUND`/`VERSION_NOT_EDITABLE` у загрузки больше не возникают; `consolidation_id = NULL`.
- **Рабочий стол.** `GET /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` (`DOCUMENT_VIEW`):
файлы ЮЛ с этим `consolidation_id` ∪ файлы ЮЛ с документом, привязанным к любой версии консолидации; `limit`,
`offset`, `include_superseded`; та же карточка `FileOut`.
- **Карточка файла.** `FileOut.consolidation_id: uuid | null` в списках и `GET /files/{id}`.
- **Автозамена.** Новый файл без контекста наследует `consolidation_id` первого (по `id`) заменённого файла, у которого
он задан.
- Спеки `primary-files`, `documents-api`, `data-model`; README («Ошибки API», таблицы маршрутов, §9.1), `CLAUDE.md`.
## Non-goals
- Несколько рабочих версий одного периода и «новая версия после `VERIFIED`» не реализуются (OQ-160, отдельный срез);
переход статуса после загрузки написан для N версий, но создаётся по-прежнему одна.
- Замороженный список первички верифицированной версии (ЕХ, Срез 7) не строится; стол показывается для версии любого
статуса, загрузка проверяет только наличие версии «в работе».
- Признак «документ привязан / подходит версии» в `FileOut` не вводится (чек-лист, OQ-120).
- `consolidation_document`, `fits`, подхват, удаление файла — без изменений.
- Перенос файла между консолидациями, правка `consolidation_id` через API — нет.
- Контракт NATS и S3 не меняется.
## Capabilities
### New Capabilities
### Modified Capabilities
- `primary-files`: загрузка в консолидацию (новый маршрут, коды ошибок, переход N версий), удаление query у загрузки ЮЛ,
наследование `consolidation_id` при автозамене.
- `documents-api`: рабочий стол консоли, `consolidation_id` в карточке файла.
- `data-model`: DBML v5.18, миграция 0006 с backfill.
## Impact
- `api/src/models/files.py`, `api/src/models/__init__.py`, `api/alembic/versions/0006_dbml_v5_18.py`, `Makefile`
(`DBML`), `api/scripts/dbml_parity.py` (путь по умолчанию), `api/tests/dbml_helpers.py`.
- `api/src/repositories/files.py` (стол), `api/src/repositories/consolidations.py` (консолидация по id, версии
консолидации под `FOR UPDATE`), `api/src/services/primary_files.py` (загрузка в консолидацию, стол),
`api/src/services/result_mapping.py` (наследование), `api/src/services/documents.py` (`file_out`),
`api/src/schemas/files.py`, `api/src/api/endpoints/files/upload.py`, новый `consolidation_files.py`,
`api/src/api/endpoints/files/__init__.py`.
- Тесты: `test_api_files_upload.py` (раздел «в контексте версии» переписывается под консолидацию),
`test_api_files_read.py`/новый `test_api_consolidation_files.py`, `test_migrations.py`, `test_models_part_04_06.py`,
тесты автозамены.
- Клиент arm-web меняется одновременно (query удалён); стенд обновляется после слияния обоих PR.

View File

@ -0,0 +1,100 @@
## RENAMED Requirements
- FROM: `### Requirement: Схема БД равна DBML v5.17`
- TO: `### Requirement: Схема БД равна DBML v5.18`
## MODIFIED Requirements
### 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, значения
по умолчанию и индексы 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 существовать.
#### Scenario: Сверка ORM с DBML
- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.18.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` их возвращает
### 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.18"`); скрипт сверки SHALL сравнивать её с версией в шапке файла
DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна.
#### Scenario: Новая версия DBML без обновления ORM
- **WHEN** в `docs/model/` появляется `data-model-v5.19.dbml`, а константа в ORM — `5.18`
- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц

View File

@ -0,0 +1,50 @@
## MODIFIED Requirements
### Requirement: Файлы организации и карточка файла
`GET /api/v1/legal-entities/{id}/files` (`DOCUMENT_VIEW`, policy; `include_superseded=false` по умолчанию) SHALL
возвращать `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`,
`comments_count`). `GET /api/v1/files/{file_id}` SHALL отдавать ту же карточку; чужой или несуществующий файл — 404.
#### Scenario: Список после обработки
- **WHEN** у ЮЛ два файла: `PROCESSED` с документом и `ERROR`
- **THEN** оба в списке; у первого `documents` непустой, у второго `job.error_code` заполнен
#### Scenario: Счётчик комментариев
- **WHEN** к документу файла добавлены два комментария
- **THEN** в карточке файла у документа `comments_count = 2`
#### Scenario: Консолидация в карточке
- **WHEN** один файл ЮЛ загружен в консолидацию `C`, другой — без контекста
- **THEN** в списке ЮЛ и в `GET /files/{id}` у первого `consolidation_id = C`, у второго `consolidation_id = null`
## ADDED Requirements
### 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

@ -0,0 +1,134 @@
## MODIFIED Requirements
### Requirement: Загрузка файлов организации
`POST /api/v1/legal-entities/{legal_entity_id}/files` (multipart, одно или несколько полей `files`; право
`DOCUMENT_EDIT`; ЮЛ в области видимости пользователя, иначе 404) SHALL для каждого файла в отдельной транзакции:
определить тип по расширению и сверить с сигнатурой содержимого (`xls` — BIFF8, `xlsx`/`xlsb` — zip, `pdf`, `png`,
`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`, `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` 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 файла, `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 МиБ
- **THEN** 201, `accepted` — один, `rejected` — два с кодами `UNSUPPORTED_FILE` и `FILE_TOO_LARGE`
#### Scenario: Расширение не совпадает с содержимым
- **WHEN** загружается файл `osv.xlsx`, байты которого — BIFF8
- **THEN** файл отклонён с кодом `UNSUPPORTED_FILE`
#### Scenario: Чужое ЮЛ
- **WHEN** `ka_rf` загружает файл для ЮЛ другого филиала
- **THEN** 404, объектов и записей нет
#### Scenario: Загрузка в контексте версии
- **WHEN** у ГК ЮЛ есть версия `INITIATED` с этим ЮЛ в периметре, файл загружен маршрутом ЮЛ с query
`consolidation_version_id` (как делал клиент до v5.18)
- **THEN** 201, параметр игнорируется; `stored_file.consolidation_id IS NULL`; версия остаётся `INITIATED`
#### Scenario: ЮЛ вне периметра
- **WHEN** ЮЛ не входит в периметр ни одной версии, файл загружен маршрутом ЮЛ
- **THEN** 201 — маршрут ЮЛ периметр не проверяет; проверка периметра — только у загрузки в консолидацию
### Requirement: Автозамена прежних версий
После успешного маппинга файла ядро SHALL найти другие файлы того же ЮЛ со статусом не `SUPERSEDED`, у которых есть
документ с теми же `document_type`, `osv_account_code`, `period_year`, `period_quarter`, и перевести их в
`SUPERSEDED` (`superseded_by_file_id` = новый файл, `updated_at`); новый файл получает `version_no` = максимум среди
заменённых + 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` с
новым файлом и `unbound_version_ids`, в `description` — перечень заменённых документов и признак `confirmed`, если
заменён документ `CONFIRMED`). Решение владельца 05.10.2026: замена происходит и для `CONFIRMED`.
Замена остаётся на уровне файла (OQ-115), но если у заменённого файла есть документ, ключа которого
(`document_type`, `osv_account_code`, `period_year`, `period_quarter`) нет среди документов нового файла (в т. ч. документ
без года или квартала), ядро SHALL добавить в `metrics.mapping_warnings` задания нового файла (карточка файла,
`JobBrief.mapping_warnings`) по одному предупреждению на заменённый файл (OQ-154): имя и версия заменённого файла,
выпавшие документы (тип, счёт, период, статус — в т. ч. `CONFIRMED`) и версии консолидации «в работе», с которых сняты
привязки этих документов. Поведение замены и статусы документов нового файла от предупреждения не меняются.
#### Scenario: Новая ОСВ 60 за 1 кв. 2025
- **WHEN** у ЮЛ есть `PROCESSED` файл с документом OSV 60 / 2025 / Q1 (`CONFIRMED`), загружен и смапплен новый файл с таким же документом
- **THEN** прежний файл — `SUPERSEDED`, `superseded_by_file_id` = новый, у нового `version_no = 2`; `audit_event(FILE_REPLACE)`
с `description`, содержащим `confirmed=true`; список файлов ЮЛ по умолчанию показывает только новый
#### Scenario: Заменённый документ был в версии
- **WHEN** заменённый `CONFIRMED` документ ОСВ 60 за 2024 Q2 был привязан к версии 2024 Q3 в `DATA_CORRECTION`
- **THEN** привязка удалена; в чек-листе ячейка ОСВ 60 показывает `missing_quarters = ["Q2"]` (или `NEEDS_CHECK`
по новому документу), пока новый документ не подтверждён; статус версии прежний
#### Scenario: Отдельный ОФР заменил книгу «ББ + ОФР»
- **WHEN** у ЮЛ есть файл с `CONFIRMED` ББ и ОФР за Q3 2025, ББ привязан к версии 2025 Q3 в `DATA_CORRECTION`; смаплен
новый файл только с ОФР за Q3 2025
- **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`
## ADDED Requirements
### 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`