feat(#190): факт исхода AUTO_CONFIRMED (§14.3 п.5)

Хук в 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 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-05 19:25:03 +04:00
parent 12ea8acd42
commit 2a9ed10a29
2 changed files with 169 additions and 1 deletions

View File

@ -5,16 +5,36 @@
`CONFIRMED` от имени системы: `confirmed_by = NULL`, у формы `reviewed_by = NULL`, `audit_event(DOCUMENT_CONFIRM)` без `CONFIRMED` от имени системы: `confirmed_by = NULL`, у формы `reviewed_by = NULL`, `audit_event(DOCUMENT_CONFIRM)` без
пользователя. Привязка к версиям и переход версии — тот же хук, что у ручного подтверждения, с автором пользователя. Привязка к версиям и переход версии — тот же хук, что у ручного подтверждения, с автором
`stored_file.uploaded_by`. Вызывается в транзакции маппинга под заданием, файлом и документами (порядок блокировок D-5). `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 import datetime
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession 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.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 import statements as statements_repo
from src.repositories.quality_facts import insert_outcome_fact
from src.services import consolidation_documents from src.services import consolidation_documents
from src.services.audit import AuditService from src.services.audit import AuditService
from src.services.statements import FORM_TYPES, form_blocker from src.services.statements import FORM_TYPES, form_blocker
@ -31,6 +51,43 @@ def transition_comment(document: Document) -> str:
return " ".join(parts) 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( async def auto_confirm(
session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime
) -> list[Document]: ) -> list[Document]:
@ -73,4 +130,15 @@ async def auto_confirm(
after_value={"status": DocumentStatus.CONFIRMED.value}, after_value={"status": DocumentStatus.CONFIRMED.value},
) )
confirmed.append(document) 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 return confirmed

View File

@ -18,15 +18,22 @@ from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice
from src.models.enums import ( from src.models.enums import (
AuditAction, AuditAction,
ConsolidationStatus, ConsolidationStatus,
DocumentIssueKind,
DocumentStatus, DocumentStatus,
DocumentType, DocumentType,
FileStatus, FileStatus,
IssueSeverity,
IssueSource,
PeriodQuarter, PeriodQuarter,
QualityCause,
QualityOutcome,
RecognitionStatus, RecognitionStatus,
StatementLineStatus, StatementLineStatus,
) )
from src.models.files import Document, StoredFile from src.models.files import Document, StoredFile
from src.models.quality_facts import QualityOutcomeFact
from src.models.statements import ( from src.models.statements import (
DocumentIssue,
FinancialStatementColumn, FinancialStatementColumn,
FinancialStatementLine, FinancialStatementLine,
FinancialStatementMeta, 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] 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 @pytest.mark.fixtures
async def test_clean_form_sets_reviewed_at(seeded: AsyncSession, fixtures_dir: Path) -> None: 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" path = fixtures_dir / GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"