# Автоподтверждение чистых документов — план реализации > **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` — только по команде «закрывай».