# Файлы в контексте консолидации — план для 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 — следом».