feat(#190): чистые снимки payload фактов качества (§14.1)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6d1a322e87
commit
26529b3bef
107
api/src/domain/quality_facts.py
Normal file
107
api/src/domain/quality_facts.py
Normal 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
|
||||
127
api/tests/test_domain_quality_facts.py
Normal file
127
api/tests/test_domain_quality_facts.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user