From 2a9ed10a298d6223fb03acae2f2cc4d5366fcd1f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 5 Nov 2026 19:25:03 +0400 Subject: [PATCH] =?UTF-8?q?feat(#190):=20=D1=84=D0=B0=D0=BA=D1=82=20=D0=B8?= =?UTF-8?q?=D1=81=D1=85=D0=BE=D0=B4=D0=B0=20AUTO=5FCONFIRMED=20(=C2=A714.3?= =?UTF-8?q?=20=D0=BF.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хук в auto_confirm: на каждый документ, подтверждённый системой, пишется quality_outcome_fact (outcome=AUTO_CONFIRMED, primary_cause=WITHOUT_PARTICIPATION всегда — правок при автоподтверждении не бывает, D-3 блокирует формы с нарушениями). payload строится build_outcome_payload по тому же шаблону, что факт приёма (Task 5): ОСВ — osv_warnings/row_count, формы — lines/values/header_source, issue_count_at_outcome — все нарушения документа без фильтра. Запись в транзакции маппинга, без flush/commit (Task 4 insert_outcome_fact). Co-Authored-By: Claude Sonnet 5 --- api/src/services/auto_confirm.py | 70 +++++++++++++++++++++- api/tests/test_auto_confirm.py | 100 +++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) diff --git a/api/src/services/auto_confirm.py b/api/src/services/auto_confirm.py index 84f943c..f639b89 100644 --- a/api/src/services/auto_confirm.py +++ b/api/src/services/auto_confirm.py @@ -5,16 +5,36 @@ `CONFIRMED` от имени системы: `confirmed_by = NULL`, у формы `reviewed_by = NULL`, `audit_event(DOCUMENT_CONFIRM)` без пользователя. Привязка к версиям и переход версии — тот же хук, что у ручного подтверждения, с автором `stored_file.uploaded_by`. Вызывается в транзакции маппинга под заданием, файлом и документами (порядок блокировок D-5). + +§14.3 п. 5 (design `quality-metrics` §4.2, AURORA-1759): на каждый подтверждённый документ пишется факт исхода +`quality_outcome_fact` с `outcome = AUTO_CONFIRMED`. Правок при автоподтверждении не бывает (D-3 блокирует формы +с нарушениями) — `primary_cause` всегда `WITHOUT_PARTICIPATION`, без вычисления приоритета §14.3 (он нужен только +общему случаю `CONFIRMED`, Task 14). Запись — в той же транзакции, без `flush`/`commit`. """ import datetime from collections.abc import Sequence +from typing import Any +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from src.models.enums import AuditAction, AuditSection, DocumentStatus +from src.domain.quality_facts import build_outcome_payload +from src.models.enums import ( + AuditAction, + AuditSection, + DocumentIssueKind, + DocumentStatus, + DocumentType, + IssueSource, + QualityCause, + QualityOutcome, +) from src.models.files import Document, StoredFile +from src.models.osv import OsvEntry +from src.models.statements import FinancialStatementMeta from src.repositories import statements as statements_repo +from src.repositories.quality_facts import insert_outcome_fact from src.services import consolidation_documents from src.services.audit import AuditService from src.services.statements import FORM_TYPES, form_blocker @@ -31,6 +51,43 @@ def transition_comment(document: Document) -> str: return " ".join(parts) +async def _outcome_fact_fields( + session: AsyncSession, document: Document, *, meta: FinancialStatementMeta | None +) -> dict[str, Any]: + """`payload` и `issue_count_at_outcome` факта исхода `AUTO_CONFIRMED` (§14.3 п. 5, design §4.2). + + Строение `payload` повторяет факт приёма (Task 5, `services/result_mapping.py`: + `_osv_intake_fields`/`_statement_intake_fields`), но как снимок ИСХОДА (`build_outcome_payload`, `fixes=()` — + правок при автоподтверждении не бывает, D-3). `issue_count_at_outcome` — ВСЕ нарушения документа на этот момент, + без фильтра по `kind`/`source` (в отличие от `warning_count` факта приёма, который фильтрует только + предупреждения сервиса).""" + issues = await statements_repo.issues(session, document.id) + if document.document_type is DocumentType.OSV: + row_count = ( + await session.execute( + select(func.count()).select_from(OsvEntry).where(OsvEntry.document_id == document.id) + ) + ).scalar_one() + osv_warnings = [ + issue.code + for issue in issues + if issue.kind is DocumentIssueKind.warning and issue.source is IssueSource.service + ] + payload = build_outcome_payload(document, osv_warnings=osv_warnings, osv_row_count=row_count, fixes=()) + elif meta is not None: + _, pairs = await statements_repo.load_form(session, meta.id) + lines = [line for line, _ in pairs] + values = [value for _, line_values in pairs for value in line_values] + payload = build_outcome_payload( + document, lines=lines, values=values, issues=issues, header_source=meta.header_source, fixes=() + ) + else: + # DISCLOSURE/OTHER и форма без шапки (в текущем `map_file` не возникает — см. Task 5): снимок без построчных + # данных дешевле, чем прерванная транзакция маппинга + payload = build_outcome_payload(document, issues=issues, fixes=()) + return {"issue_count_at_outcome": len(issues), "payload": payload} + + async def auto_confirm( session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime ) -> list[Document]: @@ -73,4 +130,15 @@ async def auto_confirm( after_value={"status": DocumentStatus.CONFIRMED.value}, ) confirmed.append(document) + fields = await _outcome_fact_fields(session, document, meta=meta) + await insert_outcome_fact( + session, + document_id=document.id, + outcome=QualityOutcome.AUTO_CONFIRMED, + outcome_at=now, + primary_cause=QualityCause.WITHOUT_PARTICIPATION, + report_date=document.reporting_date, + confirmed_by=None, + **fields, + ) return confirmed diff --git a/api/tests/test_auto_confirm.py b/api/tests/test_auto_confirm.py index 45c5e07..56e0f22 100644 --- a/api/tests/test_auto_confirm.py +++ b/api/tests/test_auto_confirm.py @@ -18,15 +18,22 @@ from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice from src.models.enums import ( AuditAction, ConsolidationStatus, + DocumentIssueKind, DocumentStatus, DocumentType, FileStatus, + IssueSeverity, + IssueSource, PeriodQuarter, + QualityCause, + QualityOutcome, RecognitionStatus, StatementLineStatus, ) from src.models.files import Document, StoredFile +from src.models.quality_facts import QualityOutcomeFact from src.models.statements import ( + DocumentIssue, FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta, @@ -177,6 +184,99 @@ async def test_mixed_documents_confirm_only_clean(seeded: AsyncSession) -> None: assert [d.status for d in documents] == [DocumentStatus.CONFIRMED, DocumentStatus.NEEDS_CHECK] +async def test_auto_confirm_writes_outcome_fact_without_participation(seeded: AsyncSession) -> None: + """§14.3 п. 5 (design `quality-metrics` §4.2): `AUTO_CONFIRMED` без правок ⇒ всегда `WITHOUT_PARTICIPATION`.""" + 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] + fact = ( + await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document.id)) + ).scalar_one() + assert fact.outcome == QualityOutcome.AUTO_CONFIRMED + assert fact.primary_cause == QualityCause.WITHOUT_PARTICIPATION + assert fact.outcome_at == now + assert fact.confirmed_by is None + assert fact.report_date == document.reporting_date + + +async def test_auto_confirm_outcome_fact_osv_payload_shape(seeded: AsyncSession) -> None: + """Payload исхода ОСВ — как у факта приёма (Task 5 `_osv_intake_fields`): без `lines`/`values`, предупреждения + сервиса `OSV_*` в `osv_warnings` (ядровые `MAPPING_WARNING` — нет), число строк `osv_entry` — `row_count`; + `issue_count_at_outcome` — ВСЕ нарушения документа, без фильтра по `source`/`kind`.""" + group, entities = await _group(seeded) + user = await _user(seeded) + file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], user.id) + now = utc_now() + seeded.add( + DocumentIssue( + document_id=document.id, + kind=DocumentIssueKind.warning, + code="OSV_ROUNDING", + severity=IssueSeverity.info, + message="тестовое предупреждение сервиса", + source=IssueSource.service, + created_at=now, + ) + ) + seeded.add( + DocumentIssue( + document_id=document.id, + kind=DocumentIssueKind.warning, + code="MAPPING_WARNING", + severity=IssueSeverity.info, + message="тестовое предупреждение ядра", + source=IssueSource.core, + created_at=now, + ) + ) + await seeded.flush() + + await auto_confirm(seeded, file=file, documents=[document], now=now) + + fact = ( + await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document.id)) + ).scalar_one() + assert "lines" not in fact.payload + assert "values" not in fact.payload + assert fact.payload["osv_warnings"] == ["OSV_ROUNDING"] + assert fact.payload["row_count"] == 0 + assert fact.payload["fixes"] == [] + assert fact.issue_count_at_outcome == 2 # noqa: PLR2004 - оба созданных нарушения, без фильтра + + +@pytest.mark.fixtures +async def test_auto_confirm_outcome_fact_for_form_has_lines(seeded: AsyncSession, fixtures_dir: Path) -> None: + """Payload исхода формы ББ/ОФР — строки и значения формы, как у факта приёма (Task 5 `_statement_intake_fields`), + `header_source` из `meta`; правок при автоподтверждении нет (`fixes=[]`).""" + 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) + 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] + + fact = ( + await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == income.id)) + ).scalar_one() + assert fact.outcome == QualityOutcome.AUTO_CONFIRMED + assert fact.primary_cause == QualityCause.WITHOUT_PARTICIPATION + assert fact.report_date == income.reporting_date + assert fact.payload["fixes"] == [] + assert fact.payload["lines"] + assert "osv_warnings" not in fact.payload + + @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"