fix(#190): header_source и счётчик строк ОСВ в payload фактов качества (§14.1)

Снимки приёма и исхода получают необязательные kwargs: header_source (сырой текст шапки
из financial_statement_meta.header_source) кладётся в header для ББ/ОФР, osv_row_count —
в payload ОСВ как row_count. Без kwargs ключи не эмитятся.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-05 16:07:45 +04:00
parent 26529b3bef
commit 5f1001c808
2 changed files with 75 additions and 8 deletions

View File

@ -10,7 +10,7 @@
Объекты строк, значений и нарушений читаются атрибутами без привязки к сессии: отсутствующий атрибут даёт `None`. Объекты строк, значений и нарушений читаются атрибутами без привязки к сессии: отсутствующий атрибут даёт `None`.
""" """
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Mapping, Sequence
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from enum import Enum from enum import Enum
@ -37,22 +37,33 @@ _VALUE_FIELDS = (
_ISSUE_FIELDS = ("kind", "code", "severity", "source", "field", "relation", "codes", "line_ids") _ISSUE_FIELDS = ("kind", "code", "severity", "source", "field", "relation", "codes", "line_ids")
def build_intake_payload( def build_intake_payload( # noqa: PLR0913 - сигнатура снимка фиксирована (design §4.1)
document: Document, document: Document,
*, *,
lines: Sequence[Any] | None = None, lines: Sequence[Any] | None = None,
values: Sequence[Any] | None = None, values: Sequence[Any] | None = None,
issues: Iterable[Any] = (), issues: Iterable[Any] = (),
osv_warnings: Iterable[str] = (), osv_warnings: Iterable[str] = (),
header_source: Mapping[str, Any] | None = None,
osv_row_count: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Снимок «как распознано» для `quality_intake_fact.payload` (§14.1, design §4.1). """Снимок «как распознано» для `quality_intake_fact.payload` (§14.1, design §4.1).
`BALANCE` / `INCOME`: `{"header", "lines", "values", "issues"}`. `OSV`: `{"osv_warnings"}` без `lines` и `values`. `BALANCE` / `INCOME`: `{"header", "lines", "values", "issues"}`; при переданном `header_source` (сырой текст и
уверенность по полям шапки, `financial_statement_meta.header_source`) он кладётся в `header["header_source"]`.
`OSV`: `{"osv_warnings"}` без `lines` и `values`; при переданном `osv_row_count` добавляется `row_count`.
Без необязательных аргументов ключей `header_source` и `row_count` в payload нет.
""" """
if document.document_type is DocumentType.OSV: if document.document_type is DocumentType.OSV:
return {"osv_warnings": list(osv_warnings)} payload: dict[str, Any] = {"osv_warnings": list(osv_warnings)}
if osv_row_count is not None:
payload["row_count"] = osv_row_count
return payload
header = _pick(document, _HEADER_FIELDS)
if header_source is not None:
header["header_source"] = {key: _plain_mapping(entry) for key, entry in header_source.items()}
return { return {
"header": _pick(document, _HEADER_FIELDS), "header": header,
"lines": [_line(line) for line in lines or ()], "lines": [_line(line) for line in lines or ()],
"values": [_value(value) for value in values or ()], "values": [_value(value) for value in values or ()],
"issues": [_pick(issue, _ISSUE_FIELDS) for issue in issues], "issues": [_pick(issue, _ISSUE_FIELDS) for issue in issues],
@ -67,13 +78,24 @@ def build_outcome_payload( # noqa: PLR0913 - сигнатура снимка ф
issues: Iterable[Any] = (), issues: Iterable[Any] = (),
osv_warnings: Iterable[str] = (), osv_warnings: Iterable[str] = (),
fixes: Iterable[Any] = (), fixes: Iterable[Any] = (),
header_source: Mapping[str, Any] | None = None,
osv_row_count: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Подтверждённый результат и правки для `quality_outcome_fact.payload` (§14.1, design §4.2). """Подтверждённый результат и правки для `quality_outcome_fact.payload` (§14.1, design §4.2).
Строение — как у снимка приёма (`build_intake_payload`), плюс `fixes`: список правок с отнесением, каждая запись Строение — как у снимка приёма (`build_intake_payload`, с теми же `header_source` и `osv_row_count`), плюс `fixes`:
строится вызывающим (состав записи — задача отнесения правок, не этой функции). список правок с отнесением, каждая запись строится вызывающим (состав записи — задача отнесения правок, не этой
функции).
""" """
payload = build_intake_payload(document, lines=lines, values=values, issues=issues, osv_warnings=osv_warnings) payload = build_intake_payload(
document,
lines=lines,
values=values,
issues=issues,
osv_warnings=osv_warnings,
header_source=header_source,
osv_row_count=osv_row_count,
)
payload["fixes"] = list(fixes) payload["fixes"] = list(fixes)
return payload return payload
@ -86,6 +108,13 @@ def _line(line: Any) -> dict[str, Any]:
} }
def _plain_mapping(entry: Any) -> Any:
"""Приводит запись `header_source` (`{"text", "confidence"}`) к JSON-совместимому виду; Decimal → строка."""
if isinstance(entry, Mapping):
return {key: _plain(item) for key, item in entry.items()}
return _plain(entry)
def _value(value: Any) -> dict[str, Any]: def _value(value: Any) -> dict[str, Any]:
return _pick(value, _VALUE_FIELDS) return _pick(value, _VALUE_FIELDS)

View File

@ -15,6 +15,8 @@ from src.models.enums import (
from src.models.files import Document from src.models.files import Document
from src.models.statements import DocumentIssue, FinancialStatementLine, FinancialStatementValue from src.models.statements import DocumentIssue, FinancialStatementLine, FinancialStatementValue
ROW_COUNT = 4_200
def make_document(document_type: DocumentType = DocumentType.BALANCE) -> Document: def make_document(document_type: DocumentType = DocumentType.BALANCE) -> Document:
return Document( return Document(
@ -125,3 +127,39 @@ def test_build_outcome_payload_osv_has_fixes_and_no_lines():
assert payload["osv_warnings"] == ["OSV_SOMETHING"] assert payload["osv_warnings"] == ["OSV_SOMETHING"]
assert payload["fixes"] == [fix] assert payload["fixes"] == [fix]
assert "lines" not in payload and "values" not in payload assert "lines" not in payload and "values" not in payload
def test_build_intake_payload_includes_header_source_for_statement():
document = make_document(document_type=DocumentType.BALANCE)
header_source = {"inn": {"text": "7701234567", "confidence": Decimal("100.00")}}
payload = build_intake_payload(document, lines=[], values=[], header_source=header_source)
assert payload["header"]["header_source"] == {"inn": {"text": "7701234567", "confidence": "100.00"}}
def test_build_outcome_payload_includes_header_source_for_statement():
document = make_document(document_type=DocumentType.INCOME)
header_source = {"okpo": {"text": "12345678", "confidence": None}}
payload = build_outcome_payload(document, lines=[], values=[], fixes=[], header_source=header_source)
assert payload["header"]["header_source"] == {"okpo": {"text": "12345678", "confidence": None}}
def test_build_intake_payload_includes_osv_row_count():
document = make_document(document_type=DocumentType.OSV)
payload = build_intake_payload(document, osv_warnings=["OSV_SOMETHING"], osv_row_count=ROW_COUNT)
assert payload["row_count"] == ROW_COUNT
assert payload["osv_warnings"] == ["OSV_SOMETHING"]
def test_build_outcome_payload_includes_osv_row_count():
document = make_document(document_type=DocumentType.OSV)
payload = build_outcome_payload(document, osv_warnings=[], fixes=[], osv_row_count=0)
assert payload["row_count"] == 0
def test_header_source_and_row_count_absent_when_kwargs_omitted():
balance = build_intake_payload(make_document(document_type=DocumentType.BALANCE), lines=[], values=[])
assert "header_source" not in balance["header"]
osv = build_intake_payload(make_document(document_type=DocumentType.OSV), osv_warnings=[])
assert "row_count" not in osv
outcome_osv = build_outcome_payload(make_document(document_type=DocumentType.OSV), osv_warnings=[], fixes=[])
assert "row_count" not in outcome_osv