From fcfb3f7f4e1183c4140f65b704aa4ac6dac2b365 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 23:30:02 +0400 Subject: [PATCH 1/8] =?UTF-8?q?openspec:=20change=20console-files=20?= =?UTF-8?q?=E2=80=94=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B5=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=81=D0=BE=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D0=B8,?= =?UTF-8?q?=20DBML=20v5.18=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- openspec/changes/console-files/.openspec.yaml | 2 + openspec/changes/console-files/design.md | 105 ++++++++++++++ openspec/changes/console-files/proposal.md | 71 ++++++++++ .../console-files/specs/data-model/spec.md | 100 +++++++++++++ .../console-files/specs/documents-api/spec.md | 50 +++++++ .../console-files/specs/primary-files/spec.md | 134 ++++++++++++++++++ 6 files changed, 462 insertions(+) create mode 100644 openspec/changes/console-files/.openspec.yaml create mode 100644 openspec/changes/console-files/design.md create mode 100644 openspec/changes/console-files/proposal.md create mode 100644 openspec/changes/console-files/specs/data-model/spec.md create mode 100644 openspec/changes/console-files/specs/documents-api/spec.md create mode 100644 openspec/changes/console-files/specs/primary-files/spec.md diff --git a/openspec/changes/console-files/.openspec.yaml b/openspec/changes/console-files/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/console-files/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/console-files/design.md b/openspec/changes/console-files/design.md new file mode 100644 index 0000000..9ef9ada --- /dev/null +++ b/openspec/changes/console-files/design.md @@ -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`. Цена ошибки: низкая. diff --git a/openspec/changes/console-files/proposal.md b/openspec/changes/console-files/proposal.md new file mode 100644 index 0000000..c6cc68c --- /dev/null +++ b/openspec/changes/console-files/proposal.md @@ -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. diff --git a/openspec/changes/console-files/specs/data-model/spec.md b/openspec/changes/console-files/specs/data-model/spec.md new file mode 100644 index 0000000..a853b3d --- /dev/null +++ b/openspec/changes/console-files/specs/data-model/spec.md @@ -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** сверка против нового файла падает с сообщением о версии до сравнения таблиц diff --git a/openspec/changes/console-files/specs/documents-api/spec.md b/openspec/changes/console-files/specs/documents-api/spec.md new file mode 100644 index 0000000..f082427 --- /dev/null +++ b/openspec/changes/console-files/specs/documents-api/spec.md @@ -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 с одинаковым телом diff --git a/openspec/changes/console-files/specs/primary-files/spec.md b/openspec/changes/console-files/specs/primary-files/spec.md new file mode 100644 index 0000000..e020280 --- /dev/null +++ b/openspec/changes/console-files/specs/primary-files/spec.md @@ -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` -- 2.47.2 From 45e761bd23645356ab715c931f3e434cb99fea60 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 23:34:53 +0400 Subject: [PATCH 2/8] =?UTF-8?q?plan:=20console-files=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=20=D0=B4=D0=BB=D1=8F=20arm-core=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../plans/2026-10-09-console-files-core.md | 1110 +++++++++++++++++ 1 file changed, 1110 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-09-console-files-core.md diff --git a/docs/superpowers/plans/2026-10-09-console-files-core.md b/docs/superpowers/plans/2026-10-09-console-files-core.md new file mode 100644 index 0000000..df2a89c --- /dev/null +++ b/docs/superpowers/plans/2026-10-09-console-files-core.md @@ -0,0 +1,1110 @@ +# Файлы в контексте консолидации — план для arm-core + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ядро хранит консолидацию загрузки файла (`stored_file.consolidation_id`, DBML v5.18), принимает файлы в +консолидацию и отдаёт её рабочий стол по маршрутам `/consolidations/{cid}/legal-entities/{le}/files`. + +**Architecture:** Колонка + миграция с backfill; новые функции репозиториев (консолидация по id, версии консолидации +под `FOR UPDATE`, стол `OR EXISTS`); сервис `PrimaryFilesService` получает загрузку в консолидацию и стол вместо +контекста версии; два новых маршрута в `endpoints/files/consolidation_files.py`, общий приёмный цикл в `upload.py`; +наследование `consolidation_id` при автозамене. Всё по TDD: интеграционные тесты через `auth_client`/`seeded`. + +**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2 async, Alembic, PostgreSQL 16 (compose `make up`), pytest-asyncio, +ruff + mypy. + +**Spec:** `openspec/changes/console-files/` (`proposal.md`, `design.md` D-1…D-10, `specs/primary-files`, +`specs/documents-api`, `specs/data-model`); сквозной дизайн — `../docs/openspec/changes/console-files/design.md`; +правила — `../docs/context/05-domain-rules.md` §9.1, §9.5 п. 1, 3, 8, 11. + +## Global Constraints + +- Worktree `/Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core`, ветка `feat/96-console-files`; все + git-команды — `git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core …`; `make` — из корня + worktree. DBML v5.18 читается из соседнего worktree docs: `../docs/model/data-model-v5.18.dbml` (от корня arm-core). +- Язык кода-комментариев, docstring, коммитов — русский; идентификаторы — английские, как в DBML. +- Сессия в маршрутах — только `SessionDep` через сервисные зависимости; сервисы не коммитят. Отдельная транзакция — + `self._transaction` / `session_transaction`, как в текущем `after_upload_to_version`. +- Коды ошибок: 404 `NOT_FOUND` с текстом «консолидация не найдена» (константа `CONSOLIDATION_NOT_FOUND`), 409 + `CONSOLIDATION_NOT_EDITABLE`, 422 `ENTITY_NOT_IN_PERIMETER` (существующий текст). Коды `VERSION_NOT_EDITABLE`, + `VERSION_NOT_FOUND` у загрузки больше не возникают. +- Маршруты: `POST /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` (`DOCUMENT_EDIT`), + `GET /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` (`DOCUMENT_VIEW`); у + `POST /legal-entities/{id}/files` query `consolidation_version_id` удаляется. +- Колонка `consolidation_id` — последняя в `StoredFile` (после `updated_at`); FK + `fk_stored_file_consolidation_id_consolidation`; индекс `ix_stored_file_legal_entity_id_consolidation_id`. +- Порядок блокировок Среза 4 не нарушать: версии — `FOR UPDATE ORDER BY id`; статус версии меняет только + `consolidation_status.transition`. +- `audit_event` — только через `AuditService.record` / `RequestAudit.record`. +- Коммит заканчивается строкой `Co-Authored-By: Claude Fable 5.1 `. +- Перед PR — `make check-full` (нужны Docker и сеть), вывод — в описание PR. + +## Review Focus + +1. Загрузка в консолидацию, у которой две версии «в работе», ЮЛ в периметре только одной: 422 быть не должно, переход + — только у версии с ЮЛ. Тест `test_upload_moves_only_versions_with_entity` в Task 3. +2. Стол консолидации не должен показывать файл другой консолидации того же ЮЛ, даже если его документ привязан к версии + **другого** периода. Тест `test_desk_excludes_other_consolidation` в Task 2. +3. `include_superseded=true` должен возвращать `SUPERSEDED` файлы и из `DESK`, и из `BOUND`. Тест + `test_desk_superseded_hidden_by_default` в Task 2. +4. Backfill 0006 на файле с привязками к версиям двух консолидаций разных лет (2024 Q4 и 2025 Q1) должен выбрать 2024 Q4 + (год важнее квартала). Тест `test_upgrade_0006_backfills_consolidation_id` в Task 1. +5. Автозамена, где новый файл без контекста заменяет два файла: один без `consolidation_id`, другой с ним, причём + «пустой» имеет меньший `id` — новый должен унаследовать непустое значение. Тест + `test_supersede_inherits_consolidation_from_first_with_value` в Task 4. + +--- + +### Task 1: DBML v5.18 — ORM, миграция 0006 с backfill, сверка + +**Files:** +- Modify: `api/src/models/__init__.py` (`DBML_VERSION`), `api/src/models/files.py` (`StoredFile`), + `api/scripts/dbml_parity.py` (`DBML_FILE_NAME`), `Makefile` (`DBML`), `api/tests/test_check_dbml_script.py` + (строки 145, 151), `api/tests/test_migrations.py`, `CLAUDE.md` (строка «ORM 1:1 с …»), `README.md` (строки с + `v5.17.dbml` и списком миграций) +- Create: `api/alembic/versions/0006_dbml_v5_18.py` +- Test: `api/tests/test_migrations.py`, `api/tests/test_models_part_04_06.py`, `api/tests/test_check_dbml_script.py` + +**Interfaces:** +- Produces: `StoredFile.consolidation_id: Mapped[uuid.UUID | None]`; ревизия `0006_dbml_v5_18`; `DBML_VERSION = "5.18"`. + +- [ ] **Step 1: Переключить версию и путь DBML (тест сверки должен покраснеть)** + +В `api/src/models/__init__.py`: `DBML_VERSION = "5.18"`. В `api/scripts/dbml_parity.py`: +`DBML_FILE_NAME = "data-model-v5.18.dbml"`. В `Makefile`: `DBML ?= ../../docs/model/data-model-v5.18.dbml`. +В `api/tests/test_check_dbml_script.py` строки 145 и 151: `.replace("v5.17", "v5.18"` → `.replace("v5.18", "v5.19"`. + +Run: `make dbml-check` +Expected: FAIL — `stored_file.consolidation_id: отсутствует в ORM` (и индекс `ix_stored_file_legal_entity_id_consolidation_id`), код 1. +Если вместо этого «файл DBML не найден» — docs worktree ещё без v5.18: дождаться Task 1 плана docs (ledger контроллера docs), не копировать файл вручную. + +- [ ] **Step 2: Колонка и индекс в ORM** + +В `api/src/models/files.py`, класс `StoredFile`: в `__table_args__` добавить после индекса `current_recognition_job_id`: + +```python + # v5.18: рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ») + Index("ix_stored_file_legal_entity_id_consolidation_id", "legal_entity_id", "consolidation_id"), +``` + +и последней колонкой класса (после `updated_at`): + +```python + # v5.18: консолидация (период ГК), в контексте которой файл загружен; NULL — без контекста (§9.5 п. 8). Последняя + # колонка: миграция 0006 добавляет её ALTER TABLE, а check_dbml сверяет порядок + consolidation_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("consolidation.id")) +``` + +Run: `make dbml-check && cd api && .venv/bin/pytest tests/test_models_part_04_06.py tests/test_check_dbml_script.py -q` +Expected: `dbml-check` — пустой вывод, код 0; pytest PASS. + +- [ ] **Step 3: Тест миграции 0006 (красный)** + +В `api/tests/test_migrations.py`: `HEAD = "0006_dbml_v5_18"`, добавить `V5_17 = "0005_dbml_v5_17"` рядом с `V5_16`; +docstring модуля дополнить «0006 (v5.18)». Добавить тест (рядом с `test_upgrade_0005_adds_nullable_kpp_columns`): + +```python +async def _consolidation_id_column(conn: AsyncConnection) -> list[Any]: + return ( + await conn.execute( + text( + "SELECT is_nullable, data_type FROM information_schema.columns " + "WHERE table_schema = 'dkb' AND table_name = 'stored_file' AND column_name = 'consolidation_id'" + ) + ) + ).all() + + +@pytest.mark.integration +async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> None: + """0006: `stored_file.consolidation_id uuid` nullable, последняя колонка, индекс `(legal_entity_id, consolidation_id)`; + backfill по `consolidation_document`: файл A привязан к версиям 2025 Q1 и 2024 Q4 → 2024 Q4 (самый ранний период, + год важнее квартала); файл B без привязок → NULL; `downgrade 0005` убирает индекс и колонку, повторный upgrade возвращает.""" + engine = create_async_engine(scratch_db_url) + now = datetime.datetime.now(datetime.UTC) + ids = {name: uuid.uuid4() for name in ("office", "group", "entity", "user", "s3a", "s3b", "file_a", "file_b", + "doc_a", "c_2025q1", "c_2024q4", "v_2025q1", "v_2024q4")} + try: + await alembic(scratch_db_url, "upgrade", V5_17) + async with engine.begin() as conn: + assert await _consolidation_id_column(conn) == [] + await conn.execute(text("INSERT INTO dkb.regional_office (id, name) VALUES (:office, 'ГО')"), ids) + await conn.execute( + text("INSERT INTO dkb.company_group (id, code, name, regional_office_id, valid_from) " + "VALUES (:group, 'G-1', 'ГК', :office, :now)"), ids | {"now": now}) + await conn.execute( + text("INSERT INTO dkb.legal_entity (id, inn, name, company_group_id, valid_from) " + "VALUES (:entity, '7701000001', 'ООО', :group, :now)"), ids | {"now": now}) + await conn.execute( + text("INSERT INTO dkb.app_user (id, email, full_name, regional_office_id) " + "VALUES (:user, 'm@x.ru', 'Тест', :office)"), ids) + for s3, file in (("s3a", "file_a"), ("s3b", "file_b")): + await conn.execute( + text("INSERT INTO dkb.s3_object (id, bucket_role, bucket, key, created_at) " + f"VALUES (:{s3}, 'uploads', 'b', :{s3}, :now)"), ids | {"now": now}) + await conn.execute( + text("INSERT INTO dkb.stored_file (id, legal_entity_id, file_type, s3_object_id, status, version_no) " + f"VALUES (:{file}, :entity, 'XLS', :{s3}, 'PROCESSED', 1)"), ids) + await conn.execute( + text("INSERT INTO dkb.document (id, stored_file_id, legal_entity_id, document_type, osv_account_code, " + "period_year, period_quarter, status) VALUES (:doc_a, :file_a, :entity, 'OSV', '60', 2024, 'Q4', 'CONFIRMED')"), + ids) + for c, v, year, quarter, rd in (("c_2025q1", "v_2025q1", 2025, "Q1", "2025-04-01"), + ("c_2024q4", "v_2024q4", 2024, "Q4", "2025-01-01")): + await conn.execute( + text("INSERT INTO dkb.consolidation (id, company_group_id, year, quarter, reporting_date) " + f"VALUES (:{c}, :group, {year}, '{quarter}', '{rd}')"), ids) + await conn.execute( + text("INSERT INTO dkb.consolidation_version (id, consolidation_id, version_no, status, scenario, " + "approach, goodwill_enabled, initiator_id, initiator_office_id, is_in_work) " + f"VALUES (:{v}, :{c}, 1, 'INITIATED', 'CREATE', 'BASIC', false, :user, :office, true)"), ids) + await conn.execute( + text("INSERT INTO dkb.consolidation_document (id, consolidation_version_id, document_id) " + f"VALUES (gen_random_uuid(), :{v}, :doc_a)"), ids) + + await alembic(scratch_db_url, "upgrade", "head") + async with engine.begin() as conn: + assert await _consolidation_id_column(conn) == [("YES", "uuid")] + last = await _scalars(conn, "SELECT column_name FROM information_schema.columns WHERE table_schema = 'dkb' " + "AND table_name = 'stored_file' ORDER BY ordinal_position DESC LIMIT 1") + assert last == ["consolidation_id"] + rows = (await conn.execute(text("SELECT id, consolidation_id FROM dkb.stored_file ORDER BY id"))).all() + assert dict(rows) == {ids["file_a"]: ids["c_2024q4"], ids["file_b"]: None} + assert await _scalars(conn, "SELECT indexname FROM pg_indexes WHERE schemaname = 'dkb' " + "AND indexname = 'ix_stored_file_legal_entity_id_consolidation_id'") == [ + "ix_stored_file_legal_entity_id_consolidation_id"] + await engine.dispose() + + await alembic(scratch_db_url, "downgrade", V5_17) + async with engine.connect() as conn: + assert await _consolidation_id_column(conn) == [] + assert await _scalars(conn, "SELECT indexname FROM pg_indexes WHERE schemaname = 'dkb' " + "AND indexname = 'ix_stored_file_legal_entity_id_consolidation_id'") == [] + await engine.dispose() + await alembic(scratch_db_url, "upgrade", "head") + async with engine.connect() as conn: + assert await _consolidation_id_column(conn) == [("YES", "uuid")] + finally: + await engine.dispose() +``` + +Если у таблиц `company_group`/`legal_entity`/`consolidation_version` есть другие NOT NULL колонки без default +(проверить `\d` или `0001_dbml_v5_13.py`), добавить их в INSERT с любыми допустимыми значениями — это тестовые данные. +Если `gen_random_uuid()` недоступна, подставить `uuid.uuid4()` из Python. + +Run: `cd api && .venv/bin/pytest tests/test_migrations.py -q -k "0006 or full_cycle" 2>&1 | tail -15` +Expected: FAIL — `alembic upgrade head` не знает ревизии / нет колонки (`_consolidation_id_column == []`). + +- [ ] **Step 4: Миграция 0006** + +Создать `api/alembic/versions/0006_dbml_v5_18.py`: + +```python +"""DBML v5.18: stored_file.consolidation_id — консолидация (период ГК), в контексте которой загружен файл +(RSHB/arm-ka-docs#90, RSHB/arm-core#96; design D-1, D-2). + +Колонка nullable, FK → consolidation, последняя в таблице; индекс (legal_entity_id, consolidation_id) — рабочий стол +консоли для ЮЛ. Backfill: файлу с документом, привязанным к версии (consolidation_document), — консолидация этой +версии; при привязках к версиям разных консолидаций — самый ранний период (год, затем квартал; enum period_quarter +упорядочен Q1 < Q2 < Q3 < Q4 по объявлению); файлы без привязок остаются NULL. + +Revision ID: 0006_dbml_v5_18 +Revises: 0005_dbml_v5_17 +Create Date: 2026-10-09 +""" + +from collections.abc import Sequence + +from alembic import op +from src.models.base import SCHEMA + +revision: str = "0006_dbml_v5_18" +down_revision: str | Sequence[str] | None = "0005_dbml_v5_17" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +INDEX = "ix_stored_file_legal_entity_id_consolidation_id" +FK = "fk_stored_file_consolidation_id_consolidation" + + +def upgrade() -> None: + op.execute( + f"ALTER TABLE {SCHEMA}.stored_file ADD COLUMN consolidation_id uuid " + f"CONSTRAINT {FK} REFERENCES {SCHEMA}.consolidation (id)" + ) + op.execute(f"CREATE INDEX {INDEX} ON {SCHEMA}.stored_file (legal_entity_id, consolidation_id)") + op.execute( + f""" + UPDATE {SCHEMA}.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 {SCHEMA}.document d + JOIN {SCHEMA}.consolidation_document cd ON cd.document_id = d.id + JOIN {SCHEMA}.consolidation_version v ON v.id = cd.consolidation_version_id + JOIN {SCHEMA}.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 + """ + ) + + +def downgrade() -> None: + op.execute(f"DROP INDEX {SCHEMA}.{INDEX}") + op.execute(f"ALTER TABLE {SCHEMA}.stored_file DROP COLUMN consolidation_id") +``` + +Run: `cd api && .venv/bin/pytest tests/test_migrations.py -q 2>&1 | tail -15` +Expected: PASS все (в т. ч. `test_full_cycle_upgrade_downgrade_upgrade`, `test_autogenerate_finds_nothing_after_upgrade`). +Если автогенерация находит расхождение имени FK — привести имя в миграции к тому, что печатает autogenerate. + +- [ ] **Step 5: Документы репозитория** + +`CLAUDE.md` строка «ORM 1:1 с `../docs/model/data-model-v5.17.dbml` …» → `v5.18.dbml`, список миграций дополнить +`+ 0006_dbml_v5_18`. `README.md`: все `data-model-v5.17.dbml` → `v5.18.dbml`; в строке `alembic/ миграции (…)` добавить +`, 0006_dbml_v5_18.py — v5.18`; строку «Срез 4 … схема по DBML v5.17» не трогать (история). + +Run: `grep -rn "v5\.17\.dbml\|v5_17\.dbml" CLAUDE.md README.md Makefile api/scripts api/src | grep -v "0005"` +Expected: пусто. + +- [ ] **Step 6: Коммит** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core add api/src/models api/scripts/dbml_parity.py Makefile api/alembic/versions/0006_dbml_v5_18.py api/tests/test_migrations.py api/tests/test_check_dbml_script.py CLAUDE.md README.md +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core commit -q -m "$(cat <<'EOF' +model: DBML v5.18 — stored_file.consolidation_id, миграция 0006 с backfill (#96) + +Co-Authored-By: Claude Fable 5.1 +EOF +)" +``` + +--- + +### Task 2: Рабочий стол консолидации и `consolidation_id` в карточке + +**Files:** +- Modify: `api/src/schemas/files.py` (`FileOut`), `api/src/services/documents.py` (`file_out`), + `api/src/repositories/files.py` (`list_desk_files`), `api/src/repositories/consolidations.py` + (`get_consolidation`), `api/src/services/consolidation_guard.py` (`CONSOLIDATION_NOT_FOUND`), + `api/src/services/primary_files.py` (`page_desk`), `api/src/api/endpoints/files/__init__.py` +- Create: `api/src/api/endpoints/files/consolidation_files.py` (пока только GET), `api/tests/test_api_consolidation_files.py` +- Test: `api/tests/test_api_consolidation_files.py`, `api/tests/test_api_files_read.py` + +**Interfaces:** +- Consumes: `StoredFile.consolidation_id` (Task 1). +- Produces: `FileOut.consolidation_id: uuid.UUID | None`; `files_repo.list_desk_files(session, scope, entity_id, + consolidation_id, *, include_superseded, limit, offset) -> tuple[list[StoredFile], int]`; + `consolidations_repo.get_consolidation(session, scope, consolidation_id) -> Consolidation | None`; + `PrimaryFilesService.page_desk(consolidation_id, entity_id, *, include_superseded, page) -> tuple[list[FileOut], int]`; + константа `consolidation_guard.CONSOLIDATION_NOT_FOUND = "консолидация не найдена"`; модуль + `endpoints/files/consolidation_files.py` с `router`, `VIEW`, `EDIT`, `NOT_FOUND` (Task 3 добавит туда POST). + +- [ ] **Step 1: Тесты (красные)** + +Создать `api/tests/test_api_consolidation_files.py`: + +```python +"""Рабочий стол консолидации для ЮЛ и `consolidation_id` в карточке файла (spec documents-api «Рабочий стол консолидации +для ЮЛ», «Файлы организации и карточка файла»; design D-6, D-7). Поверх фикстуры `app`: данные — фабрики в транзакции теста.""" + +import uuid + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.access import AppUser +from src.models.consolidation import ConsolidationDocument +from src.models.directories import LegalEntity, RegionalOffice +from src.models.enums import DocumentStatus, FileStatus, PeriodQuarter, RecognitionStatus +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_consolidation, make_document, make_legal_entity, make_stored_file + +HTTP_OK = 200 +HTTP_NOT_FOUND = 404 +HTTP_FORBIDDEN = 403 +KA_RF = "ka_rf@armka.local" # Ростовский филиал, DOCUMENT_VIEW + DOCUMENT_EDIT +CLIENT_MANAGER = "client_manager@armka.local" # Краснодарский филиал +CURATOR_GO = "curator_go@armka.local" # головной офис, права DOCUMENT_VIEW нет +ROSTOV = "Ростовский региональный филиал" +KRASNODAR = "Краснодарский региональный филиал" + +pytestmark = pytest.mark.integration + + +async def _group(session: AsyncSession, office_name: str = ROSTOV, name: str = "ГК Стол"): + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == office_name))).scalar_one() + group = await make_company_group(session, name=name, office=office) + entity = await make_legal_entity(session, name=f"ООО {name}", group=group) + initiator = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + return group, entity, initiator + + +async def _consolidation(session: AsyncSession, group, entity, initiator, year: int, quarter: PeriodQuarter): + consolidation, version = await make_consolidation( + session, group=group, year=year, quarter=quarter, members=[entity], initiator=initiator + ) + return consolidation, version + + +async def _file(session: AsyncSession, entity: LegalEntity, name: str, *, consolidation_id=None, status=FileStatus.PROCESSED): + file, job = await make_stored_file( + session, entity=entity, status=status, job_status=RecognitionStatus.done, original_name=name + ) + file.consolidation_id = consolidation_id + await session.flush() + return file, job + + +def _desk_url(consolidation_id: uuid.UUID, entity_id: uuid.UUID, **query: str) -> str: + base = f"/consolidations/{consolidation_id}/legal-entities/{entity_id}/files" + return base + ("?" + "&".join(f"{k}={v}" for k, v in query.items()) if query else "") + + +async def test_desk_shows_only_files_of_consolidation(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Сценарий «Стол показывает только файлы консоли»: три файла ЮЛ — 2025 Q1, 2025 Q2, без контекста.""" + group, entity, initiator = await _group(seeded) + c1, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) + c2, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q2) + f1, _ = await _file(seeded, entity, "q1.xls", consolidation_id=c1.id) + f2, _ = await _file(seeded, entity, "q2.xls", consolidation_id=c2.id) + f0, _ = await _file(seeded, entity, "none.xls") + client = await auth_client(KA_RF) + + desk1 = await client.get(_desk_url(c1.id, entity.id)) + desk2 = await client.get(_desk_url(c2.id, entity.id)) + all_files = await client.get(f"/legal-entities/{entity.id}/files") + + assert desk1.status_code == HTTP_OK, desk1.text + assert [item["id"] for item in desk1.json()["items"]] == [str(f1.id)] + assert desk1.json()["total"] == 1 + assert desk1.json()["items"][0]["consolidation_id"] == str(c1.id) + assert [item["id"] for item in desk2.json()["items"]] == [str(f2.id)] + assert {item["id"] for item in all_files.json()["items"]} == {str(f1.id), str(f2.id), str(f0.id)} + by_id = {item["id"]: item["consolidation_id"] for item in all_files.json()["items"]} + assert by_id[str(f0.id)] is None + + +async def test_desk_includes_file_bound_to_version(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Сценарий «Подхваченный файл виден через привязку»: файл без контекста, документ привязан к версии консоли.""" + group, entity, initiator = await _group(seeded) + c2, v2 = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q2) + file, job = await _file(seeded, entity, "q1-confirmed.xls") + document = await make_document( + seeded, file=file, job=job, account="60", year=2025, quarter=PeriodQuarter.Q1, status=DocumentStatus.CONFIRMED, + is_cumulative=False, + ) + seeded.add(ConsolidationDocument(consolidation_version_id=v2.id, document_id=document.id)) + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.get(_desk_url(c2.id, entity.id)) + + assert response.status_code == HTTP_OK, response.text + assert [item["id"] for item in response.json()["items"]] == [str(file.id)] + assert response.json()["items"][0]["consolidation_id"] is None + + +async def test_desk_excludes_other_consolidation(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Review Focus 2: документ привязан к версии ДРУГОЙ консолидации — файл в этом столе не показывается.""" + group, entity, initiator = await _group(seeded) + c1, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) + c2, v2 = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q2) + file, job = await _file(seeded, entity, "bound-elsewhere.xls", consolidation_id=c2.id) + document = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED) + seeded.add(ConsolidationDocument(consolidation_version_id=v2.id, document_id=document.id)) + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.get(_desk_url(c1.id, entity.id)) + + assert response.status_code == HTTP_OK, response.text + assert response.json()["items"] == [] + assert response.json()["total"] == 0 + + +async def test_desk_superseded_hidden_by_default(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Сценарий «Заменённый файл скрыт по умолчанию» для DESK и BOUND; `include_superseded=true` — оба.""" + group, entity, initiator = await _group(seeded) + c1, v1 = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) + new, _ = await _file(seeded, entity, "new.xls", consolidation_id=c1.id) + old_desk, _ = await _file(seeded, entity, "old-desk.xls", consolidation_id=c1.id, status=FileStatus.SUPERSEDED) + old_desk.superseded_by_file_id = new.id + old_bound, job = await _file(seeded, entity, "old-bound.xls", status=FileStatus.SUPERSEDED) + old_bound.superseded_by_file_id = new.id + document = await make_document(seeded, file=old_bound, job=job, status=DocumentStatus.CONFIRMED) + seeded.add(ConsolidationDocument(consolidation_version_id=v1.id, document_id=document.id)) + await seeded.flush() + client = await auth_client(KA_RF) + + default = await client.get(_desk_url(c1.id, entity.id)) + with_superseded = await client.get(_desk_url(c1.id, entity.id, include_superseded="true")) + + assert [item["id"] for item in default.json()["items"]] == [str(new.id)] + assert {item["id"] for item in with_superseded.json()["items"]} == {str(new.id), str(old_desk.id), str(old_bound.id)} + assert with_superseded.json()["total"] == 3 # noqa: PLR2004 + + +async def test_desk_foreign_and_missing_consolidation_are_same_404( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Сценарий «Чужая консолидация»: ГК другого филиала и несуществующий id — одинаковый 404; чужое ЮЛ — тоже 404.""" + group, entity, initiator = await _group(seeded, office_name=KRASNODAR, name="ГК Чужая") + c, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) + _, own_entity, _ = await _group(seeded, name="ГК Своя") + client = await auth_client(KA_RF) + + foreign = await client.get(_desk_url(c.id, own_entity.id)) + missing = await client.get(_desk_url(uuid.uuid4(), own_entity.id)) + + assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND + assert foreign.json() == missing.json() + assert foreign.json()["detail"]["code"] == "NOT_FOUND" + + +async def test_desk_requires_document_view(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + group, entity, initiator = await _group(seeded) + c, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) + client = await auth_client(CURATOR_GO) + + response = await client.get(_desk_url(c.id, entity.id)) + + assert response.status_code == HTTP_FORBIDDEN + + +async def test_file_card_has_consolidation_id(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Сценарий «Консолидация в карточке»: `GET /files/{id}` отдаёт `consolidation_id`.""" + group, entity, initiator = await _group(seeded) + c, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) + file, _ = await _file(seeded, entity, "card.xls", consolidation_id=c.id) + client = await auth_client(KA_RF) + + response = await client.get(f"/files/{file.id}") + + assert response.status_code == HTTP_OK, response.text + assert response.json()["consolidation_id"] == str(c.id) +``` + +Если `make_document` не принимает `is_cumulative=False` — проверить сигнатуру в `tests/factories.py` (принимает). +Если у `make_legal_entity` нет параметра `name`/`group` — см. его сигнатуру (строка 115 `factories.py`) и поправить вызов. + +Run: `cd api && .venv/bin/pytest tests/test_api_consolidation_files.py -q 2>&1 | tail -12` +Expected: FAIL — 404 на маршруте стола (нет маршрута) и `KeyError: 'consolidation_id'` в карточке. + +- [ ] **Step 2: Схема и карточка** + +`api/src/schemas/files.py`, `FileOut`: после `superseded_by_file_id` добавить +`consolidation_id: uuid.UUID | None # v5.18: консолидация, в контексте которой загружен файл; None — без контекста`. +`api/src/services/documents.py`, `file_out`: добавить `consolidation_id=file.consolidation_id,`. + +Run: `cd api && .venv/bin/pytest tests/test_api_consolidation_files.py::test_file_card_has_consolidation_id tests/test_api_files_read.py -q` +Expected: PASS. + +- [ ] **Step 3: Репозитории** + +`api/src/repositories/consolidations.py` после `get_version`: + +```python +async def get_consolidation( + session: AsyncSession, scope: VisibilityScope, consolidation_id: uuid.UUID +) -> Consolidation | None: + """Консолидация по id, если её ГК видна пользователю; чужая и несуществующая — `None` (v5.18, design D-3).""" + stmt = _visible_consolidations(scope).where(Consolidation.id == consolidation_id) + return (await session.execute(stmt)).scalar_one_or_none() +``` + +`api/src/repositories/files.py` после `list_files` (импорты `ConsolidationDocument`, `ConsolidationVersion` из +`src.models.consolidation`, `exists` из `sqlalchemy`): + +```python +async def list_desk_files( # noqa: PLR0913 - фильтр и пагинация списка передаются явными именованными аргументами + session: AsyncSession, + scope: VisibilityScope, + entity_id: uuid.UUID, + consolidation_id: uuid.UUID, + *, + include_superseded: bool, + limit: int, + offset: int, +) -> tuple[list[StoredFile], int]: + """Рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ»; §9.5 п. 11; D-6): + файлы ЮЛ с `consolidation_id` этой консолидации ИЛИ с документом, привязанным к любой её версии. Порядок и + `include_superseded` — как в `list_files`; страница и общее число тем же фильтром.""" + bound = exists().where( + Document.stored_file_id == StoredFile.id, + ConsolidationDocument.document_id == Document.id, + ConsolidationVersion.id == ConsolidationDocument.consolidation_version_id, + ConsolidationVersion.consolidation_id == consolidation_id, + ) + + def filtered(stmt: Select[Any]) -> Select[Any]: + stmt = _visible_files(stmt, scope).where( + StoredFile.legal_entity_id == entity_id, + or_(StoredFile.consolidation_id == consolidation_id, bound), + ) + if not include_superseded: + stmt = stmt.where(StoredFile.status != FileStatus.SUPERSEDED) + return stmt + + total = (await session.execute(filtered(select(func.count(StoredFile.id))))).scalar_one() + page_stmt = ( + filtered(select(StoredFile)) + .order_by(StoredFile.uploaded_at.desc().nulls_last(), StoredFile.id) + .limit(limit) + .offset(offset) + ) + files = list((await session.execute(page_stmt)).scalars().all()) + return files, total +``` + +- [ ] **Step 4: Сервис и маршрут** + +`api/src/services/consolidation_guard.py`: добавить `CONSOLIDATION_NOT_FOUND = "консолидация не найдена" # одинаково для чужой и несуществующей`. + +`api/src/services/primary_files.py`: импорт `CONSOLIDATION_NOT_FOUND` из `consolidation_guard`; метод после `page_files`: + +```python + async def page_desk( + self, consolidation_id: uuid.UUID, entity_id: uuid.UUID, *, include_superseded: bool, page: PageParams + ) -> tuple[list[FileOut], int]: + """Рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ»; §9.5 п. 11; D-6): + консолидация вне видимости — 404 `CONSOLIDATION_NOT_FOUND`, ЮЛ вне видимости — 404; принадлежность ЮЛ к ГК + консолидации не проверяется (чужому ЮЛ — пустой стол). Карточки — как в `page_files`.""" + if await consolidations_repo.get_consolidation(self._session, self._scope, consolidation_id) is None: + raise NotFoundError(CONSOLIDATION_NOT_FOUND) + entity = await self.get_entity(entity_id) + files, total = await files_repo.list_desk_files( + self._session, + self._scope, + entity.id, + consolidation_id, + include_superseded=include_superseded, + limit=page.limit, + offset=page.offset, + ) + return await self._cards(files), total +``` + +Создать `api/src/api/endpoints/files/consolidation_files.py`: + +```python +"""Файлы ЮЛ в контексте консолидации (v5.18, RSHB/arm-ka-docs#90): `GET /consolidations/{consolidation_id}/legal-entities/ +{legal_entity_id}/files` — рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ»); +`POST …/files` — загрузка в консолидацию (spec primary-files «Загрузка файлов в консолидацию»). Права — как у маршрутов +ЮЛ (`DOCUMENT_VIEW` / `DOCUMENT_EDIT`, design R-1); консолидация или ЮЛ вне видимости — 404, как несуществующие.""" + +import uuid +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Query, status + +from src.api.dependencies.auth import require +from src.api.dependencies.files import PrimaryFilesServiceDep +from src.api.endpoints.files.read import FileListQuery +from src.schemas.common import ErrorDetail, Page, PageParams +from src.schemas.files import FileOut + +router = APIRouter() + +VIEW = [Depends(require("DOCUMENT_VIEW"))] +EDIT = [Depends(require("DOCUMENT_EDIT"))] +NOT_FOUND: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "консолидация или ЮЛ: нет или вне видимости"} +} +DESK_PATH = "/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files" + + +@router.get(DESK_PATH, response_model=Page[FileOut], dependencies=VIEW, responses=NOT_FOUND, + summary="Рабочий стол консолидации для ЮЛ") +async def list_desk_files( + consolidation_id: uuid.UUID, + legal_entity_id: uuid.UUID, + service: PrimaryFilesServiceDep, + query: Annotated[FileListQuery, Query()], +) -> Page[FileOut]: + """Файлы ЮЛ, загруженные в эту консолидацию, плюс файлы с документами, привязанными к её версиям (новые первыми).""" + page = PageParams(limit=query.limit, offset=query.offset) + items, total = await service.page_desk( + consolidation_id, legal_entity_id, include_superseded=query.include_superseded, page=page + ) + return Page[FileOut](items=items, total=total, limit=page.limit, offset=page.offset) +``` + +В `api/src/api/endpoints/files/__init__.py` добавить импорт модуля и `router.include_router(consolidation_files.router)` +после `read.router`. + +Run: `cd api && .venv/bin/pytest tests/test_api_consolidation_files.py tests/test_api_files_read.py tests/test_app.py -q 2>&1 | tail -8 && cd .. && make lint 2>&1 | tail -5` +Expected: PASS; ruff/mypy чистые (`test_app.py` проверяет объявление сессии и OpenAPI). + +- [ ] **Step 5: Коммит** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core add api/src api/tests/test_api_consolidation_files.py +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core commit -q -m "$(cat <<'EOF' +api: рабочий стол консолидации для ЮЛ и consolidation_id в карточке файла (#96) + +Co-Authored-By: Claude Fable 5.1 +EOF +)" +``` + +--- + +### Task 3: Загрузка в консолидацию, удаление контекста версии у маршрута ЮЛ + +**Files:** +- Modify: `api/src/services/primary_files.py` (`check_consolidation_for_upload`, `after_upload_to_consolidation`, + `upload_one`/`_store` с `consolidation_id`, удаление `check_version_for_upload`/`after_upload_to_version`), + `api/src/repositories/consolidations.py` (`versions_of`, `lock_versions_of_consolidation`), + `api/src/api/endpoints/files/upload.py` (`accept_uploads`, удаление query), `api/src/api/endpoints/files/consolidation_files.py` + (POST), `api/tests/test_api_files_upload.py` (раздел «в контексте версии» → консолидация) +- Test: `api/tests/test_api_files_upload.py`, `api/tests/test_api_consolidation_files.py` + +**Interfaces:** +- Consumes: `consolidation_files.py` (`router`, `EDIT`, `NOT_FOUND`, `DESK_PATH`), `get_consolidation` (Task 2). +- Produces: `PrimaryFilesService.check_consolidation_for_upload(consolidation_id, entity) -> Consolidation`; + `PrimaryFilesService.after_upload_to_consolidation(consolidation_id, entity_id) -> None`; + `PrimaryFilesService.upload_one(entity, name, content, *, consolidation_id: uuid.UUID | None = None)`; + `upload.accept_uploads(service, entity, files, response, *, consolidation_id) -> UploadResult`; + `consolidations_repo.versions_of(session, consolidation_id) -> list[ConsolidationVersion]`; + `consolidations_repo.lock_versions_of_consolidation(session, consolidation_id, entity_id, statuses) -> list[ConsolidationVersion]`. + +- [ ] **Step 1: Переписать тесты раздела «в контексте версии» (красные)** + +В `api/tests/test_api_files_upload.py` функцию `_post_in_version` заменить на: + +```python +async def _post_in_consolidation( + client: httpx.AsyncClient, consolidation_id: uuid.UUID, entity_id: uuid.UUID, *uploads: Upload +) -> httpx.Response: + files = [("files", (name, content, "application/octet-stream")) for name, content in uploads] + return await client.post(f"/consolidations/{consolidation_id}/legal-entities/{entity_id}/files", files=files) +``` + +Каждый тест с `_post_in_version(client, member.id, version.id, …)` → `_post_in_consolidation(client, version.consolidation_id, member.id, …)`. +Переименовать и поправить ожидания: +- `test_upload_in_version_moves_initiated_to_documents_processing` → `test_upload_in_consolidation_moves_initiated_to_documents_processing`; + дополнительно: `file = await seeded.get(StoredFile, uuid.UUID(response.json()["accepted"][0]["file_id"]))`, + `assert file.consolidation_id == version.consolidation_id`; у события `FILE_UPLOAD` (`_upload_events`) — + `after_value["consolidation_id"] == str(version.consolidation_id)`. +- `test_upload_in_version_keeps_other_in_work_status` → `…_in_consolidation_…` (без иных правок). +- `test_upload_with_all_files_rejected_does_not_move_version` — только замена вызова. +- `test_upload_for_entity_outside_perimeter_is_422_and_writes_nothing` — только замена вызова. +- `test_upload_in_cancelled_version_is_409` → `test_upload_without_in_work_version_is_409`: ожидать код + `"CONSOLIDATION_NOT_EDITABLE"`. +- `test_upload_in_foreign_version_is_404_like_missing` → `test_upload_in_foreign_consolidation_is_404_like_missing`: + `_post_in_consolidation(client, foreign.consolidation_id, own_entity.id, …)` и `uuid.uuid4()` вместо версии. +- `test_upload_does_not_require_version_lock` — только замена вызова. +- `test_upload_without_version_does_not_touch_versions` → `test_upload_without_consolidation_does_not_touch_versions`: + дополнительно второй запрос `client.post(_files_url(member.id), params={"consolidation_version_id": str(version.id)}, files=…)` + с другим содержимым (`_xls("ignored-query")`) — 201, версия по-прежнему `INITIATED`, у обоих файлов `consolidation_id is None`. +- `test_upload_keeps_accepted_files_when_version_transition_fails` — только замена вызова. + +Добавить новый тест (Review Focus 1): + +```python +@pytest.mark.integration +async def test_upload_moves_only_versions_with_entity( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + """Две версии консолидации в INITIATED: ЮЛ в периметре только второй — переходит только она (D-4).""" + version, entities, initiator = await _version(seeded) + consolidation = await seeded.get(Consolidation, version.consolidation_id) + assert consolidation is not None + other = await make_version( + seeded, consolidation=consolidation, version_no=2, members=[entities[2]], initiator=initiator + ) + client = await auth_client(KA_RF) + + response = await _post_in_consolidation(client, consolidation.id, entities[2].id, ("osv.xls", _xls("only-v2"))) + + assert response.status_code == HTTP_CREATED, response.text + assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED + assert await _version_state(seeded, other.id) is ConsolidationStatus.DOCUMENTS_PROCESSING +``` + +(импорты `Consolidation` из `src.models.consolidation`, `make_version` из `tests.factories`). + +Run: `cd api && .venv/bin/pytest tests/test_api_files_upload.py -q -k "consolidation or version or perimeter or lock" 2>&1 | tail -15` +Expected: FAIL — 404/405 на маршруте консолидации; тест с query — `AssertionError` по статусу версии (сейчас переход происходит). + +- [ ] **Step 2: Репозитории версий консолидации** + +`api/src/repositories/consolidations.py` после `lock_versions_for_entity`: + +```python +async def versions_of(session: AsyncSession, consolidation_id: uuid.UUID) -> list[ConsolidationVersion]: + """Все версии консолидации по возрастанию `version_no` (без блокировки; проверки до приёма файлов, D-3).""" + stmt = ( + select(ConsolidationVersion) + .where(ConsolidationVersion.consolidation_id == consolidation_id) + .order_by(ConsolidationVersion.version_no) + ) + return list((await session.execute(stmt)).scalars()) + + +async def lock_versions_of_consolidation( + session: AsyncSession, + consolidation_id: uuid.UUID, + entity_id: uuid.UUID, + statuses: Collection[ConsolidationStatus], +) -> list[ConsolidationVersion]: + """Версии консолидации со статусом из `statuses`, где ЮЛ — член периметра, под `FOR UPDATE ORDER BY id` (R-2; + design D-4: переход после загрузки в консолидацию). `populate_existing` — свежий статус из БД.""" + is_member = exists().where( + ConsolidationMember.consolidation_version_id == ConsolidationVersion.id, + ConsolidationMember.legal_entity_id == entity_id, + ) + stmt = ( + select(ConsolidationVersion) + .where( + ConsolidationVersion.consolidation_id == consolidation_id, + ConsolidationVersion.status.in_(statuses), + is_member, + ) + .order_by(ConsolidationVersion.id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return list((await session.execute(stmt)).scalars()) +``` + +- [ ] **Step 3: Сервис** + +В `api/src/services/primary_files.py`: удалить `check_version_for_upload` и `after_upload_to_version`, импорт +`VERSION_NOT_FOUND` заменить на `CONSOLIDATION_NOT_FOUND`; добавить `from src.models.consolidation import Consolidation`. +Новые методы на их месте: + +```python + async def check_consolidation_for_upload(self, consolidation_id: uuid.UUID, entity: LegalEntity) -> Consolidation: + """Загрузка в консолидацию (spec primary-files «Загрузка файлов в консолидацию», §9.5 п. 8, D-3): ДО приёма — + консолидация видима (404 `CONSOLIDATION_NOT_FOUND`) → есть версия «в работе» (409 `CONSOLIDATION_NOT_EDITABLE`) + → ЮЛ в периметре хотя бы одной из них (422 `ENTITY_NOT_IN_PERIMETER`). Блокировки не нужны: статус + перечитывается под `FOR UPDATE` в `after_upload_to_consolidation`.""" + consolidation = await consolidations_repo.get_consolidation(self._session, self._scope, consolidation_id) + if consolidation is None: + raise NotFoundError(CONSOLIDATION_NOT_FOUND) + in_work = [ + version + for version in await consolidations_repo.versions_of(self._session, consolidation.id) + if version.status in IN_WORK_STATUSES + ] + if not in_work: + raise ConflictError("CONSOLIDATION_NOT_EDITABLE", "У консолидации нет версии в работе") + for version in in_work: + members = await consolidations_repo.members(self._session, version.id) + if any(member.legal_entity_id == entity.id for member, _ in members): + return consolidation + raise DomainValidationError("ENTITY_NOT_IN_PERIMETER", "ЮЛ не входит в периметр версий консолидации в работе") + + async def after_upload_to_consolidation(self, consolidation_id: uuid.UUID, entity_id: uuid.UUID) -> None: + """После приёма хотя бы одного файла: каждая версия консолидации «в работе» с ЮЛ в периметре и статусом + `INITIATED` → `DOCUMENTS_PROCESSING` (`changed_by` — загрузивший; §9.1, D-4). + + В ОТДЕЛЬНОЙ транзакции (`session_transaction`): версии под `FOR UPDATE ORDER BY id`, статус перечитан — + попадают только ещё `INITIATED`. Приём файлов важнее побочного статуса (R-10 Среза 4): любая ошибка + перехода логируется и НЕ меняет ответ. `BaseException` не перехватывается.""" + from src.services.consolidation_status import transition # noqa: PLC0415 - модули консолидации импортируют друг друга + + factory = self._transaction + if factory is None: + from src.db.db import session_transaction # noqa: PLC0415 + + factory = session_transaction + try: + async with factory() as session: + versions = await consolidations_repo.lock_versions_of_consolidation( + session, consolidation_id, entity_id, {ConsolidationStatus.INITIATED} + ) + audit = AuditService(session) + now = utc_now() + for version in versions: + await transition( + session, + audit, + version=version, + to=ConsolidationStatus.DOCUMENTS_PROCESSING, + changed_by=self._audit.user_id, + comment=None, + ip_address=self._audit.ip_address, + user_agent=self._audit.user_agent, + now=now, + ) + except Exception: + logger.exception( + "Не удалось перевести версии консолидации в DOCUMENTS_PROCESSING после загрузки, файлы приняты. " + "error_category=version_transition_error, consolidation_id={consolidation_id}", + consolidation_id=consolidation_id, + ) +``` + +`upload_one(self, entity, name, content, *, consolidation_id: uuid.UUID | None = None)` — пробросить в `_store(..., +consolidation_id=consolidation_id)`; в `_store` добавить параметр и `consolidation_id=consolidation_id` в `StoredFile(...)`, +а в `after_value` аудита — `"consolidation_id": str(consolidation_id) if consolidation_id is not None else None`. +Docstring модуля (шаг 4 списка) дополнить «и `consolidation_id` при загрузке в консолидацию». + +- [ ] **Step 4: Маршруты** + +`api/src/api/endpoints/files/upload.py`: вынести цикл приёма в функцию модуля (используется обоими маршрутами, R-5): + +```python +async def accept_uploads( + service: PrimaryFilesService, + entity: LegalEntity, + files: list[UploadFile], + response: Response, + *, + consolidation_id: uuid.UUID | None, +) -> UploadResult: + """Общий приём файлов двух маршрутов загрузки: каждый файл принимается или отклоняется отдельно; 422 — если не + принят ни один. `consolidation_id` пишется в `stored_file` (загрузка в консолидацию) или `None`.""" + service.require_storage() + accepted: list[AcceptedOut] = [] + rejected: list[RejectedOut] = [] + for upload in files: + name = upload.filename or "" + try: + content = await read_limited(upload, service.max_upload_bytes) + except HTTPException as error: + if error.status_code != status.HTTP_413_CONTENT_TOO_LARGE: + raise + rejected.append(RejectedOut(name=safe_filename(name, default=""), code=FILE_TOO_LARGE, message=error.detail)) + continue + try: + result = await service.upload_one(entity, name, content, consolidation_id=consolidation_id) + except DomainError as error: + failure = RejectedFile.from_error(safe_filename(name, default=""), error) + rejected.append( + RejectedOut(name=failure.name, code=failure.code, message=failure.message, file_id=failure.file_id) + ) + continue + accepted.append(AcceptedOut(file_id=result.file_id, job_id=result.job_id, name=result.name)) + if not accepted: + response.status_code = status.HTTP_422_UNPROCESSABLE_CONTENT + return UploadResult(accepted=accepted, rejected=rejected) +``` + +`upload_files` становится: `entity = await service.get_entity(legal_entity_id)`; `return await accept_uploads(service, +entity, files, response, consolidation_id=None)`; параметр `consolidation_version_id` и ветки с ним удалить; из `responses` +убрать 409 и упоминания версии; docstring модуля и маршрута — без контекста версии (импорты `Query`, `PrimaryFilesService`, +`LegalEntity` поправить по факту). + +`consolidation_files.py` — добавить POST (импорты `File`, `Response`, `UploadFile`, `UploadResult`, `accept_uploads`): + +```python +UPLOAD_RESPONSES: dict[int | str, dict[str, Any]] = { + **NOT_FOUND, + status.HTTP_409_CONFLICT: {"model": ErrorDetail, "description": "CONSOLIDATION_NOT_EDITABLE — нет версии в работе"}, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "model": UploadResult, + "description": "ни один файл не принят; ЮЛ вне периметра версий в работе (ENTITY_NOT_IN_PERIMETER)", + }, + status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorDetail, "description": "S3 не подключено"}, +} + + +@router.post(DESK_PATH, operation_id="upload_consolidation_files", response_model=UploadResult, + status_code=status.HTTP_201_CREATED, dependencies=EDIT, responses=UPLOAD_RESPONSES, + summary="Загрузка файлов ЮЛ в консолидацию") +async def upload_consolidation_files( + consolidation_id: uuid.UUID, + legal_entity_id: uuid.UUID, + files: Annotated[list[UploadFile], File(description="Файлы ОСВ/ББ/ОФР: xls, xlsx, xlsb, pdf, png, jpg")], + service: PrimaryFilesServiceDep, + response: Response, +) -> UploadResult: + """До приёма: консолидация и ЮЛ видимы, есть версия «в работе», ЮЛ в её периметре. Принятые файлы получают + `consolidation_id`; после приёма хотя бы одного версии `INITIATED` с этим ЮЛ → `DOCUMENTS_PROCESSING`.""" + entity = await service.get_entity(legal_entity_id) + consolidation = await service.check_consolidation_for_upload(consolidation_id, entity) + result = await accept_uploads(service, entity, files, response, consolidation_id=consolidation.id) + if result.accepted: + await service.after_upload_to_consolidation(consolidation.id, entity.id) + return result +``` + +Run: `cd api && .venv/bin/pytest tests/test_api_files_upload.py tests/test_api_consolidation_files.py tests/test_app.py -q 2>&1 | tail -10 && cd .. && make lint 2>&1 | tail -5` +Expected: PASS; линтеры чистые. Если `test_app.py`/`test_openapi_routes` жалуется на имя тела multipart — `operation_id` +задан, проверить, что он без `__`. + +- [ ] **Step 5: Коммит** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core add api/src api/tests/test_api_files_upload.py +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core commit -q -m "$(cat <<'EOF' +api: загрузка файлов в консолидацию, переход версий «в работе»; query версии у загрузки ЮЛ удалён (#96) + +Co-Authored-By: Claude Fable 5.1 +EOF +)" +``` + +--- + +### Task 4: Наследование `consolidation_id` при автозамене + +**Files:** +- Modify: `api/src/services/result_mapping.py` (`supersede_previous`), `api/tests/test_result_mapping.py` +- Test: `api/tests/test_result_mapping.py` + +**Interfaces:** +- Consumes: `StoredFile.consolidation_id` (Task 1); `_old_file`, `_prepare`, `_entity`, `_file`, `process_once`, + `OSV_60_2025_Q1` из `tests/test_result_mapping.py`. + +- [ ] **Step 1: Тесты (красные)** + +В `api/tests/test_result_mapping.py` рядом с `test_version_chain` (импорты `Consolidation` из `src.models.consolidation`, +`make_consolidation`, `make_company_group`, `make_user`? — использовать уже импортированные фабрики; консолидацию создать +сырым `Consolidation(...)`, как в `make_consolidation`, через `tests.factories.reporting_date`): + +```python +async def _consolidation_id(maker: async_sessionmaker[AsyncSession], entity_id: uuid.UUID, quarter: PeriodQuarter) -> uuid.UUID: + """Консолидация ГК этого ЮЛ за 2025/quarter (без версий — для наследования версии не нужны).""" + async with maker() as session, session.begin(): + entity = await session.get(LegalEntity, entity_id) + assert entity is not None and entity.company_group_id is not None + consolidation = Consolidation( + company_group_id=entity.company_group_id, year=2025, quarter=quarter, + reporting_date=reporting_date(2025, quarter), created_at=_now(), + ) + session.add(consolidation) + await session.flush() + return consolidation.id + + +async def _set_consolidation(maker: async_sessionmaker[AsyncSession], file_id: uuid.UUID, consolidation_id: uuid.UUID | None) -> None: + async with maker() as session, session.begin(): + await session.execute(update(StoredFile).where(StoredFile.id == file_id).values(consolidation_id=consolidation_id)) + + +async def test_supersede_inherits_consolidation_from_first_with_value( + committed_sessionmaker: async_sessionmaker[AsyncSession], +) -> None: + """Спека «Новый файл без контекста наследует консолидацию» + Review Focus 5: два заменяемых файла, у первого по `id` + `consolidation_id` пуст, у второго задан — новый получает значение второго.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id = await _entity(maker) + c1 = await _consolidation_id(maker, entity_id, PeriodQuarter.Q1) + first_id = await _old_file(maker, entity_id, [OSV_60_2025_Q1]) + second_id = await _old_file(maker, entity_id, [OSV_60_2025_Q1]) + empty_id, filled_id = sorted([first_id, second_id]) + await _set_consolidation(maker, filled_id, c1) + async with maker() as session: + entity = await session.get(LegalEntity, entity_id) + new_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity) + + assert await process_once(maker, storage, _settings()) is True + + assert (await _file(maker, new_id)).consolidation_id == c1 + assert (await _file(maker, empty_id)).status is FileStatus.SUPERSEDED + assert (await _file(maker, filled_id)).status is FileStatus.SUPERSEDED + + +async def test_supersede_keeps_own_consolidation(committed_sessionmaker: async_sessionmaker[AsyncSession]) -> None: + """Спека «Новый файл с контекстом сохраняет свой»: старый в C1, новый загружен в C2 — остаётся C2.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id = await _entity(maker) + c1 = await _consolidation_id(maker, entity_id, PeriodQuarter.Q1) + c2 = await _consolidation_id(maker, entity_id, PeriodQuarter.Q2) + old_id = await _old_file(maker, entity_id, [OSV_60_2025_Q1]) + await _set_consolidation(maker, old_id, c1) + async with maker() as session: + entity = await session.get(LegalEntity, entity_id) + new_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity) + await _set_consolidation(maker, new_id, c2) + + assert await process_once(maker, storage, _settings()) is True + + assert (await _file(maker, new_id)).consolidation_id == c2 + assert (await _file(maker, old_id)).status is FileStatus.SUPERSEDED +``` + +Если `_entity` создаёт ЮЛ без ГК (`company_group_id is None`) — расширить `_entity` параметром или создать ГК через +`make_company_group` в `_consolidation_id` и присвоить `entity.company_group_id` там же (консолидация требует ГК). +Импортировать `reporting_date` из `tests.factories` и `update` из `sqlalchemy`, если ещё нет. + +Run: `cd api && .venv/bin/pytest tests/test_result_mapping.py -q -k "consolidation" 2>&1 | tail -10` +Expected: FAIL — `consolidation_id` нового файла `None` в первом тесте; второй может пройти (проверить, что он +действительно красный хотя бы по одному утверждению; если зелёный сразу — допустимо, он фиксирует отсутствие регрессии). + +- [ ] **Step 2: Реализация** + +В `api/src/services/result_mapping.py`, `supersede_previous`, сразу после `new_file.version_no = max(...) + 1`: + +```python + if new_file.consolidation_id is None: # v5.18, §9.5 п. 3: файл без контекста наследует консоль заменённого + new_file.consolidation_id = next((old.consolidation_id for old in locked if old.consolidation_id is not None), None) +``` + +Docstring функции дополнить предложением о наследовании. + +Run: `cd api && .venv/bin/pytest tests/test_result_mapping.py tests/test_consolidation_documents.py -q 2>&1 | tail -8` +Expected: PASS. + +- [ ] **Step 3: Коммит** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core add api/src/services/result_mapping.py api/tests/test_result_mapping.py +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core commit -q -m "$(cat <<'EOF' +mapping: новый файл без контекста наследует consolidation_id заменённого (#96) + +Co-Authored-By: Claude Fable 5.1 +EOF +)" +``` + +--- + +### Task 5: README, CLAUDE.md, полная проверка, PR + +**Files:** +- Modify: `README.md` (таблицы маршрутов, «Ошибки API», §9.1-таблица), `CLAUDE.md` (раздел «Первичные документы») + +- [ ] **Step 1: README** + +- Строка `| \`POST /legal-entities/{legal_entity_id}/files\` | \`DOCUMENT_EDIT\` | загрузка файлов …` — после неё добавить: + ``` + | `POST /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` | `DOCUMENT_EDIT` | загрузка в консолидацию (v5.18): файл получает `consolidation_id`; версии консолидации «в работе» с ЮЛ в периметре `INITIATED` → `DOCUMENTS_PROCESSING` | + | `GET /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` | `DOCUMENT_VIEW` | рабочий стол консолидации для ЮЛ: файлы с её `consolidation_id` + файлы с документами, привязанными к её версиям (`limit`/`offset`/`include_superseded`) | + ``` +- Строку `| \`POST /legal-entities/{id}/files?consolidation_version_id=\` | … |` (≈666) заменить на строку про + `POST /consolidations/{id}/legal-entities/{le}/files` с пометкой «query `consolidation_version_id` у маршрута ЮЛ удалён (v5.18)». +- Строку `| загрузка с \`consolidation_version_id\`, принят ≥ 1 файл | \`INITIATED\` → \`DOCUMENTS_PROCESSING\` | загрузивший |` + → `| загрузка в консолидацию, принят ≥ 1 файл | \`INITIATED\` → \`DOCUMENTS_PROCESSING\` у каждой версии консолидации «в работе» с ЮЛ в периметре | загрузивший |`. +- «Ошибки API»: `ENTITY_NOT_IN_PERIMETER` → «загрузка в консолидацию: ЮЛ не входит в периметр ни одной версии «в работе»»; + после неё новая строка `| \`CONSOLIDATION_NOT_EDITABLE\` | 409 | загрузка в консолидацию без версии «в работе» |`; + у `VERSION_NOT_EDITABLE` убрать «загрузка с версией»; в строке `NOT_FOUND` добавить текст «консолидация не найдена». +- Строка 422 про `POST /legal-entities/{id}/files` (≈422) — дополнить «`consolidation_id` NULL; загрузка в консолидацию — маршрут консолидации». + +- [ ] **Step 2: CLAUDE.md** + +В разделе «Первичные документы (Срез 2c)» после описания `upload_one` добавить: «v5.18 (#96): загрузка в консолидацию — +`check_consolidation_for_upload` → `accept_uploads` (`endpoints/files/upload.py`, общий для обоих маршрутов) → +`after_upload_to_consolidation` (отдельная транзакция, версии консолидации `INITIATED` с ЮЛ в периметре под `FOR UPDATE +ORDER BY id`); рабочий стол — `page_desk` / `files_repo.list_desk_files` (`OR EXISTS` по `consolidation_document`); +автозамена наследует `consolidation_id`». В разделе «Консолидации» в правило (7) добавить текст 404 «консолидация не найдена». + +- [ ] **Step 3: Полная проверка** + +Run: `make check-full 2>&1 | tail -40` +Expected: всё зелёное (lint, test, dbml-check, check-infra, test-integration, e2e при доступном парсере, docker-build, +web-check). `e2e` без профиля `services` — skip, это норма. Вывод хвоста сохранить для описания PR. + +- [ ] **Step 4: Коммит, push, PR** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core add README.md CLAUDE.md +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core commit -q -m "$(cat <<'EOF' +docs: README и CLAUDE.md — маршруты файлов консолидации, коды ошибок (#96) + +Co-Authored-By: Claude Fable 5.1 +EOF +)" +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core push -u origin feat/96-console-files +``` + +PR — через superpowers:finishing-a-development-branch (`tea pulls create --login home --repo RSHB/arm-core --base main +--head feat/96-console-files …`): заголовок «Файлы в контексте консолидации: stored_file.consolidation_id, маршруты +/consolidations/{id}/legal-entities/{le}/files (DBML v5.18, #96)»; в описании — ссылка на issue #96 и docs #90, «Rulings I +made», `deferred`-находки, хвост `make check-full`, пометка «зависит от docs PR (DBML v5.18); arm-web — следом». -- 2.47.2 From d5d09d0b5cc239b12dee3fd28b7378e32e7e245d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 10 Oct 2026 01:03:26 +0400 Subject: [PATCH 3/8] =?UTF-8?q?model:=20DBML=20v5.18=20=E2=80=94=20stored?= =?UTF-8?q?=5Ffile.consolidation=5Fid,=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=200006=20=D1=81=20backfill=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 2 +- Makefile | 2 +- README.md | 8 +- api/alembic/versions/0006_dbml_v5_18.py | 52 ++++++++ api/scripts/check_dbml_vs_orm.py | 2 +- api/scripts/dbml_parity.py | 2 +- api/src/models/__init__.py | 4 +- api/src/models/enums.py | 2 +- api/src/models/files.py | 5 + api/tests/test_check_dbml_script.py | 14 +-- api/tests/test_enums.py | 4 +- api/tests/test_migrations.py | 161 +++++++++++++++++++++++- 12 files changed, 233 insertions(+), 25 deletions(-) create mode 100644 api/alembic/versions/0006_dbml_v5_18.py diff --git a/CLAUDE.md b/CLAUDE.md index 5fe14f9..7ae139e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,7 +159,7 @@ - NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`. ## Правила репозитория -- ORM 1:1 с `../docs/model/data-model-v5.17.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. +- ORM 1:1 с `../docs/model/data-model-v5.18.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. - Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring. - Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц. - Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR. diff --git a/Makefile b/Makefile index be5b416..65ebfcc 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ VENV := api/.venv BIN := $(CURDIR)/$(VENV)/bin COMPOSE_FILE ?= ../docs/dev/docker-compose.yml # DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/ -DBML ?= ../../docs/model/data-model-v5.17.dbml +DBML ?= ../../docs/model/data-model-v5.18.dbml .PHONY: venv lint test test-integration e2e dbml-check migration-cycle check check-infra docker-build web-check check-full up migrate seed seed-dev diff --git a/README.md b/README.md index 1d6be1e..7c9a9bb 100644 --- a/README.md +++ b/README.md @@ -952,7 +952,7 @@ email без учёта регистра проверяет только сер | `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен | | `make test-integration` | интеграционные тесты: `pytest -m "integration and not e2e"`, нужны PostgreSQL (и NATS) | | `make e2e` | сквозной прогон загрузки ОСВ: `pytest -m e2e`, нужны compose и tabular-parser из профиля `services` (без него — skip) | -| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.17.dbml` | +| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.18.dbml` | | `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate | | `make check` | быстрая проверка без Docker: `lint` + `test` | | `make docker-build` | сборка образа: `docker build -t arm-core .` | @@ -1004,7 +1004,7 @@ queue group `arm-core`, подписчики группы получают со ### Проверка ORM и DBML -Модель данных — `../docs/model/data-model-v5.17.dbml`. ORM обязана совпадать с ней один в один: +Модель данных — `../docs/model/data-model-v5.18.dbml`. ORM обязана совпадать с ней один в один: ```bash make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно @@ -1027,7 +1027,7 @@ make migration-cycle # проверить цикл upgr ``` api/ run_server.py точка входа (uvicorn; хост и порт — из настроек) - alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16, 0005_dbml_v5_17.py — v5.17) + alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16, 0005_dbml_v5_17.py — v5.17, 0006_dbml_v5_18.py — v5.18) rules/ statement_checks.yaml — словарь проверок §7 формы ББ/ОФР (копия словаря tabular-parser; меняется в обоих репозиториях) seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист) seed/dev/ dev-данные: филиалы и пользователи по ролям @@ -1111,7 +1111,7 @@ Dockerfile только для локального compose - [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы; - [`../docs/context/03-architecture.md`](../docs/context/03-architecture.md) — архитектура, соглашения шаблона, NATS и S3; - [`../docs/context/04-data-model-guide.md`](../docs/context/04-data-model-guide.md) и - [`../docs/model/data-model-v5.17.dbml`](../docs/model/data-model-v5.17.dbml) — модель данных; + [`../docs/model/data-model-v5.18.dbml`](../docs/model/data-model-v5.18.dbml) — модель данных; - [`../docs/context/05-domain-rules.md`](../docs/context/05-domain-rules.md) — алгоритмы предметной области; - [`../docs/context/07-open-questions.md`](../docs/context/07-open-questions.md) — открытые вопросы; - [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов; diff --git a/api/alembic/versions/0006_dbml_v5_18.py b/api/alembic/versions/0006_dbml_v5_18.py new file mode 100644 index 0000000..e930aa3 --- /dev/null +++ b/api/alembic/versions/0006_dbml_v5_18.py @@ -0,0 +1,52 @@ +"""DBML v5.18: stored_file.consolidation_id — консолидация (период ГК), в контексте которой загружен файл +(RSHB/arm-ka-docs#90, RSHB/arm-core#96; design D-1, D-2). + +Колонка nullable, FK → consolidation, последняя в таблице; индекс (legal_entity_id, consolidation_id) — рабочий стол +консоли для ЮЛ. Backfill: файлу с документом, привязанным к версии (consolidation_document), — консолидация этой +версии; при привязках к версиям разных консолидаций — самый ранний период (год, затем квартал; enum period_quarter +упорядочен Q1 < Q2 < Q3 < Q4 по объявлению); файлы без привязок остаются NULL. + +Revision ID: 0006_dbml_v5_18 +Revises: 0005_dbml_v5_17 +Create Date: 2026-10-09 +""" + +from collections.abc import Sequence + +from alembic import op +from src.models.base import SCHEMA + +revision: str = "0006_dbml_v5_18" +down_revision: str | Sequence[str] | None = "0005_dbml_v5_17" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +INDEX = "ix_stored_file_legal_entity_id_consolidation_id" +FK = "fk_stored_file_consolidation_id_consolidation" + + +def upgrade() -> None: + op.execute( + f"ALTER TABLE {SCHEMA}.stored_file ADD COLUMN consolidation_id uuid " + f"CONSTRAINT {FK} REFERENCES {SCHEMA}.consolidation (id)" + ) + op.execute(f"CREATE INDEX {INDEX} ON {SCHEMA}.stored_file (legal_entity_id, consolidation_id)") + op.execute( + f""" + UPDATE {SCHEMA}.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 {SCHEMA}.document d + JOIN {SCHEMA}.consolidation_document cd ON cd.document_id = d.id + JOIN {SCHEMA}.consolidation_version v ON v.id = cd.consolidation_version_id + JOIN {SCHEMA}.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 + """ + ) + + +def downgrade() -> None: + op.execute(f"DROP INDEX {SCHEMA}.{INDEX}") + op.execute(f"ALTER TABLE {SCHEMA}.stored_file DROP COLUMN consolidation_id") diff --git a/api/scripts/check_dbml_vs_orm.py b/api/scripts/check_dbml_vs_orm.py index 28ba2c5..24a88b8 100644 --- a/api/scripts/check_dbml_vs_orm.py +++ b/api/scripts/check_dbml_vs_orm.py @@ -2,7 +2,7 @@ Запуск из `api/`: - python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.17.dbml + python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.18.dbml Без аргумента DBML ищется в `$ARM_KA_DOCS/model/` или в соседнем `docs/` рядом с `arm-core`. diff --git a/api/scripts/dbml_parity.py b/api/scripts/dbml_parity.py index c2dc8fb..4c8dac5 100644 --- a/api/scripts/dbml_parity.py +++ b/api/scripts/dbml_parity.py @@ -57,7 +57,7 @@ from sqlalchemy.sql.functions import FunctionElement #: Эталонная схема модели. Имя схемы в БД конфигурируемо (`DB_SCHEMA`), CLI сверяет ORM, загруженную с этим значением. SCHEMA = "dkb" -DBML_FILE_NAME = "data-model-v5.17.dbml" +DBML_FILE_NAME = "data-model-v5.18.dbml" ORM = "ORM" DBML = "DBML" ABSENT = "нет" diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index 75a9c84..534245d 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -1,4 +1,4 @@ -"""ORM-модели. Источник истины — docs/model/data-model-v5.17.dbml; заполняется в Срезе 0. +"""ORM-модели. Источник истины — docs/model/data-model-v5.18.dbml; заполняется в Срезе 0. Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic). """ @@ -7,7 +7,7 @@ from . import access, accounting, audit_ex, consolidation, directories, files, k from .base import Base, UUIDPrimaryMixin #: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm). -DBML_VERSION = "5.17" +DBML_VERSION = "5.18" __all__ = [ "DBML_VERSION", diff --git a/api/src/models/enums.py b/api/src/models/enums.py index 0c48ee0..c533d8f 100644 --- a/api/src/models/enums.py +++ b/api/src/models/enums.py @@ -1,6 +1,6 @@ """Enum'ы модели данных DBML v5.17 (28 штук) и фабрика `pg_enum` для PostgreSQL-колонок. -Источник истины — docs/model/data-model-v5.17.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке +Источник истины — docs/model/data-model-v5.18.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке DBML (включая кириллические `"ЮЛ"`/`"ИП"` и значения в разном регистре); `notes` DBML значениями не являются. Имя члена совпадает со значением (все значения DBML — валидные python-идентификаторы, кириллица допустима). Классы — `enum.StrEnum` (подкласс `str`) — удобно для pydantic/JSON; `values_callable` в `pg_enum` в БД пишет `.value`. diff --git a/api/src/models/files.py b/api/src/models/files.py index 00362ef..13cd93e 100644 --- a/api/src/models/files.py +++ b/api/src/models/files.py @@ -55,6 +55,8 @@ class StoredFile(UUIDPrimaryMixin, Base): Index("ix_stored_file_legal_entity_id_status", "legal_entity_id", "status"), # v5.15 п.4 (OQ-117): проверка FK при удалении recognition_job / файла Index("ix_stored_file_current_recognition_job_id", "current_recognition_job_id"), + # v5.18: рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ») + Index("ix_stored_file_legal_entity_id_consolidation_id", "legal_entity_id", "consolidation_id"), ) legal_entity_id: Mapped[uuid.UUID] = mapped_column( @@ -79,6 +81,9 @@ class StoredFile(UUIDPrimaryMixin, Base): uploaded_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) uploaded_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + # v5.18: консолидация (период ГК), в контексте которой файл загружен; NULL — без контекста (§9.5 п. 8). Последняя + # колонка: миграция 0006 добавляет её ALTER TABLE, а check_dbml сверяет порядок + consolidation_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("consolidation.id")) class Document(UUIDPrimaryMixin, Base): diff --git a/api/tests/test_check_dbml_script.py b/api/tests/test_check_dbml_script.py index 1d249c7..502ae5a 100644 --- a/api/tests/test_check_dbml_script.py +++ b/api/tests/test_check_dbml_script.py @@ -140,18 +140,18 @@ def test_enum_values_and_sets_are_compared(dbml_file: Path) -> None: def test_newer_dbml_version_fails_before_comparing_tables(tmp_path: Path, dbml_file: Path) -> None: lines = dbml_file.read_text(encoding="utf-8").splitlines() - newer = tmp_path / "data-model-v5.18.dbml" - # шапка 5.17 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора - newer.write_text(lines[0].replace("v5.17", "v5.18") + "\nTable broken {{{\n", encoding="utf-8") - assert run_check(newer) == (1, ["версия DBML 5.18 не совпадает с версией ORM 5.17: обновите модели"]) + newer = tmp_path / "data-model-v5.19.dbml" + # шапка 5.18 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора + newer.write_text(lines[0].replace("v5.18", "v5.19") + "\nTable broken {{{\n", encoding="utf-8") + assert run_check(newer) == (1, ["версия DBML 5.19 не совпадает с версией ORM 5.18: обновите модели"]) def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None: - newer = tmp_path / "data-model-v5.18.dbml" - newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.17", "v5.18", 1), encoding="utf-8") + newer = tmp_path / "data-model-v5.19.dbml" + newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.18", "v5.19", 1), encoding="utf-8") result = _run_cli(str(newer)) assert result.returncode == 1 - assert result.stdout == "версия DBML 5.18 не совпадает с версией ORM 5.17: обновите модели\n" + assert result.stdout == "версия DBML 5.19 не совпадает с версией ORM 5.18: обновите модели\n" def test_header_without_version_is_usage_error(tmp_path: Path) -> None: diff --git a/api/tests/test_enums.py b/api/tests/test_enums.py index b3271c1..6b13db3 100644 --- a/api/tests/test_enums.py +++ b/api/tests/test_enums.py @@ -1,4 +1,4 @@ -"""Enum'ы ORM против блоков `Enum {}` DBML v5.17: имена, значения и их порядок (без БД).""" +"""Enum'ы ORM против блоков `Enum {}` DBML v5.18: имена, значения и их порядок (без БД).""" import enum import re @@ -15,7 +15,7 @@ EXPECTED_ENUM_COUNT = 28 def test_dbml_version_format() -> None: assert re.fullmatch(r"\d+\.\d+", DBML_VERSION) - assert DBML_VERSION == "5.17" + assert DBML_VERSION == "5.18" assert f"v{DBML_VERSION}" in dbml_path().name diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 695a9eb..882f681 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -1,5 +1,5 @@ -"""Миграции 0001 (DBML v5.13), 0002 (v5.14), 0003 (v5.15), 0004 (v5.16), 0005 (v5.17) на PostgreSQL 16: полный цикл, -откат на шаг, пустой autogenerate, схема = схеме ORM. +"""Миграции 0001 (DBML v5.13), 0002 (v5.14), 0003 (v5.15), 0004 (v5.16), 0005 (v5.17), 0006 (v5.18) на PostgreSQL 16: +полный цикл, откат на шаг, пустой autogenerate, схема = схеме ORM. Spec data-model, требование «Миграции применяются с нуля и откатываются» (сценарии «Полный цикл» и «Автогенерация ничего не находит»), design D-2. Alembic запускается отдельным процессом (`python -m alembic -x dburl=...`), как из @@ -36,7 +36,8 @@ from tests.test_models_part_07_09 import ( _check_active_lock_is_unique, ) -HEAD = "0005_dbml_v5_17" +HEAD = "0006_dbml_v5_18" +V5_17 = "0005_dbml_v5_17" V5_16 = "0004_dbml_v5_16" V5_15 = "0003_dbml_v5_15" V5_14 = "0002_dbml_v5_14" @@ -451,6 +452,156 @@ async def _kpp_columns(conn: AsyncConnection) -> list[Any]: ) +async def _consolidation_id_column(conn: AsyncConnection) -> list[Any]: + return ( + await conn.execute( + text( + "SELECT is_nullable, data_type FROM information_schema.columns " + "WHERE table_schema = 'dkb' AND table_name = 'stored_file' AND column_name = 'consolidation_id'" + ) + ) + ).all() + + +@pytest.mark.integration +async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> None: + """0006: `stored_file.consolidation_id uuid` nullable, последняя колонка, индекс + `(legal_entity_id, consolidation_id)`; backfill по `consolidation_document`: файл A привязан к версиям 2025 Q1 и + 2024 Q4 → 2024 Q4 (самый ранний период, год важнее квартала); файл B без привязок → NULL; `downgrade 0005` + убирает индекс и колонку, повторный upgrade возвращает.""" + engine = create_async_engine(scratch_db_url) + now = datetime.datetime.now(datetime.UTC) + ids = { + name: uuid.uuid4() + for name in ( + "office", + "group", + "entity", + "user", + "s3a", + "s3b", + "file_a", + "file_b", + "doc_a", + "c_2025q1", + "c_2024q4", + "v_2025q1", + "v_2024q4", + ) + } + try: + await alembic(scratch_db_url, "upgrade", V5_17) + async with engine.begin() as conn: + assert await _consolidation_id_column(conn) == [] + await conn.execute(text("INSERT INTO dkb.regional_office (id, name) VALUES (:office, 'ГО')"), ids) + await conn.execute( + text( + "INSERT INTO dkb.company_group (id, code, name, base_regional_office_id, valid_from) " + "VALUES (:group, 'G-1', 'ГК', :office, :now)" + ), + ids | {"now": now}, + ) + await conn.execute( + text( + "INSERT INTO dkb.legal_entity (id, inn, name, company_group_id, valid_from) " + "VALUES (:entity, '7701000001', 'ООО', :group, :now)" + ), + ids | {"now": now}, + ) + await conn.execute( + text( + "INSERT INTO dkb.app_user (id, email, full_name, regional_office_id) " + "VALUES (:user, 'm@x.ru', 'Тест', :office)" + ), + ids, + ) + for s3, file in (("s3a", "file_a"), ("s3b", "file_b")): + await conn.execute( + text( + "INSERT INTO dkb.s3_object (id, bucket_role, bucket, key, created_at) " + f"VALUES (:{s3}, 'uploads', 'b', 'k-{s3}', :now)" + ), + ids | {"now": now}, + ) + await conn.execute( + text( + "INSERT INTO dkb.stored_file (id, legal_entity_id, file_type, s3_object_id, status, " + f"version_no) VALUES (:{file}, :entity, 'XLS', :{s3}, 'PROCESSED', 1)" + ), + ids, + ) + await conn.execute( + text( + "INSERT INTO dkb.document (id, stored_file_id, legal_entity_id, document_type, osv_account_code, " + "period_year, period_quarter, status) " + "VALUES (:doc_a, :file_a, :entity, 'OSV', '60', 2024, 'Q4', 'CONFIRMED')" + ), + ids, + ) + for c, v, year, quarter, rd in ( + ("c_2025q1", "v_2025q1", 2025, "Q1", "2025-04-01"), + ("c_2024q4", "v_2024q4", 2024, "Q4", "2025-01-01"), + ): + await conn.execute( + text( + "INSERT INTO dkb.consolidation (id, company_group_id, year, quarter, reporting_date) " + f"VALUES (:{c}, :group, {year}, '{quarter}', '{rd}')" + ), + ids, + ) + await conn.execute( + text( + "INSERT INTO dkb.consolidation_version (id, consolidation_id, version_no, status, scenario, " + "approach, goodwill_enabled, initiator_id, initiator_office_id, is_in_work) " + f"VALUES (:{v}, :{c}, 1, 'INITIATED', 'CREATE', 'BASIC', false, :user, :office, true)" + ), + ids, + ) + await conn.execute( + text( + "INSERT INTO dkb.consolidation_document (id, consolidation_version_id, document_id) " + f"VALUES (gen_random_uuid(), :{v}, :doc_a)" + ), + ids, + ) + + await alembic(scratch_db_url, "upgrade", "head") + async with engine.begin() as conn: + assert await _consolidation_id_column(conn) == [("YES", "uuid")] + last = await _scalars( + conn, + "SELECT column_name FROM information_schema.columns WHERE table_schema = 'dkb' " + "AND table_name = 'stored_file' ORDER BY ordinal_position DESC LIMIT 1", + ) + assert last == ["consolidation_id"] + rows = (await conn.execute(text("SELECT id, consolidation_id FROM dkb.stored_file ORDER BY id"))).all() + assert dict(rows) == {ids["file_a"]: ids["c_2024q4"], ids["file_b"]: None} + assert await _scalars( + conn, + "SELECT indexname FROM pg_indexes WHERE schemaname = 'dkb' " + "AND indexname = 'ix_stored_file_legal_entity_id_consolidation_id'", + ) == ["ix_stored_file_legal_entity_id_consolidation_id"] + await engine.dispose() + + await alembic(scratch_db_url, "downgrade", V5_17) + async with engine.connect() as conn: + assert await _consolidation_id_column(conn) == [] + assert ( + await _scalars( + conn, + "SELECT indexname FROM pg_indexes WHERE schemaname = 'dkb' " + "AND indexname = 'ix_stored_file_legal_entity_id_consolidation_id'", + ) + == [] + ) + await engine.dispose() + await alembic(scratch_db_url, "upgrade", "head") + async with engine.connect() as conn: + assert await _consolidation_id_column(conn) == [("YES", "uuid")] + finally: + await engine.dispose() + + @pytest.mark.integration async def test_upgrade_0005_adds_nullable_kpp_columns(scratch_db_url: str) -> None: """0005: `kpp varchar` nullable у `legal_entity` и `legal_entity_history`, существующие ЮЛ — с `kpp IS NULL`; @@ -714,8 +865,8 @@ def test_offline_sql_creates_schema_before_version_table() -> None: assert schema_at < output.index("CREATE TABLE dkb.alembic_version") assert schema_at < output.index("CREATE TYPE dkb.entity_type AS ENUM ('ЮЛ', 'ИП')") assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{PREVIOUS}')" in output - # версия head пишется UPDATE'ами по цепочке 0001 → 0002 → 0003 → 0004 → 0005 — офлайн-SQL доходит до head - for new_version, old_version in ((V5_14, PREVIOUS), (V5_15, V5_14), (V5_16, V5_15), (HEAD, V5_16)): + # версия head пишется UPDATE'ами по цепочке 0001 → 0002 → 0003 → 0004 → 0005 → 0006 — офлайн-SQL доходит до head + for new_version, old_version in ((V5_14, PREVIOUS), (V5_15, V5_14), (V5_16, V5_15), (V5_17, V5_16), (HEAD, V5_17)): assert ( f"UPDATE dkb.alembic_version SET version_num='{new_version}' " f"WHERE dkb.alembic_version.version_num = '{old_version}'" in output -- 2.47.2 From 956a730a3c5557bb914b388164b1ec2719a8bfde Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 10 Oct 2026 01:10:21 +0400 Subject: [PATCH 4/8] =?UTF-8?q?api:=20=D1=80=D0=B0=D0=B1=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D0=B9=20=D1=81=D1=82=D0=BE=D0=BB=20=D0=BA=D0=BE=D0=BD=D1=81?= =?UTF-8?q?=D0=BE=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=AE=D0=9B=20=D0=B8=20consolidation=5Fid=20=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B9=D0=BB=D0=B0=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/src/api/endpoints/files/__init__.py | 3 +- .../endpoints/files/consolidation_files.py | 46 +++++ api/src/repositories/consolidations.py | 8 + api/src/repositories/files.py | 43 +++- api/src/schemas/files.py | 1 + api/src/services/consolidation_guard.py | 1 + api/src/services/documents.py | 1 + api/src/services/primary_files.py | 22 +- api/tests/test_api_consolidation_files.py | 194 ++++++++++++++++++ 9 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 api/src/api/endpoints/files/consolidation_files.py create mode 100644 api/tests/test_api_consolidation_files.py diff --git a/api/src/api/endpoints/files/__init__.py b/api/src/api/endpoints/files/__init__.py index 711ad6a..1e2a4d0 100644 --- a/api/src/api/endpoints/files/__init__.py +++ b/api/src/api/endpoints/files/__init__.py @@ -2,11 +2,12 @@ from fastapi import APIRouter -from src.api.endpoints.files import comments, documents, read, statements, upload +from src.api.endpoints.files import comments, consolidation_files, documents, read, statements, upload router = APIRouter(tags=["files"]) router.include_router(upload.router) router.include_router(read.router) +router.include_router(consolidation_files.router) router.include_router(documents.router) router.include_router(comments.router) router.include_router(statements.router) diff --git a/api/src/api/endpoints/files/consolidation_files.py b/api/src/api/endpoints/files/consolidation_files.py new file mode 100644 index 0000000..ea3a50e --- /dev/null +++ b/api/src/api/endpoints/files/consolidation_files.py @@ -0,0 +1,46 @@ +"""Файлы ЮЛ в контексте консолидации (v5.18, RSHB/arm-ka-docs#90): `GET /consolidations/{consolidation_id}/ +legal-entities/{legal_entity_id}/files` — рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол +консолидации для ЮЛ»); +`POST …/files` — загрузка в консолидацию (spec primary-files «Загрузка файлов в консолидацию»). Права — как у маршрутов +ЮЛ (`DOCUMENT_VIEW` / `DOCUMENT_EDIT`, design R-1); консолидация или ЮЛ вне видимости — 404, как несуществующие.""" + +import uuid +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Query, status + +from src.api.dependencies.auth import require +from src.api.dependencies.files import PrimaryFilesServiceDep +from src.api.endpoints.files.read import FileListQuery +from src.schemas.common import ErrorDetail, Page, PageParams +from src.schemas.files import FileOut + +router = APIRouter() + +VIEW = [Depends(require("DOCUMENT_VIEW"))] +EDIT = [Depends(require("DOCUMENT_EDIT"))] +NOT_FOUND: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "консолидация или ЮЛ: нет или вне видимости"} +} +DESK_PATH = "/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files" + + +@router.get( + DESK_PATH, + response_model=Page[FileOut], + dependencies=VIEW, + responses=NOT_FOUND, + summary="Рабочий стол консолидации для ЮЛ", +) +async def list_desk_files( + consolidation_id: uuid.UUID, + legal_entity_id: uuid.UUID, + service: PrimaryFilesServiceDep, + query: Annotated[FileListQuery, Query()], +) -> Page[FileOut]: + """Файлы ЮЛ, загруженные в эту консолидацию, плюс файлы с документами, привязанными к её версиям (новые первыми).""" + page = PageParams(limit=query.limit, offset=query.offset) + items, total = await service.page_desk( + consolidation_id, legal_entity_id, include_superseded=query.include_superseded, page=page + ) + return Page[FileOut](items=items, total=total, limit=page.limit, offset=page.offset) diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index 4f4801b..f5ca3ab 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -71,6 +71,14 @@ async def get_version( return None if row is None else (row[0], row[1]) +async def get_consolidation( + session: AsyncSession, scope: VisibilityScope, consolidation_id: uuid.UUID +) -> Consolidation | None: + """Консолидация по id, если её ГК видна пользователю; чужая и несуществующая — `None` (v5.18, design D-3).""" + stmt = _visible_consolidations(scope).where(Consolidation.id == consolidation_id) + return (await session.execute(stmt)).scalar_one_or_none() + + async def get_consolidation_for_group( session: AsyncSession, scope: VisibilityScope, group_id: uuid.UUID, year: int, quarter: PeriodQuarter ) -> Consolidation | None: diff --git a/api/src/repositories/files.py b/api/src/repositories/files.py index 98d834e..abed777 100644 --- a/api/src/repositories/files.py +++ b/api/src/repositories/files.py @@ -12,12 +12,13 @@ import uuid from collections.abc import Sequence from typing import Any -from sqlalchemy import Select, and_, delete, func, or_, select, update +from sqlalchemy import Select, and_, delete, exists, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from src.domain.policy import VisibilityScope, legal_entities_visible from src.models.access import AppUser from src.models.audit_ex import Comment +from src.models.consolidation import ConsolidationDocument, ConsolidationVersion from src.models.directories import LegalEntity from src.models.enums import FileStatus, S3BucketRole from src.models.files import Document, RecognitionJob, S3Object, StoredFile @@ -237,3 +238,43 @@ async def expire_s3_objects( ) ) await session.execute(update(S3Object).where(S3Object.id.in_(object_ids), owned).values(expires_at=now)) + + +async def list_desk_files( # noqa: PLR0913 - фильтр и пагинация списка передаются явными именованными аргументами + session: AsyncSession, + scope: VisibilityScope, + entity_id: uuid.UUID, + consolidation_id: uuid.UUID, + *, + include_superseded: bool, + limit: int, + offset: int, +) -> tuple[list[StoredFile], int]: + """Рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ»; §9.5 п. 11; D-6): + файлы ЮЛ с `consolidation_id` этой консолидации ИЛИ с документом, привязанным к любой её версии. Порядок и + `include_superseded` — как в `list_files`; страница и общее число тем же фильтром.""" + bound = exists().where( + Document.stored_file_id == StoredFile.id, + ConsolidationDocument.document_id == Document.id, + ConsolidationVersion.id == ConsolidationDocument.consolidation_version_id, + ConsolidationVersion.consolidation_id == consolidation_id, + ) + + def filtered(stmt: Select[Any]) -> Select[Any]: + stmt = _visible_files(stmt, scope).where( + StoredFile.legal_entity_id == entity_id, + or_(StoredFile.consolidation_id == consolidation_id, bound), + ) + if not include_superseded: + stmt = stmt.where(StoredFile.status != FileStatus.SUPERSEDED) + return stmt + + total = (await session.execute(filtered(select(func.count(StoredFile.id))))).scalar_one() + page_stmt = ( + filtered(select(StoredFile)) + .order_by(StoredFile.uploaded_at.desc().nulls_last(), StoredFile.id) + .limit(limit) + .offset(offset) + ) + files = list((await session.execute(page_stmt)).scalars().all()) + return files, total diff --git a/api/src/schemas/files.py b/api/src/schemas/files.py index 34a5a8e..fe57ba4 100644 --- a/api/src/schemas/files.py +++ b/api/src/schemas/files.py @@ -93,5 +93,6 @@ class FileOut(BaseModel): uploaded_at: datetime.datetime | None uploaded_by: uuid.UUID | None superseded_by_file_id: uuid.UUID | None + consolidation_id: uuid.UUID | None # v5.18: консолидация, в контексте которой загружен файл; None — без контекста job: JobBrief | None documents: list[DocumentBrief] diff --git a/api/src/services/consolidation_guard.py b/api/src/services/consolidation_guard.py index 47cde35..3f6bc93 100644 --- a/api/src/services/consolidation_guard.py +++ b/api/src/services/consolidation_guard.py @@ -18,6 +18,7 @@ from src.models.consolidation import Consolidation, ConsolidationVersion from src.repositories import consolidations as repo VERSION_NOT_FOUND = "версия консолидации не найдена" # одинаково для чужой и несуществующей +CONSOLIDATION_NOT_FOUND = "консолидация не найдена" # одинаково для чужой и несуществующей async def lock_visible_version( diff --git a/api/src/services/documents.py b/api/src/services/documents.py index 393fd60..8a7cf27 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -93,6 +93,7 @@ def file_out( uploaded_at=file.uploaded_at, uploaded_by=file.uploaded_by, superseded_by_file_id=file.superseded_by_file_id, + consolidation_id=file.consolidation_id, job=job_brief(job) if job is not None else None, documents=[ DocumentBrief.model_validate(document).model_copy(update={"comments_count": counts.get(document.id, 0)}) diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index 7454edf..847010d 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -56,7 +56,7 @@ from src.schemas.common import PageParams from src.schemas.files import FileOut from src.services import consolidation_documents from src.services.audit import AuditService -from src.services.consolidation_guard import VERSION_NOT_FOUND +from src.services.consolidation_guard import CONSOLIDATION_NOT_FOUND, VERSION_NOT_FOUND from src.services.documents import file_out from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND from src.services.result_mapping import RESULTS_PREFIX @@ -241,6 +241,26 @@ class PrimaryFilesService: ) return await self._cards(files), total + async def page_desk( + self, consolidation_id: uuid.UUID, entity_id: uuid.UUID, *, include_superseded: bool, page: PageParams + ) -> tuple[list[FileOut], int]: + """Рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ»; §9.5 п. 11; D-6): + консолидация вне видимости — 404 `CONSOLIDATION_NOT_FOUND`, ЮЛ вне видимости — 404; принадлежность ЮЛ к ГК + консолидации не проверяется (чужому ЮЛ — пустой стол). Карточки — как в `page_files`.""" + if await consolidations_repo.get_consolidation(self._session, self._scope, consolidation_id) is None: + raise NotFoundError(CONSOLIDATION_NOT_FOUND) + entity = await self.get_entity(entity_id) + files, total = await files_repo.list_desk_files( + self._session, + self._scope, + entity.id, + consolidation_id, + include_superseded=include_superseded, + limit=page.limit, + offset=page.offset, + ) + return await self._cards(files), total + async def get_file(self, file_id: uuid.UUID) -> FileOut: """Карточка видимого файла; чужой и несуществующий — одинаковый 404.""" file = await files_repo.get_file(self._session, self._scope, file_id) diff --git a/api/tests/test_api_consolidation_files.py b/api/tests/test_api_consolidation_files.py new file mode 100644 index 0000000..8abe4a4 --- /dev/null +++ b/api/tests/test_api_consolidation_files.py @@ -0,0 +1,194 @@ +"""Рабочий стол консолидации для ЮЛ и `consolidation_id` в карточке файла (spec documents-api «Рабочий стол консолидации +для ЮЛ», «Файлы организации и карточка файла»; design D-6, D-7). Поверх фикстуры `app`: данные — фабрики в транзакции +теста.""" + +import uuid + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.access import AppUser +from src.models.consolidation import ConsolidationDocument +from src.models.directories import LegalEntity, RegionalOffice +from src.models.enums import DocumentStatus, FileStatus, PeriodQuarter, RecognitionStatus +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_consolidation, make_document, make_legal_entity, make_stored_file + +HTTP_OK = 200 +HTTP_NOT_FOUND = 404 +HTTP_FORBIDDEN = 403 +KA_RF = "ka_rf@armka.local" # Ростовский филиал, DOCUMENT_VIEW + DOCUMENT_EDIT +CLIENT_MANAGER = "client_manager@armka.local" # Краснодарский филиал +CURATOR_GO = "curator_go@armka.local" # головной офис, права DOCUMENT_VIEW нет +ROSTOV = "Ростовский региональный филиал" +KRASNODAR = "Краснодарский региональный филиал" + +pytestmark = pytest.mark.integration + + +async def _group(session: AsyncSession, office_name: str = ROSTOV, name: str = "ГК Стол"): + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == office_name))).scalar_one() + group = await make_company_group(session, name=name, office=office) + entity = await make_legal_entity(session, name=f"ООО {name}", group=group) + initiator = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + return group, entity, initiator + + +async def _consolidation( # noqa: PLR0913, PLR0917 - тестовый помощник + session: AsyncSession, group, entity, initiator, year: int, quarter: PeriodQuarter +): + consolidation, version = await make_consolidation( + session, group=group, year=year, quarter=quarter, members=[entity], initiator=initiator + ) + return consolidation, version + + +async def _file( + session: AsyncSession, entity: LegalEntity, name: str, *, consolidation_id=None, status=FileStatus.PROCESSED +): + file, job = await make_stored_file( + session, entity=entity, status=status, job_status=RecognitionStatus.done, original_name=name + ) + file.consolidation_id = consolidation_id + await session.flush() + return file, job + + +def _desk_url(consolidation_id: uuid.UUID, entity_id: uuid.UUID, **query: str) -> str: + base = f"/consolidations/{consolidation_id}/legal-entities/{entity_id}/files" + return base + ("?" + "&".join(f"{k}={v}" for k, v in query.items()) if query else "") + + +async def test_desk_shows_only_files_of_consolidation(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Сценарий «Стол показывает только файлы консоли»: три файла ЮЛ — 2025 Q1, 2025 Q2, без контекста.""" + group, entity, initiator = await _group(seeded) + c1, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) + c2, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q2) + f1, _ = await _file(seeded, entity, "q1.xls", consolidation_id=c1.id) + f2, _ = await _file(seeded, entity, "q2.xls", consolidation_id=c2.id) + f0, _ = await _file(seeded, entity, "none.xls") + client = await auth_client(KA_RF) + + desk1 = await client.get(_desk_url(c1.id, entity.id)) + desk2 = await client.get(_desk_url(c2.id, entity.id)) + all_files = await client.get(f"/legal-entities/{entity.id}/files") + + assert desk1.status_code == HTTP_OK, desk1.text + assert [item["id"] for item in desk1.json()["items"]] == [str(f1.id)] + assert desk1.json()["total"] == 1 + assert desk1.json()["items"][0]["consolidation_id"] == str(c1.id) + assert [item["id"] for item in desk2.json()["items"]] == [str(f2.id)] + assert {item["id"] for item in all_files.json()["items"]} == {str(f1.id), str(f2.id), str(f0.id)} + by_id = {item["id"]: item["consolidation_id"] for item in all_files.json()["items"]} + assert by_id[str(f0.id)] is None + + +async def test_desk_includes_file_bound_to_version(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Сценарий «Подхваченный файл виден через привязку»: файл без контекста, документ привязан к версии консоли.""" + group, entity, initiator = await _group(seeded) + c2, v2 = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q2) + file, job = await _file(seeded, entity, "q1-confirmed.xls") + document = await make_document( + seeded, + file=file, + job=job, + account="60", + year=2025, + quarter=PeriodQuarter.Q1, + status=DocumentStatus.CONFIRMED, + is_cumulative=False, + ) + seeded.add(ConsolidationDocument(consolidation_version_id=v2.id, document_id=document.id)) + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.get(_desk_url(c2.id, entity.id)) + + assert response.status_code == HTTP_OK, response.text + assert [item["id"] for item in response.json()["items"]] == [str(file.id)] + assert response.json()["items"][0]["consolidation_id"] is None + + +async def test_desk_excludes_other_consolidation(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Review Focus 2: документ привязан к версии ДРУГОЙ консолидации — файл в этом столе не показывается.""" + group, entity, initiator = await _group(seeded) + c1, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) + c2, v2 = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q2) + file, job = await _file(seeded, entity, "bound-elsewhere.xls", consolidation_id=c2.id) + document = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED) + seeded.add(ConsolidationDocument(consolidation_version_id=v2.id, document_id=document.id)) + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.get(_desk_url(c1.id, entity.id)) + + assert response.status_code == HTTP_OK, response.text + assert response.json()["items"] == [] + assert response.json()["total"] == 0 + + +async def test_desk_superseded_hidden_by_default(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Сценарий «Заменённый файл скрыт по умолчанию» для DESK и BOUND; `include_superseded=true` — оба.""" + group, entity, initiator = await _group(seeded) + c1, v1 = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) + new, _ = await _file(seeded, entity, "new.xls", consolidation_id=c1.id) + old_desk, _ = await _file(seeded, entity, "old-desk.xls", consolidation_id=c1.id, status=FileStatus.SUPERSEDED) + old_desk.superseded_by_file_id = new.id + old_bound, job = await _file(seeded, entity, "old-bound.xls", status=FileStatus.SUPERSEDED) + old_bound.superseded_by_file_id = new.id + document = await make_document(seeded, file=old_bound, job=job, status=DocumentStatus.CONFIRMED) + seeded.add(ConsolidationDocument(consolidation_version_id=v1.id, document_id=document.id)) + await seeded.flush() + client = await auth_client(KA_RF) + + default = await client.get(_desk_url(c1.id, entity.id)) + with_superseded = await client.get(_desk_url(c1.id, entity.id, include_superseded="true")) + + assert [item["id"] for item in default.json()["items"]] == [str(new.id)] + assert {item["id"] for item in with_superseded.json()["items"]} == { + str(new.id), + str(old_desk.id), + str(old_bound.id), + } + assert with_superseded.json()["total"] == 3 # noqa: PLR2004 + + +async def test_desk_foreign_and_missing_consolidation_are_same_404( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Сценарий «Чужая консолидация»: ГК другого филиала и несуществующий id — одинаковый 404; чужое ЮЛ — тоже 404.""" + group, entity, initiator = await _group(seeded, office_name=KRASNODAR, name="ГК Чужая") + c, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) + _, own_entity, _ = await _group(seeded, name="ГК Своя") + client = await auth_client(KA_RF) + + foreign = await client.get(_desk_url(c.id, own_entity.id)) + missing = await client.get(_desk_url(uuid.uuid4(), own_entity.id)) + + assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND + assert foreign.json() == missing.json() + assert foreign.json()["detail"]["code"] == "NOT_FOUND" + + +async def test_desk_requires_document_view(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + group, entity, initiator = await _group(seeded) + c, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) + client = await auth_client(CURATOR_GO) + + response = await client.get(_desk_url(c.id, entity.id)) + + assert response.status_code == HTTP_FORBIDDEN + + +async def test_file_card_has_consolidation_id(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Сценарий «Консолидация в карточке»: `GET /files/{id}` отдаёт `consolidation_id`.""" + group, entity, initiator = await _group(seeded) + c, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) + file, _ = await _file(seeded, entity, "card.xls", consolidation_id=c.id) + client = await auth_client(KA_RF) + + response = await client.get(f"/files/{file.id}") + + assert response.status_code == HTTP_OK, response.text + assert response.json()["consolidation_id"] == str(c.id) -- 2.47.2 From 1e888d4670cef7a2e8040322c03c8c1aec85d185 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 10 Oct 2026 01:31:35 +0400 Subject: [PATCH 5/8] =?UTF-8?q?api:=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=20=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D0=B8=D0=B4=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8E,=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B9=20=C2=AB=D0=B2=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=B5=C2=BB;=20query=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D0=B8=20=D1=83=20=D0=B7=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=B7=D0=BA=D0=B8=20=D0=AE=D0=9B=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D1=91=D0=BD=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../endpoints/files/consolidation_files.py | 42 ++++++- api/src/api/endpoints/files/upload.py | 82 ++++++------- api/src/repositories/consolidations.py | 36 ++++++ api/src/services/primary_files.py | 111 ++++++++++-------- api/tests/test_api_files_upload.py | 97 +++++++++++---- api/tests/test_consolidation_documents.py | 2 +- api/tests/test_openapi_routes.py | 1 + 7 files changed, 254 insertions(+), 117 deletions(-) diff --git a/api/src/api/endpoints/files/consolidation_files.py b/api/src/api/endpoints/files/consolidation_files.py index ea3a50e..69527df 100644 --- a/api/src/api/endpoints/files/consolidation_files.py +++ b/api/src/api/endpoints/files/consolidation_files.py @@ -7,13 +7,14 @@ legal-entities/{legal_entity_id}/files` — рабочий стол консол import uuid from typing import Annotated, Any -from fastapi import APIRouter, Depends, Query, status +from fastapi import APIRouter, Depends, File, Query, Response, UploadFile, status from src.api.dependencies.auth import require from src.api.dependencies.files import PrimaryFilesServiceDep from src.api.endpoints.files.read import FileListQuery +from src.api.endpoints.files.upload import accept_uploads from src.schemas.common import ErrorDetail, Page, PageParams -from src.schemas.files import FileOut +from src.schemas.files import FileOut, UploadResult router = APIRouter() @@ -44,3 +45,40 @@ async def list_desk_files( consolidation_id, legal_entity_id, include_superseded=query.include_superseded, page=page ) return Page[FileOut](items=items, total=total, limit=page.limit, offset=page.offset) + + +UPLOAD_RESPONSES: dict[int | str, dict[str, Any]] = { + **NOT_FOUND, + status.HTTP_409_CONFLICT: {"model": ErrorDetail, "description": "CONSOLIDATION_NOT_EDITABLE — нет версии в работе"}, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "model": UploadResult, + "description": "ни один файл не принят; ЮЛ вне периметра версий в работе (ENTITY_NOT_IN_PERIMETER)", + }, + status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorDetail, "description": "S3 не подключено"}, +} + + +@router.post( + DESK_PATH, + operation_id="upload_consolidation_files", # имя тела multipart в OpenAPI без `__` (test_openapi_routes) + response_model=UploadResult, + status_code=status.HTTP_201_CREATED, + dependencies=EDIT, + responses=UPLOAD_RESPONSES, + summary="Загрузка файлов ЮЛ в консолидацию", +) +async def upload_consolidation_files( + consolidation_id: uuid.UUID, + legal_entity_id: uuid.UUID, + files: Annotated[list[UploadFile], File(description="Файлы ОСВ/ББ/ОФР: xls, xlsx, xlsb, pdf, png, jpg")], + service: PrimaryFilesServiceDep, + response: Response, +) -> UploadResult: + """До приёма: консолидация и ЮЛ видимы, есть версия «в работе», ЮЛ в её периметре. Принятые файлы получают + `consolidation_id`; после приёма хотя бы одного версии `INITIATED` с этим ЮЛ → `DOCUMENTS_PROCESSING`.""" + entity = await service.get_entity(legal_entity_id) + consolidation = await service.check_consolidation_for_upload(consolidation_id, entity) + result = await accept_uploads(service, entity, files, response, consolidation_id=consolidation.id) + if result.accepted: + await service.after_upload_to_consolidation(consolidation.id, entity.id) + return result diff --git a/api/src/api/endpoints/files/upload.py b/api/src/api/endpoints/files/upload.py index 9cf9ab1..4ccc70b 100644 --- a/api/src/api/endpoints/files/upload.py +++ b/api/src/api/endpoints/files/upload.py @@ -1,5 +1,6 @@ """`POST /legal-entities/{legal_entity_id}/files` — загрузка файлов организации (spec primary-files «Загрузка файлов -организации», «Дедуп по содержимому»; design D-4). +организации», «Дедуп по содержимому»; design D-4). Загрузка в консолидацию — `POST /consolidations/{id}/legal-entities/ +{id}/files` (`consolidation_files.py`), общий приём файлов — `accept_uploads`. multipart: одно или несколько полей `files`; право `DOCUMENT_EDIT`; ЮЛ — в области видимости пользователя, иначе 404. Тип документа и период не запрашиваются (макет `40`). Каждый файл принимается или отклоняется отдельно (частичный @@ -10,59 +11,30 @@ multipart: одно или несколько полей `files`; право `DO import uuid from typing import Annotated -from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status +from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status from src.api.dependencies.auth import require from src.api.dependencies.files import PrimaryFilesServiceDep from src.common.exceptions import DomainError from src.common.filenames import read_limited, safe_filename +from src.models.directories import LegalEntity from src.schemas.common import ErrorDetail from src.schemas.files import AcceptedOut, RejectedOut, UploadResult -from src.services.primary_files import FILE_TOO_LARGE, RejectedFile +from src.services.primary_files import FILE_TOO_LARGE, PrimaryFilesService, RejectedFile router = APIRouter() -@router.post( - "/legal-entities/{legal_entity_id}/files", - operation_id="upload_legal_entity_files", # имя тела multipart в OpenAPI без `__` (test_openapi_routes) - response_model=UploadResult, - status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require("DOCUMENT_EDIT"))], - responses={ - status.HTTP_404_NOT_FOUND: { - "model": ErrorDetail, - "description": "ЮЛ нет или оно вне видимости; версия консолидации не найдена или чужая (VERSION_NOT_FOUND)", - }, - status.HTTP_409_CONFLICT: { - "model": ErrorDetail, - "description": "версия консолидации не в работе (VERSION_NOT_EDITABLE)", - }, - status.HTTP_422_UNPROCESSABLE_CONTENT: { - "model": UploadResult, - "description": "ни один файл не принят; ЮЛ вне периметра версии (ENTITY_NOT_IN_PERIMETER)", - }, - status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorDetail, "description": "S3 не подключено"}, - }, - summary="Загрузка файлов организации", -) -async def upload_files( - legal_entity_id: uuid.UUID, - files: Annotated[list[UploadFile], File(description="Файлы ОСВ/ББ/ОФР: xls, xlsx, xlsb, pdf, png, jpg")], - service: PrimaryFilesServiceDep, +async def accept_uploads( + service: PrimaryFilesService, + entity: LegalEntity, + files: list[UploadFile], response: Response, - consolidation_version_id: Annotated[ - uuid.UUID | None, - Query(description="Версия консолидации, в контексте которой загружаются файлы (необязательно)"), - ] = None, + *, + consolidation_id: uuid.UUID | None, ) -> UploadResult: - """Принятые файлы (`file_id`, `job_id`, имя) и отклонённые (имя, код, пояснение, `file_id` дубля). - - С `consolidation_version_id`: до приёма — версия видима, «в работе», ЮЛ в периметре; после приёма хотя бы одного - файла версия `INITIATED` → `DOCUMENTS_PROCESSING` (отдельная транзакция, блокировка версии не нужна).""" - entity = await service.get_entity(legal_entity_id) - if consolidation_version_id is not None: - await service.check_version_for_upload(consolidation_version_id, entity) + """Общий приём файлов двух маршрутов загрузки: каждый файл принимается или отклоняется отдельно; 422 — если не + принят ни один. `consolidation_id` пишется в `stored_file` (загрузка в консолидацию) или `None`.""" service.require_storage() accepted: list[AcceptedOut] = [] rejected: list[RejectedOut] = [] @@ -78,7 +50,7 @@ async def upload_files( ) continue try: - result = await service.upload_one(entity, name, content) + result = await service.upload_one(entity, name, content, consolidation_id=consolidation_id) except DomainError as error: failure = RejectedFile.from_error(safe_filename(name, default=""), error) rejected.append( @@ -88,6 +60,28 @@ async def upload_files( accepted.append(AcceptedOut(file_id=result.file_id, job_id=result.job_id, name=result.name)) if not accepted: response.status_code = status.HTTP_422_UNPROCESSABLE_CONTENT - elif consolidation_version_id is not None: - await service.after_upload_to_version(consolidation_version_id) return UploadResult(accepted=accepted, rejected=rejected) + + +@router.post( + "/legal-entities/{legal_entity_id}/files", + operation_id="upload_legal_entity_files", # имя тела multipart в OpenAPI без `__` (test_openapi_routes) + response_model=UploadResult, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require("DOCUMENT_EDIT"))], + responses={ + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "ЮЛ нет или оно вне видимости"}, + status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": UploadResult, "description": "ни один файл не принят"}, + status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorDetail, "description": "S3 не подключено"}, + }, + summary="Загрузка файлов организации", +) +async def upload_files( + legal_entity_id: uuid.UUID, + files: Annotated[list[UploadFile], File(description="Файлы ОСВ/ББ/ОФР: xls, xlsx, xlsb, pdf, png, jpg")], + service: PrimaryFilesServiceDep, + response: Response, +) -> UploadResult: + """Принятые файлы (`file_id`, `job_id`, имя) и отклонённые (имя, код, пояснение, `file_id` дубля).""" + entity = await service.get_entity(legal_entity_id) + return await accept_uploads(service, entity, files, response, consolidation_id=None) diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index f5ca3ab..75fb746 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -179,6 +179,42 @@ async def lock_versions_for_entity( return list((await session.execute(members_only)).scalars()) +async def versions_of(session: AsyncSession, consolidation_id: uuid.UUID) -> list[ConsolidationVersion]: + """Все версии консолидации по возрастанию `version_no` (без блокировки; проверки до приёма файлов, D-3).""" + stmt = ( + select(ConsolidationVersion) + .where(ConsolidationVersion.consolidation_id == consolidation_id) + .order_by(ConsolidationVersion.version_no) + ) + return list((await session.execute(stmt)).scalars()) + + +async def lock_versions_of_consolidation( + session: AsyncSession, + consolidation_id: uuid.UUID, + entity_id: uuid.UUID, + statuses: Collection[ConsolidationStatus], +) -> list[ConsolidationVersion]: + """Версии консолидации со статусом из `statuses`, где ЮЛ — член периметра, под `FOR UPDATE ORDER BY id` (R-2; + design D-4: переход после загрузки в консолидацию). `populate_existing` — свежий статус из БД.""" + is_member = exists().where( + ConsolidationMember.consolidation_version_id == ConsolidationVersion.id, + ConsolidationMember.legal_entity_id == entity_id, + ) + stmt = ( + select(ConsolidationVersion) + .where( + ConsolidationVersion.consolidation_id == consolidation_id, + ConsolidationVersion.status.in_(statuses), + is_member, + ) + .order_by(ConsolidationVersion.id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return list((await session.execute(stmt)).scalars()) + + async def active_lock(session: AsyncSession, version_id: uuid.UUID) -> ConsolidationLock | None: """Активная блокировка версии (`released_at IS NULL`; не более одной — partial UNIQUE).""" stmt = select(ConsolidationLock).where( diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index 847010d..366830b 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -8,8 +8,8 @@ audit-trail «События файлов и документов»; design D-4) 1. рекомендательная блокировка `(ЮЛ, sha256)` до конца транзакции запроса и проверка дубля — до записи в S3; 2. `s3_object` + `flush` (конфликт ключа или ошибка БД — до загрузки объекта, бакет не засоряется); 3. `put_object`; ошибка S3 — `STORAGE_UNAVAILABLE`, SAVEPOINT откатывается (строки `s3_object` не остаётся); -4. `stored_file` (`UPLOADED`, `version_no = 1`) и `recognition_job` (`queued`) + `flush`, - `stored_file.current_recognition_job_id`, аудит `FILE_UPLOAD`. +4. `stored_file` (`UPLOADED`, `version_no = 1`, `consolidation_id` при загрузке в консолидацию) и `recognition_job` + (`queued`) + `flush`, `stored_file.current_recognition_job_id`, аудит `FILE_UPLOAD` (с `consolidation_id`). Если после шага 3 упадёт БД (вставка файла/задания, аудит или commit запроса), объект в S3 останется без записи `s3_object` — допустимо: ключ содержит свежие `file_id`/`job_id`, ни на что не ссылается и не будет переиспользован; @@ -37,7 +37,7 @@ from src.core.config import Settings from src.core.storage import ObjectNotFoundError, ObjectStorage from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file from src.domain.policy import IN_WORK_STATUSES, VisibilityScope -from src.models.consolidation import ConsolidationVersion +from src.models.consolidation import Consolidation from src.models.directories import LegalEntity from src.models.enums import ( AuditAction, @@ -56,7 +56,7 @@ from src.schemas.common import PageParams from src.schemas.files import FileOut from src.services import consolidation_documents from src.services.audit import AuditService -from src.services.consolidation_guard import CONSOLIDATION_NOT_FOUND, VERSION_NOT_FOUND +from src.services.consolidation_guard import CONSOLIDATION_NOT_FOUND from src.services.documents import file_out from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND from src.services.result_mapping import RESULTS_PREFIX @@ -160,34 +160,35 @@ class PrimaryFilesService: raise NotFoundError(LEGAL_ENTITY_NOT_FOUND) return entity - async def check_version_for_upload(self, version_id: uuid.UUID, entity: LegalEntity) -> ConsolidationVersion: - """Загрузка в контексте версии консолидации (spec primary-files «Загрузка файлов организации», D-6): ДО приёма - файлов версия видима (иначе 404 `VERSION_NOT_FOUND`) → статус «в работе» (иначе 409 `VERSION_NOT_EDITABLE`) → - ЮЛ — член периметра (иначе 422 `ENTITY_NOT_IN_PERIMETER`). Блокировка версии (`consolidation_lock`) и - `FOR UPDATE` не нужны: статус перепроверяется под блокировкой в `after_upload_to_version`.""" - found = await consolidations_repo.get_version(self._session, self._scope, version_id) - if found is None: - raise NotFoundError(VERSION_NOT_FOUND) - version = found[0] - if version.status not in IN_WORK_STATUSES: - raise ConflictError("VERSION_NOT_EDITABLE", f"Версия в статусе {version.status.value} не редактируется") - members = await consolidations_repo.members(self._session, version.id) - if all(member.legal_entity_id != entity.id for member, _ in members): - raise DomainValidationError("ENTITY_NOT_IN_PERIMETER", "ЮЛ не входит в периметр версии консолидации") - return version + async def check_consolidation_for_upload(self, consolidation_id: uuid.UUID, entity: LegalEntity) -> Consolidation: + """Загрузка в консолидацию (spec primary-files «Загрузка файлов в консолидацию», §9.5 п. 8, D-3): ДО приёма — + консолидация видима (404 `CONSOLIDATION_NOT_FOUND`) → есть версия «в работе» (409 `CONSOLIDATION_NOT_EDITABLE`) + → ЮЛ в периметре хотя бы одной из них (422 `ENTITY_NOT_IN_PERIMETER`). Блокировки не нужны: статус + перечитывается под `FOR UPDATE` в `after_upload_to_consolidation`.""" + consolidation = await consolidations_repo.get_consolidation(self._session, self._scope, consolidation_id) + if consolidation is None: + raise NotFoundError(CONSOLIDATION_NOT_FOUND) + in_work = [ + version + for version in await consolidations_repo.versions_of(self._session, consolidation.id) + if version.status in IN_WORK_STATUSES + ] + if not in_work: + raise ConflictError("CONSOLIDATION_NOT_EDITABLE", "У консолидации нет версии в работе") + for version in in_work: + members = await consolidations_repo.members(self._session, version.id) + if any(member.legal_entity_id == entity.id for member, _ in members): + return consolidation + raise DomainValidationError("ENTITY_NOT_IN_PERIMETER", "ЮЛ не входит в периметр версий консолидации в работе") - async def after_upload_to_version(self, version_id: uuid.UUID) -> None: - """После приёма хотя бы одного файла: версия `INITIATED` → `DOCUMENTS_PROCESSING` (`changed_by` — загрузивший). + async def after_upload_to_consolidation(self, consolidation_id: uuid.UUID, entity_id: uuid.UUID) -> None: + """После приёма хотя бы одного файла: каждая версия консолидации «в работе» с ЮЛ в периметре и статусом + `INITIATED` → `DOCUMENTS_PROCESSING` (`changed_by` — загрузивший; §9.1, D-4). - В ОТДЕЛЬНОЙ транзакции (`session_transaction`, как аудит входа): `lock_version` (`FOR UPDATE`) → статус - перечитывается — если уже не `INITIATED` (параллельный переход, аннулирование), ничего не меняется. Версия - блокируется на короткое время и вне транзакции запроса, чтобы загрузка не держала её на время приёма файлов - (D-9). Транзакция запроса к этому моменту версию не трогала, взаимных ожиданий нет. - - Приём файлов важнее побочного статуса (R-10, design №43): любая ошибка перехода (`Exception`) логируется и - НЕ меняет ответ — принятые файлы остаются, версия остаётся `INITIATED` до следующей загрузки или - подтверждения. `BaseException` (`asyncio.CancelledError`) не перехватывается.""" - # импорт внутри функции — как в consolidation_documents (design №36): модули консолидации импортируют друг друга + В ОТДЕЛЬНОЙ транзакции (`session_transaction`): версии под `FOR UPDATE ORDER BY id`, статус перечитан — + попадают только ещё `INITIATED`. Приём файлов важнее побочного статуса (R-10 Среза 4): любая ошибка + перехода логируется и НЕ меняет ответ. `BaseException` не перехватывается.""" + # импорт внутри функции — модули консолидации импортируют друг друга from src.services.consolidation_status import transition # noqa: PLC0415 factory = self._transaction @@ -197,25 +198,28 @@ class PrimaryFilesService: factory = session_transaction try: async with factory() as session: - version = await consolidations_repo.lock_version(session, version_id) - if version is None or version.status is not ConsolidationStatus.INITIATED: - return - await transition( - session, - AuditService(session), - version=version, - to=ConsolidationStatus.DOCUMENTS_PROCESSING, - changed_by=self._audit.user_id, - comment=None, - ip_address=self._audit.ip_address, - user_agent=self._audit.user_agent, - now=utc_now(), + versions = await consolidations_repo.lock_versions_of_consolidation( + session, consolidation_id, entity_id, {ConsolidationStatus.INITIATED} ) + audit = AuditService(session) + now = utc_now() + for version in versions: + await transition( + session, + audit, + version=version, + to=ConsolidationStatus.DOCUMENTS_PROCESSING, + changed_by=self._audit.user_id, + comment=None, + ip_address=self._audit.ip_address, + user_agent=self._audit.user_agent, + now=now, + ) except Exception: logger.exception( - "Не удалось перевести версию в DOCUMENTS_PROCESSING после загрузки, файлы приняты. " - "error_category=version_transition_error, version_id={version_id}", - version_id=version_id, + "Не удалось перевести версии консолидации в DOCUMENTS_PROCESSING после загрузки, файлы приняты. " + "error_category=version_transition_error, consolidation_id={consolidation_id}", + consolidation_id=consolidation_id, ) def require_storage(self) -> ObjectStorage: @@ -386,7 +390,9 @@ class PrimaryFilesService: ) return (await self._session.execute(stmt)).scalar_one_or_none() - async def upload_one(self, entity: LegalEntity, name: str, content: bytes) -> AcceptedFile: + async def upload_one( + self, entity: LegalEntity, name: str, content: bytes, *, consolidation_id: uuid.UUID | None = None + ) -> AcceptedFile: """Принять один файл (см. docstring модуля); отказ — `DomainError` (`UNSUPPORTED_FILE`, `DUPLICATE_FILE`, `STORAGE_UNAVAILABLE`), записи этого файла откатываются вместе с его SAVEPOINT.""" storage = self.require_storage() @@ -403,7 +409,13 @@ class PrimaryFilesService: file_id=str(duplicate.id), ) return await self._store( - entity, name=safe_name, content=content, sha256=sha256, storage=storage, detected=detected + entity, + name=safe_name, + content=content, + sha256=sha256, + storage=storage, + detected=detected, + consolidation_id=consolidation_id, ) async def _lock_content(self, entity_id: uuid.UUID, sha256: str) -> None: @@ -421,6 +433,7 @@ class PrimaryFilesService: sha256: str, storage: ObjectStorage, detected: DetectedFile, + consolidation_id: uuid.UUID | None, ) -> AcceptedFile: now = utc_now() user_id = self._audit.user_id @@ -452,6 +465,7 @@ class PrimaryFilesService: stored_file = StoredFile( id=file_id, legal_entity_id=entity.id, + consolidation_id=consolidation_id, file_type=detected.file_type, original_name=name, s3_object_id=s3_object.id, @@ -491,6 +505,7 @@ class PrimaryFilesService: "content_hash": sha256, "job_id": str(job_id), "file_type": detected.file_type.value, + "consolidation_id": str(consolidation_id) if consolidation_id is not None else None, }, ) return AcceptedFile(file_id=file_id, job_id=job_id, name=name) diff --git a/api/tests/test_api_files_upload.py b/api/tests/test_api_files_upload.py index 16534ef..c62c246 100644 --- a/api/tests/test_api_files_upload.py +++ b/api/tests/test_api_files_upload.py @@ -25,7 +25,7 @@ from src.api.dependencies.directories import get_app_settings from src.core.config import get_settings from src.models.access import AppUser from src.models.audit_ex import AuditEvent -from src.models.consolidation import ConsolidationStatusHistory, ConsolidationVersion +from src.models.consolidation import Consolidation, ConsolidationStatusHistory, ConsolidationVersion from src.models.directories import LegalEntity, RegionalOffice from src.models.enums import ( AuditAction, @@ -41,7 +41,7 @@ from src.models.enums import ( from src.models.files import RecognitionJob, S3Object, StoredFile from src.services.recognition_exchange import publish_pending from tests.conftest import AuthClientFactory, FakeStorage -from tests.factories import make_company_group, make_consolidation, make_legal_entity, make_lock +from tests.factories import make_company_group, make_consolidation, make_legal_entity, make_lock, make_version from tests.test_recognition_exchange import REQUEST_VALIDATOR, FakeNats HTTP_CREATED = 201 @@ -311,6 +311,7 @@ async def test_two_files_write_two_upload_events( "content_hash": hashlib.sha256(content).hexdigest(), "job_id": item["job_id"], "file_type": file_type, + "consolidation_id": None, } assert len(storage.objects) == len(accepted) @@ -578,26 +579,33 @@ async def _version_state(session: AsyncSession, version_id: uuid.UUID) -> Consol return (await session.execute(stmt.execution_options(populate_existing=True))).scalar_one() -async def _post_in_version( - client: httpx.AsyncClient, entity_id: uuid.UUID, version_id: uuid.UUID, *uploads: Upload +async def _post_in_consolidation( + client: httpx.AsyncClient, consolidation_id: uuid.UUID, entity_id: uuid.UUID, *uploads: Upload ) -> httpx.Response: files = [("files", (name, content, "application/octet-stream")) for name, content in uploads] - return await client.post(_files_url(entity_id), params={"consolidation_version_id": str(version_id)}, files=files) + return await client.post(f"/consolidations/{consolidation_id}/legal-entities/{entity_id}/files", files=files) @pytest.mark.integration -async def test_upload_in_version_moves_initiated_to_documents_processing( +async def test_upload_in_consolidation_moves_initiated_to_documents_processing( auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage ) -> None: version, (member, *_), _ = await _version(seeded) user_id = await _user_id(seeded, KA_RF) client = await auth_client(KA_RF) - response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("in-version"))) + response = await _post_in_consolidation( + client, version.consolidation_id, member.id, ("osv.xls", _xls("in-version")) + ) assert response.status_code == HTTP_CREATED, response.text assert len(response.json()["accepted"]) == 1 assert len(storage.objects) == 1 + file = await seeded.get(StoredFile, uuid.UUID(response.json()["accepted"][0]["file_id"])) + assert file is not None + assert file.consolidation_id == version.consolidation_id + [upload_event] = await _upload_events(seeded) + assert upload_event.after_value["consolidation_id"] == str(version.consolidation_id) assert await _version_state(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING history = ( ( @@ -627,13 +635,15 @@ async def test_upload_in_version_moves_initiated_to_documents_processing( @pytest.mark.integration @pytest.mark.parametrize("status", [ConsolidationStatus.DOCUMENTS_PROCESSING, ConsolidationStatus.DATA_CORRECTION]) -async def test_upload_in_version_keeps_other_in_work_status( +async def test_upload_in_consolidation_keeps_other_in_work_status( auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage, status: ConsolidationStatus ) -> None: version, (member, *_), _ = await _version(seeded, status=status) client = await auth_client(KA_RF) - response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls(f"keep-{status.value}"))) + response = await _post_in_consolidation( + client, version.consolidation_id, member.id, ("osv.xls", _xls(f"keep-{status.value}")) + ) assert response.status_code == HTTP_CREATED, response.text assert await _version_state(seeded, version.id) is status @@ -647,7 +657,7 @@ async def test_upload_with_all_files_rejected_does_not_move_version( version, (member, *_), _ = await _version(seeded) client = await auth_client(KA_RF) - response = await _post_in_version(client, member.id, version.id, ("notes.docx", ZIP + b"x")) + response = await _post_in_consolidation(client, version.consolidation_id, member.id, ("notes.docx", ZIP + b"x")) assert response.status_code == HTTP_UNPROCESSABLE, response.text assert response.json()["accepted"] == [] @@ -664,7 +674,9 @@ async def test_upload_for_entity_outside_perimeter_is_422_and_writes_nothing( client = await auth_client(KA_RF) before = await _counts(seeded) - response = await _post_in_version(client, outsider.id, version.id, ("osv.xls", _xls("outsider"))) + response = await _post_in_consolidation( + client, version.consolidation_id, outsider.id, ("osv.xls", _xls("outsider")) + ) assert response.status_code == HTTP_UNPROCESSABLE, response.text assert response.json()["detail"]["code"] == "ENTITY_NOT_IN_PERIMETER" @@ -674,23 +686,23 @@ async def test_upload_for_entity_outside_perimeter_is_422_and_writes_nothing( @pytest.mark.integration -async def test_upload_in_cancelled_version_is_409( +async def test_upload_without_in_work_version_is_409( auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage ) -> None: version, (member, *_), _ = await _version(seeded, status=ConsolidationStatus.CANCELLED) client = await auth_client(KA_RF) before = await _counts(seeded) - response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("cancelled"))) + response = await _post_in_consolidation(client, version.consolidation_id, member.id, ("osv.xls", _xls("cancelled"))) assert response.status_code == HTTP_CONFLICT, response.text - assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE" + assert response.json()["detail"]["code"] == "CONSOLIDATION_NOT_EDITABLE" assert await _counts(seeded) == before assert storage.objects == {} @pytest.mark.integration -async def test_upload_in_foreign_version_is_404_like_missing( +async def test_upload_in_foreign_consolidation_is_404_like_missing( auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage ) -> None: foreign, _, _ = await _version(seeded, office_name=KRASNODAR) @@ -698,8 +710,12 @@ async def test_upload_in_foreign_version_is_404_like_missing( client = await auth_client(KA_RF) before = await _counts(seeded) - foreign_response = await _post_in_version(client, own_entity.id, foreign.id, ("osv.xls", _xls("foreign-version"))) - missing_response = await _post_in_version(client, own_entity.id, uuid.uuid4(), ("osv.xls", _xls("missing-version"))) + foreign_response = await _post_in_consolidation( + client, foreign.consolidation_id, own_entity.id, ("osv.xls", _xls("foreign-version")) + ) + missing_response = await _post_in_consolidation( + client, uuid.uuid4(), own_entity.id, ("osv.xls", _xls("missing-version")) + ) assert foreign_response.status_code == missing_response.status_code == HTTP_NOT_FOUND, foreign_response.text assert foreign_response.json() == missing_response.json() @@ -718,7 +734,9 @@ async def test_upload_does_not_require_version_lock( await make_lock(seeded, version=version, user=holder) client = await auth_client(KA_RF) - response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("under-lock"))) + response = await _post_in_consolidation( + client, version.consolidation_id, member.id, ("osv.xls", _xls("under-lock")) + ) assert response.status_code == HTTP_CREATED, response.text assert await _version_state(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING @@ -726,17 +744,28 @@ async def test_upload_does_not_require_version_lock( @pytest.mark.integration -async def test_upload_without_version_does_not_touch_versions( +async def test_upload_without_consolidation_does_not_touch_versions( auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage ) -> None: version, (member, *_), _ = await _version(seeded) client = await auth_client(KA_RF) response = await _post(client, member.id, ("osv.xls", _xls("no-version"))) + ignored = await client.post( + _files_url(member.id), + params={"consolidation_version_id": str(version.id)}, + files=[("files", ("osv.xls", _xls("ignored-query"), "application/octet-stream"))], + ) assert response.status_code == HTTP_CREATED, response.text + assert ignored.status_code == HTTP_CREATED, ignored.text + bodies = (response.json(), ignored.json()) assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED - assert len(storage.objects) == 1 + assert len(storage.objects) == len(bodies) + for body in bodies: + file = await seeded.get(StoredFile, uuid.UUID(body["accepted"][0]["file_id"])) + assert file is not None + assert file.consolidation_id is None @pytest.mark.integration @@ -755,7 +784,9 @@ async def test_upload_keeps_accepted_files_when_version_transition_fails( messages: list[str] = [] sink = logger.add(lambda message: messages.append(str(message)), level="ERROR") try: - response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("transition-fails"))) + response = await _post_in_consolidation( + client, version.consolidation_id, member.id, ("osv.xls", _xls("transition-fails")) + ) finally: logger.remove(sink) @@ -764,4 +795,26 @@ async def test_upload_keeps_accepted_files_when_version_transition_fails( assert (await seeded.get(StoredFile, uuid.UUID(accepted["file_id"]))) is not None assert len(storage.objects) == 1 assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED - assert any(str(version.id) in message and "version_transition_error" in message for message in messages) + assert any( + str(version.consolidation_id) in message and "version_transition_error" in message for message in messages + ) + + +@pytest.mark.integration +async def test_upload_moves_only_versions_with_entity( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + """Две версии консолидации в INITIATED: ЮЛ в периметре только второй — переходит только она (D-4).""" + version, entities, initiator = await _version(seeded) + consolidation = await seeded.get(Consolidation, version.consolidation_id) + assert consolidation is not None + other = await make_version( + seeded, consolidation=consolidation, version_no=2, members=[entities[2]], initiator=initiator + ) + client = await auth_client(KA_RF) + + response = await _post_in_consolidation(client, consolidation.id, entities[2].id, ("osv.xls", _xls("only-v2"))) + + assert response.status_code == HTTP_CREATED, response.text + assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED + assert await _version_state(seeded, other.id) is ConsolidationStatus.DOCUMENTS_PROCESSING diff --git a/api/tests/test_consolidation_documents.py b/api/tests/test_consolidation_documents.py index 38b2765..68d45bb 100644 --- a/api/tests/test_consolidation_documents.py +++ b/api/tests/test_consolidation_documents.py @@ -1159,7 +1159,7 @@ async def test_on_documents_mapped_waits_for_member_edit_and_sees_new_member( async def test_on_documents_mapped_waits_for_transition_to_documents_processing( committed_sessionmaker: async_sessionmaker[AsyncSession], ) -> None: - """R-2/№40, маппинг ∥ загрузка (`after_upload_to_version`): версия `INITIATED` держится транзакцией, которая + """R-2/№40, маппинг ∥ загрузка (`after_upload_to_consolidation`): версия `INITIATED` держится транзакцией, которая переводит её в `DOCUMENTS_PROCESSING` (не зафиксировано). Хук блокирует надмножество версий «в работе» (а не только `DOCUMENTS_PROCESSING`), поэтому встаёт в ожидание строки версии и после commit видит `DOCUMENTS_PROCESSING` — переход в `DATA_CORRECTION` не теряется. Хук, ищущий версии только в `DOCUMENTS_PROCESSING`, снимок `INITIATED` diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 0ce684e..8bc79b4 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -49,6 +49,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = { f"{A}/roles/{{code}}/permissions": {"put"}, # primary-files, documents-api (Срез 2c) "/api/v1/legal-entities/{legal_entity_id}/files": {"get", "post"}, + "/api/v1/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files": {"get", "post"}, # v5.18 "/api/v1/files/{file_id}": {"get", "delete"}, "/api/v1/files/{file_id}/content": {"get"}, "/api/v1/documents/{document_id}": {"get"}, -- 2.47.2 From 25307edee8c8a4acf1dd99ad19ae6eece740f8e1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 10 Oct 2026 01:45:31 +0400 Subject: [PATCH 6/8] =?UTF-8?q?mapping:=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9?= =?UTF-8?q?=20=D1=84=D0=B0=D0=B9=D0=BB=20=D0=B1=D0=B5=D0=B7=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=D0=B4=D1=83=D0=B5=D1=82=20consolidation=5Fid=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D1=91=D0=BD=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/src/services/result_mapping.py | 10 +++- api/tests/test_result_mapping.py | 85 +++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index b890f93..6b41290 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -660,8 +660,10 @@ async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена ) -> list[StoredFile]: """D-10, spec primary-files «Автозамена прежних версий»: файлы того же ЮЛ (не `SUPERSEDED`, не сам новый) с документом тех же `document_type`, `osv_account_code`, `period_year`, `period_quarter` → `SUPERSEDED`, - `superseded_by_file_id = new_file.id`; у нового файла `version_no` = максимум заменённых + 1. На каждый - заменённый файл — одно системное событие `FILE_REPLACE` (`user_id = NULL`, инициатор загрузки — в `description`, + `superseded_by_file_id = new_file.id`; у нового файла `version_no` = максимум заменённых + 1; файл без + консолидации (`consolidation_id` пуст) наследует её у первого по `id` заменённого файла, где она задана (v5.18, + §9.5 п. 3). На каждый заменённый файл — одно системное событие `FILE_REPLACE` (`user_id = NULL`, инициатор + загрузки — в `description`, `confirmed=true`, если среди заменённых документов есть `CONFIRMED`; решение владельца 05.10.2026). Документы и строки заменённых файлов не трогаются (история). Возвращает заменённые файлы; в транзакции вызывающего, без commit. @@ -729,6 +731,10 @@ async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена .execution_options(synchronize_session=False) ) new_file.version_no = max(old.version_no for old in locked) + 1 + if new_file.consolidation_id is None: # v5.18, §9.5 п. 3: файл без контекста наследует консоль заменённого + new_file.consolidation_id = next( + (old.consolidation_id for old in locked if old.consolidation_id is not None), None + ) new_file.updated_at = now await session.flush() # привязки к версиям «в работе» снимаются у ВСЕХ документов заменённого файла (spec primary-files), а не только у diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index 48ac9bb..8c25454 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -20,17 +20,23 @@ from typing import Any import pytest from loguru import logger -from sqlalchemy import func, select +from sqlalchemy import func, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.contract import ack_msg_id, result_msg_id from src.contract.result import RecognitionResult from src.core.config import Settings, get_settings +from src.domain.consolidation_periods import reporting_date from src.models.access import AppUser from src.models.accounting import ChartOfAccount from src.models.audit_ex import AuditEvent -from src.models.consolidation import ConsolidationDocument, ConsolidationStatusHistory, ConsolidationVersion +from src.models.consolidation import ( + Consolidation, + ConsolidationDocument, + ConsolidationStatusHistory, + ConsolidationVersion, +) from src.models.directories import LegalEntity from src.models.enums import ( AuditAction, @@ -1044,6 +1050,81 @@ async def test_version_chain(committed_sessionmaker: async_sessionmaker[AsyncSes assert event.after_value == {"file_id": str(v3_id), "version_no": 3, "unbound_version_ids": []} +async def _consolidation_id( + maker: async_sessionmaker[AsyncSession], entity_id: uuid.UUID, quarter: PeriodQuarter +) -> uuid.UUID: + """Консолидация ГК этого ЮЛ за 2025/quarter (без версий: для наследования они не нужны). + + ЮЛ без ГК получает ГК (консолидация требует группу) и привязывается к ней.""" + async with maker() as session, session.begin(): + entity = await session.get(LegalEntity, entity_id) + assert entity is not None + if entity.company_group_id is None: + group = await make_company_group(session, name="ГК Наследование") + entity.company_group_id = group.id + consolidation = Consolidation( + company_group_id=entity.company_group_id, + year=2025, + quarter=quarter, + reporting_date=reporting_date(2025, quarter), + created_at=_now(), + ) + session.add(consolidation) + await session.flush() + return consolidation.id + + +async def _set_consolidation( + maker: async_sessionmaker[AsyncSession], file_id: uuid.UUID, consolidation_id: uuid.UUID | None +) -> None: + async with maker() as session, session.begin(): + await session.execute( + update(StoredFile).where(StoredFile.id == file_id).values(consolidation_id=consolidation_id) + ) + + +async def test_supersede_inherits_consolidation_from_first_with_value( + committed_sessionmaker: async_sessionmaker[AsyncSession], +) -> None: + """Спека «Новый файл без контекста наследует консолидацию» + Review Focus 5: два заменяемых файла, у первого по `id` + `consolidation_id` пуст, у второго задан — новый получает значение второго.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id = await _entity(maker) + c1 = await _consolidation_id(maker, entity_id, PeriodQuarter.Q1) + first_id = await _old_file(maker, entity_id, [OSV_60_2025_Q1]) + second_id = await _old_file(maker, entity_id, [OSV_60_2025_Q1]) + empty_id, filled_id = sorted([first_id, second_id]) + await _set_consolidation(maker, filled_id, c1) + async with maker() as session: + entity = await session.get(LegalEntity, entity_id) + new_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity) + + assert await process_once(maker, storage, _settings()) is True + + assert (await _file(maker, new_id)).consolidation_id == c1 + assert (await _file(maker, empty_id)).status is FileStatus.SUPERSEDED + assert (await _file(maker, filled_id)).status is FileStatus.SUPERSEDED + + +async def test_supersede_keeps_own_consolidation(committed_sessionmaker: async_sessionmaker[AsyncSession]) -> None: + """Спека «Новый файл с контекстом сохраняет свой»: старый в C1, новый загружен в C2 — остаётся C2.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id = await _entity(maker) + c1 = await _consolidation_id(maker, entity_id, PeriodQuarter.Q1) + c2 = await _consolidation_id(maker, entity_id, PeriodQuarter.Q2) + old_id = await _old_file(maker, entity_id, [OSV_60_2025_Q1]) + await _set_consolidation(maker, old_id, c1) + async with maker() as session: + entity = await session.get(LegalEntity, entity_id) + new_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity) + await _set_consolidation(maker, new_id, c2) + + assert await process_once(maker, storage, _settings()) is True + + assert (await _file(maker, new_id)).consolidation_id == c2 + assert (await _file(maker, old_id)).status is FileStatus.SUPERSEDED + + async def test_one_event_per_replaced_file(committed_sessionmaker: async_sessionmaker[AsyncSession]): """Новый файл с ОСВ 60 и 62 заменяет прежний файл с теми же двумя документами одним событием `FILE_REPLACE`, в `description` — оба документа.""" -- 2.47.2 From 5899525ca5f5db4995e5590400668dbcfc1a439d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 10 Oct 2026 02:02:24 +0400 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20README=20=D0=B8=20CLAUDE.md=20?= =?UTF-8?q?=E2=80=94=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=D1=8B=20?= =?UTF-8?q?=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=81?= =?UTF-8?q?=D0=BE=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D0=B8,=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=D1=8B=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA=20(#9?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 4 ++-- README.md | 16 ++++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7ae139e..8cd038f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ `OUTBOX_MAX_REPUBLISH`=3, счётчик — `recognition_job.metrics.republish_count`) → `dlq`; дедуп — `request_msg_id` / `result_msg_id`. Статусы задания в Срезе 0 `audit_event` не пишут. - **Первичные документы (Срез 2c)** — `services/primary_files.py` (загрузка `upload_one`: дедуп по `(ЮЛ, sha256)` под - advisory-lock, SAVEPOINT на файл, `delete`), `services/file_status.py` (единственное место смены `stored_file.status` + advisory-lock, SAVEPOINT на файл, `delete`), v5.18 (#96): загрузка в консолидацию — `check_consolidation_for_upload` → `accept_uploads` (`endpoints/files/upload.py`, общий для обоих маршрутов) → `after_upload_to_consolidation` (отдельная транзакция, версии консолидации `INITIATED` с ЮЛ в периметре под `FOR UPDATE ORDER BY id`); рабочий стол — `page_desk` / `files_repo.list_desk_files` (`OR EXISTS` по `consolidation_document`); автозамена наследует `consolidation_id`; `services/file_status.py` (единственное место смены `stored_file.status` по ТЕКУЩЕМУ заданию; `PROCESSED`/`SUPERSEDED` обменом не перезаписываются), `services/result_mapping.py` (цикл маппинга: `pick_candidate` → проверка ключа результата (OQ-70) → чтение S3 вне транзакции → `map_file` в одной транзакции → автозамена `FILE_REPLACE`), `services/documents.py` (карточки, `confirm`), `services/auto_confirm.py` (автоподтверждение чистых документов в транзакции маппинга: `_map_candidate`: `map_file` → автозамена → `auto_confirm` → `on_documents_mapped`; `confirmed_by` NULL, `DOCUMENT_CONFIRM` без пользователя, переход версии — от `uploaded_by`; OQ-157), `domain/osv_mapping.py` (чистые функции: @@ -129,7 +129,7 @@ (`counterparty_id` + `counterparty_match_kind` + алиас); (5) ключ замены привязки — «тип, счёт, квартал, нарастающий» (R-1); ОСВ за Q1 равнозначна нарастающей и квартальной (R-14, Q1-эквивалентность — только для ОСВ, ББ/ОФР за Q1 версиям Q2–Q4 не подходят, R-14a); (6) «строка версии» для ручного сопоставления/автоприменения — только строки свода (R-11); (7) 404 — - код `NOT_FOUND`, различие по тексту; правки — `ensure_version_editable` (`VERSION_NOT_EDITABLE`/`LOCK_REQUIRED`/ + код `NOT_FOUND`, различие по тексту (в т. ч. «консолидация не найдена»); правки — `ensure_version_editable` (`VERSION_NOT_EDITABLE`/`LOCK_REQUIRED`/ `LOCKED_BY_OTHER` без `locked_by_name`). Деньги в ответах — строки с двумя знаками. Аудит: `CONSOLIDATION_CREATE`, `CONSOLIDATION_STATUS_CHANGE`, `CONSOLIDATION_LOCK`, `CONSOLIDATION_LOCK_TAKEOVER`, `MEMBERSHIP_CHANGE`, `OSV_COUNTERPARTY_MAP`, `LEGAL_ENTITY_CHANGE`, `EXPORT`; `FILE_REPLACE.unbound_version_ids`. Контракт для фронта — diff --git a/README.md b/README.md index 7c9a9bb..f7fd220 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,7 @@ cd web && npm install && npm run start # фронтенд (rsbuild dev), о ``` POST /legal-entities/{id}/files → S3 uploads/… + stored_file(UPLOADED) + recognition_job(queued) + audit FILE_UPLOAD + (`consolidation_id` NULL; загрузка в консолидацию — маршрут консолидации) → публикатор (outbox) → NATS files.tabular_uploaded → tabular-parser (читает S3, пишет results/…) → files.tabular_parsed → inbox (задание done, файл PROCESSING) → цикл маппинга (document + osv_entry, файл PROCESSED) → автозамена прежних версий (SUPERSEDED) → GET /files/{id}, GET /documents/{id}/entries → POST /documents/{id}/confirm (только для NEEDS_CHECK и форм после правки) @@ -526,6 +527,8 @@ S3 (storage); без S3 — предупреждение в логе, цикл |---|---|---| | `POST /legal-entities/{legal_entity_id}/files` | `DOCUMENT_EDIT` | загрузка файлов (multipart `files`), ответ `{accepted[], rejected[]}` | | `GET /legal-entities/{legal_entity_id}/files` | `DOCUMENT_VIEW` | файлы ЮЛ, новые первыми (`limit`/`offset`; `include_superseded=true` — с заменёнными) | +| `POST /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` | `DOCUMENT_EDIT` | загрузка в консолидацию (v5.18): файл получает `consolidation_id`; версии консолидации «в работе» с ЮЛ в периметре `INITIATED` → `DOCUMENTS_PROCESSING` | +| `GET /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` | `DOCUMENT_VIEW` | рабочий стол консолидации для ЮЛ: файлы с её `consolidation_id` + файлы с документами, привязанными к её версиям (`limit`/`offset`/`include_superseded`) | | `GET /files/{file_id}` | `DOCUMENT_VIEW` | карточка файла: статус, `job` (`error_code`, `error_message`, `mapping_error`, `warnings`, `mapping_warnings`), документы | | `DELETE /files/{file_id}` | `DOCUMENT_EDIT` | удалить файл (см. «Удаление и замена»), 204 | | `GET /documents/{document_id}` | `DOCUMENT_VIEW` | документ: статус, проверки, `locator`, `group_schema` листа, `warnings`, `file_id` | @@ -616,7 +619,7 @@ advisory-блокировкой. - Аудит обмена (смена статусов задания/файла в inbox, вахтёре, цикле маппинга) не пишется — `ASSUMPTION(OQ-55)`; `audit_event` пишут только загрузка, автозамена, удаление, подтверждение. - Объекты S3 при удалении не стираются (OQ-22, OQ-111): растёт бакет; e2e оставляет ~2 объекта за прогон. -- Общий размер multipart-запроса `POST /legal-entities/{id}/files` и число файлов в нём приложением не ограничены +- Общий размер multipart-запроса `POST /legal-entities/{id}/files` (и маршрута консолидации) и число файлов в нём приложением не ограничены (Starlette спулит части на диск, `UPLOAD_MAX_BYTES` — лимит одного файла); лимит запроса задаётся ingress платформы. - Маппер Среза 2c разбирал только листы ОСВ (`osvDocument`); с Среза 3b он мапит и формы ББ/ОФР (раздел «Формы ББ/ОФР»). Документы `DISCLOSURE`/`OTHER` результата не игнорируются: на каждый тип создаётся один документ без данных (`RECOGNIZED` при маппинге, затем автоподтверждение; @@ -663,7 +666,7 @@ advisory-блокировкой. | `GET /consolidations/versions/{id}/osv/export` | VIEW | xlsx: лист «Сводная ОСВ» и лист «Пробелы» | | `GET /consolidations/versions/{id}/counterparty-candidates` | VIEW | кандидаты для сопоставления: `scope=group\|all`, `q`, `entry_id` | | `POST /consolidations/versions/{id}/osv/entries/{entry_id}/counterparty` | EDIT | ручное сопоставление контрагента строки свода | -| `POST /legal-entities/{id}/files?consolidation_version_id=` | `DOCUMENT_EDIT` | загрузка в контексте версии (необязательный параметр, Срез 2c) | +| `POST /consolidations/{id}/legal-entities/{le}/files` | `DOCUMENT_EDIT` | загрузка в контексте консолидации (v5.18); query `consolidation_version_id` у маршрута ЮЛ удалён (v5.18) | Деньги в ответах — строки JSON с двумя знаками (`"100.00"`); версия любого статуса (включая `CANCELLED`) читается. @@ -678,7 +681,7 @@ advisory-блокировкой. | Событие | Переход | `changed_by` | |---|---|---| -| загрузка с `consolidation_version_id`, принят ≥ 1 файл | `INITIATED` → `DOCUMENTS_PROCESSING` | загрузивший | +| загрузка в консолидацию, принят ≥ 1 файл | `INITIATED` → `DOCUMENTS_PROCESSING` у каждой версии консолидации «в работе» с ЮЛ в периметре | загрузивший | | маппинг создал документ `NEEDS_CHECK`, подходящий версии | `DOCUMENTS_PROCESSING` → `DATA_CORRECTION` | `uploaded_by` файла (нет — переход пропускается, лог) | | подтверждение документа, привязанного к версии | `INITIATED`/`DOCUMENTS_PROCESSING` → `DATA_CORRECTION` | подтвердивший | | `POST …/status {CANCELLED, comment}` | любой «в работе» → `CANCELLED` (снимает блокировку) | пользователь | @@ -822,7 +825,7 @@ email без учёта регистра проверяет только сер | Код | HTTP | Где | |---|---|---| -| `NOT_FOUND` | 404 | объекта нет или он вне видимости; в консолидациях различие — по тексту: «версия консолидации не найдена», «ГК не найдена», «ГСК/ГСЗ не найдена», «строка ОСВ не найдена в версии», «ЮЛ не найдено» | +| `NOT_FOUND` | 404 | объекта нет или он вне видимости; в консолидациях различие — по тексту: «версия консолидации не найдена», «консолидация не найдена», «ГК не найдена», «ГСК/ГСЗ не найдена», «строка ОСВ не найдена в версии», «ЮЛ не найдено» | | `OFFICE_NOT_FOUND` | 422 | филиал из тела запроса не существует (ГК, ЮЛ, пользователь) | | `OFFICE_NOT_ALLOWED` | 422 | ГК можно закрепить только за своим филиалом | | `INVALID_INN` | 422 | ИНН не из 10 (ЮЛ) / 12 (ИП) цифр; в правке формы (`PATCH …/statement`) — не из 10 или 12 цифр | @@ -869,10 +872,11 @@ email без учёта регистра проверяет только сер | `PERIMETER_TOO_SMALL` | 422 | в ГСК (при создании без списка), в выбранных ЮЛ (`legal_entity_ids` при создании) или в новом периметре (`PUT …/members`) меньше двух ЮЛ; ничего не создаётся и не меняется | | `RELATED_GROUP_MISMATCH` | 422 | ГСК принадлежит другой ГК или имеет вид `gsz` (невидимая/неизвестная ГСК — 404 `NOT_FOUND`) | | `ENTITY_NOT_IN_GROUP` | 422 | `POST /consolidations` с `legal_entity_ids` и `PUT …/members`: ЮЛ не из ГК консолидации, без ГК или несуществующее (`legal_entity_ids` — перечень); проверка раньше `PERIMETER_TOO_SMALL` | -| `ENTITY_NOT_IN_PERIMETER` | 422 | загрузка с `consolidation_version_id`: ЮЛ файла не входит в периметр версии | +| `ENTITY_NOT_IN_PERIMETER` | 422 | загрузка в консолидацию: ЮЛ не входит в периметр ни одной версии «в работе» | +| `CONSOLIDATION_NOT_EDITABLE` | 409 | загрузка в консолидацию без версии «в работе» | | `QUERY_TOO_SHORT` | 422 | кандидаты `scope=all`: `q` короче 3 символов после обрезки | | `COMMENT_REQUIRED` | 422 | `POST …/status` в `CANCELLED` без комментария (из одних пробелов — тоже) | -| `VERSION_NOT_EDITABLE` | 409 | версия не «в работе» (правки, загрузка с версией) либо `CANCELLED` при «взять в работу» | +| `VERSION_NOT_EDITABLE` | 409 | версия не «в работе» (правки) либо `CANCELLED` при «взять в работу» | | `LOCK_REQUIRED` | 409 | правка версии без своей активной блокировки | | `LOCKED_BY_OTHER` | 409 | версию держит другой пользователь: из `POST …/lock` — `locked_by`, `locked_by_name`, `locked_at`; из правок (`ensure_version_editable`) — только `locked_by`, `locked_at`; гонка двух взятий (частичный UNIQUE) — то же 409 | | `NOT_LOCK_OWNER` | 409 | `DELETE …/lock`: блокировка чужая (`locked_by`, `locked_at`) | -- 2.47.2 From 4abfa03539bff744ea76bddcbd11855ae0fc96f8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 10 Oct 2026 02:23:09 +0400 Subject: [PATCH 8/8] =?UTF-8?q?fix-=D0=B2=D0=BE=D0=BB=D0=BD=D0=B0=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E:=20delta=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=81=D0=BE=D0=B2,=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D0=B8=D0=B4=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8,=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-2: delta MODIFIED consolidations-api «Статусы Среза 4» (загрузка в консолидацию). T1.1: шапки моделей — DBML v5.18. T3.1: lock_versions_of_consolidation — двухшаговая блокировка (FOR UPDATE без членства, затем выборка членов); тесты гонки и выборки. M-1: тест миграции 0006 — порядок кварталов внутри года. M-2: тест 404 для ЮЛ чужого филиала на столе своей консолидации. T4.1: docstring supersede_previous — перенос строк. Co-Authored-By: Claude Fable 5.1 --- api/src/models/audit_ex.py | 2 +- api/src/models/base.py | 2 +- api/src/models/consolidation.py | 2 +- api/src/models/directories.py | 2 +- api/src/models/enums.py | 2 +- api/src/models/files.py | 2 +- api/src/models/kfo.py | 2 +- api/src/models/osv.py | 2 +- api/src/models/statements.py | 2 +- api/src/repositories/consolidations.py | 31 ++++++--- api/src/services/result_mapping.py | 6 +- api/tests/test_api_consolidation_files.py | 7 +- api/tests/test_consolidation_documents.py | 64 +++++++++++++++++++ api/tests/test_migrations.py | 38 ++++++++--- .../specs/consolidations-api/spec.md | 28 ++++++++ 15 files changed, 159 insertions(+), 33 deletions(-) create mode 100644 openspec/changes/console-files/specs/consolidations-api/spec.md diff --git a/api/src/models/audit_ex.py b/api/src/models/audit_ex.py index f9de8a7..4bf7804 100644 --- a/api/src/models/audit_ex.py +++ b/api/src/models/audit_ex.py @@ -1,4 +1,4 @@ -"""Часть 09 DBML v5.17 «Комментарии, аудит, ЕХФД, прочее»: комментарии, журнал аудита, срезы ЕХ, чек-лист документов. +"""Часть 09 DBML v5.18 «Комментарии, аудит, ЕХФД, прочее»: комментарии, журнал аудита, срезы ЕХ, чек-лист документов. `audit_event` — append-only, высокообъёмная (before/after — jsonb). Срезы ЕХ самодостаточны (v5.12): FK на консолидацию и её результаты отсутствуют (их удаляют по сроку, срез хранится «вечно»): `ex_snapshot.source_version_id` diff --git a/api/src/models/base.py b/api/src/models/base.py index 762e0f9..75a8b49 100644 --- a/api/src/models/base.py +++ b/api/src/models/base.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, mapped_co from src.core.config import settings -#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.17); единственный источник — `settings.DB_SCHEMA`. +#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.18); единственный источник — `settings.DB_SCHEMA`. #: Читается при импорте: MetaData, enum и sequence привязываются к схеме сразу (см. R-1 PR #38). SCHEMA = settings.DB_SCHEMA diff --git a/api/src/models/consolidation.py b/api/src/models/consolidation.py index 45941ac..79c99a6 100644 --- a/api/src/models/consolidation.py +++ b/api/src/models/consolidation.py @@ -1,4 +1,4 @@ -"""Часть 07 DBML v5.17 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы. +"""Часть 07 DBML v5.18 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы. Производные консолидации (сводная ОСВ, пары ВГО, шахматка) НЕ хранятся (DECISION-5): считаются запросом из `osv_entry` × `consolidation_member` × `elimination_rule`. Версия после VERIFIED — read-only (ТЗ 6.10). diff --git a/api/src/models/directories.py b/api/src/models/directories.py index 64e18a1..7c64d69 100644 --- a/api/src/models/directories.py +++ b/api/src/models/directories.py @@ -1,4 +1,4 @@ -"""Часть 01 DBML v5.17 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2).""" +"""Часть 01 DBML v5.18 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2).""" import datetime import uuid diff --git a/api/src/models/enums.py b/api/src/models/enums.py index c533d8f..7279617 100644 --- a/api/src/models/enums.py +++ b/api/src/models/enums.py @@ -1,4 +1,4 @@ -"""Enum'ы модели данных DBML v5.17 (28 штук) и фабрика `pg_enum` для PostgreSQL-колонок. +"""Enum'ы модели данных DBML v5.18 (28 штук) и фабрика `pg_enum` для PostgreSQL-колонок. Источник истины — docs/model/data-model-v5.18.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке DBML (включая кириллические `"ЮЛ"`/`"ИП"` и значения в разном регистре); `notes` DBML значениями не являются. diff --git a/api/src/models/files.py b/api/src/models/files.py index 13cd93e..42a2e25 100644 --- a/api/src/models/files.py +++ b/api/src/models/files.py @@ -1,4 +1,4 @@ -"""Часть 04 DBML v5.17 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания. +"""Часть 04 DBML v5.18 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания. Цикл FK `stored_file.current_recognition_job_id` -> `recognition_job` и `recognition_job.file_id` -> `stored_file` разорван через `use_alter=True` на nullable-стороне (`stored_file.current_recognition_job_id`): `create_all` создаёт diff --git a/api/src/models/kfo.py b/api/src/models/kfo.py index a425e66..5878668 100644 --- a/api/src/models/kfo.py +++ b/api/src/models/kfo.py @@ -1,4 +1,4 @@ -"""Часть 08 DBML v5.17 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента. +"""Часть 08 DBML v5.18 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента. Корректировки и гудвилл — ручные решения аналитика, хранятся поверх первички (overlay). Результат КФО замораживается при VERIFIED. Деньги — `numeric(18,2)`, проценты — `numeric(7,4)` / `numeric(9,4)`. diff --git a/api/src/models/osv.py b/api/src/models/osv.py index 2fc38f0..1352085 100644 --- a/api/src/models/osv.py +++ b/api/src/models/osv.py @@ -1,4 +1,4 @@ -"""Часть 05 DBML v5.17 «ОСВ»: строка оборотно-сальдовой ведомости (ТЗ 6.2, 6.5). +"""Часть 05 DBML v5.18 «ОСВ»: строка оборотно-сальдовой ведомости (ТЗ 6.2, 6.5). Первичка не мутируется: сырые значения хранятся как пришли, правка аналитика — рядом (`is_modified`, `original_values`, `modified_by/at` + audit_event(RECOGNIZED_DATA_EDIT)). diff --git a/api/src/models/statements.py b/api/src/models/statements.py index b82870f..2a1d04d 100644 --- a/api/src/models/statements.py +++ b/api/src/models/statements.py @@ -1,4 +1,4 @@ -"""Часть 06 DBML v5.17 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н). +"""Часть 06 DBML v5.18 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н). Первичка не мутируется: `*_original_value` / `original_amount` / `is_modified` — рядом с распознанным значением. Логические проверки (ТЗ 6.3) и «требует проверки» не хранятся — считаются на лету. diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index 75fb746..8ddc373 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -196,23 +196,36 @@ async def lock_versions_of_consolidation( statuses: Collection[ConsolidationStatus], ) -> list[ConsolidationVersion]: """Версии консолидации со статусом из `statuses`, где ЮЛ — член периметра, под `FOR UPDATE ORDER BY id` (R-2; - design D-4: переход после загрузки в консолидацию). `populate_existing` — свежий статус из БД.""" + design D-4: переход после загрузки в консолидацию). + + Двухшаговая схема, как в `lock_versions_for_entity`: сначала блокируются ВСЕ версии консолидации с нужными + статусами (`ORDER BY id FOR UPDATE`) — без условия членства: в READ COMMITTED после ожидания блокировки `EXISTS` + в том же операторе не пересчитывается и пропустил бы члена, добавленного ожидавшейся правкой периметра. Затем + вторым запросом, без блокировки (строки уже заблокированы), выбираются версии, где ЮЛ — член; он получает свежий + снимок и видит закоммиченные правки. `populate_existing` — состояние версий в сессии заменяется свежим.""" + locked = ( + select(ConsolidationVersion.id) + .where( + ConsolidationVersion.consolidation_id == consolidation_id, + ConsolidationVersion.status.in_(statuses), + ) + .order_by(ConsolidationVersion.id) + .with_for_update() + ) + locked_ids = list((await session.execute(locked)).scalars()) + if not locked_ids: + return [] is_member = exists().where( ConsolidationMember.consolidation_version_id == ConsolidationVersion.id, ConsolidationMember.legal_entity_id == entity_id, ) - stmt = ( + members_only = ( select(ConsolidationVersion) - .where( - ConsolidationVersion.consolidation_id == consolidation_id, - ConsolidationVersion.status.in_(statuses), - is_member, - ) + .where(ConsolidationVersion.id.in_(locked_ids), is_member) .order_by(ConsolidationVersion.id) - .with_for_update() .execution_options(populate_existing=True) ) - return list((await session.execute(stmt)).scalars()) + return list((await session.execute(members_only)).scalars()) async def active_lock(session: AsyncSession, version_id: uuid.UUID) -> ConsolidationLock | None: diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 6b41290..61bdf40 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -663,9 +663,9 @@ async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена `superseded_by_file_id = new_file.id`; у нового файла `version_no` = максимум заменённых + 1; файл без консолидации (`consolidation_id` пуст) наследует её у первого по `id` заменённого файла, где она задана (v5.18, §9.5 п. 3). На каждый заменённый файл — одно системное событие `FILE_REPLACE` (`user_id = NULL`, инициатор - загрузки — в `description`, - `confirmed=true`, если среди заменённых документов есть `CONFIRMED`; решение владельца 05.10.2026). Документы и - строки заменённых файлов не трогаются (история). Возвращает заменённые файлы; в транзакции вызывающего, без commit. + загрузки — в `description`, `confirmed=true`, если среди заменённых документов есть `CONFIRMED`; решение + владельца 05.10.2026). Документы и строки заменённых файлов не трогаются (история). Возвращает заменённые файлы; + в транзакции вызывающего, без commit. Привязки документов заменённых файлов к версиям консолидации «в работе» снимаются (`consolidation_documents.on_documents_superseded`); id версий — в `after_value.unbound_version_ids` события diff --git a/api/tests/test_api_consolidation_files.py b/api/tests/test_api_consolidation_files.py index 8abe4a4..d7e7487 100644 --- a/api/tests/test_api_consolidation_files.py +++ b/api/tests/test_api_consolidation_files.py @@ -160,15 +160,18 @@ async def test_desk_foreign_and_missing_consolidation_are_same_404( """Сценарий «Чужая консолидация»: ГК другого филиала и несуществующий id — одинаковый 404; чужое ЮЛ — тоже 404.""" group, entity, initiator = await _group(seeded, office_name=KRASNODAR, name="ГК Чужая") c, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) - _, own_entity, _ = await _group(seeded, name="ГК Своя") + own_group, own_entity, own_initiator = await _group(seeded, name="ГК Своя") + own_c, _ = await _consolidation(seeded, own_group, own_entity, own_initiator, 2025, PeriodQuarter.Q1) client = await auth_client(KA_RF) foreign = await client.get(_desk_url(c.id, own_entity.id)) missing = await client.get(_desk_url(uuid.uuid4(), own_entity.id)) + foreign_entity = await client.get(_desk_url(own_c.id, entity.id)) # своя консолидация, ЮЛ чужого филиала - assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND + assert foreign.status_code == missing.status_code == foreign_entity.status_code == HTTP_NOT_FOUND assert foreign.json() == missing.json() assert foreign.json()["detail"]["code"] == "NOT_FOUND" + assert foreign_entity.json()["detail"]["code"] == "NOT_FOUND" async def test_desk_requires_document_view(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: diff --git a/api/tests/test_consolidation_documents.py b/api/tests/test_consolidation_documents.py index 68d45bb..ce17c20 100644 --- a/api/tests/test_consolidation_documents.py +++ b/api/tests/test_consolidation_documents.py @@ -1155,6 +1155,70 @@ async def test_on_documents_mapped_waits_for_member_edit_and_sees_new_member( assert await _status(session, data["version_id"]) is ConsolidationStatus.DATA_CORRECTION +async def _lock_of_consolidation_in_own_transaction( + maker: async_sessionmaker[AsyncSession], consolidation_id: uuid.UUID, entity_id: uuid.UUID +) -> list[tuple[uuid.UUID, ConsolidationStatus]]: + async with maker() as session, session.begin(): + versions = await repo.lock_versions_of_consolidation( + session, consolidation_id, entity_id, (ConsolidationStatus.INITIATED,) + ) + return [(version.id, version.status) for version in versions] + + +@pytest.mark.integration +async def test_lock_versions_of_consolidation_waits_for_member_edit_and_sees_new_member( + committed_sessionmaker: async_sessionmaker[AsyncSession], +) -> None: + """T3.1: правка периметра держит версию (`FOR UPDATE`) и добавила ЮЛ (не зафиксировано); блокировка версий + консолидации ждёт правку и после commit видит нового члена — `EXISTS` не пересчитывается в одном операторе с + `FOR UPDATE`, поэтому членство выбирается вторым запросом.""" + maker = committed_sessionmaker + data = await _committed_setup(maker) + async with maker() as session: + version = await session.get(ConsolidationVersion, data["version_id"]) + assert version is not None + consolidation_id = version.consolidation_id + + async with maker() as editor, editor.begin(): + assert await repo.lock_version(editor, data["version_id"]) is not None + editor.add(ConsolidationMember(consolidation_version_id=data["version_id"], legal_entity_id=data["owner_id"])) + await editor.flush() + waiter = asyncio.create_task( + _lock_of_consolidation_in_own_transaction(maker, consolidation_id, data["owner_id"]) + ) + await _wait_for_lock_waiter(maker) + assert not waiter.done() + locked = await asyncio.wait_for(waiter, LOCK_WAIT_SECONDS) + + assert locked == [(data["version_id"], ConsolidationStatus.INITIATED)] + + +@pytest.mark.integration +async def test_lock_versions_of_consolidation_returns_only_member_versions( + committed_sessionmaker: async_sessionmaker[AsyncSession], +) -> None: + """T3.1: ЮЛ вне периметра — пусто; член периметра — версия со свежим статусом; статус не из набора — пусто.""" + maker = committed_sessionmaker + data = await _committed_setup(maker) + async with maker() as session: + version = await session.get(ConsolidationVersion, data["version_id"]) + assert version is not None + consolidation_id = version.consolidation_id + member_id, outsider_id = data["entity_ids"][0], data["owner_id"] + + assert await _lock_of_consolidation_in_own_transaction(maker, consolidation_id, outsider_id) == [] + assert await _lock_of_consolidation_in_own_transaction(maker, consolidation_id, member_id) == [ + (data["version_id"], ConsolidationStatus.INITIATED) + ] + async with maker() as session, session.begin(): + assert ( + await repo.lock_versions_of_consolidation( + session, consolidation_id, member_id, (ConsolidationStatus.DATA_CORRECTION,) + ) + == [] + ) + + @pytest.mark.integration async def test_on_documents_mapped_waits_for_transition_to_documents_processing( committed_sessionmaker: async_sessionmaker[AsyncSession], diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 882f681..020b0fb 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -467,7 +467,8 @@ async def _consolidation_id_column(conn: AsyncConnection) -> list[Any]: async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> None: """0006: `stored_file.consolidation_id uuid` nullable, последняя колонка, индекс `(legal_entity_id, consolidation_id)`; backfill по `consolidation_document`: файл A привязан к версиям 2025 Q1 и - 2024 Q4 → 2024 Q4 (самый ранний период, год важнее квартала); файл B без привязок → NULL; `downgrade 0005` + 2024 Q4 → 2024 Q4 (самый ранний период, год важнее квартала); файл B без привязок → NULL; файл C привязан к + версиям 2025 Q2 и 2025 Q1 → 2025 Q1 (квартал внутри года по возрастанию); `downgrade 0005` убирает индекс и колонку, повторный upgrade возвращает.""" engine = create_async_engine(scratch_db_url) now = datetime.datetime.now(datetime.UTC) @@ -480,12 +481,18 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N "user", "s3a", "s3b", + "s3c", "file_a", "file_b", + "file_c", "doc_a", + "doc_c1", + "doc_c2", "c_2025q1", + "c_2025q2", "c_2024q4", "v_2025q1", + "v_2025q2", "v_2024q4", ) } @@ -515,7 +522,7 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N ), ids, ) - for s3, file in (("s3a", "file_a"), ("s3b", "file_b")): + for s3, file in (("s3a", "file_a"), ("s3b", "file_b"), ("s3c", "file_c")): await conn.execute( text( "INSERT INTO dkb.s3_object (id, bucket_role, bucket, key, created_at) " @@ -538,8 +545,18 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N ), ids, ) + for doc, account in (("doc_c1", "62"), ("doc_c2", "66")): + await conn.execute( + text( + "INSERT INTO dkb.document (id, stored_file_id, legal_entity_id, document_type, " + "osv_account_code, period_year, period_quarter, status) " + f"VALUES (:{doc}, :file_c, :entity, 'OSV', '{account}', 2025, 'Q2', 'CONFIRMED')" + ), + ids, + ) for c, v, year, quarter, rd in ( ("c_2025q1", "v_2025q1", 2025, "Q1", "2025-04-01"), + ("c_2025q2", "v_2025q2", 2025, "Q2", "2025-07-01"), ("c_2024q4", "v_2024q4", 2024, "Q4", "2025-01-01"), ): await conn.execute( @@ -557,13 +574,14 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N ), ids, ) - await conn.execute( - text( - "INSERT INTO dkb.consolidation_document (id, consolidation_version_id, document_id) " - f"VALUES (gen_random_uuid(), :{v}, :doc_a)" - ), - ids, - ) + for doc in {"v_2025q1": ("doc_a", "doc_c2"), "v_2025q2": ("doc_c1",), "v_2024q4": ("doc_a",)}[v]: + await conn.execute( + text( + "INSERT INTO dkb.consolidation_document (id, consolidation_version_id, document_id) " + f"VALUES (gen_random_uuid(), :{v}, :{doc})" + ), + ids, + ) await alembic(scratch_db_url, "upgrade", "head") async with engine.begin() as conn: @@ -575,7 +593,7 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N ) assert last == ["consolidation_id"] rows = (await conn.execute(text("SELECT id, consolidation_id FROM dkb.stored_file ORDER BY id"))).all() - assert dict(rows) == {ids["file_a"]: ids["c_2024q4"], ids["file_b"]: None} + assert dict(rows) == {ids["file_a"]: ids["c_2024q4"], ids["file_b"]: None, ids["file_c"]: ids["c_2025q1"]} assert await _scalars( conn, "SELECT indexname FROM pg_indexes WHERE schemaname = 'dkb' " diff --git a/openspec/changes/console-files/specs/consolidations-api/spec.md b/openspec/changes/console-files/specs/consolidations-api/spec.md new file mode 100644 index 0000000..f703299 --- /dev/null +++ b/openspec/changes/console-files/specs/consolidations-api/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements + +### Requirement: Статусы Среза 4 +Версия SHALL менять статус только через сервис статусов, который пишет `consolidation_status_history(from, to, +changed_by, changed_at, comment)` и `audit_event(CONSOLIDATION_STATUS_CHANGE, before = from, after = to, comment)`. +Автоматические переходы: загрузка в консолидацию (`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` из +любого статуса «в работе» (`comment` обязателен, иначе 422 `COMMENT_REQUIRED`; `cancelled_reason = comment`, +`is_in_work = false`, активная блокировка снимается) и `INITIATED` из `DOCUMENTS_PROCESSING` / `DATA_CORRECTION` +(`comment` необязателен; `consolidation_document` сохраняются). Любой другой `to_status` → 409 `TRANSITION_NOT_ALLOWED` +с текущим и запрошенным статусами. Повтор того же статуса → 409 `TRANSITION_NOT_ALLOWED`. Статус «в работе» = +{`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`}. + +#### Scenario: Аннулирование +- **WHEN** владелец блокировки переводит версию `DATA_CORRECTION` в `CANCELLED` с комментарием +- **THEN** 200; `status = CANCELLED`, `cancelled_reason` = комментарий, `is_in_work = false`, блокировка снята; запись + истории `DATA_CORRECTION → CANCELLED` с комментарием; `audit_event(CONSOLIDATION_STATUS_CHANGE)` + +#### Scenario: Переход Среза 7 +- **WHEN** запрошен `to_status = CONSOLIDATION_RF` +- **THEN** 409 `TRANSITION_NOT_ALLOWED`, статус не изменился + +#### Scenario: Аннулирование без комментария +- **WHEN** `to_status = CANCELLED`, `comment` пуст +- **THEN** 422 `COMMENT_REQUIRED` -- 2.47.2