68 KiB
Файлы в контексте консолидации — план для 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), 409CONSOLIDATION_NOT_EDITABLE, 422ENTITY_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}/filesqueryconsolidation_version_idудаляется. - Колонка
consolidation_id— последняя вStoredFile(послеupdated_at); FKfk_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 <noreply@anthropic.com>. - Перед PR —
make check-full(нужны Docker и сеть), вывод — в описание PR.
Review Focus
- Загрузка в консолидацию, у которой две версии «в работе», ЮЛ в периметре только одной: 422 быть не должно, переход
— только у версии с ЮЛ. Тест
test_upload_moves_only_versions_with_entityв Task 3. - Стол консолидации не должен показывать файл другой консолидации того же ЮЛ, даже если его документ привязан к версии
другого периода. Тест
test_desk_excludes_other_consolidationв Task 2. include_superseded=trueдолжен возвращатьSUPERSEDEDфайлы и изDESK, и изBOUND. Тестtest_desk_superseded_hidden_by_defaultв Task 2.- Backfill 0006 на файле с привязками к версиям двух консолидаций разных лет (2024 Q4 и 2025 Q1) должен выбрать 2024 Q4
(год важнее квартала). Тест
test_upgrade_0006_backfills_consolidation_idв Task 1. - Автозамена, где новый файл без контекста заменяет два файла: один без
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:
# v5.18: рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ»)
Index("ix_stored_file_legal_entity_id_consolidation_id", "legal_entity_id", "consolidation_id"),
и последней колонкой класса (после updated_at):
# 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):
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:
"""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: Коммит
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 <noreply@anthropic.com>
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:
"""Рабочий стол консолидации для ЮЛ и `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:
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):
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:
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:
"""Файлы ЮЛ в контексте консолидации (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: Коммит
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 <noreply@anthropic.com>
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 заменить на:
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):
@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:
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.
Новые методы на их месте:
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):
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):
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: Коммит
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 <noreply@anthropic.com>
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):
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:
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: Коммит
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 <noreply@anthropic.com>
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с пометкой «queryconsolidation_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_idNULL; загрузка в консолидацию — маршрут консолидации». -
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
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 <noreply@anthropic.com>
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 — следом».