diff --git a/docs/superpowers/plans/2026-10-08-auto-confirm-clean-documents.md b/docs/superpowers/plans/2026-10-08-auto-confirm-clean-documents.md new file mode 100644 index 0000000..ca4d394 --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-auto-confirm-clean-documents.md @@ -0,0 +1,1189 @@ +# Автоподтверждение чистых документов — план реализации + +> **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_`. `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 = «автоподтверждение документа »` (счёт — только если есть, через пробел; например «автоподтверждение документа 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 `. +- Перед 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`) добавить: + +```python +@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`: + +```python +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`, заменить вызов: + +```python + 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** + +```bash +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 " +``` + +--- + +### 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`: + +```python +"""Условия подтверждения формы ББ/ОФР — общие для ручного и автоматического пути (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({...})` добавить: + +```python +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`: + +```python + 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`, заменить импорт и вызов: + +```python + from src.services.statements import FORM_TYPES, statement_hard_failures # noqa: PLC0415 - цикл импорта +``` +```python + 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`), затем: + +```bash +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 " +``` + +--- + +### 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`: + +```python +"""Автоподтверждение чистых документов (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`: + +```python +"""Автоподтверждение чистых документов при маппинге результата (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`, затем: + +```bash +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 " +``` + +--- + +### 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` уже импортированы): + +```python +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/'))['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`: + +```python + 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`, затем: + +```bash +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 " +``` + +--- + +### 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` и добавить: + +```python + confirmed.confirmed_by = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one().id + await seeded.flush() +``` + +2) В конец файла добавить (импорты `AppUser`, `ConsolidationDocument`, `ConsolidationVersion`, `ConsolidationStatus`, `make_company_group`, `make_consolidation`, `PeriodQuarter`, `utc_now` — добавить в шапку при отсутствии): + +```python +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`): + +```python +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(...)`: + +```python + 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`, затем: + +```bash +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 " +``` + +--- + +### 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** + +```bash +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 " +``` + +--- + +### 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** + +```bash +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 " +``` + +--- + +## После задач (контроллер) + +- Финальное ревью всей ветки 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` — только по команде «закрывай».