openspec: change console-files — файлы в контексте консолидации, DBML v5.18 (#96)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
fde860be7c
commit
fcfb3f7f4e
2
openspec/changes/console-files/.openspec.yaml
Normal file
2
openspec/changes/console-files/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-09
|
||||
105
openspec/changes/console-files/design.md
Normal file
105
openspec/changes/console-files/design.md
Normal file
@ -0,0 +1,105 @@
|
||||
## Context
|
||||
|
||||
Сквозной дизайн — `../docs/openspec/changes/console-files/design.md` (модель, алгоритмы, API, сравнение вариантов
|
||||
привязки). Здесь — только решения уровня ядра: где лежит код, как считается стол, как переводятся версии, миграция.
|
||||
|
||||
Точки опоры в коде: единственная точка создания `stored_file` — `services/primary_files._store`; переход версии после
|
||||
загрузки — `after_upload_to_version` (отдельная транзакция `session_transaction`, `lock_version FOR UPDATE`, затем
|
||||
`consolidation_status.transition`); автозамена — `services/result_mapping.supersede_previous` (старые файлы под
|
||||
`FOR UPDATE ORDER BY id`); карточка файла — `services/documents.file_out`; видимость консолидации —
|
||||
`repositories/consolidations._visible_consolidations` (ГК в области пользователя).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
Goals: см. proposal. Non-goals: см. proposal.
|
||||
|
||||
## Decisions
|
||||
|
||||
**D-1. Колонка последней.** `StoredFile.consolidation_id` объявляется после `updated_at` в ORM и в DBML v5.18:
|
||||
`ALTER TABLE … ADD COLUMN` ставит её в конец, а `check_dbml_vs_orm` сверяет порядок (как `kpp` в v5.17). FK —
|
||||
`fk_stored_file_consolidation_id_consolidation` по соглашению имён 0001; индекс `ix_stored_file_legal_entity_id_consolidation_id`.
|
||||
|
||||
**D-2. Backfill в миграции 0006 — одним UPDATE.** Для каждого файла с документом в `consolidation_document` берётся
|
||||
консолидация версии; при нескольких — самый ранний период (`ORDER BY c.year, c.quarter, c.id`, enum `period_quarter`
|
||||
упорядочен Q1 < … < Q4 по объявлению):
|
||||
|
||||
```sql
|
||||
UPDATE {S}.stored_file AS sf SET consolidation_id = pick.consolidation_id
|
||||
FROM (
|
||||
SELECT DISTINCT ON (d.stored_file_id) d.stored_file_id, c.id AS consolidation_id
|
||||
FROM {S}.document d
|
||||
JOIN {S}.consolidation_document cd ON cd.document_id = d.id
|
||||
JOIN {S}.consolidation_version v ON v.id = cd.consolidation_version_id
|
||||
JOIN {S}.consolidation c ON c.id = v.consolidation_id
|
||||
ORDER BY d.stored_file_id, c.year, c.quarter, c.id
|
||||
) AS pick
|
||||
WHERE sf.id = pick.stored_file_id
|
||||
```
|
||||
|
||||
Откат — `DROP INDEX`, `DROP COLUMN` (FK уходит вместе с колонкой).
|
||||
|
||||
**D-3. Проверки загрузки в консолидацию — до приёма, без блокировок.** `PrimaryFilesService.check_consolidation_for_upload
|
||||
(consolidation_id, entity) -> Consolidation`: `consolidations_repo.get_consolidation(session, scope, id)` (видимость по ГК,
|
||||
`None` → 404 `NOT_FOUND` «консолидация не найдена»); `consolidations_repo.versions_of(session, consolidation_id)`; версии
|
||||
со статусом из `IN_WORK_STATUSES` — пусто → 409 `CONSOLIDATION_NOT_EDITABLE`; `consolidations_repo.members` по каждой из
|
||||
них — ЮЛ ни в одной → 422 `ENTITY_NOT_IN_PERIMETER`. Как и раньше, `FOR UPDATE` не нужен: статус перечитывается под
|
||||
блокировкой при переходе.
|
||||
|
||||
**D-4. Переход N версий одной отдельной транзакцией.** `after_upload_to_consolidation(consolidation_id, entity_id)`:
|
||||
в `session_transaction` новый репозиторий `consolidations_repo.lock_versions_of_consolidation(session, consolidation_id,
|
||||
entity_id, statuses={INITIATED})` — `SELECT … FOR UPDATE ORDER BY id` версий этой консолидации с ЮЛ в периметре;
|
||||
по каждой `transition(INITIATED → DOCUMENTS_PROCESSING, changed_by = загрузивший)`. Любое исключение логируется и не
|
||||
меняет ответ (R-10 Среза 4 сохраняется). Advisory-lock ГК не берётся: версии создаются только через `POST
|
||||
/consolidations`, которое берёт его само, а новая версия не видна до commit — она просто не попадёт в выборку, и её
|
||||
статус `INITIATED` переведёт следующая загрузка или подтверждение (то же, что сейчас для одной версии).
|
||||
|
||||
**D-5. Один цикл приёма для двух маршрутов.** Логика приёма файлов (чтение с лимитом, `upload_one`, сбор
|
||||
`accepted`/`rejected`, 422 при пустом `accepted`) выносится из `upload.py` в функцию `accept_uploads(service, entity,
|
||||
files, response, *, consolidation_id) -> UploadResult`; маршрут ЮЛ вызывает её с `None`, маршрут консолидации — с id
|
||||
после проверок D-3 и затем `after_upload_to_consolidation`. Новый модуль `api/endpoints/files/consolidation_files.py`
|
||||
(оба маршрута консолидации), подключается в `files/__init__.py`. Проверка права — `DOCUMENT_EDIT`/`DOCUMENT_VIEW`, как
|
||||
у маршрутов ЮЛ (загружает и смотрит первичку тот же аналитик; `CONSOLIDATION_*` права не требуются).
|
||||
|
||||
**D-6. Рабочий стол — один запрос с `OR EXISTS`.** `files_repo.list_desk_files(session, scope, entity_id,
|
||||
consolidation_id, *, include_superseded, limit, offset)` повторяет `list_files` с условием
|
||||
`StoredFile.consolidation_id == cid OR EXISTS (document d JOIN consolidation_document cd JOIN consolidation_version v
|
||||
WHERE d.stored_file_id = StoredFile.id AND v.consolidation_id = cid)`; `count` тем же фильтром; порядок прежний. Сервис
|
||||
`page_desk(consolidation_id, entity_id, *, include_superseded, page)`: консолидация видима (404), ЮЛ видимо (404),
|
||||
затем `_cards`. Привязка ЮЛ к ГК консолидации не проверяется: стол ЮЛ чужой ГК просто пуст.
|
||||
|
||||
**D-7. `FileOut.consolidation_id`** — из `file.consolidation_id` в `file_out`; `DocumentBrief` не меняется.
|
||||
|
||||
**D-8. Наследование при автозамене** — в `supersede_previous` сразу после `new_file.version_no = …`:
|
||||
`if new_file.consolidation_id is None: new_file.consolidation_id = next((old.consolidation_id for old in locked if
|
||||
old.consolidation_id is not None), None)`; `locked` уже упорядочен по `id`.
|
||||
|
||||
**D-9. Удаление старого контекста версии.** `check_version_for_upload`, `after_upload_to_version` и query
|
||||
`consolidation_version_id` удаляются вместе с тестами на них (переписываются под консолидацию); код
|
||||
`VERSION_NOT_EDITABLE` остаётся для правок версии, `VERSION_NOT_FOUND` — для маршрутов версии.
|
||||
|
||||
**D-10. Аудит.** `FILE_UPLOAD.after_value` получает `consolidation_id` (строка uuid или `null`); отдельного события
|
||||
«загрузка в консолидацию» нет; переходы версий пишут `CONSOLIDATION_STATUS_CHANGE` как раньше.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- `OR EXISTS` по `consolidation_document` на большом ЮЛ — подзапрос по индексу `(consolidation_version_id, document_id)`
|
||||
и `document (stored_file_id)`; объёмы — сотни файлов на ЮЛ, не тысячи.
|
||||
- Переход нескольких версий в одной транзакции — пока версия одна; порядок `ORDER BY id` совпадает с R-2 Среза 4.
|
||||
- Backfill «самый ранний период» — эвристика для тестовых стендов; на проде данных до миграции нет.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
1. 09.10.2026 — **R-1:** права маршрутов консолидации — `DOCUMENT_EDIT`/`DOCUMENT_VIEW`, а не `CONSOLIDATION_*`:
|
||||
первичку загружает и смотрит тот же пользователь, что и на странице ЮЛ. Цена ошибки: низкая — одна константа в
|
||||
`dependencies` маршрута.
|
||||
2. 09.10.2026 — **R-2:** 404 загрузки/стола по консолидации — код `NOT_FOUND` с текстом «консолидация не найдена»
|
||||
(константа `CONSOLIDATION_NOT_FOUND` в `services/consolidation_guard.py`), в духе правила (7) README. Цена ошибки:
|
||||
низкая.
|
||||
3. 09.10.2026 — **R-3:** стол не проверяет принадлежность ЮЛ к ГК консолидации (D-6). Цена ошибки: низкая — пустой
|
||||
список вместо 422.
|
||||
4. 09.10.2026 — **R-4:** advisory-lock ГК при переходе версий после загрузки не берётся (D-4). Цена ошибки: низкая —
|
||||
версия, созданная параллельно с загрузкой, останется `INITIATED` до следующего события, как и сейчас.
|
||||
5. 09.10.2026 — **R-5:** общий приёмный цикл `accept_uploads` живёт в `endpoints/files/upload.py` и импортируется
|
||||
`consolidation_files.py` (а не в сервисе): он работает с `UploadFile`/`Response` FastAPI. Цена ошибки: низкая.
|
||||
6. 09.10.2026 — **R-6:** backfill миграции тестируется сценарием «файл с двумя привязками к версиям разных периодов →
|
||||
самый ранний»; файл без привязок остаётся `NULL`. Цена ошибки: низкая.
|
||||
71
openspec/changes/console-files/proposal.md
Normal file
71
openspec/changes/console-files/proposal.md
Normal file
@ -0,0 +1,71 @@
|
||||
## Why
|
||||
|
||||
Панель «Первичные документы» ЮЛ, открытая из версии консолидации, показывает все файлы ЮЛ: `stored_file` не знает,
|
||||
в какую консолидацию загружен, `GET /legal-entities/{id}/files` не фильтруется, а `consolidation_version_id` при
|
||||
загрузке служит только переходу статуса версии. Решения владельца 09.10.2026 (RSHB/arm-ka-docs#90, сквозной change
|
||||
`../docs/openspec/changes/console-files/`, DBML v5.18): файл загружается **в консолидацию** (ГК + период) и образует
|
||||
её «рабочий стол», общий для всех версий периода; что использует конкретная версия, по-прежнему определяет
|
||||
`consolidation_document`; загрузка и список в контексте консоли — маршрутами от консолидации.
|
||||
|
||||
Срез: доработка Срезов 2c/4 вне плана `09-delivery-plan.md` (запись в разделе «Срез 2d»). Репозиторий — только
|
||||
`arm-core`; фронт — RSHB/arm-web (свой change), документы — RSHB/arm-ka-docs#90. Issue — RSHB/arm-core#96.
|
||||
|
||||
Правила домена: `05-domain-rules.md` §9.1 (переход при загрузке), §9.5 п. 1, 3, 8 и 11 (редакция v5.18 из docs #90).
|
||||
Таблицы DBML v5.18: `stored_file` (новая колонка `consolidation_id`, индекс `(legal_entity_id, consolidation_id)`),
|
||||
читаются `consolidation`, `consolidation_version`, `consolidation_member`, `consolidation_document`, `document`.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Модель и миграция.** `StoredFile.consolidation_id` (nullable FK → `consolidation`, последняя колонка), индекс
|
||||
`ix_stored_file_legal_entity_id_consolidation_id`; `DBML_VERSION = "5.18"`, `make dbml-check` по
|
||||
`../docs/model/data-model-v5.18.dbml`; миграция `0006_dbml_v5_18`: колонка, FK, индекс, backfill по
|
||||
`consolidation_document` (при привязках к версиям разных периодов — самый ранний), откат — удаление.
|
||||
- **Загрузка в консолидацию.** `POST /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files`
|
||||
(`DOCUMENT_EDIT`): консолидация и ЮЛ видимы (404), есть версия «в работе» (409 `CONSOLIDATION_NOT_EDITABLE`), ЮЛ в
|
||||
периметре хотя бы одной из них (422 `ENTITY_NOT_IN_PERIMETER`); файл получает `consolidation_id`,
|
||||
`FILE_UPLOAD.after_value.consolidation_id`; после приёма ≥ 1 файла каждая версия консолидации «в работе» с ЮЛ в
|
||||
периметре и статусом `INITIATED` переходит в `DOCUMENTS_PROCESSING` отдельной транзакцией.
|
||||
- **Загрузка без контекста.** `POST /legal-entities/{id}/files`: query `consolidation_version_id` удалён, коды
|
||||
`VERSION_NOT_FOUND`/`VERSION_NOT_EDITABLE` у загрузки больше не возникают; `consolidation_id = NULL`.
|
||||
- **Рабочий стол.** `GET /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` (`DOCUMENT_VIEW`):
|
||||
файлы ЮЛ с этим `consolidation_id` ∪ файлы ЮЛ с документом, привязанным к любой версии консолидации; `limit`,
|
||||
`offset`, `include_superseded`; та же карточка `FileOut`.
|
||||
- **Карточка файла.** `FileOut.consolidation_id: uuid | null` в списках и `GET /files/{id}`.
|
||||
- **Автозамена.** Новый файл без контекста наследует `consolidation_id` первого (по `id`) заменённого файла, у которого
|
||||
он задан.
|
||||
- Спеки `primary-files`, `documents-api`, `data-model`; README («Ошибки API», таблицы маршрутов, §9.1), `CLAUDE.md`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Несколько рабочих версий одного периода и «новая версия после `VERIFIED`» не реализуются (OQ-160, отдельный срез);
|
||||
переход статуса после загрузки написан для N версий, но создаётся по-прежнему одна.
|
||||
- Замороженный список первички верифицированной версии (ЕХ, Срез 7) не строится; стол показывается для версии любого
|
||||
статуса, загрузка проверяет только наличие версии «в работе».
|
||||
- Признак «документ привязан / подходит версии» в `FileOut` не вводится (чек-лист, OQ-120).
|
||||
- `consolidation_document`, `fits`, подхват, удаление файла — без изменений.
|
||||
- Перенос файла между консолидациями, правка `consolidation_id` через API — нет.
|
||||
- Контракт NATS и S3 не меняется.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
- `primary-files`: загрузка в консолидацию (новый маршрут, коды ошибок, переход N версий), удаление query у загрузки ЮЛ,
|
||||
наследование `consolidation_id` при автозамене.
|
||||
- `documents-api`: рабочий стол консоли, `consolidation_id` в карточке файла.
|
||||
- `data-model`: DBML v5.18, миграция 0006 с backfill.
|
||||
|
||||
## Impact
|
||||
|
||||
- `api/src/models/files.py`, `api/src/models/__init__.py`, `api/alembic/versions/0006_dbml_v5_18.py`, `Makefile`
|
||||
(`DBML`), `api/scripts/dbml_parity.py` (путь по умолчанию), `api/tests/dbml_helpers.py`.
|
||||
- `api/src/repositories/files.py` (стол), `api/src/repositories/consolidations.py` (консолидация по id, версии
|
||||
консолидации под `FOR UPDATE`), `api/src/services/primary_files.py` (загрузка в консолидацию, стол),
|
||||
`api/src/services/result_mapping.py` (наследование), `api/src/services/documents.py` (`file_out`),
|
||||
`api/src/schemas/files.py`, `api/src/api/endpoints/files/upload.py`, новый `consolidation_files.py`,
|
||||
`api/src/api/endpoints/files/__init__.py`.
|
||||
- Тесты: `test_api_files_upload.py` (раздел «в контексте версии» переписывается под консолидацию),
|
||||
`test_api_files_read.py`/новый `test_api_consolidation_files.py`, `test_migrations.py`, `test_models_part_04_06.py`,
|
||||
тесты автозамены.
|
||||
- Клиент arm-web меняется одновременно (query удалён); стенд обновляется после слияния обоих PR.
|
||||
100
openspec/changes/console-files/specs/data-model/spec.md
Normal file
100
openspec/changes/console-files/specs/data-model/spec.md
Normal file
@ -0,0 +1,100 @@
|
||||
## RENAMED Requirements
|
||||
- FROM: `### Requirement: Схема БД равна DBML v5.17`
|
||||
- TO: `### Requirement: Схема БД равна DBML v5.18`
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Схема БД равна DBML v5.18
|
||||
ORM-метаданные SHALL содержать ровно 43 таблицы и 28 enum'ов из `../docs/model/data-model-v5.18.dbml` в схеме PostgreSQL
|
||||
из настройки `DB_SCHEMA` (по умолчанию `dkb`; в контуре — `OPENBAO__APP__DB_SCHEMA`; простой идентификатор
|
||||
`[a-z_][a-z0-9_]{0,62}`, не начинающийся с `pg_`, иначе приложение не стартует); сверка с DBML идёт в эталонной схеме `dkb`;
|
||||
имена таблиц, колонок, enum'ов и их значений SHALL совпадать с DBML буквально; nullability, PK, UNIQUE, FK, значения
|
||||
по умолчанию и индексы SHALL соответствовать DBML, включая частичные уникальные индексы (`related_group_member`
|
||||
`(related_group_id, legal_entity_id)` и `(legal_entity_id, kind)` WHERE `is_current`, `consolidation_lock` WHERE
|
||||
`released_at IS NULL`) и `NULLS NOT DISTINCT` на `document (stored_file_id, document_type, osv_account_code)`.
|
||||
`related_group_member.kind` SHALL быть NOT NULL и равняться `related_group.kind` группы (проверяет ядро при вставке).
|
||||
`audit_action` SHALL содержать `LEGAL_ENTITY_CHANGE` и `USER_CHANGE`; `osv_data_type` SHALL содержать `NU` (после `BU`).
|
||||
Enum `counterparty_match_kind` SHALL содержать `INN`, `NAME`, `ALIAS`, `MANUAL`, `PROPAGATED`; колонка
|
||||
`osv_entry.counterparty_match_kind` (nullable) SHALL быть NULL тогда и только тогда, когда `counterparty_id` NULL
|
||||
(проверяет ядро при записи). `app_user.email` SHALL храниться в нижнем регистре. Миграция `0004_dbml_v5_16` SHALL
|
||||
создавать enum и колонку и заполнять её по уже смапленным строкам (ИНН строки без пробелов = `legal_entity.inn` → `INN`;
|
||||
`trim(counterparty_name) = legal_entity.name` → `NAME`; остальные с `counterparty_id` → `ALIAS`); откат SHALL удалять
|
||||
колонку и enum. Колонка `kpp` (`varchar`, nullable, без индексов и уникальности; уникален только `inn`) SHALL быть
|
||||
последней в `legal_entity` и `legal_entity_history`; формат и запрет КПП у ИП проверяет ядро. Миграция `0005_dbml_v5_17`
|
||||
SHALL добавлять обе колонки (существующие записи — `kpp IS NULL`), откат — удалять.
|
||||
Колонка `stored_file.consolidation_id` (`uuid`, nullable, FK → `consolidation.id`; v5.18) SHALL быть последней в
|
||||
`stored_file`; индекс `ix_stored_file_legal_entity_id_consolidation_id (legal_entity_id, consolidation_id)`. Миграция
|
||||
`0006_dbml_v5_18` SHALL добавлять колонку, FK и индекс и заполнять колонку по `consolidation_document`: файлу с документом,
|
||||
привязанным к версии, — консолидация этой версии; при привязках к версиям разных консолидаций — самый ранний период
|
||||
(год, затем квартал); файлы без привязок остаются `NULL`; откат SHALL удалять индекс и колонку. Поля, отсутствующие в
|
||||
DBML, MUST NOT существовать.
|
||||
|
||||
#### Scenario: Сверка ORM с DBML
|
||||
- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.18.dbml` (`make dbml-check`)
|
||||
- **THEN** вывод пуст и код возврата 0
|
||||
|
||||
#### Scenario: Лишняя колонка в ORM
|
||||
- **WHEN** в ORM добавлена колонка, которой нет в DBML (или удалена существующая)
|
||||
- **THEN** скрипт печатает `таблица.колонка: отсутствует в DBML` (или `отсутствует в ORM`) и возвращает код 1
|
||||
|
||||
#### Scenario: Расхождение типа или nullability
|
||||
- **WHEN** колонка `osv_entry.opening_debit` объявлена в ORM как `Numeric(18, 4)` или `NOT NULL`
|
||||
- **THEN** скрипт сообщает `ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL` и возвращает код 1
|
||||
|
||||
#### Scenario: ЮЛ одновременно в ГСК и ГСЗ
|
||||
- **WHEN** для одного `legal_entity_id` вставлены две текущие строки `related_group_member` с `kind = gsk` и `kind = gsz`
|
||||
- **THEN** обе вставки проходят; третья текущая строка с `kind = gsk` нарушает уникальный индекс
|
||||
|
||||
#### Scenario: Строка налогового учёта
|
||||
- **WHEN** в `osv_entry` вставляется строка с `data_type = 'NU'`
|
||||
- **THEN** вставка проходит
|
||||
|
||||
#### Scenario: Backfill при миграции
|
||||
- **WHEN** до миграции 0004 есть строки с `counterparty_id`, найденным по ИНН, по имени и по алиасу, и строка без `counterparty_id`
|
||||
- **THEN** после `alembic upgrade head` у них `counterparty_match_kind` = `INN`, `NAME`, `ALIAS` и NULL соответственно;
|
||||
`alembic downgrade 0003` удаляет колонку и enum
|
||||
|
||||
#### Scenario: Backfill консолидации файла при миграции 0006
|
||||
- **WHEN** до миграции 0006 есть файл `A` с документом, привязанным к версии консолидации 2025 Q2 и к версии консолидации
|
||||
2025 Q1, и файл `B` без привязок
|
||||
- **THEN** после `alembic upgrade head` у `A` `consolidation_id` = консолидация 2025 Q1, у `B` — NULL; колонка последняя в
|
||||
`stored_file`; `alembic downgrade 0005_dbml_v5_17` удаляет индекс и колонку, повторный `upgrade` их возвращает
|
||||
|
||||
### Requirement: Миграции применяются с нуля и откатываются
|
||||
Цепочка ревизий `0001_dbml_v5_13` → `0002_dbml_v5_14` → `0003_dbml_v5_15` → `0004_dbml_v5_16` → `0005_dbml_v5_17` →
|
||||
`0006_dbml_v5_18` SHALL создавать схему из `DB_SCHEMA` (если нет), все
|
||||
enum'ы, таблицы, ограничения, индексы и последовательности кодов (`company_group_code_seq`, `related_group_code_seq`)
|
||||
на пустой базе PostgreSQL 16 (требуется ≥ 15); `0003` SHALL добавлять `NU` в `osv_data_type`, приводить
|
||||
`app_user.email` к нижнему регистру и создавать индексы `ix_osv_entry_parent_entry_id`, `ix_osv_entry_recognition_job_id`,
|
||||
`ix_stored_file_current_recognition_job_id` (DBML v5.15 п. 4); `downgrade` до `base` SHALL удалять всё созданное (новые значения enum — через
|
||||
пересоздание типа), оставляя пустую схему; повторный `upgrade` после `downgrade` SHALL проходить без ошибок.
|
||||
|
||||
#### Scenario: Полный цикл
|
||||
- **WHEN** на пустой БД выполняются `alembic upgrade head`, `alembic downgrade base`, `alembic upgrade head`
|
||||
- **THEN** все три команды завершаются успешно, после последней в `information_schema.tables` схемы `DB_SCHEMA` 43 таблицы модели (+ служебная `alembic_version`)
|
||||
и в `pg_type` 28 enum-типов
|
||||
|
||||
#### Scenario: Откат до v5.14
|
||||
- **WHEN** после `upgrade head` выполняется `alembic downgrade 0002_dbml_v5_14`
|
||||
- **THEN** `osv_data_type` не содержит `NU`, индексов v5.15 п. 4 нет; регистр `app_user.email` не возвращается (данные)
|
||||
|
||||
#### Scenario: КПП добавляется и откатывается
|
||||
- **WHEN** на базе с ЮЛ выполняется `alembic upgrade head`, затем `alembic downgrade 0004_dbml_v5_16`
|
||||
- **THEN** после `upgrade` у `legal_entity` и `legal_entity_history` есть последняя колонка `kpp varchar NULL`, у существующих ЮЛ
|
||||
`kpp IS NULL`; после `downgrade` колонок нет, повторный `upgrade` их возвращает
|
||||
|
||||
#### Scenario: Нестандартная схема
|
||||
- **WHEN** `DB_SCHEMA = dkb_alt` и на пустой БД выполняется `alembic upgrade head`
|
||||
- **THEN** таблицы и enum'ы модели создаются в схеме `dkb_alt`, приложение работает с ней; схема `dkb` не создаётся
|
||||
|
||||
#### Scenario: Автогенерация ничего не находит
|
||||
- **WHEN** после `upgrade head` выполняется `alembic revision --autogenerate` на той же БД
|
||||
- **THEN** сгенерированная ревизия не содержит операций (пустые `upgrade`/`downgrade`)
|
||||
|
||||
### Requirement: Версия DBML зафиксирована в коде
|
||||
Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.18"`); скрипт сверки SHALL сравнивать её с версией в шапке файла
|
||||
DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна.
|
||||
|
||||
#### Scenario: Новая версия DBML без обновления ORM
|
||||
- **WHEN** в `docs/model/` появляется `data-model-v5.19.dbml`, а константа в ORM — `5.18`
|
||||
- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц
|
||||
50
openspec/changes/console-files/specs/documents-api/spec.md
Normal file
50
openspec/changes/console-files/specs/documents-api/spec.md
Normal file
@ -0,0 +1,50 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Файлы организации и карточка файла
|
||||
`GET /api/v1/legal-entities/{id}/files` (`DOCUMENT_VIEW`, policy; `include_superseded=false` по умолчанию) SHALL
|
||||
возвращать `Page` файлов ЮЛ по убыванию `uploaded_at`: поля `stored_file` (в том числе `consolidation_id: uuid | null`,
|
||||
v5.18 — консолидация, в контексте которой файл загружен), текущее задание (`status`, `error_code`,
|
||||
`error_message`, `mapping_error` (`metrics.mapping_error`: причина `ERROR` файла после неудачного маппинга, когда у задания
|
||||
`error_code` пуст), `metrics.warnings`, `metrics.mapping_warnings`, `finished_at`), документы файла (`id`, `document_type`,
|
||||
`osv_account_code`, `period_year`, `period_quarter`, `is_cumulative`, `status`, `period_check`, `counterparty_check`,
|
||||
`comments_count`). `GET /api/v1/files/{file_id}` SHALL отдавать ту же карточку; чужой или несуществующий файл — 404.
|
||||
|
||||
#### Scenario: Список после обработки
|
||||
- **WHEN** у ЮЛ два файла: `PROCESSED` с документом и `ERROR`
|
||||
- **THEN** оба в списке; у первого `documents` непустой, у второго `job.error_code` заполнен
|
||||
|
||||
#### Scenario: Счётчик комментариев
|
||||
- **WHEN** к документу файла добавлены два комментария
|
||||
- **THEN** в карточке файла у документа `comments_count = 2`
|
||||
|
||||
#### Scenario: Консолидация в карточке
|
||||
- **WHEN** один файл ЮЛ загружен в консолидацию `C`, другой — без контекста
|
||||
- **THEN** в списке ЮЛ и в `GET /files/{id}` у первого `consolidation_id = C`, у второго `consolidation_id = null`
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Рабочий стол консолидации для ЮЛ
|
||||
`GET /api/v1/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` (`DOCUMENT_VIEW`; `limit`,
|
||||
`offset`, `include_superseded=false` по умолчанию; §9.5 п. 11 v5.18) SHALL отвечать 404 `NOT_FOUND` на консолидацию вне
|
||||
видимости или несуществующую («консолидация не найдена») и на ЮЛ вне видимости, иначе возвращать `Page[FileOut]` —
|
||||
объединение файлов ЮЛ, у которых `stored_file.consolidation_id = consolidation_id`, и файлов ЮЛ, у которых есть документ
|
||||
с записью `consolidation_document` к любой версии этой консолидации; порядок и карточка — как у списка файлов ЮЛ;
|
||||
`SUPERSEDED` — только при `include_superseded=true` (в обоих множествах). Принадлежность ЮЛ к ГК консолидации не
|
||||
проверяется: чужому ЮЛ соответствует пустой список.
|
||||
|
||||
#### Scenario: Стол показывает только файлы консоли
|
||||
- **WHEN** у ЮЛ три файла: загружен в консолидацию 2025 Q1, загружен в консолидацию 2025 Q2, без контекста
|
||||
- **THEN** стол 2025 Q1 содержит только первый; стол 2025 Q2 — только второй; `GET /legal-entities/{id}/files` — все три
|
||||
|
||||
#### Scenario: Подхваченный файл виден через привязку
|
||||
- **WHEN** файл с `CONFIRMED` ОСВ за 2025 Q1 загружен без контекста и его документ привязан к версии консолидации 2025 Q2
|
||||
(подхват при создании версии)
|
||||
- **THEN** файл есть в столе 2025 Q2, хотя `consolidation_id IS NULL`
|
||||
|
||||
#### Scenario: Заменённый файл скрыт по умолчанию
|
||||
- **WHEN** файл консолидации заменён (`SUPERSEDED`) новым файлом той же консолидации
|
||||
- **THEN** по умолчанию в столе только новый; с `include_superseded=true` — оба
|
||||
|
||||
#### Scenario: Чужая консолидация
|
||||
- **WHEN** `ka_rf` запрашивает стол консолидации ГК другого филиала и несуществующий id
|
||||
- **THEN** оба ответа 404 с одинаковым телом
|
||||
134
openspec/changes/console-files/specs/primary-files/spec.md
Normal file
134
openspec/changes/console-files/specs/primary-files/spec.md
Normal file
@ -0,0 +1,134 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Загрузка файлов организации
|
||||
`POST /api/v1/legal-entities/{legal_entity_id}/files` (multipart, одно или несколько полей `files`; право
|
||||
`DOCUMENT_EDIT`; ЮЛ в области видимости пользователя, иначе 404) SHALL для каждого файла в отдельной транзакции:
|
||||
определить тип по расширению и сверить с сигнатурой содержимого (`xls` — BIFF8, `xlsx`/`xlsb` — zip, `pdf`, `png`,
|
||||
`jpg`); отвергнуть файл больше `UPLOAD_MAX_BYTES` (50 МиБ) или неподдерживаемого типа; вычислить sha256; создать
|
||||
`s3_object` (`bucket_role = uploads`, ключ `uploads/{file_id}/{job_id}/{безопасное имя}`, `sha256`, `size_bytes`,
|
||||
`content_type`, `created_by = user:{id}`), положить объект в S3, создать `stored_file` (`status = UPLOADED`,
|
||||
`version_no = 1`, `uploaded_by`, `uploaded_at`, `content_hash`, `original_name`, `consolidation_id = NULL`) и
|
||||
`recognition_job` (`status = queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg → `image`;
|
||||
`max_attempts` 3 / 1), установить `stored_file.current_recognition_job_id`, записать
|
||||
`audit_event(FILE_UPLOAD, PRIMARY_DOCUMENTS)` с `after_value.consolidation_id = null`. Ответ SHALL
|
||||
перечислять принятые файлы (`accepted[]` с `file_id`, `job_id`, `name`) и отклонённые (`rejected[]` с `name`, `code`,
|
||||
`message`); код ответа 201, если принят хотя бы один, иначе 422. Тип документа и период при загрузке MUST NOT
|
||||
запрашиваться (макет `40`). Без доступного S3 — 503 `STORAGE_UNAVAILABLE`.
|
||||
Query-параметр `consolidation_version_id` MUST NOT приниматься (удалён в v5.18): загрузка этим маршрутом — без
|
||||
контекста консолидации и не меняет статусы версий; загрузка в консолидацию — требование «Загрузка файлов в
|
||||
консолидацию».
|
||||
|
||||
#### Scenario: Реальная ОСВ принята и ушла в outbox
|
||||
- **WHEN** `ka_rf` загружает `rb/2024-FY/ОСВ_66 за 2024.xls` для ЮЛ своего филиала
|
||||
- **THEN** 201, `accepted[0].file_id` существует; `stored_file.status = UPLOADED`, `file_type = XLS`, `content_hash` =
|
||||
sha256 файла, `consolidation_id IS NULL`; `recognition_job.status = queued`, `pipeline = tabular`; объект в S3 по ключу
|
||||
`uploads/{file_id}/{job_id}/ОСВ_66 за 2024.xls`; публикатор outbox публикует запрос с этим ключом
|
||||
|
||||
#### Scenario: Смесь принятых и отклонённых
|
||||
- **WHEN** в одном запросе xlsx, файл `.docx` и файл 51 МиБ
|
||||
- **THEN** 201, `accepted` — один, `rejected` — два с кодами `UNSUPPORTED_FILE` и `FILE_TOO_LARGE`
|
||||
|
||||
#### Scenario: Расширение не совпадает с содержимым
|
||||
- **WHEN** загружается файл `osv.xlsx`, байты которого — BIFF8
|
||||
- **THEN** файл отклонён с кодом `UNSUPPORTED_FILE`
|
||||
|
||||
#### Scenario: Чужое ЮЛ
|
||||
- **WHEN** `ka_rf` загружает файл для ЮЛ другого филиала
|
||||
- **THEN** 404, объектов и записей нет
|
||||
|
||||
#### Scenario: Загрузка в контексте версии
|
||||
- **WHEN** у ГК ЮЛ есть версия `INITIATED` с этим ЮЛ в периметре, файл загружен маршрутом ЮЛ с query
|
||||
`consolidation_version_id` (как делал клиент до v5.18)
|
||||
- **THEN** 201, параметр игнорируется; `stored_file.consolidation_id IS NULL`; версия остаётся `INITIATED`
|
||||
|
||||
#### Scenario: ЮЛ вне периметра
|
||||
- **WHEN** ЮЛ не входит в периметр ни одной версии, файл загружен маршрутом ЮЛ
|
||||
- **THEN** 201 — маршрут ЮЛ периметр не проверяет; проверка периметра — только у загрузки в консолидацию
|
||||
|
||||
### Requirement: Автозамена прежних версий
|
||||
После успешного маппинга файла ядро SHALL найти другие файлы того же ЮЛ со статусом не `SUPERSEDED`, у которых есть
|
||||
документ с теми же `document_type`, `osv_account_code`, `period_year`, `period_quarter`, и перевести их в
|
||||
`SUPERSEDED` (`superseded_by_file_id` = новый файл, `updated_at`); новый файл получает `version_no` = максимум среди
|
||||
заменённых + 1. Если у нового файла `consolidation_id IS NULL`, он SHALL получить `consolidation_id` первого по `id`
|
||||
заменённого файла, у которого он задан (v5.18); файл, загруженный в консолидацию, сохраняет свой `consolidation_id`.
|
||||
Документы и строки заменённых файлов SHALL сохраняться (история), но по умолчанию не показываться.
|
||||
Привязки `consolidation_document` документов заменённых файлов к версиям «в работе» SHALL удаляться в той же
|
||||
транзакции (версии блокируются `FOR UPDATE` по возрастанию `id` после старых файлов); привязки к версиям в других
|
||||
статусах не трогаются; статус версии не меняется. Аудит `FILE_REPLACE` (`object_id` заменённого, `after_value` с
|
||||
новым файлом и `unbound_version_ids`, в `description` — перечень заменённых документов и признак `confirmed`, если
|
||||
заменён документ `CONFIRMED`). Решение владельца 05.10.2026: замена происходит и для `CONFIRMED`.
|
||||
Замена остаётся на уровне файла (OQ-115), но если у заменённого файла есть документ, ключа которого
|
||||
(`document_type`, `osv_account_code`, `period_year`, `period_quarter`) нет среди документов нового файла (в т. ч. документ
|
||||
без года или квартала), ядро SHALL добавить в `metrics.mapping_warnings` задания нового файла (карточка файла,
|
||||
`JobBrief.mapping_warnings`) по одному предупреждению на заменённый файл (OQ-154): имя и версия заменённого файла,
|
||||
выпавшие документы (тип, счёт, период, статус — в т. ч. `CONFIRMED`) и версии консолидации «в работе», с которых сняты
|
||||
привязки этих документов. Поведение замены и статусы документов нового файла от предупреждения не меняются.
|
||||
|
||||
#### Scenario: Новая ОСВ 60 за 1 кв. 2025
|
||||
- **WHEN** у ЮЛ есть `PROCESSED` файл с документом OSV 60 / 2025 / Q1 (`CONFIRMED`), загружен и смапплен новый файл с таким же документом
|
||||
- **THEN** прежний файл — `SUPERSEDED`, `superseded_by_file_id` = новый, у нового `version_no = 2`; `audit_event(FILE_REPLACE)`
|
||||
с `description`, содержащим `confirmed=true`; список файлов ЮЛ по умолчанию показывает только новый
|
||||
|
||||
#### Scenario: Заменённый документ был в версии
|
||||
- **WHEN** заменённый `CONFIRMED` документ ОСВ 60 за 2024 Q2 был привязан к версии 2024 Q3 в `DATA_CORRECTION`
|
||||
- **THEN** привязка удалена; в чек-листе ячейка ОСВ 60 показывает `missing_quarters = ["Q2"]` (или `NEEDS_CHECK`
|
||||
по новому документу), пока новый документ не подтверждён; статус версии прежний
|
||||
|
||||
#### Scenario: Отдельный ОФР заменил книгу «ББ + ОФР»
|
||||
- **WHEN** у ЮЛ есть файл с `CONFIRMED` ББ и ОФР за Q3 2025, ББ привязан к версии 2025 Q3 в `DATA_CORRECTION`; смаплен
|
||||
новый файл только с ОФР за Q3 2025
|
||||
- **THEN** прежний файл — `SUPERSEDED`, привязка ББ снята; в `metrics.mapping_warnings` нового задания одно предупреждение:
|
||||
имя прежнего файла, `BALANCE 2025 Q3 (CONFIRMED)` и версия «ГК» 2025 Q3, версия 1; ОФР в предупреждении нет
|
||||
|
||||
#### Scenario: Новый файл без контекста наследует консолидацию
|
||||
- **WHEN** прежний файл загружен в консолидацию 2025 Q1 (`consolidation_id = C`), новый файл с тем же документом загружен
|
||||
маршрутом ЮЛ (`consolidation_id IS NULL`) и смаплен
|
||||
- **THEN** у нового файла `consolidation_id = C`
|
||||
|
||||
#### Scenario: Новый файл с контекстом сохраняет свой
|
||||
- **WHEN** прежний файл в консолидации 2025 Q1 (`C1`), новый с тем же документом загружен в консолидацию 2025 Q2 (`C2`)
|
||||
- **THEN** у нового файла `consolidation_id = C2`
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Загрузка файлов в консолидацию
|
||||
`POST /api/v1/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` (multipart `files`, право
|
||||
`DOCUMENT_EDIT`; §9.5 п. 8 v5.18) SHALL до приёма файлов проверить: консолидация видима (ГК в области пользователя;
|
||||
чужая и несуществующая — одинаковый 404 `NOT_FOUND` «консолидация не найдена»), ЮЛ видимо (404), у консолидации есть
|
||||
хотя бы одна версия со статусом «в работе» (`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`; иначе 409
|
||||
`CONSOLIDATION_NOT_EDITABLE`), ЮЛ — член периметра хотя бы одной такой версии (иначе 422 `ENTITY_NOT_IN_PERIMETER`
|
||||
объектом `detail`). Приём файлов, дедуп, ответ и коды `rejected[]` — как у маршрута ЮЛ; принятый файл SHALL получить
|
||||
`stored_file.consolidation_id = consolidation_id`, `FILE_UPLOAD.after_value.consolidation_id` = его строка. Если принят
|
||||
хотя бы один файл, ядро SHALL в отдельной транзакции после приёма заблокировать `FOR UPDATE ORDER BY id` версии этой
|
||||
консолидации со статусом `INITIATED` и ЮЛ в периметре и перевести каждую в `DOCUMENTS_PROCESSING` (`changed_by` =
|
||||
загрузивший, запись `consolidation_status_history` и `CONSOLIDATION_STATUS_CHANGE`); сбой перехода логируется и не
|
||||
меняет ответ. Блокировка версии (`consolidation_lock`) для загрузки MUST NOT требоваться.
|
||||
|
||||
#### Scenario: Загрузка в консолидацию переводит версию
|
||||
- **WHEN** у консолидации 2024 Q3 версия 1 в `INITIATED`, ЮЛ в периметре, загружен один принятый файл
|
||||
- **THEN** 201; `stored_file.consolidation_id` = консолидация; версия в `DOCUMENTS_PROCESSING`, история с `changed_by`
|
||||
= пользователь; `FILE_UPLOAD.after_value.consolidation_id` = id консолидации
|
||||
|
||||
#### Scenario: Версия в другом рабочем статусе
|
||||
- **WHEN** единственная версия консолидации в `DATA_CORRECTION`, загружен принятый файл
|
||||
- **THEN** 201; статус версии не меняется; файл получил `consolidation_id`
|
||||
|
||||
#### Scenario: Нет версии в работе
|
||||
- **WHEN** единственная версия консолидации `CANCELLED`
|
||||
- **THEN** 409 `CONSOLIDATION_NOT_EDITABLE`, файлы не приняты, объектов в S3 нет
|
||||
|
||||
#### Scenario: ЮЛ вне периметра
|
||||
- **WHEN** ЮЛ той же ГК не входит в периметр ни одной версии «в работе»
|
||||
- **THEN** 422 `ENTITY_NOT_IN_PERIMETER`, файлы не приняты
|
||||
|
||||
#### Scenario: Чужая и несуществующая консолидация
|
||||
- **WHEN** `ka_rf` загружает файл в консолидацию ГК другого филиала и в несуществующий id
|
||||
- **THEN** оба ответа 404 с одинаковым телом (`code = NOT_FOUND`), записей нет
|
||||
|
||||
#### Scenario: Все файлы отклонены
|
||||
- **WHEN** в консолидацию с версией `INITIATED` загружен только `.docx`
|
||||
- **THEN** 422 телом `UploadResult`, версия остаётся `INITIATED`
|
||||
|
||||
#### Scenario: Загрузка не требует блокировки версии
|
||||
- **WHEN** версию держит другой пользователь (`consolidation_lock`), ЮЛ в периметре
|
||||
- **THEN** 201; версия `INITIATED` → `DOCUMENTS_PROCESSING`
|
||||
Loading…
x
Reference in New Issue
Block a user