From 26529b3bef0165f015437ac1109f191bd50ede3e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 5 Nov 2026 16:04:25 +0400 Subject: [PATCH] =?UTF-8?q?feat(#190):=20=D1=87=D0=B8=D1=81=D1=82=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=81=D0=BD=D0=B8=D0=BC=D0=BA=D0=B8=20payload=20?= =?UTF-8?q?=D1=84=D0=B0=D0=BA=D1=82=D0=BE=D0=B2=20=D0=BA=D0=B0=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D0=B0=20(=C2=A714.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Haiku 5.5 --- api/src/domain/quality_facts.py | 107 +++++++++++++++++++++ api/tests/test_domain_quality_facts.py | 127 +++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 api/src/domain/quality_facts.py create mode 100644 api/tests/test_domain_quality_facts.py diff --git a/api/src/domain/quality_facts.py b/api/src/domain/quality_facts.py new file mode 100644 index 0000000..a013204 --- /dev/null +++ b/api/src/domain/quality_facts.py @@ -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 diff --git a/api/tests/test_domain_quality_facts.py b/api/tests/test_domain_quality_facts.py new file mode 100644 index 0000000..6bae63a --- /dev/null +++ b/api/tests/test_domain_quality_facts.py @@ -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