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)` без
пользователя. Привязка к версиям и переход версии — тот же хук, что у ручного подтверждения, с автором
`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

View File

@ -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"