106 lines
10 KiB
Markdown
106 lines
10 KiB
Markdown
## 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`. Цена ошибки: низкая.
|