Merge pull request 'Файлы в контексте консолидации: stored_file.consolidation_id, маршруты /consolidations/{id}/legal-entities/{le}/files (DBML v5.18, #96)' (#97) from feat/96-console-files into main

Reviewed-on: #97
This commit is contained in:
aep 2026-10-10 08:59:57 +03:00
commit 9be7d7508b
42 changed files with 2612 additions and 165 deletions

View File

@ -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`. Контракт для фронта —
@ -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.

View File

@ -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

View File

@ -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`) |
@ -952,7 +956,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 +1008,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 +1031,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 +1115,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) — план срезов;

View File

@ -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")

View File

@ -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`.

View File

@ -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 = "нет"

View File

@ -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)

View File

@ -0,0 +1,84 @@
"""Файлы ЮЛ в контексте консолидации (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, 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, UploadResult
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)
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

View File

@ -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)

View File

@ -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",

View File

@ -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`

View File

@ -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

View File

@ -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).

View File

@ -1,4 +1,4 @@
"""Часть 01 DBML v5.17 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2)."""
"""Часть 01 DBML v5.18 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2)."""
import datetime
import uuid

View File

@ -1,6 +1,6 @@
"""Enum'ы модели данных DBML v5.17 (28 штук) и фабрика `pg_enum` для PostgreSQL-колонок.
"""Enum'ы модели данных DBML v5.18 (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`.

View File

@ -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` создаёт
@ -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):

View File

@ -1,4 +1,4 @@
"""Часть 08 DBML v5.17 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента.
"""Часть 08 DBML v5.18 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента.
Корректировки и гудвилл — ручные решения аналитика, хранятся поверх первички (overlay). Результат КФО
замораживается при VERIFIED. Деньги — `numeric(18,2)`, проценты — `numeric(7,4)` / `numeric(9,4)`.

View File

@ -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)).

View File

@ -1,4 +1,4 @@
"""Часть 06 DBML v5.17 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н).
"""Часть 06 DBML v5.18 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н).
Первичка не мутируется: `*_original_value` / `original_amount` / `is_modified` — рядом с распознанным значением.
Логические проверки (ТЗ 6.3) и «требует проверки» не хранятся — считаются на лету.

View File

@ -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:
@ -171,6 +179,55 @@ 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: переход после загрузки в консолидацию).
Двухшаговая схема, как в `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,
)
members_only = (
select(ConsolidationVersion)
.where(ConsolidationVersion.id.in_(locked_ids), is_member)
.order_by(ConsolidationVersion.id)
.execution_options(populate_existing=True)
)
return list((await session.execute(members_only)).scalars())
async def active_lock(session: AsyncSession, version_id: uuid.UUID) -> ConsolidationLock | None:
"""Активная блокировка версии (`released_at IS NULL`; не более одной — partial UNIQUE)."""
stmt = select(ConsolidationLock).where(

View File

@ -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

View File

@ -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]

View File

@ -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(

View File

@ -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)})

View File

@ -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 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:
@ -241,6 +245,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)
@ -366,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()
@ -383,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:
@ -401,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
@ -432,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,
@ -471,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)

View File

@ -660,10 +660,12 @@ 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`,
`confirmed=true`, если среди заменённых документов есть `CONFIRMED`; решение владельца 05.10.2026). Документы и
строки заменённых файлов не трогаются (история). Возвращает заменённые файлы; в транзакции вызывающего, без commit.
`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.
Привязки документов заменённых файлов к версиям консолидации «в работе» снимаются
(`consolidation_documents.on_documents_superseded`); id версий — в `after_value.unbound_version_ids` события
@ -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), а не только у

View File

@ -0,0 +1,197 @@
"""Рабочий стол консолидации для ЮЛ и `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_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 == 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:
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)

View File

@ -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

View File

@ -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:

View File

@ -1155,11 +1155,75 @@ 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],
) -> 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`

View File

@ -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

View File

@ -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,174 @@ 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; файл C привязан к
версиям 2025 Q2 и 2025 Q1 → 2025 Q1 (квартал внутри года по возрастанию); `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",
"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",
)
}
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"), ("s3c", "file_c")):
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 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(
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,
)
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:
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, ids["file_c"]: ids["c_2025q1"]}
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 +883,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

View File

@ -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"},

View File

@ -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` — оба документа."""

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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`

View File

@ -0,0 +1,100 @@
## RENAMED Requirements
- FROM: `### Requirement: Схема БД равна DBML v5.17`
- TO: `### Requirement: Схема БД равна DBML v5.18`
## MODIFIED Requirements
### Requirement: Схема БД равна DBML v5.18
ORM-метаданные SHALL содержать ровно 43 таблицы и 28 enum'ов из `../docs/model/data-model-v5.18.dbml` в схеме PostgreSQL
из настройки `DB_SCHEMA` (по умолчанию `dkb`; в контуре — `OPENBAO__APP__DB_SCHEMA`; простой идентификатор
`[a-z_][a-z0-9_]{0,62}`, не начинающийся с `pg_`, иначе приложение не стартует); сверка с DBML идёт в эталонной схеме `dkb`;
имена таблиц, колонок, enum'ов и их значений SHALL совпадать с DBML буквально; nullability, PK, UNIQUE, FK, значения
по умолчанию и индексы SHALL соответствовать DBML, включая частичные уникальные индексы (`related_group_member`
`(related_group_id, legal_entity_id)` и `(legal_entity_id, kind)` WHERE `is_current`, `consolidation_lock` WHERE
`released_at IS NULL`) и `NULLS NOT DISTINCT` на `document (stored_file_id, document_type, osv_account_code)`.
`related_group_member.kind` SHALL быть NOT NULL и равняться `related_group.kind` группы (проверяет ядро при вставке).
`audit_action` SHALL содержать `LEGAL_ENTITY_CHANGE` и `USER_CHANGE`; `osv_data_type` SHALL содержать `NU` (после `BU`).
Enum `counterparty_match_kind` SHALL содержать `INN`, `NAME`, `ALIAS`, `MANUAL`, `PROPAGATED`; колонка
`osv_entry.counterparty_match_kind` (nullable) SHALL быть NULL тогда и только тогда, когда `counterparty_id` NULL
(проверяет ядро при записи). `app_user.email` SHALL храниться в нижнем регистре. Миграция `0004_dbml_v5_16` SHALL
создавать enum и колонку и заполнять её по уже смапленным строкам (ИНН строки без пробелов = `legal_entity.inn` → `INN`;
`trim(counterparty_name) = legal_entity.name` → `NAME`; остальные с `counterparty_id` → `ALIAS`); откат SHALL удалять
колонку и enum. Колонка `kpp` (`varchar`, nullable, без индексов и уникальности; уникален только `inn`) SHALL быть
последней в `legal_entity` и `legal_entity_history`; формат и запрет КПП у ИП проверяет ядро. Миграция `0005_dbml_v5_17`
SHALL добавлять обе колонки (существующие записи — `kpp IS NULL`), откат — удалять.
Колонка `stored_file.consolidation_id` (`uuid`, nullable, FK → `consolidation.id`; v5.18) SHALL быть последней в
`stored_file`; индекс `ix_stored_file_legal_entity_id_consolidation_id (legal_entity_id, consolidation_id)`. Миграция
`0006_dbml_v5_18` SHALL добавлять колонку, FK и индекс и заполнять колонку по `consolidation_document`: файлу с документом,
привязанным к версии, — консолидация этой версии; при привязках к версиям разных консолидаций — самый ранний период
(год, затем квартал); файлы без привязок остаются `NULL`; откат SHALL удалять индекс и колонку. Поля, отсутствующие в
DBML, MUST NOT существовать.
#### Scenario: Сверка ORM с DBML
- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.18.dbml` (`make dbml-check`)
- **THEN** вывод пуст и код возврата 0
#### Scenario: Лишняя колонка в ORM
- **WHEN** в ORM добавлена колонка, которой нет в DBML (или удалена существующая)
- **THEN** скрипт печатает `таблица.колонка: отсутствует в DBML` (или `отсутствует в ORM`) и возвращает код 1
#### Scenario: Расхождение типа или nullability
- **WHEN** колонка `osv_entry.opening_debit` объявлена в ORM как `Numeric(18, 4)` или `NOT NULL`
- **THEN** скрипт сообщает `ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL` и возвращает код 1
#### Scenario: ЮЛ одновременно в ГСК и ГСЗ
- **WHEN** для одного `legal_entity_id` вставлены две текущие строки `related_group_member` с `kind = gsk` и `kind = gsz`
- **THEN** обе вставки проходят; третья текущая строка с `kind = gsk` нарушает уникальный индекс
#### Scenario: Строка налогового учёта
- **WHEN** в `osv_entry` вставляется строка с `data_type = 'NU'`
- **THEN** вставка проходит
#### Scenario: Backfill при миграции
- **WHEN** до миграции 0004 есть строки с `counterparty_id`, найденным по ИНН, по имени и по алиасу, и строка без `counterparty_id`
- **THEN** после `alembic upgrade head` у них `counterparty_match_kind` = `INN`, `NAME`, `ALIAS` и NULL соответственно;
`alembic downgrade 0003` удаляет колонку и enum
#### Scenario: Backfill консолидации файла при миграции 0006
- **WHEN** до миграции 0006 есть файл `A` с документом, привязанным к версии консолидации 2025 Q2 и к версии консолидации
2025 Q1, и файл `B` без привязок
- **THEN** после `alembic upgrade head` у `A` `consolidation_id` = консолидация 2025 Q1, у `B` — NULL; колонка последняя в
`stored_file`; `alembic downgrade 0005_dbml_v5_17` удаляет индекс и колонку, повторный `upgrade` их возвращает
### Requirement: Миграции применяются с нуля и откатываются
Цепочка ревизий `0001_dbml_v5_13` → `0002_dbml_v5_14` → `0003_dbml_v5_15` → `0004_dbml_v5_16` → `0005_dbml_v5_17` →
`0006_dbml_v5_18` SHALL создавать схему из `DB_SCHEMA` (если нет), все
enum'ы, таблицы, ограничения, индексы и последовательности кодов (`company_group_code_seq`, `related_group_code_seq`)
на пустой базе PostgreSQL 16 (требуется ≥ 15); `0003` SHALL добавлять `NU` в `osv_data_type`, приводить
`app_user.email` к нижнему регистру и создавать индексы `ix_osv_entry_parent_entry_id`, `ix_osv_entry_recognition_job_id`,
`ix_stored_file_current_recognition_job_id` (DBML v5.15 п. 4); `downgrade` до `base` SHALL удалять всё созданное (новые значения enum — через
пересоздание типа), оставляя пустую схему; повторный `upgrade` после `downgrade` SHALL проходить без ошибок.
#### Scenario: Полный цикл
- **WHEN** на пустой БД выполняются `alembic upgrade head`, `alembic downgrade base`, `alembic upgrade head`
- **THEN** все три команды завершаются успешно, после последней в `information_schema.tables` схемы `DB_SCHEMA` 43 таблицы модели (+ служебная `alembic_version`)
и в `pg_type` 28 enum-типов
#### Scenario: Откат до v5.14
- **WHEN** после `upgrade head` выполняется `alembic downgrade 0002_dbml_v5_14`
- **THEN** `osv_data_type` не содержит `NU`, индексов v5.15 п. 4 нет; регистр `app_user.email` не возвращается (данные)
#### Scenario: КПП добавляется и откатывается
- **WHEN** на базе с ЮЛ выполняется `alembic upgrade head`, затем `alembic downgrade 0004_dbml_v5_16`
- **THEN** после `upgrade` у `legal_entity` и `legal_entity_history` есть последняя колонка `kpp varchar NULL`, у существующих ЮЛ
`kpp IS NULL`; после `downgrade` колонок нет, повторный `upgrade` их возвращает
#### Scenario: Нестандартная схема
- **WHEN** `DB_SCHEMA = dkb_alt` и на пустой БД выполняется `alembic upgrade head`
- **THEN** таблицы и enum'ы модели создаются в схеме `dkb_alt`, приложение работает с ней; схема `dkb` не создаётся
#### Scenario: Автогенерация ничего не находит
- **WHEN** после `upgrade head` выполняется `alembic revision --autogenerate` на той же БД
- **THEN** сгенерированная ревизия не содержит операций (пустые `upgrade`/`downgrade`)
### Requirement: Версия DBML зафиксирована в коде
Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.18"`); скрипт сверки SHALL сравнивать её с версией в шапке файла
DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна.
#### Scenario: Новая версия DBML без обновления ORM
- **WHEN** в `docs/model/` появляется `data-model-v5.19.dbml`, а константа в ORM — `5.18`
- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц

View File

@ -0,0 +1,50 @@
## MODIFIED Requirements
### Requirement: Файлы организации и карточка файла
`GET /api/v1/legal-entities/{id}/files` (`DOCUMENT_VIEW`, policy; `include_superseded=false` по умолчанию) SHALL
возвращать `Page` файлов ЮЛ по убыванию `uploaded_at`: поля `stored_file` (в том числе `consolidation_id: uuid | null`,
v5.18 — консолидация, в контексте которой файл загружен), текущее задание (`status`, `error_code`,
`error_message`, `mapping_error` (`metrics.mapping_error`: причина `ERROR` файла после неудачного маппинга, когда у задания
`error_code` пуст), `metrics.warnings`, `metrics.mapping_warnings`, `finished_at`), документы файла (`id`, `document_type`,
`osv_account_code`, `period_year`, `period_quarter`, `is_cumulative`, `status`, `period_check`, `counterparty_check`,
`comments_count`). `GET /api/v1/files/{file_id}` SHALL отдавать ту же карточку; чужой или несуществующий файл — 404.
#### Scenario: Список после обработки
- **WHEN** у ЮЛ два файла: `PROCESSED` с документом и `ERROR`
- **THEN** оба в списке; у первого `documents` непустой, у второго `job.error_code` заполнен
#### Scenario: Счётчик комментариев
- **WHEN** к документу файла добавлены два комментария
- **THEN** в карточке файла у документа `comments_count = 2`
#### Scenario: Консолидация в карточке
- **WHEN** один файл ЮЛ загружен в консолидацию `C`, другой — без контекста
- **THEN** в списке ЮЛ и в `GET /files/{id}` у первого `consolidation_id = C`, у второго `consolidation_id = null`
## ADDED Requirements
### Requirement: Рабочий стол консолидации для ЮЛ
`GET /api/v1/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` (`DOCUMENT_VIEW`; `limit`,
`offset`, `include_superseded=false` по умолчанию; §9.5 п. 11 v5.18) SHALL отвечать 404 `NOT_FOUND` на консолидацию вне
видимости или несуществующую («консолидация не найдена») и на ЮЛ вне видимости, иначе возвращать `Page[FileOut]` —
объединение файлов ЮЛ, у которых `stored_file.consolidation_id = consolidation_id`, и файлов ЮЛ, у которых есть документ
с записью `consolidation_document` к любой версии этой консолидации; порядок и карточка — как у списка файлов ЮЛ;
`SUPERSEDED` — только при `include_superseded=true` (в обоих множествах). Принадлежность ЮЛ к ГК консолидации не
проверяется: чужому ЮЛ соответствует пустой список.
#### Scenario: Стол показывает только файлы консоли
- **WHEN** у ЮЛ три файла: загружен в консолидацию 2025 Q1, загружен в консолидацию 2025 Q2, без контекста
- **THEN** стол 2025 Q1 содержит только первый; стол 2025 Q2 — только второй; `GET /legal-entities/{id}/files` — все три
#### Scenario: Подхваченный файл виден через привязку
- **WHEN** файл с `CONFIRMED` ОСВ за 2025 Q1 загружен без контекста и его документ привязан к версии консолидации 2025 Q2
(подхват при создании версии)
- **THEN** файл есть в столе 2025 Q2, хотя `consolidation_id IS NULL`
#### Scenario: Заменённый файл скрыт по умолчанию
- **WHEN** файл консолидации заменён (`SUPERSEDED`) новым файлом той же консолидации
- **THEN** по умолчанию в столе только новый; с `include_superseded=true` — оба
#### Scenario: Чужая консолидация
- **WHEN** `ka_rf` запрашивает стол консолидации ГК другого филиала и несуществующий id
- **THEN** оба ответа 404 с одинаковым телом

View File

@ -0,0 +1,134 @@
## MODIFIED Requirements
### Requirement: Загрузка файлов организации
`POST /api/v1/legal-entities/{legal_entity_id}/files` (multipart, одно или несколько полей `files`; право
`DOCUMENT_EDIT`; ЮЛ в области видимости пользователя, иначе 404) SHALL для каждого файла в отдельной транзакции:
определить тип по расширению и сверить с сигнатурой содержимого (`xls` — BIFF8, `xlsx`/`xlsb` — zip, `pdf`, `png`,
`jpg`); отвергнуть файл больше `UPLOAD_MAX_BYTES` (50 МиБ) или неподдерживаемого типа; вычислить sha256; создать
`s3_object` (`bucket_role = uploads`, ключ `uploads/{file_id}/{job_id}/{безопасное имя}`, `sha256`, `size_bytes`,
`content_type`, `created_by = user:{id}`), положить объект в S3, создать `stored_file` (`status = UPLOADED`,
`version_no = 1`, `uploaded_by`, `uploaded_at`, `content_hash`, `original_name`, `consolidation_id = NULL`) и
`recognition_job` (`status = queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg → `image`;
`max_attempts` 3 / 1), установить `stored_file.current_recognition_job_id`, записать
`audit_event(FILE_UPLOAD, PRIMARY_DOCUMENTS)` с `after_value.consolidation_id = null`. Ответ SHALL
перечислять принятые файлы (`accepted[]` с `file_id`, `job_id`, `name`) и отклонённые (`rejected[]` с `name`, `code`,
`message`); код ответа 201, если принят хотя бы один, иначе 422. Тип документа и период при загрузке MUST NOT
запрашиваться (макет `40`). Без доступного S3 — 503 `STORAGE_UNAVAILABLE`.
Query-параметр `consolidation_version_id` MUST NOT приниматься (удалён в v5.18): загрузка этим маршрутом — без
контекста консолидации и не меняет статусы версий; загрузка в консолидацию — требование «Загрузка файлов в
консолидацию».
#### Scenario: Реальная ОСВ принята и ушла в outbox
- **WHEN** `ka_rf` загружает `rb/2024-FY/ОСВ_66 за 2024.xls` для ЮЛ своего филиала
- **THEN** 201, `accepted[0].file_id` существует; `stored_file.status = UPLOADED`, `file_type = XLS`, `content_hash` =
sha256 файла, `consolidation_id IS NULL`; `recognition_job.status = queued`, `pipeline = tabular`; объект в S3 по ключу
`uploads/{file_id}/{job_id}/ОСВ_66 за 2024.xls`; публикатор outbox публикует запрос с этим ключом
#### Scenario: Смесь принятых и отклонённых
- **WHEN** в одном запросе xlsx, файл `.docx` и файл 51 МиБ
- **THEN** 201, `accepted` — один, `rejected` — два с кодами `UNSUPPORTED_FILE` и `FILE_TOO_LARGE`
#### Scenario: Расширение не совпадает с содержимым
- **WHEN** загружается файл `osv.xlsx`, байты которого — BIFF8
- **THEN** файл отклонён с кодом `UNSUPPORTED_FILE`
#### Scenario: Чужое ЮЛ
- **WHEN** `ka_rf` загружает файл для ЮЛ другого филиала
- **THEN** 404, объектов и записей нет
#### Scenario: Загрузка в контексте версии
- **WHEN** у ГК ЮЛ есть версия `INITIATED` с этим ЮЛ в периметре, файл загружен маршрутом ЮЛ с query
`consolidation_version_id` (как делал клиент до v5.18)
- **THEN** 201, параметр игнорируется; `stored_file.consolidation_id IS NULL`; версия остаётся `INITIATED`
#### Scenario: ЮЛ вне периметра
- **WHEN** ЮЛ не входит в периметр ни одной версии, файл загружен маршрутом ЮЛ
- **THEN** 201 — маршрут ЮЛ периметр не проверяет; проверка периметра — только у загрузки в консолидацию
### Requirement: Автозамена прежних версий
После успешного маппинга файла ядро SHALL найти другие файлы того же ЮЛ со статусом не `SUPERSEDED`, у которых есть
документ с теми же `document_type`, `osv_account_code`, `period_year`, `period_quarter`, и перевести их в
`SUPERSEDED` (`superseded_by_file_id` = новый файл, `updated_at`); новый файл получает `version_no` = максимум среди
заменённых + 1. Если у нового файла `consolidation_id IS NULL`, он SHALL получить `consolidation_id` первого по `id`
заменённого файла, у которого он задан (v5.18); файл, загруженный в консолидацию, сохраняет свой `consolidation_id`.
Документы и строки заменённых файлов SHALL сохраняться (история), но по умолчанию не показываться.
Привязки `consolidation_document` документов заменённых файлов к версиям «в работе» SHALL удаляться в той же
транзакции (версии блокируются `FOR UPDATE` по возрастанию `id` после старых файлов); привязки к версиям в других
статусах не трогаются; статус версии не меняется. Аудит `FILE_REPLACE` (`object_id` заменённого, `after_value` с
новым файлом и `unbound_version_ids`, в `description` — перечень заменённых документов и признак `confirmed`, если
заменён документ `CONFIRMED`). Решение владельца 05.10.2026: замена происходит и для `CONFIRMED`.
Замена остаётся на уровне файла (OQ-115), но если у заменённого файла есть документ, ключа которого
(`document_type`, `osv_account_code`, `period_year`, `period_quarter`) нет среди документов нового файла (в т. ч. документ
без года или квартала), ядро SHALL добавить в `metrics.mapping_warnings` задания нового файла (карточка файла,
`JobBrief.mapping_warnings`) по одному предупреждению на заменённый файл (OQ-154): имя и версия заменённого файла,
выпавшие документы (тип, счёт, период, статус — в т. ч. `CONFIRMED`) и версии консолидации «в работе», с которых сняты
привязки этих документов. Поведение замены и статусы документов нового файла от предупреждения не меняются.
#### Scenario: Новая ОСВ 60 за 1 кв. 2025
- **WHEN** у ЮЛ есть `PROCESSED` файл с документом OSV 60 / 2025 / Q1 (`CONFIRMED`), загружен и смапплен новый файл с таким же документом
- **THEN** прежний файл — `SUPERSEDED`, `superseded_by_file_id` = новый, у нового `version_no = 2`; `audit_event(FILE_REPLACE)`
с `description`, содержащим `confirmed=true`; список файлов ЮЛ по умолчанию показывает только новый
#### Scenario: Заменённый документ был в версии
- **WHEN** заменённый `CONFIRMED` документ ОСВ 60 за 2024 Q2 был привязан к версии 2024 Q3 в `DATA_CORRECTION`
- **THEN** привязка удалена; в чек-листе ячейка ОСВ 60 показывает `missing_quarters = ["Q2"]` (или `NEEDS_CHECK`
по новому документу), пока новый документ не подтверждён; статус версии прежний
#### Scenario: Отдельный ОФР заменил книгу «ББ + ОФР»
- **WHEN** у ЮЛ есть файл с `CONFIRMED` ББ и ОФР за Q3 2025, ББ привязан к версии 2025 Q3 в `DATA_CORRECTION`; смаплен
новый файл только с ОФР за Q3 2025
- **THEN** прежний файл — `SUPERSEDED`, привязка ББ снята; в `metrics.mapping_warnings` нового задания одно предупреждение:
имя прежнего файла, `BALANCE 2025 Q3 (CONFIRMED)` и версия «ГК» 2025 Q3, версия 1; ОФР в предупреждении нет
#### Scenario: Новый файл без контекста наследует консолидацию
- **WHEN** прежний файл загружен в консолидацию 2025 Q1 (`consolidation_id = C`), новый файл с тем же документом загружен
маршрутом ЮЛ (`consolidation_id IS NULL`) и смаплен
- **THEN** у нового файла `consolidation_id = C`
#### Scenario: Новый файл с контекстом сохраняет свой
- **WHEN** прежний файл в консолидации 2025 Q1 (`C1`), новый с тем же документом загружен в консолидацию 2025 Q2 (`C2`)
- **THEN** у нового файла `consolidation_id = C2`
## ADDED Requirements
### Requirement: Загрузка файлов в консолидацию
`POST /api/v1/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` (multipart `files`, право
`DOCUMENT_EDIT`; §9.5 п. 8 v5.18) SHALL до приёма файлов проверить: консолидация видима (ГК в области пользователя;
чужая и несуществующая — одинаковый 404 `NOT_FOUND` «консолидация не найдена»), ЮЛ видимо (404), у консолидации есть
хотя бы одна версия со статусом «в работе» (`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`; иначе 409
`CONSOLIDATION_NOT_EDITABLE`), ЮЛ — член периметра хотя бы одной такой версии (иначе 422 `ENTITY_NOT_IN_PERIMETER`
объектом `detail`). Приём файлов, дедуп, ответ и коды `rejected[]` — как у маршрута ЮЛ; принятый файл SHALL получить
`stored_file.consolidation_id = consolidation_id`, `FILE_UPLOAD.after_value.consolidation_id` = его строка. Если принят
хотя бы один файл, ядро SHALL в отдельной транзакции после приёма заблокировать `FOR UPDATE ORDER BY id` версии этой
консолидации со статусом `INITIATED` и ЮЛ в периметре и перевести каждую в `DOCUMENTS_PROCESSING` (`changed_by` =
загрузивший, запись `consolidation_status_history` и `CONSOLIDATION_STATUS_CHANGE`); сбой перехода логируется и не
меняет ответ. Блокировка версии (`consolidation_lock`) для загрузки MUST NOT требоваться.
#### Scenario: Загрузка в консолидацию переводит версию
- **WHEN** у консолидации 2024 Q3 версия 1 в `INITIATED`, ЮЛ в периметре, загружен один принятый файл
- **THEN** 201; `stored_file.consolidation_id` = консолидация; версия в `DOCUMENTS_PROCESSING`, история с `changed_by`
= пользователь; `FILE_UPLOAD.after_value.consolidation_id` = id консолидации
#### Scenario: Версия в другом рабочем статусе
- **WHEN** единственная версия консолидации в `DATA_CORRECTION`, загружен принятый файл
- **THEN** 201; статус версии не меняется; файл получил `consolidation_id`
#### Scenario: Нет версии в работе
- **WHEN** единственная версия консолидации `CANCELLED`
- **THEN** 409 `CONSOLIDATION_NOT_EDITABLE`, файлы не приняты, объектов в S3 нет
#### Scenario: ЮЛ вне периметра
- **WHEN** ЮЛ той же ГК не входит в периметр ни одной версии «в работе»
- **THEN** 422 `ENTITY_NOT_IN_PERIMETER`, файлы не приняты
#### Scenario: Чужая и несуществующая консолидация
- **WHEN** `ka_rf` загружает файл в консолидацию ГК другого филиала и в несуществующий id
- **THEN** оба ответа 404 с одинаковым телом (`code = NOT_FOUND`), записей нет
#### Scenario: Все файлы отклонены
- **WHEN** в консолидацию с версией `INITIATED` загружен только `.docx`
- **THEN** 422 телом `UploadResult`, версия остаётся `INITIATED`
#### Scenario: Загрузка не требует блокировки версии
- **WHEN** версию держит другой пользователь (`consolidation_lock`), ЮЛ в периметре
- **THEN** 201; версия `INITIATED` → `DOCUMENTS_PROCESSING`