From fcfb3f7f4e1183c4140f65b704aa4ac6dac2b365 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 23:30:02 +0400 Subject: [PATCH] =?UTF-8?q?openspec:=20change=20console-files=20=E2=80=94?= =?UTF-8?q?=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B2=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B5=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,=20DBML=20v5.1?= =?UTF-8?q?8=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`