feat(#190): чистые снимки payload фактов качества (§14.1)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-05 16:04:25 +04:00
parent 6d1a322e87
commit 26529b3bef
2 changed files with 234 additions and 0 deletions

View File

@ -0,0 +1,107 @@
"""Чистые снимки `payload` фактов качества первички (§14.1; design `quality-metrics` §4.1, §4.2).
Чистые функции без БД и сессии: строят JSON-блоб `payload` для `quality_intake_fact` (снимок «как распознано», при
приёме) и `quality_outcome_fact` (подтверждённый результат + список правок, при исходе). Запись в таблицы — в
репозитории и хуках сервисов, не здесь.
Форма зависит от типа документа (§14.1): `BALANCE` / `INCOME` — шапка, строки, значения и нарушения; `OSV` —
только предупреждения `OSV_*` (построчного снимка и правок сумм в ядре нет). Снимок и исход различаются только
ключом `fixes` у исхода.
Объекты строк, значений и нарушений читаются атрибутами без привязки к сессии: отсутствующий атрибут даёт `None`.
"""
from collections.abc import Iterable, Sequence
from datetime import date, datetime
from decimal import Decimal
from enum import Enum
from typing import Any
from uuid import UUID
from src.models.enums import DocumentType
from src.models.files import Document
__all__ = ["build_intake_payload", "build_outcome_payload"]
_HEADER_FIELDS = ("document_type", "period_year", "period_quarter", "is_cumulative", "reporting_date")
_LINE_FIELDS = ("row_index", "line_code", "line_code_text", "line_status", "needs_review")
_VALUE_FIELDS = (
"line_id",
"column_id",
"amount",
"string_value",
"source_text",
"confidence",
"is_recognized",
"needs_review",
)
_ISSUE_FIELDS = ("kind", "code", "severity", "source", "field", "relation", "codes", "line_ids")
def build_intake_payload(
document: Document,
*,
lines: Sequence[Any] | None = None,
values: Sequence[Any] | None = None,
issues: Iterable[Any] = (),
osv_warnings: Iterable[str] = (),
) -> dict[str, Any]:
"""Снимок «как распознано» для `quality_intake_fact.payload` (§14.1, design §4.1).
`BALANCE` / `INCOME`: `{"header", "lines", "values", "issues"}`. `OSV`: `{"osv_warnings"}` без `lines` и `values`.
"""
if document.document_type is DocumentType.OSV:
return {"osv_warnings": list(osv_warnings)}
return {
"header": _pick(document, _HEADER_FIELDS),
"lines": [_line(line) for line in lines or ()],
"values": [_value(value) for value in values or ()],
"issues": [_pick(issue, _ISSUE_FIELDS) for issue in issues],
}
def build_outcome_payload( # noqa: PLR0913 - сигнатура снимка фиксирована (design §4.2)
document: Document,
*,
lines: Sequence[Any] | None = None,
values: Sequence[Any] | None = None,
issues: Iterable[Any] = (),
osv_warnings: Iterable[str] = (),
fixes: Iterable[Any] = (),
) -> dict[str, Any]:
"""Подтверждённый результат и правки для `quality_outcome_fact.payload` (§14.1, design §4.2).
Строение — как у снимка приёма (`build_intake_payload`), плюс `fixes`: список правок с отнесением, каждая запись
строится вызывающим (состав записи — задача отнесения правок, не этой функции).
"""
payload = build_intake_payload(document, lines=lines, values=values, issues=issues, osv_warnings=osv_warnings)
payload["fixes"] = list(fixes)
return payload
def _line(line: Any) -> dict[str, Any]:
return {
"id": _plain(getattr(line, "id", None)),
"ref": _plain(getattr(line, "source_ref", None)),
**_pick(line, _LINE_FIELDS),
}
def _value(value: Any) -> dict[str, Any]:
return _pick(value, _VALUE_FIELDS)
def _pick(obj: Any, fields: Sequence[str]) -> dict[str, Any]:
return {field: _plain(getattr(obj, field, None)) for field in fields}
def _plain(value: Any) -> Any:
"""Приводит значение к JSON-совместимому виду: Decimal, UUID и даты — строкой, enum — своим значением."""
if isinstance(value, Enum):
return value.value
if isinstance(value, Decimal):
return str(value)
if isinstance(value, UUID):
return str(value)
if isinstance(value, datetime | date):
return value.isoformat()
return value

View File

@ -0,0 +1,127 @@
"""Чистые снимки payload фактов качества (§14.1): форма для ОБ/ОФР и для ОСВ."""
import uuid
from decimal import Decimal
from src.domain.quality_facts import build_intake_payload, build_outcome_payload
from src.models.enums import (
DocumentIssueKind,
DocumentStatus,
DocumentType,
IssueSeverity,
IssueSource,
StatementLineStatus,
)
from src.models.files import Document
from src.models.statements import DocumentIssue, FinancialStatementLine, FinancialStatementValue
def make_document(document_type: DocumentType = DocumentType.BALANCE) -> Document:
return Document(
id=uuid.uuid4(),
document_type=document_type,
status=DocumentStatus.RECOGNIZED,
)
def test_build_intake_payload_statement_has_lines_and_values():
document = make_document(document_type=DocumentType.BALANCE)
payload = build_intake_payload(document, lines=[...], values=[...], issues=[])
assert "lines" in payload and "values" in payload
assert "osv_warnings" not in payload
def test_build_intake_payload_osv_has_no_lines_or_values():
document = make_document(document_type=DocumentType.OSV)
payload = build_intake_payload(document, osv_warnings=["OSV_SOMETHING"])
assert "lines" not in payload and "values" not in payload
assert payload["osv_warnings"] == ["OSV_SOMETHING"]
def test_build_intake_payload_statement_serializes_line_value_and_issue_fields():
document = make_document(document_type=DocumentType.INCOME)
line_id = uuid.uuid4()
column_id = uuid.uuid4()
line = FinancialStatementLine(
id=line_id,
source_ref="row:3",
row_index=3,
line_code="2110",
line_code_text="2110",
line_status=StatementLineStatus.ok,
needs_review=True,
)
value = FinancialStatementValue(
line_id=line_id,
column_id=column_id,
amount=Decimal("1500.00"),
string_value=None,
source_text="1 500",
confidence=Decimal("95.00"),
is_recognized=True,
needs_review=False,
)
issue = DocumentIssue(
document_id=document.id,
kind=DocumentIssueKind.relation,
code="RELATION_BROKEN",
severity=IssueSeverity.error,
message="нарушено",
source=IssueSource.core,
relation="2110 = 2120",
codes=["2110"],
)
payload = build_intake_payload(document, lines=[line], values=[value], issues=[issue])
assert payload["lines"] == [
{
"id": str(line_id),
"ref": "row:3",
"row_index": 3,
"line_code": "2110",
"line_code_text": "2110",
"line_status": "ok",
"needs_review": True,
}
]
assert payload["values"] == [
{
"line_id": str(line_id),
"column_id": str(column_id),
"amount": "1500.00",
"string_value": None,
"source_text": "1 500",
"confidence": "95.00",
"is_recognized": True,
"needs_review": False,
}
]
assert payload["issues"] == [
{
"kind": "relation",
"code": "RELATION_BROKEN",
"severity": "error",
"source": "core",
"field": None,
"relation": "2110 = 2120",
"codes": ["2110"],
"line_ids": None,
}
]
def test_build_outcome_payload_statement_has_fixes_and_no_osv_warnings():
document = make_document(document_type=DocumentType.BALANCE)
fix = {"what": "line_code", "was": "2110", "became": "2120", "source": "service", "rule": "§5"}
payload = build_outcome_payload(document, lines=[], values=[], issues=[], fixes=[fix])
assert payload["fixes"] == [fix]
assert "lines" in payload and "values" in payload
assert "osv_warnings" not in payload
def test_build_outcome_payload_osv_has_fixes_and_no_lines():
document = make_document(document_type=DocumentType.OSV)
fix = {"what": "period", "was": "2024 Q1", "became": "2024 Q2", "source": "ours", "rule": "§5"}
payload = build_outcome_payload(document, osv_warnings=["OSV_SOMETHING"], fixes=[fix])
assert payload["osv_warnings"] == ["OSV_SOMETHING"]
assert payload["fixes"] == [fix]
assert "lines" not in payload and "values" not in payload