diff --git a/docs/superpowers/plans/2026-10-09-console-files-core.md b/docs/superpowers/plans/2026-10-09-console-files-core.md new file mode 100644 index 0000000..df2a89c --- /dev/null +++ b/docs/superpowers/plans/2026-10-09-console-files-core.md @@ -0,0 +1,1110 @@ +# Файлы в контексте консолидации — план для arm-core + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ядро хранит консолидацию загрузки файла (`stored_file.consolidation_id`, DBML v5.18), принимает файлы в +консолидацию и отдаёт её рабочий стол по маршрутам `/consolidations/{cid}/legal-entities/{le}/files`. + +**Architecture:** Колонка + миграция с backfill; новые функции репозиториев (консолидация по id, версии консолидации +под `FOR UPDATE`, стол `OR EXISTS`); сервис `PrimaryFilesService` получает загрузку в консолидацию и стол вместо +контекста версии; два новых маршрута в `endpoints/files/consolidation_files.py`, общий приёмный цикл в `upload.py`; +наследование `consolidation_id` при автозамене. Всё по TDD: интеграционные тесты через `auth_client`/`seeded`. + +**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2 async, Alembic, PostgreSQL 16 (compose `make up`), pytest-asyncio, +ruff + mypy. + +**Spec:** `openspec/changes/console-files/` (`proposal.md`, `design.md` D-1…D-10, `specs/primary-files`, +`specs/documents-api`, `specs/data-model`); сквозной дизайн — `../docs/openspec/changes/console-files/design.md`; +правила — `../docs/context/05-domain-rules.md` §9.1, §9.5 п. 1, 3, 8, 11. + +## Global Constraints + +- Worktree `/Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core`, ветка `feat/96-console-files`; все + git-команды — `git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core …`; `make` — из корня + worktree. DBML v5.18 читается из соседнего worktree docs: `../docs/model/data-model-v5.18.dbml` (от корня arm-core). +- Язык кода-комментариев, docstring, коммитов — русский; идентификаторы — английские, как в DBML. +- Сессия в маршрутах — только `SessionDep` через сервисные зависимости; сервисы не коммитят. Отдельная транзакция — + `self._transaction` / `session_transaction`, как в текущем `after_upload_to_version`. +- Коды ошибок: 404 `NOT_FOUND` с текстом «консолидация не найдена» (константа `CONSOLIDATION_NOT_FOUND`), 409 + `CONSOLIDATION_NOT_EDITABLE`, 422 `ENTITY_NOT_IN_PERIMETER` (существующий текст). Коды `VERSION_NOT_EDITABLE`, + `VERSION_NOT_FOUND` у загрузки больше не возникают. +- Маршруты: `POST /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` (`DOCUMENT_EDIT`), + `GET /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` (`DOCUMENT_VIEW`); у + `POST /legal-entities/{id}/files` query `consolidation_version_id` удаляется. +- Колонка `consolidation_id` — последняя в `StoredFile` (после `updated_at`); FK + `fk_stored_file_consolidation_id_consolidation`; индекс `ix_stored_file_legal_entity_id_consolidation_id`. +- Порядок блокировок Среза 4 не нарушать: версии — `FOR UPDATE ORDER BY id`; статус версии меняет только + `consolidation_status.transition`. +- `audit_event` — только через `AuditService.record` / `RequestAudit.record`. +- Коммит заканчивается строкой `Co-Authored-By: Claude Fable 5.1 `. +- Перед PR — `make check-full` (нужны Docker и сеть), вывод — в описание PR. + +## Review Focus + +1. Загрузка в консолидацию, у которой две версии «в работе», ЮЛ в периметре только одной: 422 быть не должно, переход + — только у версии с ЮЛ. Тест `test_upload_moves_only_versions_with_entity` в Task 3. +2. Стол консолидации не должен показывать файл другой консолидации того же ЮЛ, даже если его документ привязан к версии + **другого** периода. Тест `test_desk_excludes_other_consolidation` в Task 2. +3. `include_superseded=true` должен возвращать `SUPERSEDED` файлы и из `DESK`, и из `BOUND`. Тест + `test_desk_superseded_hidden_by_default` в Task 2. +4. Backfill 0006 на файле с привязками к версиям двух консолидаций разных лет (2024 Q4 и 2025 Q1) должен выбрать 2024 Q4 + (год важнее квартала). Тест `test_upgrade_0006_backfills_consolidation_id` в Task 1. +5. Автозамена, где новый файл без контекста заменяет два файла: один без `consolidation_id`, другой с ним, причём + «пустой» имеет меньший `id` — новый должен унаследовать непустое значение. Тест + `test_supersede_inherits_consolidation_from_first_with_value` в Task 4. + +--- + +### Task 1: DBML v5.18 — ORM, миграция 0006 с backfill, сверка + +**Files:** +- Modify: `api/src/models/__init__.py` (`DBML_VERSION`), `api/src/models/files.py` (`StoredFile`), + `api/scripts/dbml_parity.py` (`DBML_FILE_NAME`), `Makefile` (`DBML`), `api/tests/test_check_dbml_script.py` + (строки 145, 151), `api/tests/test_migrations.py`, `CLAUDE.md` (строка «ORM 1:1 с …»), `README.md` (строки с + `v5.17.dbml` и списком миграций) +- Create: `api/alembic/versions/0006_dbml_v5_18.py` +- Test: `api/tests/test_migrations.py`, `api/tests/test_models_part_04_06.py`, `api/tests/test_check_dbml_script.py` + +**Interfaces:** +- Produces: `StoredFile.consolidation_id: Mapped[uuid.UUID | None]`; ревизия `0006_dbml_v5_18`; `DBML_VERSION = "5.18"`. + +- [ ] **Step 1: Переключить версию и путь DBML (тест сверки должен покраснеть)** + +В `api/src/models/__init__.py`: `DBML_VERSION = "5.18"`. В `api/scripts/dbml_parity.py`: +`DBML_FILE_NAME = "data-model-v5.18.dbml"`. В `Makefile`: `DBML ?= ../../docs/model/data-model-v5.18.dbml`. +В `api/tests/test_check_dbml_script.py` строки 145 и 151: `.replace("v5.17", "v5.18"` → `.replace("v5.18", "v5.19"`. + +Run: `make dbml-check` +Expected: FAIL — `stored_file.consolidation_id: отсутствует в ORM` (и индекс `ix_stored_file_legal_entity_id_consolidation_id`), код 1. +Если вместо этого «файл DBML не найден» — docs worktree ещё без v5.18: дождаться Task 1 плана docs (ledger контроллера docs), не копировать файл вручную. + +- [ ] **Step 2: Колонка и индекс в ORM** + +В `api/src/models/files.py`, класс `StoredFile`: в `__table_args__` добавить после индекса `current_recognition_job_id`: + +```python + # v5.18: рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ») + Index("ix_stored_file_legal_entity_id_consolidation_id", "legal_entity_id", "consolidation_id"), +``` + +и последней колонкой класса (после `updated_at`): + +```python + # v5.18: консолидация (период ГК), в контексте которой файл загружен; NULL — без контекста (§9.5 п. 8). Последняя + # колонка: миграция 0006 добавляет её ALTER TABLE, а check_dbml сверяет порядок + consolidation_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("consolidation.id")) +``` + +Run: `make dbml-check && cd api && .venv/bin/pytest tests/test_models_part_04_06.py tests/test_check_dbml_script.py -q` +Expected: `dbml-check` — пустой вывод, код 0; pytest PASS. + +- [ ] **Step 3: Тест миграции 0006 (красный)** + +В `api/tests/test_migrations.py`: `HEAD = "0006_dbml_v5_18"`, добавить `V5_17 = "0005_dbml_v5_17"` рядом с `V5_16`; +docstring модуля дополнить «0006 (v5.18)». Добавить тест (рядом с `test_upgrade_0005_adds_nullable_kpp_columns`): + +```python +async def _consolidation_id_column(conn: AsyncConnection) -> list[Any]: + return ( + await conn.execute( + text( + "SELECT is_nullable, data_type FROM information_schema.columns " + "WHERE table_schema = 'dkb' AND table_name = 'stored_file' AND column_name = 'consolidation_id'" + ) + ) + ).all() + + +@pytest.mark.integration +async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> None: + """0006: `stored_file.consolidation_id uuid` nullable, последняя колонка, индекс `(legal_entity_id, consolidation_id)`; + backfill по `consolidation_document`: файл A привязан к версиям 2025 Q1 и 2024 Q4 → 2024 Q4 (самый ранний период, + год важнее квартала); файл B без привязок → NULL; `downgrade 0005` убирает индекс и колонку, повторный upgrade возвращает.""" + engine = create_async_engine(scratch_db_url) + now = datetime.datetime.now(datetime.UTC) + ids = {name: uuid.uuid4() for name in ("office", "group", "entity", "user", "s3a", "s3b", "file_a", "file_b", + "doc_a", "c_2025q1", "c_2024q4", "v_2025q1", "v_2024q4")} + try: + await alembic(scratch_db_url, "upgrade", V5_17) + async with engine.begin() as conn: + assert await _consolidation_id_column(conn) == [] + await conn.execute(text("INSERT INTO dkb.regional_office (id, name) VALUES (:office, 'ГО')"), ids) + await conn.execute( + text("INSERT INTO dkb.company_group (id, code, name, regional_office_id, valid_from) " + "VALUES (:group, 'G-1', 'ГК', :office, :now)"), ids | {"now": now}) + await conn.execute( + text("INSERT INTO dkb.legal_entity (id, inn, name, company_group_id, valid_from) " + "VALUES (:entity, '7701000001', 'ООО', :group, :now)"), ids | {"now": now}) + await conn.execute( + text("INSERT INTO dkb.app_user (id, email, full_name, regional_office_id) " + "VALUES (:user, 'm@x.ru', 'Тест', :office)"), ids) + for s3, file in (("s3a", "file_a"), ("s3b", "file_b")): + await conn.execute( + text("INSERT INTO dkb.s3_object (id, bucket_role, bucket, key, created_at) " + f"VALUES (:{s3}, 'uploads', 'b', :{s3}, :now)"), ids | {"now": now}) + await conn.execute( + text("INSERT INTO dkb.stored_file (id, legal_entity_id, file_type, s3_object_id, status, version_no) " + f"VALUES (:{file}, :entity, 'XLS', :{s3}, 'PROCESSED', 1)"), ids) + await conn.execute( + text("INSERT INTO dkb.document (id, stored_file_id, legal_entity_id, document_type, osv_account_code, " + "period_year, period_quarter, status) VALUES (:doc_a, :file_a, :entity, 'OSV', '60', 2024, 'Q4', 'CONFIRMED')"), + ids) + for c, v, year, quarter, rd in (("c_2025q1", "v_2025q1", 2025, "Q1", "2025-04-01"), + ("c_2024q4", "v_2024q4", 2024, "Q4", "2025-01-01")): + await conn.execute( + text("INSERT INTO dkb.consolidation (id, company_group_id, year, quarter, reporting_date) " + f"VALUES (:{c}, :group, {year}, '{quarter}', '{rd}')"), ids) + await conn.execute( + text("INSERT INTO dkb.consolidation_version (id, consolidation_id, version_no, status, scenario, " + "approach, goodwill_enabled, initiator_id, initiator_office_id, is_in_work) " + f"VALUES (:{v}, :{c}, 1, 'INITIATED', 'CREATE', 'BASIC', false, :user, :office, true)"), ids) + await conn.execute( + text("INSERT INTO dkb.consolidation_document (id, consolidation_version_id, document_id) " + f"VALUES (gen_random_uuid(), :{v}, :doc_a)"), ids) + + await alembic(scratch_db_url, "upgrade", "head") + async with engine.begin() as conn: + assert await _consolidation_id_column(conn) == [("YES", "uuid")] + last = await _scalars(conn, "SELECT column_name FROM information_schema.columns WHERE table_schema = 'dkb' " + "AND table_name = 'stored_file' ORDER BY ordinal_position DESC LIMIT 1") + assert last == ["consolidation_id"] + rows = (await conn.execute(text("SELECT id, consolidation_id FROM dkb.stored_file ORDER BY id"))).all() + assert dict(rows) == {ids["file_a"]: ids["c_2024q4"], ids["file_b"]: None} + assert await _scalars(conn, "SELECT indexname FROM pg_indexes WHERE schemaname = 'dkb' " + "AND indexname = 'ix_stored_file_legal_entity_id_consolidation_id'") == [ + "ix_stored_file_legal_entity_id_consolidation_id"] + await engine.dispose() + + await alembic(scratch_db_url, "downgrade", V5_17) + async with engine.connect() as conn: + assert await _consolidation_id_column(conn) == [] + assert await _scalars(conn, "SELECT indexname FROM pg_indexes WHERE schemaname = 'dkb' " + "AND indexname = 'ix_stored_file_legal_entity_id_consolidation_id'") == [] + await engine.dispose() + await alembic(scratch_db_url, "upgrade", "head") + async with engine.connect() as conn: + assert await _consolidation_id_column(conn) == [("YES", "uuid")] + finally: + await engine.dispose() +``` + +Если у таблиц `company_group`/`legal_entity`/`consolidation_version` есть другие NOT NULL колонки без default +(проверить `\d` или `0001_dbml_v5_13.py`), добавить их в INSERT с любыми допустимыми значениями — это тестовые данные. +Если `gen_random_uuid()` недоступна, подставить `uuid.uuid4()` из Python. + +Run: `cd api && .venv/bin/pytest tests/test_migrations.py -q -k "0006 or full_cycle" 2>&1 | tail -15` +Expected: FAIL — `alembic upgrade head` не знает ревизии / нет колонки (`_consolidation_id_column == []`). + +- [ ] **Step 4: Миграция 0006** + +Создать `api/alembic/versions/0006_dbml_v5_18.py`: + +```python +"""DBML v5.18: stored_file.consolidation_id — консолидация (период ГК), в контексте которой загружен файл +(RSHB/arm-ka-docs#90, RSHB/arm-core#96; design D-1, D-2). + +Колонка nullable, FK → consolidation, последняя в таблице; индекс (legal_entity_id, consolidation_id) — рабочий стол +консоли для ЮЛ. Backfill: файлу с документом, привязанным к версии (consolidation_document), — консолидация этой +версии; при привязках к версиям разных консолидаций — самый ранний период (год, затем квартал; enum period_quarter +упорядочен Q1 < Q2 < Q3 < Q4 по объявлению); файлы без привязок остаются NULL. + +Revision ID: 0006_dbml_v5_18 +Revises: 0005_dbml_v5_17 +Create Date: 2026-10-09 +""" + +from collections.abc import Sequence + +from alembic import op +from src.models.base import SCHEMA + +revision: str = "0006_dbml_v5_18" +down_revision: str | Sequence[str] | None = "0005_dbml_v5_17" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +INDEX = "ix_stored_file_legal_entity_id_consolidation_id" +FK = "fk_stored_file_consolidation_id_consolidation" + + +def upgrade() -> None: + op.execute( + f"ALTER TABLE {SCHEMA}.stored_file ADD COLUMN consolidation_id uuid " + f"CONSTRAINT {FK} REFERENCES {SCHEMA}.consolidation (id)" + ) + op.execute(f"CREATE INDEX {INDEX} ON {SCHEMA}.stored_file (legal_entity_id, consolidation_id)") + op.execute( + f""" + UPDATE {SCHEMA}.stored_file AS sf SET consolidation_id = pick.consolidation_id + FROM ( + SELECT DISTINCT ON (d.stored_file_id) d.stored_file_id, c.id AS consolidation_id + FROM {SCHEMA}.document d + JOIN {SCHEMA}.consolidation_document cd ON cd.document_id = d.id + JOIN {SCHEMA}.consolidation_version v ON v.id = cd.consolidation_version_id + JOIN {SCHEMA}.consolidation c ON c.id = v.consolidation_id + ORDER BY d.stored_file_id, c.year, c.quarter, c.id + ) AS pick + WHERE sf.id = pick.stored_file_id + """ + ) + + +def downgrade() -> None: + op.execute(f"DROP INDEX {SCHEMA}.{INDEX}") + op.execute(f"ALTER TABLE {SCHEMA}.stored_file DROP COLUMN consolidation_id") +``` + +Run: `cd api && .venv/bin/pytest tests/test_migrations.py -q 2>&1 | tail -15` +Expected: PASS все (в т. ч. `test_full_cycle_upgrade_downgrade_upgrade`, `test_autogenerate_finds_nothing_after_upgrade`). +Если автогенерация находит расхождение имени FK — привести имя в миграции к тому, что печатает autogenerate. + +- [ ] **Step 5: Документы репозитория** + +`CLAUDE.md` строка «ORM 1:1 с `../docs/model/data-model-v5.17.dbml` …» → `v5.18.dbml`, список миграций дополнить +`+ 0006_dbml_v5_18`. `README.md`: все `data-model-v5.17.dbml` → `v5.18.dbml`; в строке `alembic/ миграции (…)` добавить +`, 0006_dbml_v5_18.py — v5.18`; строку «Срез 4 … схема по DBML v5.17» не трогать (история). + +Run: `grep -rn "v5\.17\.dbml\|v5_17\.dbml" CLAUDE.md README.md Makefile api/scripts api/src | grep -v "0005"` +Expected: пусто. + +- [ ] **Step 6: Коммит** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core add api/src/models api/scripts/dbml_parity.py Makefile api/alembic/versions/0006_dbml_v5_18.py api/tests/test_migrations.py api/tests/test_check_dbml_script.py CLAUDE.md README.md +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core commit -q -m "$(cat <<'EOF' +model: DBML v5.18 — stored_file.consolidation_id, миграция 0006 с backfill (#96) + +Co-Authored-By: Claude Fable 5.1 +EOF +)" +``` + +--- + +### Task 2: Рабочий стол консолидации и `consolidation_id` в карточке + +**Files:** +- Modify: `api/src/schemas/files.py` (`FileOut`), `api/src/services/documents.py` (`file_out`), + `api/src/repositories/files.py` (`list_desk_files`), `api/src/repositories/consolidations.py` + (`get_consolidation`), `api/src/services/consolidation_guard.py` (`CONSOLIDATION_NOT_FOUND`), + `api/src/services/primary_files.py` (`page_desk`), `api/src/api/endpoints/files/__init__.py` +- Create: `api/src/api/endpoints/files/consolidation_files.py` (пока только GET), `api/tests/test_api_consolidation_files.py` +- Test: `api/tests/test_api_consolidation_files.py`, `api/tests/test_api_files_read.py` + +**Interfaces:** +- Consumes: `StoredFile.consolidation_id` (Task 1). +- Produces: `FileOut.consolidation_id: uuid.UUID | None`; `files_repo.list_desk_files(session, scope, entity_id, + consolidation_id, *, include_superseded, limit, offset) -> tuple[list[StoredFile], int]`; + `consolidations_repo.get_consolidation(session, scope, consolidation_id) -> Consolidation | None`; + `PrimaryFilesService.page_desk(consolidation_id, entity_id, *, include_superseded, page) -> tuple[list[FileOut], int]`; + константа `consolidation_guard.CONSOLIDATION_NOT_FOUND = "консолидация не найдена"`; модуль + `endpoints/files/consolidation_files.py` с `router`, `VIEW`, `EDIT`, `NOT_FOUND` (Task 3 добавит туда POST). + +- [ ] **Step 1: Тесты (красные)** + +Создать `api/tests/test_api_consolidation_files.py`: + +```python +"""Рабочий стол консолидации для ЮЛ и `consolidation_id` в карточке файла (spec documents-api «Рабочий стол консолидации +для ЮЛ», «Файлы организации и карточка файла»; design D-6, D-7). Поверх фикстуры `app`: данные — фабрики в транзакции теста.""" + +import uuid + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.access import AppUser +from src.models.consolidation import ConsolidationDocument +from src.models.directories import LegalEntity, RegionalOffice +from src.models.enums import DocumentStatus, FileStatus, PeriodQuarter, RecognitionStatus +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_consolidation, make_document, make_legal_entity, make_stored_file + +HTTP_OK = 200 +HTTP_NOT_FOUND = 404 +HTTP_FORBIDDEN = 403 +KA_RF = "ka_rf@armka.local" # Ростовский филиал, DOCUMENT_VIEW + DOCUMENT_EDIT +CLIENT_MANAGER = "client_manager@armka.local" # Краснодарский филиал +CURATOR_GO = "curator_go@armka.local" # головной офис, права DOCUMENT_VIEW нет +ROSTOV = "Ростовский региональный филиал" +KRASNODAR = "Краснодарский региональный филиал" + +pytestmark = pytest.mark.integration + + +async def _group(session: AsyncSession, office_name: str = ROSTOV, name: str = "ГК Стол"): + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == office_name))).scalar_one() + group = await make_company_group(session, name=name, office=office) + entity = await make_legal_entity(session, name=f"ООО {name}", group=group) + initiator = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + return group, entity, initiator + + +async def _consolidation(session: AsyncSession, group, entity, initiator, year: int, quarter: PeriodQuarter): + consolidation, version = await make_consolidation( + session, group=group, year=year, quarter=quarter, members=[entity], initiator=initiator + ) + return consolidation, version + + +async def _file(session: AsyncSession, entity: LegalEntity, name: str, *, consolidation_id=None, status=FileStatus.PROCESSED): + file, job = await make_stored_file( + session, entity=entity, status=status, job_status=RecognitionStatus.done, original_name=name + ) + file.consolidation_id = consolidation_id + await session.flush() + return file, job + + +def _desk_url(consolidation_id: uuid.UUID, entity_id: uuid.UUID, **query: str) -> str: + base = f"/consolidations/{consolidation_id}/legal-entities/{entity_id}/files" + return base + ("?" + "&".join(f"{k}={v}" for k, v in query.items()) if query else "") + + +async def test_desk_shows_only_files_of_consolidation(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Сценарий «Стол показывает только файлы консоли»: три файла ЮЛ — 2025 Q1, 2025 Q2, без контекста.""" + group, entity, initiator = await _group(seeded) + c1, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) + c2, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q2) + f1, _ = await _file(seeded, entity, "q1.xls", consolidation_id=c1.id) + f2, _ = await _file(seeded, entity, "q2.xls", consolidation_id=c2.id) + f0, _ = await _file(seeded, entity, "none.xls") + client = await auth_client(KA_RF) + + desk1 = await client.get(_desk_url(c1.id, entity.id)) + desk2 = await client.get(_desk_url(c2.id, entity.id)) + all_files = await client.get(f"/legal-entities/{entity.id}/files") + + assert desk1.status_code == HTTP_OK, desk1.text + assert [item["id"] for item in desk1.json()["items"]] == [str(f1.id)] + assert desk1.json()["total"] == 1 + assert desk1.json()["items"][0]["consolidation_id"] == str(c1.id) + assert [item["id"] for item in desk2.json()["items"]] == [str(f2.id)] + assert {item["id"] for item in all_files.json()["items"]} == {str(f1.id), str(f2.id), str(f0.id)} + by_id = {item["id"]: item["consolidation_id"] for item in all_files.json()["items"]} + assert by_id[str(f0.id)] is None + + +async def test_desk_includes_file_bound_to_version(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Сценарий «Подхваченный файл виден через привязку»: файл без контекста, документ привязан к версии консоли.""" + group, entity, initiator = await _group(seeded) + c2, v2 = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q2) + file, job = await _file(seeded, entity, "q1-confirmed.xls") + document = await make_document( + seeded, file=file, job=job, account="60", year=2025, quarter=PeriodQuarter.Q1, status=DocumentStatus.CONFIRMED, + is_cumulative=False, + ) + seeded.add(ConsolidationDocument(consolidation_version_id=v2.id, document_id=document.id)) + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.get(_desk_url(c2.id, entity.id)) + + assert response.status_code == HTTP_OK, response.text + assert [item["id"] for item in response.json()["items"]] == [str(file.id)] + assert response.json()["items"][0]["consolidation_id"] is None + + +async def test_desk_excludes_other_consolidation(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Review Focus 2: документ привязан к версии ДРУГОЙ консолидации — файл в этом столе не показывается.""" + group, entity, initiator = await _group(seeded) + c1, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) + c2, v2 = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q2) + file, job = await _file(seeded, entity, "bound-elsewhere.xls", consolidation_id=c2.id) + document = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED) + seeded.add(ConsolidationDocument(consolidation_version_id=v2.id, document_id=document.id)) + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.get(_desk_url(c1.id, entity.id)) + + assert response.status_code == HTTP_OK, response.text + assert response.json()["items"] == [] + assert response.json()["total"] == 0 + + +async def test_desk_superseded_hidden_by_default(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Сценарий «Заменённый файл скрыт по умолчанию» для DESK и BOUND; `include_superseded=true` — оба.""" + group, entity, initiator = await _group(seeded) + c1, v1 = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) + new, _ = await _file(seeded, entity, "new.xls", consolidation_id=c1.id) + old_desk, _ = await _file(seeded, entity, "old-desk.xls", consolidation_id=c1.id, status=FileStatus.SUPERSEDED) + old_desk.superseded_by_file_id = new.id + old_bound, job = await _file(seeded, entity, "old-bound.xls", status=FileStatus.SUPERSEDED) + old_bound.superseded_by_file_id = new.id + document = await make_document(seeded, file=old_bound, job=job, status=DocumentStatus.CONFIRMED) + seeded.add(ConsolidationDocument(consolidation_version_id=v1.id, document_id=document.id)) + await seeded.flush() + client = await auth_client(KA_RF) + + default = await client.get(_desk_url(c1.id, entity.id)) + with_superseded = await client.get(_desk_url(c1.id, entity.id, include_superseded="true")) + + assert [item["id"] for item in default.json()["items"]] == [str(new.id)] + assert {item["id"] for item in with_superseded.json()["items"]} == {str(new.id), str(old_desk.id), str(old_bound.id)} + assert with_superseded.json()["total"] == 3 # noqa: PLR2004 + + +async def test_desk_foreign_and_missing_consolidation_are_same_404( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Сценарий «Чужая консолидация»: ГК другого филиала и несуществующий id — одинаковый 404; чужое ЮЛ — тоже 404.""" + group, entity, initiator = await _group(seeded, office_name=KRASNODAR, name="ГК Чужая") + c, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) + _, own_entity, _ = await _group(seeded, name="ГК Своя") + client = await auth_client(KA_RF) + + foreign = await client.get(_desk_url(c.id, own_entity.id)) + missing = await client.get(_desk_url(uuid.uuid4(), own_entity.id)) + + assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND + assert foreign.json() == missing.json() + assert foreign.json()["detail"]["code"] == "NOT_FOUND" + + +async def test_desk_requires_document_view(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + group, entity, initiator = await _group(seeded) + c, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) + client = await auth_client(CURATOR_GO) + + response = await client.get(_desk_url(c.id, entity.id)) + + assert response.status_code == HTTP_FORBIDDEN + + +async def test_file_card_has_consolidation_id(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Сценарий «Консолидация в карточке»: `GET /files/{id}` отдаёт `consolidation_id`.""" + group, entity, initiator = await _group(seeded) + c, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) + file, _ = await _file(seeded, entity, "card.xls", consolidation_id=c.id) + client = await auth_client(KA_RF) + + response = await client.get(f"/files/{file.id}") + + assert response.status_code == HTTP_OK, response.text + assert response.json()["consolidation_id"] == str(c.id) +``` + +Если `make_document` не принимает `is_cumulative=False` — проверить сигнатуру в `tests/factories.py` (принимает). +Если у `make_legal_entity` нет параметра `name`/`group` — см. его сигнатуру (строка 115 `factories.py`) и поправить вызов. + +Run: `cd api && .venv/bin/pytest tests/test_api_consolidation_files.py -q 2>&1 | tail -12` +Expected: FAIL — 404 на маршруте стола (нет маршрута) и `KeyError: 'consolidation_id'` в карточке. + +- [ ] **Step 2: Схема и карточка** + +`api/src/schemas/files.py`, `FileOut`: после `superseded_by_file_id` добавить +`consolidation_id: uuid.UUID | None # v5.18: консолидация, в контексте которой загружен файл; None — без контекста`. +`api/src/services/documents.py`, `file_out`: добавить `consolidation_id=file.consolidation_id,`. + +Run: `cd api && .venv/bin/pytest tests/test_api_consolidation_files.py::test_file_card_has_consolidation_id tests/test_api_files_read.py -q` +Expected: PASS. + +- [ ] **Step 3: Репозитории** + +`api/src/repositories/consolidations.py` после `get_version`: + +```python +async def get_consolidation( + session: AsyncSession, scope: VisibilityScope, consolidation_id: uuid.UUID +) -> Consolidation | None: + """Консолидация по id, если её ГК видна пользователю; чужая и несуществующая — `None` (v5.18, design D-3).""" + stmt = _visible_consolidations(scope).where(Consolidation.id == consolidation_id) + return (await session.execute(stmt)).scalar_one_or_none() +``` + +`api/src/repositories/files.py` после `list_files` (импорты `ConsolidationDocument`, `ConsolidationVersion` из +`src.models.consolidation`, `exists` из `sqlalchemy`): + +```python +async def list_desk_files( # noqa: PLR0913 - фильтр и пагинация списка передаются явными именованными аргументами + session: AsyncSession, + scope: VisibilityScope, + entity_id: uuid.UUID, + consolidation_id: uuid.UUID, + *, + include_superseded: bool, + limit: int, + offset: int, +) -> tuple[list[StoredFile], int]: + """Рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ»; §9.5 п. 11; D-6): + файлы ЮЛ с `consolidation_id` этой консолидации ИЛИ с документом, привязанным к любой её версии. Порядок и + `include_superseded` — как в `list_files`; страница и общее число тем же фильтром.""" + bound = exists().where( + Document.stored_file_id == StoredFile.id, + ConsolidationDocument.document_id == Document.id, + ConsolidationVersion.id == ConsolidationDocument.consolidation_version_id, + ConsolidationVersion.consolidation_id == consolidation_id, + ) + + def filtered(stmt: Select[Any]) -> Select[Any]: + stmt = _visible_files(stmt, scope).where( + StoredFile.legal_entity_id == entity_id, + or_(StoredFile.consolidation_id == consolidation_id, bound), + ) + if not include_superseded: + stmt = stmt.where(StoredFile.status != FileStatus.SUPERSEDED) + return stmt + + total = (await session.execute(filtered(select(func.count(StoredFile.id))))).scalar_one() + page_stmt = ( + filtered(select(StoredFile)) + .order_by(StoredFile.uploaded_at.desc().nulls_last(), StoredFile.id) + .limit(limit) + .offset(offset) + ) + files = list((await session.execute(page_stmt)).scalars().all()) + return files, total +``` + +- [ ] **Step 4: Сервис и маршрут** + +`api/src/services/consolidation_guard.py`: добавить `CONSOLIDATION_NOT_FOUND = "консолидация не найдена" # одинаково для чужой и несуществующей`. + +`api/src/services/primary_files.py`: импорт `CONSOLIDATION_NOT_FOUND` из `consolidation_guard`; метод после `page_files`: + +```python + async def page_desk( + self, consolidation_id: uuid.UUID, entity_id: uuid.UUID, *, include_superseded: bool, page: PageParams + ) -> tuple[list[FileOut], int]: + """Рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ»; §9.5 п. 11; D-6): + консолидация вне видимости — 404 `CONSOLIDATION_NOT_FOUND`, ЮЛ вне видимости — 404; принадлежность ЮЛ к ГК + консолидации не проверяется (чужому ЮЛ — пустой стол). Карточки — как в `page_files`.""" + if await consolidations_repo.get_consolidation(self._session, self._scope, consolidation_id) is None: + raise NotFoundError(CONSOLIDATION_NOT_FOUND) + entity = await self.get_entity(entity_id) + files, total = await files_repo.list_desk_files( + self._session, + self._scope, + entity.id, + consolidation_id, + include_superseded=include_superseded, + limit=page.limit, + offset=page.offset, + ) + return await self._cards(files), total +``` + +Создать `api/src/api/endpoints/files/consolidation_files.py`: + +```python +"""Файлы ЮЛ в контексте консолидации (v5.18, RSHB/arm-ka-docs#90): `GET /consolidations/{consolidation_id}/legal-entities/ +{legal_entity_id}/files` — рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ»); +`POST …/files` — загрузка в консолидацию (spec primary-files «Загрузка файлов в консолидацию»). Права — как у маршрутов +ЮЛ (`DOCUMENT_VIEW` / `DOCUMENT_EDIT`, design R-1); консолидация или ЮЛ вне видимости — 404, как несуществующие.""" + +import uuid +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Query, status + +from src.api.dependencies.auth import require +from src.api.dependencies.files import PrimaryFilesServiceDep +from src.api.endpoints.files.read import FileListQuery +from src.schemas.common import ErrorDetail, Page, PageParams +from src.schemas.files import FileOut + +router = APIRouter() + +VIEW = [Depends(require("DOCUMENT_VIEW"))] +EDIT = [Depends(require("DOCUMENT_EDIT"))] +NOT_FOUND: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "консолидация или ЮЛ: нет или вне видимости"} +} +DESK_PATH = "/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files" + + +@router.get(DESK_PATH, response_model=Page[FileOut], dependencies=VIEW, responses=NOT_FOUND, + summary="Рабочий стол консолидации для ЮЛ") +async def list_desk_files( + consolidation_id: uuid.UUID, + legal_entity_id: uuid.UUID, + service: PrimaryFilesServiceDep, + query: Annotated[FileListQuery, Query()], +) -> Page[FileOut]: + """Файлы ЮЛ, загруженные в эту консолидацию, плюс файлы с документами, привязанными к её версиям (новые первыми).""" + page = PageParams(limit=query.limit, offset=query.offset) + items, total = await service.page_desk( + consolidation_id, legal_entity_id, include_superseded=query.include_superseded, page=page + ) + return Page[FileOut](items=items, total=total, limit=page.limit, offset=page.offset) +``` + +В `api/src/api/endpoints/files/__init__.py` добавить импорт модуля и `router.include_router(consolidation_files.router)` +после `read.router`. + +Run: `cd api && .venv/bin/pytest tests/test_api_consolidation_files.py tests/test_api_files_read.py tests/test_app.py -q 2>&1 | tail -8 && cd .. && make lint 2>&1 | tail -5` +Expected: PASS; ruff/mypy чистые (`test_app.py` проверяет объявление сессии и OpenAPI). + +- [ ] **Step 5: Коммит** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core add api/src api/tests/test_api_consolidation_files.py +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core commit -q -m "$(cat <<'EOF' +api: рабочий стол консолидации для ЮЛ и consolidation_id в карточке файла (#96) + +Co-Authored-By: Claude Fable 5.1 +EOF +)" +``` + +--- + +### Task 3: Загрузка в консолидацию, удаление контекста версии у маршрута ЮЛ + +**Files:** +- Modify: `api/src/services/primary_files.py` (`check_consolidation_for_upload`, `after_upload_to_consolidation`, + `upload_one`/`_store` с `consolidation_id`, удаление `check_version_for_upload`/`after_upload_to_version`), + `api/src/repositories/consolidations.py` (`versions_of`, `lock_versions_of_consolidation`), + `api/src/api/endpoints/files/upload.py` (`accept_uploads`, удаление query), `api/src/api/endpoints/files/consolidation_files.py` + (POST), `api/tests/test_api_files_upload.py` (раздел «в контексте версии» → консолидация) +- Test: `api/tests/test_api_files_upload.py`, `api/tests/test_api_consolidation_files.py` + +**Interfaces:** +- Consumes: `consolidation_files.py` (`router`, `EDIT`, `NOT_FOUND`, `DESK_PATH`), `get_consolidation` (Task 2). +- Produces: `PrimaryFilesService.check_consolidation_for_upload(consolidation_id, entity) -> Consolidation`; + `PrimaryFilesService.after_upload_to_consolidation(consolidation_id, entity_id) -> None`; + `PrimaryFilesService.upload_one(entity, name, content, *, consolidation_id: uuid.UUID | None = None)`; + `upload.accept_uploads(service, entity, files, response, *, consolidation_id) -> UploadResult`; + `consolidations_repo.versions_of(session, consolidation_id) -> list[ConsolidationVersion]`; + `consolidations_repo.lock_versions_of_consolidation(session, consolidation_id, entity_id, statuses) -> list[ConsolidationVersion]`. + +- [ ] **Step 1: Переписать тесты раздела «в контексте версии» (красные)** + +В `api/tests/test_api_files_upload.py` функцию `_post_in_version` заменить на: + +```python +async def _post_in_consolidation( + client: httpx.AsyncClient, consolidation_id: uuid.UUID, entity_id: uuid.UUID, *uploads: Upload +) -> httpx.Response: + files = [("files", (name, content, "application/octet-stream")) for name, content in uploads] + return await client.post(f"/consolidations/{consolidation_id}/legal-entities/{entity_id}/files", files=files) +``` + +Каждый тест с `_post_in_version(client, member.id, version.id, …)` → `_post_in_consolidation(client, version.consolidation_id, member.id, …)`. +Переименовать и поправить ожидания: +- `test_upload_in_version_moves_initiated_to_documents_processing` → `test_upload_in_consolidation_moves_initiated_to_documents_processing`; + дополнительно: `file = await seeded.get(StoredFile, uuid.UUID(response.json()["accepted"][0]["file_id"]))`, + `assert file.consolidation_id == version.consolidation_id`; у события `FILE_UPLOAD` (`_upload_events`) — + `after_value["consolidation_id"] == str(version.consolidation_id)`. +- `test_upload_in_version_keeps_other_in_work_status` → `…_in_consolidation_…` (без иных правок). +- `test_upload_with_all_files_rejected_does_not_move_version` — только замена вызова. +- `test_upload_for_entity_outside_perimeter_is_422_and_writes_nothing` — только замена вызова. +- `test_upload_in_cancelled_version_is_409` → `test_upload_without_in_work_version_is_409`: ожидать код + `"CONSOLIDATION_NOT_EDITABLE"`. +- `test_upload_in_foreign_version_is_404_like_missing` → `test_upload_in_foreign_consolidation_is_404_like_missing`: + `_post_in_consolidation(client, foreign.consolidation_id, own_entity.id, …)` и `uuid.uuid4()` вместо версии. +- `test_upload_does_not_require_version_lock` — только замена вызова. +- `test_upload_without_version_does_not_touch_versions` → `test_upload_without_consolidation_does_not_touch_versions`: + дополнительно второй запрос `client.post(_files_url(member.id), params={"consolidation_version_id": str(version.id)}, files=…)` + с другим содержимым (`_xls("ignored-query")`) — 201, версия по-прежнему `INITIATED`, у обоих файлов `consolidation_id is None`. +- `test_upload_keeps_accepted_files_when_version_transition_fails` — только замена вызова. + +Добавить новый тест (Review Focus 1): + +```python +@pytest.mark.integration +async def test_upload_moves_only_versions_with_entity( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + """Две версии консолидации в INITIATED: ЮЛ в периметре только второй — переходит только она (D-4).""" + version, entities, initiator = await _version(seeded) + consolidation = await seeded.get(Consolidation, version.consolidation_id) + assert consolidation is not None + other = await make_version( + seeded, consolidation=consolidation, version_no=2, members=[entities[2]], initiator=initiator + ) + client = await auth_client(KA_RF) + + response = await _post_in_consolidation(client, consolidation.id, entities[2].id, ("osv.xls", _xls("only-v2"))) + + assert response.status_code == HTTP_CREATED, response.text + assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED + assert await _version_state(seeded, other.id) is ConsolidationStatus.DOCUMENTS_PROCESSING +``` + +(импорты `Consolidation` из `src.models.consolidation`, `make_version` из `tests.factories`). + +Run: `cd api && .venv/bin/pytest tests/test_api_files_upload.py -q -k "consolidation or version or perimeter or lock" 2>&1 | tail -15` +Expected: FAIL — 404/405 на маршруте консолидации; тест с query — `AssertionError` по статусу версии (сейчас переход происходит). + +- [ ] **Step 2: Репозитории версий консолидации** + +`api/src/repositories/consolidations.py` после `lock_versions_for_entity`: + +```python +async def versions_of(session: AsyncSession, consolidation_id: uuid.UUID) -> list[ConsolidationVersion]: + """Все версии консолидации по возрастанию `version_no` (без блокировки; проверки до приёма файлов, D-3).""" + stmt = ( + select(ConsolidationVersion) + .where(ConsolidationVersion.consolidation_id == consolidation_id) + .order_by(ConsolidationVersion.version_no) + ) + return list((await session.execute(stmt)).scalars()) + + +async def lock_versions_of_consolidation( + session: AsyncSession, + consolidation_id: uuid.UUID, + entity_id: uuid.UUID, + statuses: Collection[ConsolidationStatus], +) -> list[ConsolidationVersion]: + """Версии консолидации со статусом из `statuses`, где ЮЛ — член периметра, под `FOR UPDATE ORDER BY id` (R-2; + design D-4: переход после загрузки в консолидацию). `populate_existing` — свежий статус из БД.""" + is_member = exists().where( + ConsolidationMember.consolidation_version_id == ConsolidationVersion.id, + ConsolidationMember.legal_entity_id == entity_id, + ) + stmt = ( + select(ConsolidationVersion) + .where( + ConsolidationVersion.consolidation_id == consolidation_id, + ConsolidationVersion.status.in_(statuses), + is_member, + ) + .order_by(ConsolidationVersion.id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return list((await session.execute(stmt)).scalars()) +``` + +- [ ] **Step 3: Сервис** + +В `api/src/services/primary_files.py`: удалить `check_version_for_upload` и `after_upload_to_version`, импорт +`VERSION_NOT_FOUND` заменить на `CONSOLIDATION_NOT_FOUND`; добавить `from src.models.consolidation import Consolidation`. +Новые методы на их месте: + +```python + async def check_consolidation_for_upload(self, consolidation_id: uuid.UUID, entity: LegalEntity) -> Consolidation: + """Загрузка в консолидацию (spec primary-files «Загрузка файлов в консолидацию», §9.5 п. 8, D-3): ДО приёма — + консолидация видима (404 `CONSOLIDATION_NOT_FOUND`) → есть версия «в работе» (409 `CONSOLIDATION_NOT_EDITABLE`) + → ЮЛ в периметре хотя бы одной из них (422 `ENTITY_NOT_IN_PERIMETER`). Блокировки не нужны: статус + перечитывается под `FOR UPDATE` в `after_upload_to_consolidation`.""" + consolidation = await consolidations_repo.get_consolidation(self._session, self._scope, consolidation_id) + if consolidation is None: + raise NotFoundError(CONSOLIDATION_NOT_FOUND) + in_work = [ + version + for version in await consolidations_repo.versions_of(self._session, consolidation.id) + if version.status in IN_WORK_STATUSES + ] + if not in_work: + raise ConflictError("CONSOLIDATION_NOT_EDITABLE", "У консолидации нет версии в работе") + for version in in_work: + members = await consolidations_repo.members(self._session, version.id) + if any(member.legal_entity_id == entity.id for member, _ in members): + return consolidation + raise DomainValidationError("ENTITY_NOT_IN_PERIMETER", "ЮЛ не входит в периметр версий консолидации в работе") + + async def after_upload_to_consolidation(self, consolidation_id: uuid.UUID, entity_id: uuid.UUID) -> None: + """После приёма хотя бы одного файла: каждая версия консолидации «в работе» с ЮЛ в периметре и статусом + `INITIATED` → `DOCUMENTS_PROCESSING` (`changed_by` — загрузивший; §9.1, D-4). + + В ОТДЕЛЬНОЙ транзакции (`session_transaction`): версии под `FOR UPDATE ORDER BY id`, статус перечитан — + попадают только ещё `INITIATED`. Приём файлов важнее побочного статуса (R-10 Среза 4): любая ошибка + перехода логируется и НЕ меняет ответ. `BaseException` не перехватывается.""" + from src.services.consolidation_status import transition # noqa: PLC0415 - модули консолидации импортируют друг друга + + factory = self._transaction + if factory is None: + from src.db.db import session_transaction # noqa: PLC0415 + + factory = session_transaction + try: + async with factory() as session: + versions = await consolidations_repo.lock_versions_of_consolidation( + session, consolidation_id, entity_id, {ConsolidationStatus.INITIATED} + ) + audit = AuditService(session) + now = utc_now() + for version in versions: + await transition( + session, + audit, + version=version, + to=ConsolidationStatus.DOCUMENTS_PROCESSING, + changed_by=self._audit.user_id, + comment=None, + ip_address=self._audit.ip_address, + user_agent=self._audit.user_agent, + now=now, + ) + except Exception: + logger.exception( + "Не удалось перевести версии консолидации в DOCUMENTS_PROCESSING после загрузки, файлы приняты. " + "error_category=version_transition_error, consolidation_id={consolidation_id}", + consolidation_id=consolidation_id, + ) +``` + +`upload_one(self, entity, name, content, *, consolidation_id: uuid.UUID | None = None)` — пробросить в `_store(..., +consolidation_id=consolidation_id)`; в `_store` добавить параметр и `consolidation_id=consolidation_id` в `StoredFile(...)`, +а в `after_value` аудита — `"consolidation_id": str(consolidation_id) if consolidation_id is not None else None`. +Docstring модуля (шаг 4 списка) дополнить «и `consolidation_id` при загрузке в консолидацию». + +- [ ] **Step 4: Маршруты** + +`api/src/api/endpoints/files/upload.py`: вынести цикл приёма в функцию модуля (используется обоими маршрутами, R-5): + +```python +async def accept_uploads( + service: PrimaryFilesService, + entity: LegalEntity, + files: list[UploadFile], + response: Response, + *, + consolidation_id: uuid.UUID | None, +) -> UploadResult: + """Общий приём файлов двух маршрутов загрузки: каждый файл принимается или отклоняется отдельно; 422 — если не + принят ни один. `consolidation_id` пишется в `stored_file` (загрузка в консолидацию) или `None`.""" + service.require_storage() + accepted: list[AcceptedOut] = [] + rejected: list[RejectedOut] = [] + for upload in files: + name = upload.filename or "" + try: + content = await read_limited(upload, service.max_upload_bytes) + except HTTPException as error: + if error.status_code != status.HTTP_413_CONTENT_TOO_LARGE: + raise + rejected.append(RejectedOut(name=safe_filename(name, default=""), code=FILE_TOO_LARGE, message=error.detail)) + continue + try: + result = await service.upload_one(entity, name, content, consolidation_id=consolidation_id) + except DomainError as error: + failure = RejectedFile.from_error(safe_filename(name, default=""), error) + rejected.append( + RejectedOut(name=failure.name, code=failure.code, message=failure.message, file_id=failure.file_id) + ) + continue + accepted.append(AcceptedOut(file_id=result.file_id, job_id=result.job_id, name=result.name)) + if not accepted: + response.status_code = status.HTTP_422_UNPROCESSABLE_CONTENT + return UploadResult(accepted=accepted, rejected=rejected) +``` + +`upload_files` становится: `entity = await service.get_entity(legal_entity_id)`; `return await accept_uploads(service, +entity, files, response, consolidation_id=None)`; параметр `consolidation_version_id` и ветки с ним удалить; из `responses` +убрать 409 и упоминания версии; docstring модуля и маршрута — без контекста версии (импорты `Query`, `PrimaryFilesService`, +`LegalEntity` поправить по факту). + +`consolidation_files.py` — добавить POST (импорты `File`, `Response`, `UploadFile`, `UploadResult`, `accept_uploads`): + +```python +UPLOAD_RESPONSES: dict[int | str, dict[str, Any]] = { + **NOT_FOUND, + status.HTTP_409_CONFLICT: {"model": ErrorDetail, "description": "CONSOLIDATION_NOT_EDITABLE — нет версии в работе"}, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "model": UploadResult, + "description": "ни один файл не принят; ЮЛ вне периметра версий в работе (ENTITY_NOT_IN_PERIMETER)", + }, + status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorDetail, "description": "S3 не подключено"}, +} + + +@router.post(DESK_PATH, operation_id="upload_consolidation_files", response_model=UploadResult, + status_code=status.HTTP_201_CREATED, dependencies=EDIT, responses=UPLOAD_RESPONSES, + summary="Загрузка файлов ЮЛ в консолидацию") +async def upload_consolidation_files( + consolidation_id: uuid.UUID, + legal_entity_id: uuid.UUID, + files: Annotated[list[UploadFile], File(description="Файлы ОСВ/ББ/ОФР: xls, xlsx, xlsb, pdf, png, jpg")], + service: PrimaryFilesServiceDep, + response: Response, +) -> UploadResult: + """До приёма: консолидация и ЮЛ видимы, есть версия «в работе», ЮЛ в её периметре. Принятые файлы получают + `consolidation_id`; после приёма хотя бы одного версии `INITIATED` с этим ЮЛ → `DOCUMENTS_PROCESSING`.""" + entity = await service.get_entity(legal_entity_id) + consolidation = await service.check_consolidation_for_upload(consolidation_id, entity) + result = await accept_uploads(service, entity, files, response, consolidation_id=consolidation.id) + if result.accepted: + await service.after_upload_to_consolidation(consolidation.id, entity.id) + return result +``` + +Run: `cd api && .venv/bin/pytest tests/test_api_files_upload.py tests/test_api_consolidation_files.py tests/test_app.py -q 2>&1 | tail -10 && cd .. && make lint 2>&1 | tail -5` +Expected: PASS; линтеры чистые. Если `test_app.py`/`test_openapi_routes` жалуется на имя тела multipart — `operation_id` +задан, проверить, что он без `__`. + +- [ ] **Step 5: Коммит** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core add api/src api/tests/test_api_files_upload.py +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core commit -q -m "$(cat <<'EOF' +api: загрузка файлов в консолидацию, переход версий «в работе»; query версии у загрузки ЮЛ удалён (#96) + +Co-Authored-By: Claude Fable 5.1 +EOF +)" +``` + +--- + +### Task 4: Наследование `consolidation_id` при автозамене + +**Files:** +- Modify: `api/src/services/result_mapping.py` (`supersede_previous`), `api/tests/test_result_mapping.py` +- Test: `api/tests/test_result_mapping.py` + +**Interfaces:** +- Consumes: `StoredFile.consolidation_id` (Task 1); `_old_file`, `_prepare`, `_entity`, `_file`, `process_once`, + `OSV_60_2025_Q1` из `tests/test_result_mapping.py`. + +- [ ] **Step 1: Тесты (красные)** + +В `api/tests/test_result_mapping.py` рядом с `test_version_chain` (импорты `Consolidation` из `src.models.consolidation`, +`make_consolidation`, `make_company_group`, `make_user`? — использовать уже импортированные фабрики; консолидацию создать +сырым `Consolidation(...)`, как в `make_consolidation`, через `tests.factories.reporting_date`): + +```python +async def _consolidation_id(maker: async_sessionmaker[AsyncSession], entity_id: uuid.UUID, quarter: PeriodQuarter) -> uuid.UUID: + """Консолидация ГК этого ЮЛ за 2025/quarter (без версий — для наследования версии не нужны).""" + async with maker() as session, session.begin(): + entity = await session.get(LegalEntity, entity_id) + assert entity is not None and entity.company_group_id is not None + consolidation = Consolidation( + company_group_id=entity.company_group_id, year=2025, quarter=quarter, + reporting_date=reporting_date(2025, quarter), created_at=_now(), + ) + session.add(consolidation) + await session.flush() + return consolidation.id + + +async def _set_consolidation(maker: async_sessionmaker[AsyncSession], file_id: uuid.UUID, consolidation_id: uuid.UUID | None) -> None: + async with maker() as session, session.begin(): + await session.execute(update(StoredFile).where(StoredFile.id == file_id).values(consolidation_id=consolidation_id)) + + +async def test_supersede_inherits_consolidation_from_first_with_value( + committed_sessionmaker: async_sessionmaker[AsyncSession], +) -> None: + """Спека «Новый файл без контекста наследует консолидацию» + Review Focus 5: два заменяемых файла, у первого по `id` + `consolidation_id` пуст, у второго задан — новый получает значение второго.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id = await _entity(maker) + c1 = await _consolidation_id(maker, entity_id, PeriodQuarter.Q1) + first_id = await _old_file(maker, entity_id, [OSV_60_2025_Q1]) + second_id = await _old_file(maker, entity_id, [OSV_60_2025_Q1]) + empty_id, filled_id = sorted([first_id, second_id]) + await _set_consolidation(maker, filled_id, c1) + async with maker() as session: + entity = await session.get(LegalEntity, entity_id) + new_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity) + + assert await process_once(maker, storage, _settings()) is True + + assert (await _file(maker, new_id)).consolidation_id == c1 + assert (await _file(maker, empty_id)).status is FileStatus.SUPERSEDED + assert (await _file(maker, filled_id)).status is FileStatus.SUPERSEDED + + +async def test_supersede_keeps_own_consolidation(committed_sessionmaker: async_sessionmaker[AsyncSession]) -> None: + """Спека «Новый файл с контекстом сохраняет свой»: старый в C1, новый загружен в C2 — остаётся C2.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id = await _entity(maker) + c1 = await _consolidation_id(maker, entity_id, PeriodQuarter.Q1) + c2 = await _consolidation_id(maker, entity_id, PeriodQuarter.Q2) + old_id = await _old_file(maker, entity_id, [OSV_60_2025_Q1]) + await _set_consolidation(maker, old_id, c1) + async with maker() as session: + entity = await session.get(LegalEntity, entity_id) + new_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity) + await _set_consolidation(maker, new_id, c2) + + assert await process_once(maker, storage, _settings()) is True + + assert (await _file(maker, new_id)).consolidation_id == c2 + assert (await _file(maker, old_id)).status is FileStatus.SUPERSEDED +``` + +Если `_entity` создаёт ЮЛ без ГК (`company_group_id is None`) — расширить `_entity` параметром или создать ГК через +`make_company_group` в `_consolidation_id` и присвоить `entity.company_group_id` там же (консолидация требует ГК). +Импортировать `reporting_date` из `tests.factories` и `update` из `sqlalchemy`, если ещё нет. + +Run: `cd api && .venv/bin/pytest tests/test_result_mapping.py -q -k "consolidation" 2>&1 | tail -10` +Expected: FAIL — `consolidation_id` нового файла `None` в первом тесте; второй может пройти (проверить, что он +действительно красный хотя бы по одному утверждению; если зелёный сразу — допустимо, он фиксирует отсутствие регрессии). + +- [ ] **Step 2: Реализация** + +В `api/src/services/result_mapping.py`, `supersede_previous`, сразу после `new_file.version_no = max(...) + 1`: + +```python + if new_file.consolidation_id is None: # v5.18, §9.5 п. 3: файл без контекста наследует консоль заменённого + new_file.consolidation_id = next((old.consolidation_id for old in locked if old.consolidation_id is not None), None) +``` + +Docstring функции дополнить предложением о наследовании. + +Run: `cd api && .venv/bin/pytest tests/test_result_mapping.py tests/test_consolidation_documents.py -q 2>&1 | tail -8` +Expected: PASS. + +- [ ] **Step 3: Коммит** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core add api/src/services/result_mapping.py api/tests/test_result_mapping.py +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core commit -q -m "$(cat <<'EOF' +mapping: новый файл без контекста наследует consolidation_id заменённого (#96) + +Co-Authored-By: Claude Fable 5.1 +EOF +)" +``` + +--- + +### Task 5: README, CLAUDE.md, полная проверка, PR + +**Files:** +- Modify: `README.md` (таблицы маршрутов, «Ошибки API», §9.1-таблица), `CLAUDE.md` (раздел «Первичные документы») + +- [ ] **Step 1: README** + +- Строка `| \`POST /legal-entities/{legal_entity_id}/files\` | \`DOCUMENT_EDIT\` | загрузка файлов …` — после неё добавить: + ``` + | `POST /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` | `DOCUMENT_EDIT` | загрузка в консолидацию (v5.18): файл получает `consolidation_id`; версии консолидации «в работе» с ЮЛ в периметре `INITIATED` → `DOCUMENTS_PROCESSING` | + | `GET /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` | `DOCUMENT_VIEW` | рабочий стол консолидации для ЮЛ: файлы с её `consolidation_id` + файлы с документами, привязанными к её версиям (`limit`/`offset`/`include_superseded`) | + ``` +- Строку `| \`POST /legal-entities/{id}/files?consolidation_version_id=\` | … |` (≈666) заменить на строку про + `POST /consolidations/{id}/legal-entities/{le}/files` с пометкой «query `consolidation_version_id` у маршрута ЮЛ удалён (v5.18)». +- Строку `| загрузка с \`consolidation_version_id\`, принят ≥ 1 файл | \`INITIATED\` → \`DOCUMENTS_PROCESSING\` | загрузивший |` + → `| загрузка в консолидацию, принят ≥ 1 файл | \`INITIATED\` → \`DOCUMENTS_PROCESSING\` у каждой версии консолидации «в работе» с ЮЛ в периметре | загрузивший |`. +- «Ошибки API»: `ENTITY_NOT_IN_PERIMETER` → «загрузка в консолидацию: ЮЛ не входит в периметр ни одной версии «в работе»»; + после неё новая строка `| \`CONSOLIDATION_NOT_EDITABLE\` | 409 | загрузка в консолидацию без версии «в работе» |`; + у `VERSION_NOT_EDITABLE` убрать «загрузка с версией»; в строке `NOT_FOUND` добавить текст «консолидация не найдена». +- Строка 422 про `POST /legal-entities/{id}/files` (≈422) — дополнить «`consolidation_id` NULL; загрузка в консолидацию — маршрут консолидации». + +- [ ] **Step 2: CLAUDE.md** + +В разделе «Первичные документы (Срез 2c)» после описания `upload_one` добавить: «v5.18 (#96): загрузка в консолидацию — +`check_consolidation_for_upload` → `accept_uploads` (`endpoints/files/upload.py`, общий для обоих маршрутов) → +`after_upload_to_consolidation` (отдельная транзакция, версии консолидации `INITIATED` с ЮЛ в периметре под `FOR UPDATE +ORDER BY id`); рабочий стол — `page_desk` / `files_repo.list_desk_files` (`OR EXISTS` по `consolidation_document`); +автозамена наследует `consolidation_id`». В разделе «Консолидации» в правило (7) добавить текст 404 «консолидация не найдена». + +- [ ] **Step 3: Полная проверка** + +Run: `make check-full 2>&1 | tail -40` +Expected: всё зелёное (lint, test, dbml-check, check-infra, test-integration, e2e при доступном парсере, docker-build, +web-check). `e2e` без профиля `services` — skip, это норма. Вывод хвоста сохранить для описания PR. + +- [ ] **Step 4: Коммит, push, PR** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core add README.md CLAUDE.md +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core commit -q -m "$(cat <<'EOF' +docs: README и CLAUDE.md — маршруты файлов консолидации, коды ошибок (#96) + +Co-Authored-By: Claude Fable 5.1 +EOF +)" +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/console-files/arm-core push -u origin feat/96-console-files +``` + +PR — через superpowers:finishing-a-development-branch (`tea pulls create --login home --repo RSHB/arm-core --base main +--head feat/96-console-files …`): заголовок «Файлы в контексте консолидации: stored_file.consolidation_id, маршруты +/consolidations/{id}/legal-entities/{le}/files (DBML v5.18, #96)»; в описании — ссылка на issue #96 и docs #90, «Rulings I +made», `deferred`-находки, хвост `make check-full`, пометка «зависит от docs PR (DBML v5.18); arm-web — следом».