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:
parent
12ea8acd42
commit
2a9ed10a29
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user