arm-core/docs/superpowers/plans/2026-10-08-auto-confirm-clean-documents.md
Andrey Epifancev d9324f2a38 docs(plan): план автоподтверждения чистых документов (#64)
7 задач: хук подтверждения с автором перехода, form_confirmable, auto_confirm,
вызов в маппинге, удаление файла, документация arm-core, правило и OQ-157 в docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:43:07 +04:00

80 KiB
Raw Permalink Blame History

Автоподтверждение чистых документов — план реализации

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: документ, который маппинг сделал бы RECOGNIZED, сразу становится CONFIRMED («Загружен») от имени системы, привязывается к версиям и двигает их как при ручном подтверждении; файл с автоподтверждёнными документами можно удалить, пока версии не ушли дальше DATA_CORRECTION.

Architecture: новый модуль services/auto_confirm.py вызывается в транзакции маппинга (result_mapping._map_candidate) после map_file (а значит после автозамены) и до on_documents_mapped; он переиспользует хук ручного подтверждения consolidation_documents.on_document_confirmed, которому теперь передают changed_by/comment. Условия формы ББ/ОФР — общая функция services/statements.form_confirmable. Удаление файла (primary_files.delete) снимает привязки автоподтверждённых документов через новые функции consolidation_documents.bound_versions / unbind_documents. Правило и OQ — отдельный PR в arm-ka-docs.

Tech Stack: Python 3.11, FastAPI, SQLAlchemy 2 async, PostgreSQL 16, pytest-asyncio; OpenSpec; Gitea tea.

Spec: openspec/changes/auto-confirm-clean-documents/ (proposal.md, design.md, specs/osv-result-mapping, specs/primary-files, specs/audit-trail). Issue RSHB/arm-core#64.

Рабочее место: зеркало /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/: arm-core/ — worktree ветки feat/64-auto-confirm, docs/ — worktree ветки feat/64-auto-confirm-rules, fixtures, arm-web, CLAUDE.md, .claude — симлинки. Команды make — из .wt/auto-confirm/arm-core, pytest — из .wt/auto-confirm/arm-core/api (.venv/bin/pytest; venv создаётся make venv, если его нет). Интеграционные тесты требуют PostgreSQL/NATS/MinIO стенда (docker ps — контейнеры arm-ka-*); на стенде работает ядро владельца на :8000 — его НЕ останавливать, тесты используют свою БД armcore_pytest_<hex>. codegraph_explore — с projectPath=/Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core.

Global Constraints

  • DBML v5.17 не меняется; новых полей и миграций нет (confirmed_by уже nullable, audit_event.user_id — «NULL для системных событий»).
  • Автор автоподтверждения: document.confirmed_by = NULL, financial_statement_meta.reviewed_by = NULL; audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS, object_type='document') с user_id = NULL, description = «автоподтверждение: без замечаний», before_value = {"status": "RECOGNIZED"}, after_value = {"status": "CONFIRMED"}.
  • Переход версии при автоподтверждении: changed_by = stored_file.uploaded_by, comment = «автоподтверждение документа <document_type> <osv_account_code>» (счёт — только если есть, через пробел; например «автоподтверждение документа OSV 60», «автоподтверждение документа INCOME»). uploaded_by IS NULL → привязка есть, перехода нет, строка в лог.
  • Автоматически подтверждается только документ со статусом RECOGNIZED сразу после map_file; NEEDS_CHECK — никогда; форма — только если form_confirmable (период определён, жёстких нарушений §7 нет).
  • Порядок блокировок: задание → файл → документы → advisory ГК → версии по id (CLAUDE.md arm-core «Консолидации», правило 2).
  • Удаление: confirmed_by IS NOT NULL у любого CONFIRMED документа файла → 409 FILE_HAS_CONFIRMED_DOCUMENTS; привязка автоподтверждённого документа к версии вне IN_WORK_STATUSES (INITIATED, DOCUMENTS_PROCESSING, DATA_CORRECTION) → 409 FILE_HAS_CONFIRMED_DOCUMENTS; иначе привязки снимаются, FILE_DELETE.after_value = {"unbound_version_ids": [...]} (UUID по возрастанию; [] если нет).
  • Язык комментариев, docstring, коммитов — русский; номер § правил — в docstring; сообщения коммитов заканчиваются строкой Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
  • Перед PR — make check-full (из .wt/auto-confirm/arm-core); вывод — в описание PR.

Review Focus

  1. Файл с документами RECOGNIZED и NEEDS_CHECK вперемешку (книга «ББ чистый + ОФР с нарушением») — чистый подтверждается и привязывается, NEEDS_CHECK остаётся и двигает версию из DOCUMENTS_PROCESSING хуком on_documents_mapped; версия получает ровно один переход (второй хук видит уже DATA_CORRECTION). Тест — Task 4, test_mixed_file_confirms_clean_and_moves_version_once.
  2. Автозамена: новый чистый файл того же ключа — привязка документа старого файла снимается, новый документ CONFIRMED и привязан к той же версии, в версии нет двух привязок одного ключа. Тест — Task 4, test_supersede_rebinds_auto_confirmed_document.
  3. Повтор маппинга того же задания после сбоя (транзакция откатилась, mapping_attempts + 1) — автоподтверждение откатывается вместе с маппингом, при повторе нет двойных событий DOCUMENT_CONFIRM. Тест — Task 4, test_auto_confirm_rolls_back_with_mapping.
  4. Ручное подтверждение уже автоподтверждённого документа (фронт со старым статусом в кэше жмёт «Подтвердить») — 409 ALREADY_CONFIRMED, ничего не меняется. Тест — Task 3, test_manual_confirm_of_auto_confirmed_is_409.
  5. Удаление файла, чей автоподтверждённый документ привязан к двум версиям, одна из которых ушла на согласование — 409, ни одна привязка не снята, файл на месте. Тест — Task 5, test_delete_blocked_if_any_bound_version_left_in_work.

Task 1: Хук подтверждения принимает автора перехода и комментарий

Files:

  • Modify: api/src/services/consolidation_documents.py (on_document_confirmed, ~строки 200–240)
  • Modify: api/src/services/documents.py (DocumentService.confirm, вызов хука, ~строка 275)
  • Test: api/tests/test_consolidation_documents.py

Interfaces:

  • Produces: async def on_document_confirmed(session: AsyncSession, audit: AuditService, *, document: Document, changed_by: uuid.UUID | None, now: datetime.datetime, comment: str | None = None) -> list[uuid.UUID] — параметр user_id переименован в changed_by; при changed_by is None привязка создаётся, переход версии пропускается с logger.info.

  • Step 1: Найти всех вызывающих

Run: cd api && grep -rn "on_document_confirmed" src tests Expected: src/services/documents.py (confirm) и tests/test_consolidation_documents.py (test_on_document_confirmed_returns_bound_versions и, возможно, _confirm_in). Все они будут переименованы на changed_by=.

  • Step 2: Написать падающие тесты

В конец раздела «on_document_confirmed» файла api/tests/test_consolidation_documents.py (после test_on_document_confirmed_returns_bound_versions) добавить:

@pytest.mark.integration
async def test_on_document_confirmed_without_changed_by_binds_but_keeps_status(seeded: AsyncSession) -> None:
    """Автоподтверждение файла без `uploaded_by` (OQ-157, D-2): привязка есть, перехода версии нет."""
    group, entities = await _group(seeded)
    version = await _version(seeded, group, entities)
    document = await _confirmed(seeded, entities[0], quarter=Q2)
    history = len(await _history(seeded, version.id))

    bound = await consolidation_documents.on_document_confirmed(
        seeded, AuditService(seeded), document=document, changed_by=None, now=utc_now()
    )

    assert bound == [version.id]
    assert document.id in await _bound(seeded, version.id)
    assert await _status(seeded, version.id) is ConsolidationStatus.INITIATED
    assert len(await _history(seeded, version.id)) == history


@pytest.mark.integration
async def test_on_document_confirmed_writes_comment_to_history(seeded: AsyncSession) -> None:
    """Комментарий перехода от вызывающего (автоподтверждение) попадает в историю статусов."""
    group, entities = await _group(seeded)
    version = await _version(seeded, group, entities)
    user = await _user(seeded)
    document = await _confirmed(seeded, entities[0], quarter=Q2)

    await consolidation_documents.on_document_confirmed(
        seeded,
        AuditService(seeded),
        document=document,
        changed_by=user.id,
        comment="автоподтверждение документа OSV 60",
        now=utc_now(),
    )

    last = (await _history(seeded, version.id))[-1]
    assert (last.to_status, last.changed_by, last.comment) == (
        ConsolidationStatus.DATA_CORRECTION,
        user.id,
        "автоподтверждение документа OSV 60",
    )
  • Step 3: Запустить — должны упасть

Run: cd api && .venv/bin/pytest tests/test_consolidation_documents.py -k "without_changed_by or writes_comment" -v Expected: FAIL — TypeError: on_document_confirmed() got an unexpected keyword argument 'changed_by'.

  • Step 4: Изменить хук

В api/src/services/consolidation_documents.py заменить сигнатуру и тело on_document_confirmed:

async def on_document_confirmed(  # noqa: PLR0913 - автор и комментарий перехода задаёт вызывающий (OQ-157)
    session: AsyncSession,
    audit: AuditService,
    *,
    document: Document,
    changed_by: uuid.UUID | None,
    now: datetime.datetime,
    comment: str | None = None,
) -> list[uuid.UUID]:
    """Хук подтверждения (spec «Подтверждение документа», osv-result-mapping «Автоподтверждение чистого документа»):
    привязывает уже `CONFIRMED` документ ко всем версиям «в работе», которым он подходит, заменяя привязанный документ
    того же ключа (R-1); версия из `INITIATED` / `DOCUMENTS_PROCESSING` переходит в `DATA_CORRECTION` с `changed_by` и
    `comment` вызывающего (ручное подтверждение — пользователь и `None`, автоподтверждение — загрузивший файл и
    «автоподтверждение документа …», D-2). `changed_by is None` (файл без `uploaded_by`) — привязка создаётся, переход
    пропускается с записью в лог (`consolidation_status_history.changed_by` NOT NULL). Возвращает id версий, получивших
    привязку (по возрастанию).

    Вызывать после блокировки файла и документа (D-9): версии блокирует `repo.lock_versions_for_entity` — advisory ГК
    (создание версии ∥ подтверждение) и `FOR UPDATE` надмножества версий ГК (правка периметра ∥ подтверждение, R-2).
    Блокировка версии (`consolidation_lock`) не требуется.
    """
    # цикл импортов: consolidation_status → consolidations → consolidation_documents
    from src.services.consolidation_status import transition  # noqa: PLC0415

    versions = await repo.lock_versions_for_entity(session, document.legal_entity_id, IN_WORK_STATUSES)
    bound: list[uuid.UUID] = []
    for version in versions:
        consolidation = await session.get(Consolidation, version.consolidation_id)
        if consolidation is None:  # FK гарантирует наличие
            continue
        if not fits(document, version, consolidation, await _member_ids(session, version.id)):
            continue
        await _bind(session, version.id, document)
        if version.status in MOVES_TO_CORRECTION:
            if changed_by is None:
                logger.info(
                    "Переход версии по подтверждению пропущен: нет автора (changed_by обязателен). "
                    "version_id={version_id}, document_id={document_id}",
                    version_id=version.id,
                    document_id=document.id,
                )
            else:
                await transition(
                    session,
                    audit,
                    version=version,
                    to=ConsolidationStatus.DATA_CORRECTION,
                    changed_by=changed_by,
                    comment=comment,
                    now=now,
                )
        bound.append(version.id)
    return bound

В api/src/services/documents.py, в DocumentService.confirm, заменить вызов:

        await consolidation_documents.on_document_confirmed(
            self._session, self._audit.service, document=document, changed_by=self._audit.user_id, now=now
        )

В api/tests/test_consolidation_documents.py заменить у остальных вызовов user_id= на changed_by= (найдены в Step 1).

  • Step 5: Запустить тесты модуля

Run: cd api && .venv/bin/pytest tests/test_consolidation_documents.py tests/test_api_documents.py -v Expected: PASS (новые два и все прежние).

  • Step 6: Линтеры

Run: cd /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core && make lint Expected: ruff и mypy без ошибок.

  • Step 7: Commit
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core add api/src/services/consolidation_documents.py api/src/services/documents.py api/tests/test_consolidation_documents.py
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core commit -m "refactor(consolidations): on_document_confirmed принимает автора и комментарий перехода (#64)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 2: Условия подтверждения формы — одна функция для ручного и автоматического пути

Files:

  • Modify: api/src/services/statements.py (модульные функции рядом с FORM_TYPES; метод StatementService.hard_failures)
  • Modify: api/src/services/documents.py (_check_statement использует statement_hard_failures)
  • Test: api/tests/test_form_confirmable.py (новый)

Interfaces:

  • Produces: async def statement_hard_failures(session: AsyncSession, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...] — жёсткие нарушения §7; при акцепте ().

  • Produces: async def form_confirmable(session: AsyncSession, document: Document) -> bool — True для не-формы (document_type ∉ FORM_TYPES) и для формы без шапки; для формы с шапкой — period_year и period_quarter не None и statement_hard_failures(...) == ().

  • Step 1: Написать падающий тест

Создать api/tests/test_form_confirmable.py:

"""Условия подтверждения формы ББ/ОФР — общие для ручного и автоматического пути (OQ-157, design D-3; D-9 Среза 3b)."""

import json
from pathlib import Path

import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from src.models.directories import LegalEntity
from src.models.enums import DocumentType
from src.models.files import Document
from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue
from src.services.statements import form_confirmable, statement_hard_failures
from tests.factories import make_legal_entity
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot

pytestmark = [pytest.mark.integration, pytest.mark.fixtures]

MEZH = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3"
SYNTH = GOLDEN_STATEMENTS / "synthetic"


async def _owner(session: AsyncSession, path: Path) -> LegalEntity:
    """Владелец с ИНН шапки формы (MATCH)."""
    data = json.loads(path.read_text("utf-8"))
    inn = next((d["header"].get("inn") for d in data["documents"] if d.get("header", {}).get("inn")), None)
    return await make_legal_entity(session, name="ООО Владелец", inn=inn)


async def _income(session: AsyncSession, fixtures_dir: Path) -> Document:
    path = fixtures_dir / MEZH / "1_Форма2_бух_09.json"
    (income,) = await map_golden_snapshot(session, entity=await _owner(session, path), path=path, uploaded_by=None)
    assert income.document_type is DocumentType.INCOME
    return income


async def test_clean_form_is_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None:
    assert await form_confirmable(seeded, await _income(seeded, fixtures_dir)) is True


async def test_form_without_period_is_not_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None:
    income = await _income(seeded, fixtures_dir)
    income.period_quarter = None
    await seeded.flush()
    assert await form_confirmable(seeded, income) is False


async def test_form_with_hard_failure_is_not_confirmable_until_accepted(
    seeded: AsyncSession, fixtures_dir: Path
) -> None:
    income = await _income(seeded, fixtures_dir)
    meta = (
        await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id))
    ).scalar_one()
    # ломаем итог 2400 «Чистая прибыль» во всех колонках: соотношение 2400 = … перестаёт сходиться
    values = (
        await seeded.execute(
            select(FinancialStatementValue)
            .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id)
            .where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "2400")
        )
    ).scalars()
    for value in values:
        value.amount = (value.amount or 0) + 1000
    await seeded.flush()

    assert await statement_hard_failures(seeded, income, meta) != ()
    assert await form_confirmable(seeded, income) is False

    meta.checks_accepted_at = income.created_at  # акцепт: любые непустые дата и автор
    await seeded.flush()
    assert await form_confirmable(seeded, income) is True


async def test_disclosure_is_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None:
    owner = await make_legal_entity(seeded, name="ООО Синтетика")
    documents = await map_golden_snapshot(
        seeded, entity=owner, path=fixtures_dir / SYNTH / "st06_header_variants.json", uploaded_by=None
    )
    disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE)
    assert await form_confirmable(seeded, disclosure) is True
  • Step 2: Запустить — должен упасть

Run: cd api && .venv/bin/pytest tests/test_form_confirmable.py -v Expected: FAIL — ImportError: cannot import name 'form_confirmable'.

  • Step 3: Реализовать

В api/src/services/statements.py сразу после FORM_TYPES = frozenset({...}) добавить:

async def statement_hard_failures(
    session: AsyncSession, document: Document, meta: FinancialStatementMeta
) -> tuple[FailedCheck, ...]:
    """Нарушенные жёсткие проверки §7 по текущим суммам формы; при акцепте — пустой кортеж (D-8 Среза 3b)."""
    if meta.checks_accepted_at is not None:
        return ()
    column_indexes, amounts = await repo.statement_amounts(session, meta.id)
    report = run_checks(document.document_type.value, amounts, column_indexes, load_check_rules())
    return tuple(report.failed)


async def form_confirmable(session: AsyncSession, document: Document) -> bool:
    """Условия подтверждения формы ББ/ОФР без исключений (D-9 Среза 3b; автоподтверждение — design D-3, OQ-157):
    период определён и жёстких нарушений §7 нет или они акцептованы. Не-форма и форма без шапки (созданная до Среза 3b)
    — `True`: условий у них нет. Ручной путь (`DocumentService._check_statement`) проверяет то же, но отвечает 409."""
    if document.document_type not in FORM_TYPES:
        return True
    meta = await repo.get_meta(session, document.id, refresh=True)
    if meta is None:
        return True
    if document.period_year is None or document.period_quarter is None:
        return False
    return not await statement_hard_failures(session, document, meta)

Заменить тело метода StatementService.hard_failures:

    async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]:
        """Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы)."""
        return await statement_hard_failures(self._session, document, meta)

В api/src/services/documents.py, в _check_statement, заменить импорт и вызов:

        from src.services.statements import FORM_TYPES, statement_hard_failures  # noqa: PLC0415 - цикл импорта
        failures = await statement_hard_failures(self._session, document, meta)

(Импорты FailedCheck, run_checks, load_check_rules в statements.py уже есть — их использует метод; проверить grep -n "FailedCheck\|run_checks\|load_check_rules" api/src/services/statements.py.)

  • Step 4: Запустить тесты

Run: cd api && .venv/bin/pytest tests/test_form_confirmable.py tests/test_api_statements.py tests/test_api_documents.py -v Expected: PASS.

  • Step 5: Линтеры и commit

Run: make lint (из .wt/auto-confirm/arm-core), затем:

git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core add api/src/services/statements.py api/src/services/documents.py api/tests/test_form_confirmable.py
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core commit -m "refactor(statements): form_confirmable — условия подтверждения формы для ручного и автоматического пути (#64)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 3: auto_confirm — подтверждение чистых документов файла

Files:

  • Create: api/src/services/auto_confirm.py
  • Test: api/tests/test_auto_confirm.py (новый)

Interfaces:

  • Consumes: on_document_confirmed(..., changed_by=..., comment=...) (Task 1); form_confirmable(session, document) и FORM_TYPES из src.services.statements (Task 2).

  • Produces: AUTO_CONFIRM_DESCRIPTION: str = "автоподтверждение: без замечаний"; def transition_comment(document: Document) -> str; async def auto_confirm(session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime) -> list[Document] — возвращает подтверждённые документы (по возрастанию id).

  • Step 1: Написать падающие тесты

Создать api/tests/test_auto_confirm.py:

"""Автоподтверждение чистых документов (osv-result-mapping «Автоподтверждение чистого документа»; OQ-157; design D-1–D-3)."""

import uuid
from pathlib import Path

import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from src.common.clock import utc_now
from src.models.access import AppUser
from src.models.audit_ex import AuditEvent
from src.models.consolidation import ConsolidationDocument, ConsolidationStatusHistory, ConsolidationVersion
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice
from src.models.enums import (
    AuditAction,
    ConsolidationStatus,
    DocumentStatus,
    DocumentType,
    FileStatus,
    PeriodQuarter,
    RecognitionStatus,
)
from src.models.files import Document, StoredFile
from src.models.statements import FinancialStatementMeta
from src.services.auto_confirm import AUTO_CONFIRM_DESCRIPTION, auto_confirm, transition_comment
from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_consolidation, make_document, make_legal_entity, make_stored_file
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot

pytestmark = pytest.mark.integration

ROSTOV = "Ростовский региональный филиал"
KA_RF = "ka_rf@armka.local"
YEAR = 2024
Q2, Q3 = PeriodQuarter.Q2, PeriodQuarter.Q3
HTTP_CONFLICT = 409


async def _user(session: AsyncSession) -> AppUser:
    return (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()


async def _group(session: AsyncSession) -> tuple[CompanyGroup, list[LegalEntity]]:
    office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
    group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office)
    return group, [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(2)]


async def _version(
    session: AsyncSession, group: CompanyGroup, members: list[LegalEntity], status: ConsolidationStatus
) -> ConsolidationVersion:
    _, version = await make_consolidation(
        session, group=group, year=YEAR, quarter=Q3, members=members, initiator=await _user(session), status=status
    )
    return version


async def _file_with(
    session: AsyncSession, entity: LegalEntity, statuses: list[DocumentStatus], uploaded_by: uuid.UUID | None
) -> tuple[StoredFile, list[Document]]:
    """Файл `PROCESSED` с квартальными ОСВ 60, 62, … за 2024 Q2 в статусах `statuses`."""
    file, job = await make_stored_file(
        session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done, uploaded_by=uploaded_by
    )
    documents = [
        await make_document(
            session, file=file, job=job, account=account, year=YEAR, quarter=Q2, status=status, is_cumulative=False
        )
        for account, status in zip(["60", "62", "76"], statuses, strict=False)
    ]
    return file, documents


async def _bound(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]:
    stmt = select(ConsolidationDocument.document_id).where(ConsolidationDocument.consolidation_version_id == version_id)
    return set((await session.execute(stmt)).scalars())


async def _confirm_events(session: AsyncSession) -> list[AuditEvent]:
    stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.DOCUMENT_CONFIRM)
    return list((await session.execute(stmt)).scalars())


def test_transition_comment() -> None:
    osv = Document(document_type=DocumentType.OSV, osv_account_code="60")
    income = Document(document_type=DocumentType.INCOME, osv_account_code=None)
    assert transition_comment(osv) == "автоподтверждение документа OSV 60"
    assert transition_comment(income) == "автоподтверждение документа INCOME"


async def test_clean_document_is_confirmed_by_system(seeded: AsyncSession) -> None:
    group, entities = await _group(seeded)
    user = await _user(seeded)
    file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], user.id)
    now = utc_now()

    confirmed = await auto_confirm(seeded, file=file, documents=[document], now=now)

    assert confirmed == [document]
    assert (document.status, document.confirmed_by, document.confirmed_at) == (DocumentStatus.CONFIRMED, None, now)
    [event] = await _confirm_events(seeded)
    assert (event.object_id, event.user_id, event.description) == (document.id, None, AUTO_CONFIRM_DESCRIPTION)
    assert (event.before_value, event.after_value) == ({"status": "RECOGNIZED"}, {"status": "CONFIRMED"})


async def test_needs_check_is_left_for_analyst(seeded: AsyncSession) -> None:
    group, entities = await _group(seeded)
    version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED)
    file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.NEEDS_CHECK], uuid.uuid4())

    assert await auto_confirm(seeded, file=file, documents=[document], now=utc_now()) == []
    assert document.status is DocumentStatus.NEEDS_CHECK and document.confirmed_at is None
    assert await _confirm_events(seeded) == []
    assert await _bound(seeded, version.id) == set()


async def test_binds_and_moves_version_with_uploader_and_comment(seeded: AsyncSession) -> None:
    group, entities = await _group(seeded)
    version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED)
    user = await _user(seeded)
    file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], user.id)

    await auto_confirm(seeded, file=file, documents=[document], now=utc_now())

    assert await _bound(seeded, version.id) == {document.id}
    refreshed = await seeded.get(ConsolidationVersion, version.id, populate_existing=True)
    assert refreshed is not None and refreshed.status is ConsolidationStatus.DATA_CORRECTION
    last = (
        await seeded.execute(
            select(ConsolidationStatusHistory)
            .where(ConsolidationStatusHistory.version_id == version.id)
            .order_by(ConsolidationStatusHistory.changed_at.desc())
            .limit(1)
        )
    ).scalar_one()
    assert (last.changed_by, last.comment) == (user.id, "автоподтверждение документа OSV 60")


async def test_file_without_uploader_binds_without_transition(seeded: AsyncSession) -> None:
    group, entities = await _group(seeded)
    version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED)
    file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], None)

    await auto_confirm(seeded, file=file, documents=[document], now=utc_now())

    assert document.status is DocumentStatus.CONFIRMED
    assert await _bound(seeded, version.id) == {document.id}
    refreshed = await seeded.get(ConsolidationVersion, version.id, populate_existing=True)
    assert refreshed is not None and refreshed.status is ConsolidationStatus.INITIATED


async def test_mixed_documents_confirm_only_clean(seeded: AsyncSession) -> None:
    group, entities = await _group(seeded)
    file, documents = await _file_with(
        seeded, entities[0], [DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK], uuid.uuid4()
    )

    confirmed = await auto_confirm(seeded, file=file, documents=documents, now=utc_now())

    assert confirmed == [documents[0]]
    assert [d.status for d in documents] == [DocumentStatus.CONFIRMED, DocumentStatus.NEEDS_CHECK]


@pytest.mark.fixtures
async def test_clean_form_sets_reviewed_at(seeded: AsyncSession, fixtures_dir: Path) -> None:
    path = fixtures_dir / GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
    import json  # noqa: PLC0415 - нужен только здесь

    data = json.loads(path.read_text("utf-8"))
    inn = next(d["header"]["inn"] for d in data["documents"] if d.get("header", {}).get("inn"))
    owner = await make_legal_entity(seeded, name="ООО Владелец", inn=inn)
    (income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
    assert income.status is DocumentStatus.RECOGNIZED
    file = await seeded.get(StoredFile, income.stored_file_id)
    assert file is not None
    now = utc_now()

    assert await auto_confirm(seeded, file=file, documents=[income], now=now) == [income]

    meta = (
        await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id))
    ).scalar_one()
    assert (income.status, meta.reviewed_by, meta.reviewed_at) == (DocumentStatus.CONFIRMED, None, now)


@pytest.mark.fixtures
async def test_recognized_form_without_period_stays(seeded: AsyncSession, fixtures_dir: Path) -> None:
    path = fixtures_dir / GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
    import json  # noqa: PLC0415 - нужен только здесь

    data = json.loads(path.read_text("utf-8"))
    inn = next(d["header"]["inn"] for d in data["documents"] if d.get("header", {}).get("inn"))
    owner = await make_legal_entity(seeded, name="ООО Владелец", inn=inn)
    (income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
    income.period_quarter = None  # рассинхрон правил: RECOGNIZED без периода не должен подтверждаться сам
    await seeded.flush()
    file = await seeded.get(StoredFile, income.stored_file_id)
    assert file is not None

    assert await auto_confirm(seeded, file=file, documents=[income], now=utc_now()) == []
    assert income.status is DocumentStatus.RECOGNIZED


async def test_manual_confirm_of_auto_confirmed_is_409(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
    """Review Focus 4: фронт с устаревшим статусом жмёт «Подтвердить» — 409 `ALREADY_CONFIRMED`."""
    office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
    entity = await make_legal_entity(seeded, name="ООО Ростов", office=office)
    file, [document] = await _file_with(seeded, entity, [DocumentStatus.RECOGNIZED], None)
    await auto_confirm(seeded, file=file, documents=[document], now=utc_now())
    client = await auth_client(KA_RF)

    response = await client.post(f"/documents/{document.id}/confirm")

    assert response.status_code == HTTP_CONFLICT, response.text
    assert response.json()["detail"]["code"] == "ALREADY_CONFIRMED"
    assert len(await _confirm_events(seeded)) == 1

Перед запуском свериться с фабриками: grep -n "async def make_legal_entity" -A12 api/tests/factories.py — если у make_legal_entity нет параметра office, в test_manual_confirm_of_auto_confirmed_is_409 создать ЮЛ через _group(seeded) (ГК в Ростовском филиале — ЮЛ видно ka_rf) и взять entities[0].

  • Step 2: Запустить — должны упасть

Run: cd api && .venv/bin/pytest tests/test_auto_confirm.py -v Expected: FAIL — ModuleNotFoundError: No module named 'src.services.auto_confirm'.

  • Step 3: Реализовать модуль

Создать api/src/services/auto_confirm.py:

"""Автоподтверждение чистых документов при маппинге результата (osv-result-mapping «Автоподтверждение чистого
документа»; `05-domain-rules.md` §9.3, §9.5; OQ-157; design D-1–D-3 change `auto-confirm-clean-documents`).

Документ, получивший при маппинге `RECOGNIZED` (ТЗ 03_9 п. 4: «при отсутствии замечаний — „Загружен“»), становится
`CONFIRMED` от имени системы: `confirmed_by = NULL`, у формы `reviewed_by = NULL`, `audit_event(DOCUMENT_CONFIRM)` без
пользователя. Привязка к версиям и переход версии — тот же хук, что у ручного подтверждения, с автором
`stored_file.uploaded_by`. Вызывается в транзакции маппинга под заданием, файлом и документами (порядок блокировок D-5).
"""

import datetime
from collections.abc import Sequence

from sqlalchemy.ext.asyncio import AsyncSession

from src.models.enums import AuditAction, AuditSection, DocumentStatus
from src.models.files import Document, StoredFile
from src.repositories import statements as statements_repo
from src.services import consolidation_documents
from src.services.audit import AuditService
from src.services.statements import FORM_TYPES, form_confirmable

AUTO_CONFIRM_DESCRIPTION = "автоподтверждение: без замечаний"
OBJECT_TYPE = "document"


def transition_comment(document: Document) -> str:
    """Комментарий перехода версии: «автоподтверждение документа <тип> [<счёт>]» (design решение №6)."""
    parts = ["автоподтверждение документа", document.document_type.value]
    if document.osv_account_code:
        parts.append(document.osv_account_code)
    return " ".join(parts)


async def auto_confirm(
    session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime
) -> list[Document]:
    """Подтверждает документы `RECOGNIZED` файла (форма — только при `form_confirmable`, D-3); `NEEDS_CHECK` и прочие
    не трогает. Возвращает подтверждённые документы по возрастанию `id`."""
    audit = AuditService(session)
    confirmed: list[Document] = []
    for document in sorted(documents, key=lambda item: item.id):
        if document.status is not DocumentStatus.RECOGNIZED:
            continue
        if not await form_confirmable(session, document):
            continue
        document.status = DocumentStatus.CONFIRMED
        document.confirmed_by = None
        document.confirmed_at = now
        document.updated_at = now
        if document.document_type in FORM_TYPES:
            meta = await statements_repo.get_meta(session, document.id)
            if meta is not None:
                meta.reviewed_by = None
                meta.reviewed_at = now
        await session.flush()
        await consolidation_documents.on_document_confirmed(
            session,
            audit,
            document=document,
            changed_by=file.uploaded_by,
            comment=transition_comment(document),
            now=now,
        )
        await audit.record(
            action=AuditAction.DOCUMENT_CONFIRM,
            section=AuditSection.PRIMARY_DOCUMENTS,
            object_type=OBJECT_TYPE,
            object_id=document.id,
            user_id=None,
            description=AUTO_CONFIRM_DESCRIPTION,
            before_value={"status": DocumentStatus.RECOGNIZED.value},
            after_value={"status": DocumentStatus.CONFIRMED.value},
        )
        confirmed.append(document)
    return confirmed
  • Step 4: Запустить тесты

Run: cd api && .venv/bin/pytest tests/test_auto_confirm.py -v Expected: PASS (11 тестов). Если падает импорт из-за цикла (statements ↔ documents) — проверить python -c "import src.services.auto_confirm" из api/ и перенести импорт form_confirmable внутрь функции с # noqa: PLC0415 - цикл импорта.

  • Step 5: Линтеры и commit

Run: make lint, затем:

git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core add api/src/services/auto_confirm.py api/tests/test_auto_confirm.py
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core commit -m "feat(documents): auto_confirm — чистый документ подтверждается системой (#64, OQ-157)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 4: Автоподтверждение в цикле маппинга

Files:

  • Modify: api/src/services/result_mapping.py (_map_candidate, ~строки 897–902; docstring модуля — шаг маппинга)
  • Modify: api/tests/test_result_mapping.py (ожидания RECOGNIZED после process_once: строки ~239, ~620, test_clean_result, test_mapping_moves_documents_processing_version_only_for_needs_check)
  • Modify: api/tests/e2e/test_osv_ingest_e2e.py (строки ~144, ~150)
  • Test: api/tests/test_result_mapping.py (новые тесты в конце раздела «переход версий по маппингу»)

Interfaces:

  • Consumes: auto_confirm(session, *, file, documents, now) (Task 3).

  • Step 1: Написать падающие тесты

В api/tests/test_result_mapping.py после test_mapping_does_not_move_initiated_version добавить (импорты ConsolidationDocument, AuditEvent, AuditAction — добавить в шапку файла, если их нет; func, select уже импортированы):

async def _history_of(maker: async_sessionmaker[AsyncSession], version_id: uuid.UUID) -> list[ConsolidationStatusHistory]:
    async with maker() as session:
        stmt = (
            select(ConsolidationStatusHistory)
            .where(ConsolidationStatusHistory.version_id == version_id)
            .order_by(ConsolidationStatusHistory.changed_at)
        )
        return list((await session.execute(stmt)).scalars())


async def _bound_ids(maker: async_sessionmaker[AsyncSession], version_id: uuid.UUID) -> set[uuid.UUID]:
    async with maker() as session:
        stmt = select(ConsolidationDocument.document_id).where(
            ConsolidationDocument.consolidation_version_id == version_id
        )
        return set((await session.execute(stmt)).scalars())


async def test_clean_document_is_auto_confirmed_bound_and_moves_version(
    committed_sessionmaker: async_sessionmaker[AsyncSession],
):
    """osv-result-mapping «Привязка и переход версии»: чистая ОСВ 60 за 2025 Q1 → `CONFIRMED` системой, привязана к
    версии 2025 Q3 (`DOCUMENTS_PROCESSING`), переход в `DATA_CORRECTION` от загрузившего с комментарием."""
    maker, storage = committed_sessionmaker, FakeStorage()
    entity_id, version_id, user_id = await _version_in(maker)
    file_id, _, _ = await _prepare(
        maker, storage, _minimal(), entity=await _load_entity(maker, entity_id), uploaded_by=user_id
    )

    assert await process_once(maker, storage, _settings()) is True

    [document] = await _documents(maker, file_id)
    assert (document.status, document.confirmed_by) == (DocumentStatus.CONFIRMED, None)
    assert document.confirmed_at is not None
    assert await _bound_ids(maker, version_id) == {document.id}
    last = (await _history_of(maker, version_id))[-1]
    assert (last.to_status, last.changed_by, last.comment) == (
        ConsolidationStatus.DATA_CORRECTION,
        user_id,
        "автоподтверждение документа OSV 60",
    )
    async with maker() as session:
        events = (
            await session.execute(
                select(AuditEvent).where(
                    AuditEvent.action == AuditAction.DOCUMENT_CONFIRM, AuditEvent.object_id == document.id
                )
            )
        ).scalars().all()
    assert [(event.user_id, event.description) for event in events] == [(None, "автоподтверждение: без замечаний")]


async def test_mixed_file_confirms_clean_and_moves_version_once(
    committed_sessionmaker: async_sessionmaker[AsyncSession],
):
    """Review Focus 1: в файле чистый и `NEEDS_CHECK` документы — версия получает ровно один переход."""
    maker, storage = committed_sessionmaker, FakeStorage()
    entity_id, version_id, user_id = await _version_in(maker)
    result = _minimal()
    second = copy.deepcopy(result["documents"][0])
    second["header"]["account_code_raw"] = "62"
    second["locator"] = "sheet:Лист_2"
    second["checks"] = {"total_row_matches_root": False}  # нарушение проверок → NEEDS_CHECK
    result["documents"].append(second)
    file_id, _, _ = await _prepare(
        maker, storage, result, entity=await _load_entity(maker, entity_id), uploaded_by=user_id
    )

    assert await process_once(maker, storage, _settings()) is True

    statuses = sorted((d.osv_account_code, d.status) for d in await _documents(maker, file_id))
    assert statuses == [("60", DocumentStatus.CONFIRMED), ("62", DocumentStatus.NEEDS_CHECK)]
    history = await _history_of(maker, version_id)
    assert [h.to_status for h in history].count(ConsolidationStatus.DATA_CORRECTION) == 1


async def test_supersede_rebinds_auto_confirmed_document(committed_sessionmaker: async_sessionmaker[AsyncSession]):
    """Review Focus 2: новая чистая версия файла того же ключа заменяет привязку старой."""
    maker, storage = committed_sessionmaker, FakeStorage()
    entity_id, version_id, user_id = await _version_in(maker)
    entity = await _load_entity(maker, entity_id)
    first_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity, uploaded_by=user_id)
    assert await process_once(maker, storage, _settings()) is True
    [first] = await _documents(maker, first_id)
    assert await _bound_ids(maker, version_id) == {first.id}

    second_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity, uploaded_by=user_id)
    assert await process_once(maker, storage, _settings()) is True

    [second] = await _documents(maker, second_id)
    assert second.status is DocumentStatus.CONFIRMED
    assert await _bound_ids(maker, version_id) == {second.id}
    assert (await _file(maker, first_id)).status is FileStatus.SUPERSEDED


async def test_auto_confirm_rolls_back_with_mapping(
    committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch
):
    """Review Focus 3: сбой после автоподтверждения откатывает и его; повтор — одно событие `DOCUMENT_CONFIRM`."""
    maker, storage = committed_sessionmaker, FakeStorage()
    entity_id, _, user_id = await _version_in(maker)
    file_id, job_id, _ = await _prepare(
        maker, storage, _minimal(), entity=await _load_entity(maker, entity_id), uploaded_by=user_id
    )
    original = consolidation_documents.on_documents_mapped
    calls = {"n": 0}

    async def flaky(*args: Any, **kwargs: Any) -> list[uuid.UUID]:
        calls["n"] += 1
        if calls["n"] == 1:
            raise RuntimeError("сбой после автоподтверждения")
        return await original(*args, **kwargs)

    monkeypatch.setattr(consolidation_documents, "on_documents_mapped", flaky)

    assert await process_once(maker, storage, _settings()) is False  # повторяемая неудача (R-15)
    assert await _documents(maker, file_id) == []  # маппинг вместе с автоподтверждением откатился целиком
    assert await process_once(maker, storage, _settings()) is True

    [document] = await _documents(maker, file_id)
    assert document.status is DocumentStatus.CONFIRMED
    async with maker() as session:
        count = (
            await session.execute(
                select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.DOCUMENT_CONFIRM)
            )
        ).scalar_one()
    assert count == 1

Проверить имена в шапке test_result_mapping.py: grep -n "^from\|^import" api/tests/test_result_mapping.py — нужны copy, Any, uuid, consolidation_documents (from src.services import consolidation_documents), ConsolidationDocument, ConsolidationStatusHistory, AuditEvent, AuditAction. Ключи снимка _minimal() (header.account_code_raw, locator, checks) сверить: python -c "import json;print(json.load(open('tests/data/<MINIMAL_RESULT>'))['documents'][0].keys())" (путь — константа MINIMAL_RESULT в начале файла). Между двумя process_once пауза не нужна: process_once вызывается напрямую, цикл со сном не участвует; если кандидат после неудачи не берётся повторно (например, отсечка по finished_at), посмотреть pick_candidate и при необходимости вызвать _map_candidate вторично через тот же кандидат.

  • Step 2: Запустить — должны упасть

Run: cd api && .venv/bin/pytest tests/test_result_mapping.py -k "auto_confirmed or mixed_file or supersede_rebinds or rolls_back" -v Expected: FAIL — документ RECOGNIZED, а не CONFIRMED.

  • Step 3: Вызвать auto_confirm в маппинге

В api/src/services/result_mapping.py добавить импорт from src.services.auto_confirm import auto_confirm (рядом с from src.services import consolidation_documents) и заменить хвост _map_candidate:

        warnings = await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=now)
        # После `map_file`, а значит после автозамены (advisory ЮЛ → старые файлы → advisory ГК → версии): версии
        # блокируются последними, порядок «задание → файл → документы → версии» (D-9) не нарушается. Сначала
        # автоподтверждение чистых документов (привязка и переход версии, OQ-157, design D-1), затем переход по
        # `NEEDS_CHECK` (D-6) — он уже видит версию после автоподтверждения и второго перехода не делает.
        documents = list((await session.execute(select(Document).where(Document.stored_file_id == file.id))).scalars())
        await auto_confirm(session, file=file, documents=documents, now=now)
        await consolidation_documents.on_documents_mapped(session, file=file, documents=documents, now=now)
  • Step 4: Обновить прежние ожидания

В api/tests/test_result_mapping.py:

  • строка ~239: assert document.status is DocumentStatus.CONFIRMED # done, проверки чистые, шапка = владелец по имени → автоподтверждение;
  • строка ~620 (ветка else): assert document.status is DocumentStatus.CONFIRMED;
  • test_clean_result: в parametrize (RecognitionStatus.done, DocumentStatus.CONFIRMED), docstring «… → CONFIRMED (автоподтверждение, OQ-157), MATCH, UNKNOWN»;
  • test_mapping_moves_documents_processing_version_only_for_needs_check: ветку чистого документа переписать — документ CONFIRMED, bindings == 1, версия DATA_CORRECTION, последняя запись истории с changed_by = user_id и комментарием «автоподтверждение документа OSV 60»; для NEEDS_CHECK — как было (bindings == 0). Утверждение assert bindings == 0 перенести внутрь ветки if moved:.

В api/tests/e2e/test_osv_ingest_e2e.py строки ~144 и ~150: "RECOGNIZED" → "CONFIRMED", комментарий «чистая ОСВ подтверждена автоматически (OQ-157)». Если ниже в e2e есть POST …/confirm для этого документа — ожидать 409 ALREADY_CONFIRMED (проверить grep -n "confirm" api/tests/e2e/test_osv_ingest_e2e.py).

Найти остальные ожидания RECOGNIZED после process_once: grep -n "process_once" -A12 api/tests/test_result_mapping.py api/tests/test_api_files_delete.py | grep RECOGNIZED — каждое заменить на CONFIRMED с тем же комментарием. Тесты, вызывающие map_file напрямую (test_mapping_statements.py, test_mapping_golden.py, fixtures_helpers.map_golden_snapshot), НЕ меняются: автоподтверждение — в _map_candidate, не в map_file.

  • Step 5: Запустить

Run: cd api && .venv/bin/pytest tests/test_result_mapping.py tests/test_consolidation_documents.py tests/test_mapping_statements.py tests/test_api_consolidation_checklist.py -v Expected: PASS.

  • Step 6: Обновить docstring модуля маппинга

В docstring api/src/services/result_mapping.py (модельные шаги прохода) дописать к шагу 3: «… → автоподтверждение чистых документов (services/auto_confirm.py, OQ-157) → переход версий по NEEDS_CHECK».

  • Step 7: Линтеры и commit

Run: make lint, затем:

git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core add api/src/services/result_mapping.py api/tests/test_result_mapping.py api/tests/e2e/test_osv_ingest_e2e.py
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core commit -m "feat(mapping): автоподтверждение чистых документов в цикле маппинга (#64, OQ-157)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 5: Удаление файла с автоподтверждёнными документами

Files:

  • Modify: api/src/services/consolidation_documents.py (новые bound_versions, unbind_documents)
  • Modify: api/src/services/primary_files.py (PrimaryFilesService.delete, ~строки 281–330)
  • Modify: api/src/api/endpoints/files/read.py (описание 409 у DELETE, ~строка 34)
  • Test: api/tests/test_api_files_delete.py

Interfaces:

  • Produces: async def bound_versions(session: AsyncSession, *, documents: Sequence[Document]) -> list[tuple[uuid.UUID, ConsolidationStatus]] — блокирует версии «в работе» ЮЛ документов (lock_versions_for_entity по каждому ЮЛ, по возрастанию id ЮЛ) и возвращает пары (версия, статус) всех привязок documents (любой статус версии), отсортированные по id версии.

  • Produces: async def unbind_documents(session: AsyncSession, *, document_ids: Collection[uuid.UUID]) -> int — удаляет все consolidation_document этих документов.

  • Step 1: Написать падающие тесты

В api/tests/test_api_files_delete.py:

  1. В test_confirmed_document_blocks_delete документ CONFIRMED сделать подтверждённым пользователем — после make_document(... account="62", status=DocumentStatus.CONFIRMED) сохранить его в переменную confirmed и добавить:
    confirmed.confirmed_by = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one().id
    await seeded.flush()
  1. В конец файла добавить (импорты AppUser, ConsolidationDocument, ConsolidationVersion, ConsolidationStatus, make_company_group, make_consolidation, PeriodQuarter, utc_now — добавить в шапку при отсутствии):
async def _auto_confirmed_in_versions(
    session: AsyncSession, statuses: list[ConsolidationStatus]
) -> tuple[StoredFile, Document, list[ConsolidationVersion]]:
    """ЮЛ Ростова в ГК; по версии на каждый статус (2024 Q3, Q4 — разные консолидации); файл с автоподтверждённой ОСВ 60
    за 2024 Q2 (`confirmed_by` NULL), привязанной ко всем версиям."""
    office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == "Ростовский региональный филиал"))).scalar_one()
    group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office)
    entity = await make_legal_entity(session, name="ООО Автоподтверждение", group=group)
    other = await make_legal_entity(session, name="ООО Второе", group=group)
    initiator = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()
    versions = []
    for quarter, status in zip([PeriodQuarter.Q3, PeriodQuarter.Q4], statuses, strict=False):
        _, version = await make_consolidation(
            session, group=group, year=2024, quarter=quarter, members=[entity, other], initiator=initiator, status=status
        )
        versions.append(version)
    file, job = await make_stored_file(session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done)
    document = await make_document(
        session, file=file, job=job, account="60", year=2024, quarter=PeriodQuarter.Q2,
        status=DocumentStatus.CONFIRMED, is_cumulative=False,
    )
    document.confirmed_at = utc_now()
    for version in versions:
        session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id))
    await session.flush()
    return file, document, versions


async def test_delete_auto_confirmed_in_correction_unbinds(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
    file, document, [version] = await _auto_confirmed_in_versions(seeded, [ConsolidationStatus.DATA_CORRECTION])
    file_id, version_id = file.id, version.id
    client = await auth_client(KA_RF)

    response = await client.delete(f"/files/{file_id}")

    assert response.status_code == HTTP_NO_CONTENT, response.text
    assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 0
    assert await _count(seeded, ConsolidationDocument, ConsolidationDocument.consolidation_version_id == version_id) == 0
    refreshed = await seeded.get(ConsolidationVersion, version_id, populate_existing=True)
    assert refreshed is not None and refreshed.status is ConsolidationStatus.DATA_CORRECTION
    [event] = await _delete_events(seeded)
    assert event.after_value == {"unbound_version_ids": [str(version_id)]}


async def test_delete_blocked_if_any_bound_version_left_in_work(
    seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
    """Review Focus 5: одна из двух версий на согласовании — 409, ни одна привязка не снята."""
    file, document, versions = await _auto_confirmed_in_versions(
        seeded, [ConsolidationStatus.DATA_CORRECTION, ConsolidationStatus.CONSOLIDATION_RF]
    )
    file_id, document_id = file.id, document.id
    client = await auth_client(KA_RF)

    response = await client.delete(f"/files/{file_id}")

    assert response.status_code == HTTP_CONFLICT, response.text
    assert response.json()["detail"]["code"] == "FILE_HAS_CONFIRMED_DOCUMENTS"
    assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 1
    assert await _count(seeded, ConsolidationDocument, ConsolidationDocument.document_id == document_id) == len(versions)
    assert await _delete_events(seeded) == []


async def test_delete_auto_confirmed_without_bindings(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
    entity = await _rostov_entity(seeded)
    file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done)
    await make_document(seeded, file=file, job=job, account="60", status=DocumentStatus.CONFIRMED)  # confirmed_by NULL
    file_id = file.id
    client = await auth_client(KA_RF)

    response = await client.delete(f"/files/{file_id}")

    assert response.status_code == HTTP_NO_CONTENT, response.text
    [event] = await _delete_events(seeded)
    assert event.after_value == {"unbound_version_ids": []}

Проверить: HTTP_NO_CONTENT, HTTP_CONFLICT, _count, _delete_events, _rostov_entity, KA_RF, RegionalOffice уже определены/импортированы в файле (grep -n "HTTP_NO_CONTENT\|HTTP_CONFLICT\|^KA_RF\|RegionalOffice" api/tests/test_api_files_delete.py); make_consolidation переводит версию в нужный статус, включая CONSOLIDATION_RF (grep -n "async def make_consolidation" -A30 api/tests/factories.py) — если не умеет, после создания выставить version.status = ConsolidationStatus.CONSOLIDATION_RF и flush. Формат after_value (str UUID или UUID) сверить с тем, как FILE_REPLACE.unbound_version_ids проверяется в test_result_mapping.py (grep -n "unbound_version_ids" api/tests/test_result_mapping.py) и привести ожидания к нему. Также проверить test_delete_error_file: если он сравнивает after_value с None, заменить ожидание на {"unbound_version_ids": []}.

  • Step 2: Запустить — должны упасть

Run: cd api && .venv/bin/pytest tests/test_api_files_delete.py -v Expected: FAIL — 409 вместо 204 у test_delete_auto_confirmed_in_correction_unbinds и test_delete_auto_confirmed_without_bindings.

  • Step 3: Функции привязок

В api/src/services/consolidation_documents.py добавить (после unbind_entity):

async def bound_versions(
    session: AsyncSession, *, documents: Sequence[Document]
) -> list[tuple[uuid.UUID, ConsolidationStatus]]:
    """Привязки `documents` к версиям любого статуса — `(version_id, status)` по возрастанию `version_id` (удаление
    файла с автоподтверждёнными документами, OQ-157, design D-4). Сначала блокируются версии «в работе» каждого ЮЛ
    (`lock_versions_for_entity`, advisory ГК → версии по `id`), чтобы статус не сменился между проверкой и снятием
    привязки; версии вне «в работе» читаются без блокировки — из них удаление и так запрещено."""
    if not documents:
        return []
    for entity_id in sorted({document.legal_entity_id for document in documents}):
        await repo.lock_versions_for_entity(session, entity_id, IN_WORK_STATUSES)
    stmt = (
        select(ConsolidationDocument.consolidation_version_id, ConsolidationVersion.status)
        .join(ConsolidationVersion, ConsolidationVersion.id == ConsolidationDocument.consolidation_version_id)
        .where(ConsolidationDocument.document_id.in_([document.id for document in documents]))
        .order_by(ConsolidationDocument.consolidation_version_id)
        .execution_options(populate_existing=True)
    )
    return [(version_id, status) for version_id, status in (await session.execute(stmt)).all()]


async def unbind_documents(session: AsyncSession, *, document_ids: Collection[uuid.UUID]) -> int:
    """Снимает все привязки `document_ids` (удаление файла, D-4); статус версий не меняется. Версии — под блокировкой
    вызывающего (`bound_versions`)."""
    if not document_ids:
        return 0
    result = await session.execute(
        delete(ConsolidationDocument).where(ConsolidationDocument.document_id.in_(list(document_ids)))
    )
    return _rowcount(result)
  • Step 4: Правило удаления

В api/src/services/primary_files.py добавить импорт from src.services import consolidation_documents и DocumentStatus (если нет) и заменить в delete блок после documents = await files_repo.lock_file_documents(...) до current = next(...):

        confirmed = [document for document in documents if document.status is DocumentStatus.CONFIRMED]
        if any(document.confirmed_by is not None for document in confirmed):
            raise ConflictError(
                FILE_HAS_CONFIRMED_DOCUMENTS, "файл нельзя удалить: в нём есть документы, подтверждённые пользователем"
            )
        if await files_repo.supersedes_others(self._session, file_id):
            raise ConflictError(
                FILE_SUPERSEDES_OTHERS,
                "файл нельзя удалить: он заменил более ранние версии, цепочка версий сохраняется",
            )
        # OQ-157, design D-4: автоподтверждённые документы удаляются, пока их версии «в работе»; привязки снимаются
        bound = await consolidation_documents.bound_versions(self._session, documents=confirmed)
        if any(status not in IN_WORK_STATUSES for _, status in bound):
            raise ConflictError(
                FILE_HAS_CONFIRMED_DOCUMENTS,
                "файл нельзя удалить: документ уже участвует в версии консолидации на согласовании",
            )
        unbound_version_ids = sorted({version_id for version_id, _ in bound})
        await consolidation_documents.unbind_documents(
            self._session, document_ids=[document.id for document in confirmed]
        )

В вызове self._audit.record(action=AuditAction.FILE_DELETE, ...) добавить after_value={"unbound_version_ids": unbound_version_ids},. Обновить docstring delete: «409 FILE_HAS_CONFIRMED_DOCUMENTS — есть документ, подтверждённый пользователем, или автоподтверждённый документ привязан к версии вне «в работе» (OQ-157); привязки автоподтверждённых документов снимаются, after_value.unbound_version_ids». Порядок 409 в docstring: пользовательское подтверждение → FILE_SUPERSEDES_OTHERS → версия на согласовании.

В api/src/api/endpoints/files/read.py (описание 409 у DELETE): «FILE_HAS_CONFIRMED_DOCUMENTS (документ подтверждён пользователем или автоподтверждённый документ в версии на согласовании) или FILE_SUPERSEDES_OTHERS …».

  • Step 5: Запустить

Run: cd api && .venv/bin/pytest tests/test_api_files_delete.py tests/test_consolidation_documents.py -v Expected: PASS.

  • Step 6: Линтеры и commit

Run: make lint, затем:

git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core add api/src/services/consolidation_documents.py api/src/services/primary_files.py api/src/api/endpoints/files/read.py api/tests/test_api_files_delete.py
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core commit -m "feat(files): удаление файла с автоподтверждёнными документами до согласования (#64, OQ-157)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 6: Документация arm-core и полная проверка

Files:

  • Modify: CLAUDE.md (arm-core: раздел «Первичные документы (Срез 2c)» и «Консолидации (Срез 4)»)

  • Modify: README.md (arm-core: каталог ошибок — FILE_HAS_CONFIRMED_DOCUMENTS; поведение подтверждения)

  • Modify: openspec/changes/auto-confirm-clean-documents/design.md (раздел «Decisions made without discussion» — дописать решения, принятые при реализации, если были)

  • Step 1: CLAUDE.md

В разделе «Первичные документы (Срез 2c)» после фразы о services/documents.py (карточки, confirm) добавить: «services/auto_confirm.py — автоподтверждение чистых документов в транзакции маппинга (_map_candidate: map_file → auto_confirm → on_documents_mapped; confirmed_by NULL, DOCUMENT_CONFIRM без пользователя, переход версии — от uploaded_by; OQ-157)». В перечне аудита: «DOCUMENT_CONFIRM (пользователь или система при автоподтверждении)». В разделе «Консолидации» у хуков: «on_document_confirmed(changed_by, comment); bound_versions / unbind_documents — удаление файла с автоподтверждёнными документами (OQ-157)».

  • Step 2: README

Найти каталог ошибок: grep -n "FILE_HAS_CONFIRMED_DOCUMENTS" README.md. Обновить строку: «409 — в файле есть документ, подтверждённый пользователем, или автоподтверждённый документ привязан к версии на согласовании (вне INITIATED/DOCUMENTS_PROCESSING/DATA_CORRECTION)». Если в README описан жизненный цикл документа (grep -n "RECOGNIZED" README.md) — дописать: «чистый документ при маппинге сразу CONFIRMED (система, confirmed_by = null, OQ-157); RECOGNIZED остаётся у документов до этой версии и у форм после правки».

  • Step 3: Полная проверка

Run: cd /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core && make check-full 2>&1 | tail -40 Expected: lint, unit, dbml-check, check-infra, integration, e2e (skip без профиля services — допустимо, если так и было), docker-build, web-check — зелёные. Падения от окружения (стенд владельца в той же queue group NATS) — записать в ledger как O-находку с выводом и НЕ гасить стенд владельца без вопроса.

  • Step 4: Commit
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core add CLAUDE.md README.md openspec/changes/auto-confirm-clean-documents/design.md
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core commit -m "docs: автоподтверждение чистых документов в CLAUDE.md и README (#64)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 7: Правило, OQ и гайды в arm-ka-docs

Files (репозиторий docs, worktree /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/docs, ветка feat/64-auto-confirm-rules):

  • Modify: context/05-domain-rules.md (§9.3, §9.5 п. 4 и п. 5)

  • Modify: context/07-open-questions.md (новая строка OQ-157 после OQ-156; пометки в строках OQ-05 и OQ-110)

  • Modify: dev/qa-guide.md (§8 W13, W16–W18, §7.1; раздел 6 про подтверждение и удаление)

  • Modify: dev/frontend-api-guide.md (статусы документа, confirmed_by = null, удаление)

  • Step 1: §9.3 и §9.5

В context/05-domain-rules.md, §9.3 (абзац «RECOGNIZED — задание done …»), после «(OQ-05 закрыт: RECOGNIZED нужен)» вставить: «Автоподтверждение (OQ-157, решение владельца 08.10.2026; ТЗ 03_9 п. 4, 10_10): документ, получивший при маппинге RECOGNIZED, в той же транзакции становится CONFIRMED от имени системы (confirmed_by NULL, audit_event(DOCUMENT_CONFIRM) с user_id NULL и описанием «автоподтверждение: без замечаний»), привязывается к версиям и двигает их, как при ручном подтверждении (§3 п. 1); переход версии — от stored_file.uploaded_by с комментарием «автоподтверждение документа …». Форма ББ/ОФР — только при условиях ручного подтверждения (п. 4); RECOGNIZED формы после правки или акцепта — только вручную. NEEDS_CHECK — только вручную.»

В §9.5 п. 4 (Подтверждение) дописать: «Автоподтверждённый документ (confirmed_by NULL) повторно подтвердить нельзя — 409 ALREADY_CONFIRMED.» В §9.5 п. 5 (Удаление) заменить условие запрета: «запрещено, если у файла есть документ, подтверждённый пользователем (confirmed_by не NULL), или автоподтверждённый документ привязан к версии вне INITIATED/DOCUMENTS_PROCESSING/DATA_CORRECTION (FILE_HAS_CONFIRMED_DOCUMENTS; OQ-157, уточняет OQ-110); привязки автоподтверждённых документов снимаются, статус версий не меняется, FILE_DELETE.after_value.unbound_version_ids».

  • Step 2: OQ

В context/07-open-questions.md после строки | OQ-156 | вставить:

| OQ-157 | Чистый документ (без замечаний) требовал ручного «Подтвердить» (`RECOGNIZED` → `CONFIRMED`), хотя строки ОСВ не правятся (OQ-114) и ТЗ 03_9 п. 4 / 10_10 даёт «Загружен» сразу при отсутствии замечаний; на комплекте 1 кв. 2025 — 16 пустых кликов | прогон владельца 08.10.2026 | arm-core `services/auto_confirm.py`, `result_mapping`, `primary_files.delete`; §9.3, §9.5 п. 4–5 | **решено** (владелец 08.10.2026): чистый документ при маппинге сразу `CONFIRMED` системой (`confirmed_by` NULL), переход версии — от загрузившего; удаление автоподтверждённого файла — пока версии «в работе»; RSHB/arm-core#64, change `auto-confirm-clean-documents` |

В строке OQ-05 в последний столбец дописать: «Пересмотрено OQ-157 (08.10.2026): после маппинга чистый документ сразу CONFIRMED; RECOGNIZED остаётся у старых документов и форм после правки.» В строке OQ-110 — «Уточнено OQ-157: файл с автоподтверждёнными документами удаляется, пока их версии «в работе»; привязки снимаются.»

  • Step 3: Гайды

dev/qa-guide.md:

  • §8 W13: ожидание «у каждого документ … статус «Требуется проверка»» → «чистые ОСВ сразу «Загружен» (автоподтверждение, OQ-157); «Требуется проверка» — только с замечаниями (например, шапка другой организации)»;
  • W16–W17: «Просмотр ОСВ» открывается для документа «Загружен», кнопка «✓ Подтвердить» неактивна; проверка подтверждения — на документе «Требуется проверка» (загрузить ОСВ Счастливочной под ЮЛ РБ: Контрагент ✗);
  • W18: demo-prepare.sh подтверждает только неподтверждённое — вывод скрипта без изменений; статус версии «Коррекция данных» появляется сразу после распознавания;
  • §6 (раздел про подтверждение и удаление файлов): сценарии «удаление автоподтверждённого файла в «Коррекции данных» → 204, привязка снята» и «версия на согласовании → 409».

dev/frontend-api-guide.md: в разделе статусов документа — «после маппинга чистый документ приходит сразу CONFIRMED с confirmed_by: null (автоподтверждение); кнопку «Подтвердить» показывать неактивной; удаление файла с таким документом возможно (409 FILE_HAS_CONFIRMED_DOCUMENTS — если подтверждал пользователь или версия на согласовании)».

Точные места найти: grep -n "W13\|W16\|W17\|W18\|FILE_HAS_CONFIRMED\|RECOGNIZED" dev/qa-guide.md dev/frontend-api-guide.md.

  • Step 4: Commit
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/docs add context/05-domain-rules.md context/07-open-questions.md dev/qa-guide.md dev/frontend-api-guide.md
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/docs commit -m "docs: автоподтверждение чистых документов — §9.3, §9.5, OQ-157, гайды (RSHB/arm-core#64)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

После задач (контроллер)

  • Финальное ревью всей ветки arm-core (модель opus) по спеке change и Review Focus.
  • PR arm-core feat/64-auto-confirm → main (closes #64; вывод make check-full; таблица находок с маркерами; «Rulings I made»); PR arm-ka-docs feat/64-auto-confirm-rules → main (ссылка на arm-core PR, «сливать вместе»).
  • Ничего не мержить; openspec archive — только по команде «закрывай».