feat(statements): адаптер arm_statements за протоколом StatementsEngine и вход пересчёта из БД
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
42f0dc5ca7
commit
6ce36b3e2d
126
api/src/domain/stored_form.py
Normal file
126
api/src/domain/stored_form.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""Вход пересчёта формы ББ/ОФР из строк БД: сборка `StoredForm` пакета `arm_statements` (часть Б, B1).
|
||||
|
||||
Единственное место ядра (вместе с `services/statements_engine.py`), где импортируется пакет. Здесь же все соглашения
|
||||
границы: перечисления пакета — `StrEnum`, уверенности — `float | None`, `NULL` у `is_recognized` — `False`,
|
||||
`period_kind = NULL` — `PeriodKind.NONE`, ключи строки и колонки — `str(uuid)`.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from arm_statements import (
|
||||
DocumentType,
|
||||
LineStatus,
|
||||
PeriodKind,
|
||||
Severity,
|
||||
StoredColumn,
|
||||
StoredForm,
|
||||
StoredLine,
|
||||
StoredValue,
|
||||
StoredWarning,
|
||||
)
|
||||
from src.contract import StatementDocumentV2
|
||||
from src.models.enums import DocumentIssueKind
|
||||
from src.models.files import Document
|
||||
from src.models.statements import (
|
||||
DocumentIssue,
|
||||
FinancialStatementColumn,
|
||||
FinancialStatementLine,
|
||||
FinancialStatementMeta,
|
||||
FinancialStatementValue,
|
||||
)
|
||||
|
||||
__all__ = ["build_stored_form", "edition_codes"]
|
||||
|
||||
_SURE = 100 # «уверенный» код результата — уверенность ровно 100
|
||||
|
||||
|
||||
def _confidence(value: object) -> float | None:
|
||||
return None if value is None else float(value) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _stored_column(column: FinancialStatementColumn) -> StoredColumn:
|
||||
return StoredColumn(
|
||||
key=str(column.id),
|
||||
column_index=column.column_index,
|
||||
period_date=column.period_date,
|
||||
period_kind=PeriodKind(column.period_kind) if column.period_kind else PeriodKind.NONE,
|
||||
)
|
||||
|
||||
|
||||
def _stored_value(value: FinancialStatementValue) -> StoredValue:
|
||||
return StoredValue(
|
||||
column_key=str(value.column_id),
|
||||
amount=value.amount,
|
||||
text=value.source_text,
|
||||
confidence=_confidence(value.confidence),
|
||||
is_recognized=bool(value.is_recognized),
|
||||
value_fixed=bool(value.is_modified) or bool(value.is_confirmed),
|
||||
)
|
||||
|
||||
|
||||
def _parent_keys(lines: Sequence[FinancialStatementLine]) -> dict[int, str]:
|
||||
"""`row_index` → ключ строки; при повторе `row_index` предпочитается строка `ok`."""
|
||||
result: dict[int, str] = {}
|
||||
for line in lines:
|
||||
if line.row_index is None:
|
||||
continue
|
||||
if line.row_index not in result or line.line_status.value == LineStatus.OK.value:
|
||||
result[line.row_index] = str(line.id)
|
||||
return result
|
||||
|
||||
|
||||
def build_stored_form(
|
||||
document: Document,
|
||||
meta: FinancialStatementMeta,
|
||||
columns: Sequence[FinancialStatementColumn],
|
||||
lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]],
|
||||
warnings: Sequence[DocumentIssue],
|
||||
) -> StoredForm:
|
||||
"""Собирает вход `recompute` из строк БД формы; `warnings` фильтруются до `kind = warning`.
|
||||
|
||||
Тип формы — `document.document_type` (`BALANCE` / `INCOME` / `DISCLOSURE`): `meta.statement_type` знает только
|
||||
`balance` / `income`.
|
||||
"""
|
||||
row_keys = _parent_keys([line for line, _ in lines])
|
||||
stored_lines: list[StoredLine] = []
|
||||
for line, values in lines:
|
||||
status = LineStatus(line.line_status.value)
|
||||
parent_key = (
|
||||
row_keys.get(line.parent_row_index)
|
||||
if status is LineStatus.DETAIL and line.parent_row_index is not None
|
||||
else None
|
||||
)
|
||||
stored_lines.append(
|
||||
StoredLine(
|
||||
key=str(line.id),
|
||||
row_index=line.row_index,
|
||||
line_status=status,
|
||||
line_code=line.line_code,
|
||||
line_code_confidence=_confidence(line.line_code_confidence),
|
||||
line_name=line.line_name,
|
||||
code_fixed=bool(line.line_code_modified) or bool(line.is_confirmed),
|
||||
parent_key=parent_key,
|
||||
values=tuple(_stored_value(v) for v in values),
|
||||
)
|
||||
)
|
||||
return StoredForm(
|
||||
document_type=DocumentType(document.document_type.value),
|
||||
effective_date=document.reporting_date,
|
||||
has_organization_or_inn=bool(meta.organization_name) or bool(meta.inn),
|
||||
period_known=document.reporting_date is not None,
|
||||
needs_review_fields=tuple(meta.needs_review_fields or ()),
|
||||
accepted=meta.checks_accepted_at is not None,
|
||||
warnings=tuple(
|
||||
StoredWarning(code=w.code, severity=Severity(w.severity.value))
|
||||
for w in warnings
|
||||
if w.kind == DocumentIssueKind.warning
|
||||
),
|
||||
columns=tuple(_stored_column(c) for c in columns),
|
||||
lines=tuple(stored_lines),
|
||||
)
|
||||
|
||||
|
||||
def edition_codes(doc: StatementDocumentV2) -> list[tuple[str, bool]]:
|
||||
"""Пары `(line_code, уверенный)` строк `ok` результата по `row_index` — вход `engine.edition`."""
|
||||
ok_lines = sorted((ln for ln in doc.lines if ln.line_status == "ok"), key=lambda ln: ln.row_index)
|
||||
return [(str(ln.line_code), ln.line_code_confidence == _SURE) for ln in ok_lines]
|
||||
99
api/src/services/statements_engine.py
Normal file
99
api/src/services/statements_engine.py
Normal file
@ -0,0 +1,99 @@
|
||||
"""Адаптер пакета `arm_statements` за протоколом `StatementsEngine` (часть Б, B1).
|
||||
|
||||
Единственная точка вызова функций пакета в ядре; остальной код работает с протоколом, модульные тесты подменяют
|
||||
его `FakeEngine`. Правила пакета читаются один раз на процесс (`default_engine` кэширует адаптер).
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import functools
|
||||
from collections.abc import Mapping, Sequence
|
||||
from decimal import Decimal
|
||||
from typing import Any, Protocol
|
||||
|
||||
from arm_statements import (
|
||||
DocStatus,
|
||||
DocumentType,
|
||||
Edition,
|
||||
FormVariant,
|
||||
PeriodKind,
|
||||
RecomputeResult,
|
||||
Rules,
|
||||
StoredForm,
|
||||
choose_edition,
|
||||
default_rules,
|
||||
document_status,
|
||||
recompute,
|
||||
resolve_relative,
|
||||
statements_version,
|
||||
status_input_from_contract,
|
||||
)
|
||||
|
||||
__all__ = ["ArmStatementsEngine", "StatementsEngine", "default_engine"]
|
||||
|
||||
|
||||
class StatementsEngine(Protocol):
|
||||
@property
|
||||
def version(self) -> str | None: ...
|
||||
def recompute(self, form: StoredForm) -> RecomputeResult: ...
|
||||
def contract_document_status(self, document: Mapping[str, Any]) -> DocStatus: ...
|
||||
def edition(
|
||||
self,
|
||||
document_type: DocumentType,
|
||||
codes: Sequence[tuple[str, bool]],
|
||||
effective_date: datetime.date | None,
|
||||
form_variant: FormVariant | None,
|
||||
) -> Edition | None: ...
|
||||
def code_order(
|
||||
self, edition: Edition, document_type: DocumentType, form_variant: FormVariant
|
||||
) -> tuple[str, ...]: ...
|
||||
def editions(self) -> tuple[Edition, ...]: ...
|
||||
def resolve_relative(self, kind: PeriodKind, effective_date: datetime.date | None) -> datetime.date | None: ...
|
||||
def tolerance(self) -> Decimal: ...
|
||||
|
||||
|
||||
class ArmStatementsEngine:
|
||||
"""Реализация поверх пакета: правила — `default_rules()` пакета."""
|
||||
|
||||
def __init__(self, rules: Rules) -> None:
|
||||
self._rules = rules
|
||||
|
||||
@property
|
||||
def version(self) -> str | None:
|
||||
return statements_version()
|
||||
|
||||
def recompute(self, form: StoredForm) -> RecomputeResult:
|
||||
return recompute(form, self._rules)
|
||||
|
||||
def contract_document_status(self, document: Mapping[str, Any]) -> DocStatus:
|
||||
return document_status(status_input_from_contract(document))
|
||||
|
||||
def edition(
|
||||
self,
|
||||
document_type: DocumentType,
|
||||
codes: Sequence[tuple[str, bool]],
|
||||
effective_date: datetime.date | None,
|
||||
form_variant: FormVariant | None,
|
||||
) -> Edition | None:
|
||||
return choose_edition(document_type, codes, effective_date, self._rules, variant=form_variant)
|
||||
|
||||
def code_order(self, edition: Edition, document_type: DocumentType, form_variant: FormVariant) -> tuple[str, ...]:
|
||||
if document_type is DocumentType.DISCLOSURE:
|
||||
return ()
|
||||
return self._rules.code_orders.sequence(edition, document_type, form_variant)
|
||||
|
||||
def editions(self) -> tuple[Edition, ...]:
|
||||
return self._rules.code_orders.editions
|
||||
|
||||
def resolve_relative(self, kind: PeriodKind, effective_date: datetime.date | None) -> datetime.date | None:
|
||||
if kind in (PeriodKind.ABSOLUTE, PeriodKind.NONE):
|
||||
return None
|
||||
return resolve_relative(kind, effective_date)
|
||||
|
||||
def tolerance(self) -> Decimal:
|
||||
return self._rules.checks.tolerance
|
||||
|
||||
|
||||
@functools.cache
|
||||
def default_engine() -> StatementsEngine:
|
||||
"""Адаптер с правилами пакета; кэш на процесс."""
|
||||
return ArmStatementsEngine(default_rules())
|
||||
88
api/tests/fake_engine.py
Normal file
88
api/tests/fake_engine.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""`FakeEngine` для модульных тестов ядра: возвращает заданные ответы и запоминает вход (часть Б, B1)."""
|
||||
|
||||
import datetime
|
||||
from collections.abc import Mapping, Sequence
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from arm_statements import (
|
||||
DocStatus,
|
||||
DocumentType,
|
||||
Edition,
|
||||
FormVariant,
|
||||
PeriodKind,
|
||||
RecomputeResult,
|
||||
StatementChecks,
|
||||
StoredForm,
|
||||
)
|
||||
|
||||
|
||||
def empty_result(status: DocStatus = DocStatus.DONE) -> RecomputeResult:
|
||||
"""`RecomputeResult` без нарушений и признаков."""
|
||||
return RecomputeResult(
|
||||
form_variant=FormVariant.FULL,
|
||||
edition=Edition.FSBU4,
|
||||
checks=StatementChecks((), ()),
|
||||
line_needs_review={},
|
||||
value_needs_review={},
|
||||
detail_parents={},
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
class FakeEngine:
|
||||
"""Подмена `StatementsEngine`: ответы задаются в конструкторе, вызовы запоминаются."""
|
||||
|
||||
def __init__( # noqa: PLR0913
|
||||
self,
|
||||
result: RecomputeResult | None = None,
|
||||
*,
|
||||
document_status: DocStatus = DocStatus.DONE,
|
||||
edition: Edition | None = Edition.FSBU4,
|
||||
version: str | None = "fake",
|
||||
code_orders: Mapping[tuple[Edition, DocumentType, FormVariant], tuple[str, ...]] | None = None,
|
||||
tolerance: Decimal = Decimal(1),
|
||||
) -> None:
|
||||
self.result = result if result is not None else empty_result()
|
||||
self.document_status = document_status
|
||||
self.edition_value = edition
|
||||
self._version = version
|
||||
self.orders = dict(code_orders or {})
|
||||
self._tolerance = tolerance
|
||||
self.forms: list[StoredForm] = []
|
||||
self.edition_calls: list[
|
||||
tuple[DocumentType, list[tuple[str, bool]], datetime.date | None, FormVariant | None]
|
||||
] = []
|
||||
|
||||
@property
|
||||
def version(self) -> str | None:
|
||||
return self._version
|
||||
|
||||
def recompute(self, form: StoredForm) -> RecomputeResult:
|
||||
self.forms.append(form)
|
||||
return self.result
|
||||
|
||||
def contract_document_status(self, document: Mapping[str, Any]) -> DocStatus:
|
||||
return self.document_status
|
||||
|
||||
def edition(
|
||||
self,
|
||||
document_type: DocumentType,
|
||||
codes: Sequence[tuple[str, bool]],
|
||||
effective_date: datetime.date | None,
|
||||
form_variant: FormVariant | None,
|
||||
) -> Edition | None:
|
||||
self.edition_calls.append((document_type, list(codes), effective_date, form_variant))
|
||||
return self.edition_value
|
||||
|
||||
def code_order(self, edition: Edition, document_type: DocumentType, form_variant: FormVariant) -> tuple[str, ...]:
|
||||
return self.orders.get((edition, document_type, form_variant), ())
|
||||
|
||||
def editions(self) -> tuple[Edition, ...]:
|
||||
return (Edition.RAS_66N, Edition.FSBU4)
|
||||
|
||||
def resolve_relative(self, kind: PeriodKind, effective_date: datetime.date | None) -> datetime.date | None:
|
||||
return None
|
||||
|
||||
def tolerance(self) -> Decimal:
|
||||
return self._tolerance
|
||||
185
api/tests/test_domain_stored_form.py
Normal file
185
api/tests/test_domain_stored_form.py
Normal file
@ -0,0 +1,185 @@
|
||||
"""`build_stored_form` и `edition_codes` (часть Б, B1): отображение строк БД в вход пересчёта пакета."""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from arm_statements import DocumentType as PkgDocumentType
|
||||
from arm_statements import LineStatus, PeriodKind, Severity
|
||||
from src.contract import StatementDocumentV2
|
||||
from src.domain.stored_form import build_stored_form, edition_codes
|
||||
from src.models.enums import (
|
||||
DocumentIssueKind,
|
||||
DocumentStatus,
|
||||
DocumentType,
|
||||
IssueSeverity,
|
||||
IssueSource,
|
||||
StatementLineStatus,
|
||||
StatementType,
|
||||
)
|
||||
from src.models.files import Document
|
||||
from src.models.statements import (
|
||||
DocumentIssue,
|
||||
FinancialStatementColumn,
|
||||
FinancialStatementLine,
|
||||
FinancialStatementMeta,
|
||||
FinancialStatementValue,
|
||||
)
|
||||
|
||||
CONFIDENCE = 67.5
|
||||
EXAMPLES = Path(__file__).resolve().parent.parent / "contracts" / "examples"
|
||||
|
||||
|
||||
def make_document(reporting_date: datetime.date | None = datetime.date(2025, 9, 30)) -> Document:
|
||||
return Document(
|
||||
id=uuid.uuid4(),
|
||||
document_type=DocumentType.BALANCE,
|
||||
status=DocumentStatus.RECOGNIZED,
|
||||
reporting_date=reporting_date,
|
||||
)
|
||||
|
||||
|
||||
def make_meta(**kwargs: object) -> FinancialStatementMeta:
|
||||
return FinancialStatementMeta(id=uuid.uuid4(), statement_type=StatementType.balance, **kwargs)
|
||||
|
||||
|
||||
def make_column(index: int = 1, period_kind: str | None = "relative:current") -> FinancialStatementColumn:
|
||||
return FinancialStatementColumn(
|
||||
id=uuid.uuid4(), column_index=index, period_kind=period_kind, period_date=datetime.date(2025, 9, 30)
|
||||
)
|
||||
|
||||
|
||||
def make_line( # noqa: PLR0913
|
||||
code: str | None,
|
||||
*,
|
||||
row_index: int | None,
|
||||
status: StatementLineStatus = StatementLineStatus.ok,
|
||||
confidence: str | None = "100",
|
||||
modified: bool | None = False,
|
||||
confirmed: bool = False,
|
||||
parent_row_index: int | None = None,
|
||||
) -> FinancialStatementLine:
|
||||
return FinancialStatementLine(
|
||||
id=uuid.uuid4(),
|
||||
line_code=code,
|
||||
line_code_confidence=None if confidence is None else Decimal(confidence),
|
||||
line_code_modified=modified,
|
||||
line_status=status,
|
||||
row_index=row_index,
|
||||
parent_row_index=parent_row_index,
|
||||
is_confirmed=confirmed,
|
||||
)
|
||||
|
||||
|
||||
def make_value( # noqa: PLR0913
|
||||
line: FinancialStatementLine,
|
||||
column: FinancialStatementColumn,
|
||||
amount: str | None,
|
||||
*,
|
||||
confidence: str | None = "100",
|
||||
recognized: bool | None = True,
|
||||
modified: bool = False,
|
||||
confirmed: bool = False,
|
||||
text: str | None = None,
|
||||
) -> FinancialStatementValue:
|
||||
return FinancialStatementValue(
|
||||
id=uuid.uuid4(),
|
||||
line_id=line.id,
|
||||
column_id=column.id,
|
||||
amount=None if amount is None else Decimal(amount),
|
||||
confidence=None if confidence is None else Decimal(confidence),
|
||||
is_recognized=recognized,
|
||||
is_modified=modified,
|
||||
is_confirmed=confirmed,
|
||||
source_text=text,
|
||||
)
|
||||
|
||||
|
||||
def make_issue(kind: DocumentIssueKind, code: str, severity: IssueSeverity = IssueSeverity.info) -> DocumentIssue:
|
||||
return DocumentIssue(
|
||||
id=uuid.uuid4(), kind=kind, code=code, severity=severity, message="m", source=IssueSource.service
|
||||
)
|
||||
|
||||
|
||||
def test_build_stored_form_maps_flags() -> None:
|
||||
column = make_column()
|
||||
template = make_line("1260", row_index=None, confidence=None, modified=None)
|
||||
edited = make_line("1230", row_index=0)
|
||||
confirmed = make_line("1210", row_index=1)
|
||||
assigned = make_line("1250", row_index=2, confidence="25", modified=True)
|
||||
parent = make_line("1200", row_index=3)
|
||||
detail = make_line(None, row_index=4, status=StatementLineStatus.detail, parent_row_index=3)
|
||||
pairs = [
|
||||
(template, [make_value(template, column, "10", modified=True)]),
|
||||
(edited, [make_value(edited, column, "5", modified=True, text="5 0")]),
|
||||
(confirmed, [make_value(confirmed, column, "7", confirmed=True)]),
|
||||
(assigned, [make_value(assigned, column, "1")]),
|
||||
(parent, [make_value(parent, column, "1")]),
|
||||
(detail, [make_value(detail, column, "2")]),
|
||||
]
|
||||
warnings = [
|
||||
make_issue(DocumentIssueKind.warning, "MAPPING_WARNING", IssueSeverity.error),
|
||||
make_issue(DocumentIssueKind.relation, "1200 = ..."),
|
||||
make_issue(DocumentIssueKind.soft, "x"),
|
||||
]
|
||||
meta = make_meta(organization_name="ООО", needs_review_fields=["inn"])
|
||||
form = build_stored_form(make_document(), meta, [column], pairs, warnings)
|
||||
|
||||
by_key = {line.key: line for line in form.lines}
|
||||
assert by_key[str(template.id)].row_index is None
|
||||
assert by_key[str(template.id)].values[0].value_fixed
|
||||
assert by_key[str(edited.id)].values[0].value_fixed
|
||||
assert by_key[str(edited.id)].values[0].text == "5 0"
|
||||
assert by_key[str(confirmed.id)].values[0].value_fixed
|
||||
assert not by_key[str(confirmed.id)].code_fixed
|
||||
assert by_key[str(assigned.id)].code_fixed
|
||||
assert by_key[str(detail.id)].parent_key == str(parent.id)
|
||||
assert by_key[str(detail.id)].line_status is LineStatus.DETAIL
|
||||
assert by_key[str(parent.id)].parent_key is None
|
||||
assert [(w.code, w.severity) for w in form.warnings] == [("MAPPING_WARNING", Severity.ERROR)]
|
||||
assert form.effective_date == datetime.date(2025, 9, 30)
|
||||
assert form.period_known
|
||||
assert form.has_organization_or_inn
|
||||
assert form.needs_review_fields == ("inn",)
|
||||
assert not form.accepted
|
||||
assert form.document_type is PkgDocumentType.BALANCE
|
||||
|
||||
|
||||
def test_confirmed_line_is_code_fixed_and_accepted_flag() -> None:
|
||||
column = make_column()
|
||||
line = make_line("1210", row_index=0, confirmed=True)
|
||||
meta = make_meta(checks_accepted_at=datetime.datetime(2026, 10, 18, tzinfo=datetime.UTC))
|
||||
form = build_stored_form(make_document(None), meta, [column], [(line, [])], [])
|
||||
assert form.lines[0].code_fixed
|
||||
assert form.accepted
|
||||
assert not form.period_known
|
||||
assert form.effective_date is None
|
||||
assert not form.has_organization_or_inn
|
||||
|
||||
|
||||
def test_stored_form_types() -> None:
|
||||
column = make_column(period_kind=None)
|
||||
line = make_line("1210", row_index=0, confidence="67.50")
|
||||
value = make_value(line, column, "1", confidence="67.50", recognized=None)
|
||||
form = build_stored_form(make_document(), make_meta(), [column], [(line, [value])], [])
|
||||
stored = form.lines[0]
|
||||
assert stored.line_code_confidence == CONFIDENCE
|
||||
assert type(stored.line_code_confidence) is float
|
||||
assert type(stored.values[0].confidence) is float
|
||||
assert stored.values[0].is_recognized is False
|
||||
assert form.columns[0].period_kind is PeriodKind.NONE
|
||||
assert form.columns[0].key == str(column.id)
|
||||
assert stored.line_status is LineStatus.OK
|
||||
assert isinstance(stored.line_status, LineStatus)
|
||||
|
||||
|
||||
def test_edition_codes_from_result_by_row_index() -> None:
|
||||
raw = json.loads((EXAMPLES / "result-statement-minimal.json").read_text(encoding="utf-8"))
|
||||
doc = StatementDocumentV2.model_validate(raw["documents"][0])
|
||||
pairs = edition_codes(doc)
|
||||
# unreadable (row 2) не входит; порядок — по row_index
|
||||
assert pairs == [(c, True) for c in ("1210", "1230", "1200", "1600", "1370", "1300", "1700")]
|
||||
doc.lines.reverse()
|
||||
assert edition_codes(doc) == pairs
|
||||
124
api/tests/test_statements_engine.py
Normal file
124
api/tests/test_statements_engine.py
Normal file
@ -0,0 +1,124 @@
|
||||
"""Адаптер `StatementsEngine` поверх реального пакета `arm_statements` (часть Б, B1)."""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from arm_statements import DocStatus, DocumentType, Edition, FormVariant, PeriodKind, default_rules, statements_version
|
||||
from src.contract import StatementDocumentV2
|
||||
from src.domain.stored_form import build_stored_form, edition_codes
|
||||
from src.models.enums import StatementLineStatus
|
||||
from src.services.statements_engine import ArmStatementsEngine, default_engine
|
||||
from tests.fake_engine import FakeEngine, empty_result
|
||||
from tests.test_domain_stored_form import make_column, make_document, make_line, make_meta, make_value
|
||||
|
||||
EXAMPLES = Path(__file__).resolve().parent.parent / "contracts" / "examples"
|
||||
ENGINE = default_engine()
|
||||
|
||||
|
||||
def _documents(name: str) -> list[dict[str, object]]:
|
||||
raw = json.loads((EXAMPLES / name).read_text(encoding="utf-8"))
|
||||
return [d for d in raw["documents"] if d["document_type"] in ("BALANCE", "INCOME", "DISCLOSURE")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["result-statement-minimal.json", "result-statement-excel.json"])
|
||||
def test_contract_document_status_matches_examples(name: str) -> None:
|
||||
documents = _documents(name)
|
||||
assert documents
|
||||
for doc in documents:
|
||||
assert ENGINE.contract_document_status(doc) == DocStatus(doc["status"])
|
||||
if name == "result-statement-minimal.json":
|
||||
assert ENGINE.contract_document_status(documents[0]) is DocStatus.NEEDS_CHECK # строка unreadable
|
||||
|
||||
|
||||
def test_edition_codes_and_engine_edition() -> None:
|
||||
doc = StatementDocumentV2.model_validate(_documents("result-statement-minimal.json")[0])
|
||||
pairs = edition_codes(doc)
|
||||
effective = datetime.date(2025, 9, 30)
|
||||
edition = ENGINE.edition(DocumentType.BALANCE, pairs, effective, FormVariant.FULL)
|
||||
assert isinstance(edition, Edition)
|
||||
|
||||
# та же форма, собранная из этого результата в строки БД: редакция пересчёта совпадает с редакцией приёма
|
||||
column = make_column()
|
||||
lines = []
|
||||
for ln in doc.lines:
|
||||
line = make_line(
|
||||
ln.line_code,
|
||||
row_index=ln.row_index,
|
||||
status=StatementLineStatus(ln.line_status),
|
||||
confidence=None if ln.line_code_confidence is None else str(ln.line_code_confidence),
|
||||
)
|
||||
lines.append((line, [make_value(line, column, v.amount, confidence=str(v.confidence)) for v in ln.values]))
|
||||
form = build_stored_form(make_document(effective), make_meta(organization_name="ООО"), [column], lines, [])
|
||||
assert ENGINE.recompute(form).edition == edition
|
||||
|
||||
|
||||
def test_engine_scenario_recompute_after_edit() -> None:
|
||||
column = make_column()
|
||||
effective = datetime.date(2025, 9, 30)
|
||||
l1210 = make_line("1210", row_index=0)
|
||||
l1230 = make_line("1230", row_index=1)
|
||||
l1250 = make_line("1250", row_index=2, confidence="25", modified=True) # бывшая unreadable с назначенным кодом
|
||||
l1260 = make_line("1260", row_index=None, confidence=None, modified=None) # шаблонная строка
|
||||
l1200 = make_line("1200", row_index=3)
|
||||
detail = make_line(None, row_index=4, status=StatementLineStatus.detail, parent_row_index=3)
|
||||
pairs = [
|
||||
(l1210, [make_value(l1210, column, "100")]),
|
||||
(l1230, [make_value(l1230, column, "428311", confidence="60", modified=True)]),
|
||||
(l1250, [make_value(l1250, column, "50", confidence="67", modified=True)]),
|
||||
(l1260, [make_value(l1260, column, "10", confidence=None, recognized=None, modified=True)]),
|
||||
(l1200, [make_value(l1200, column, "428471")]),
|
||||
(detail, [make_value(detail, column, "5", confidence="60")]),
|
||||
]
|
||||
form = build_stored_form(make_document(effective), make_meta(organization_name="ООО"), [column], pairs, [])
|
||||
result = ENGINE.recompute(form)
|
||||
|
||||
assert result.edition is Edition.FSBU4
|
||||
assert result.form_variant is FormVariant.FULL
|
||||
key = str(column.id)
|
||||
assert result.value_needs_review[(str(l1230.id), key)] is False
|
||||
assert result.value_needs_review[(str(detail.id), key)] is False
|
||||
assert result.line_needs_review[str(l1250.id)] is False
|
||||
assert result.detail_parents[str(detail.id)] == str(l1200.id) # ближайшая выше строка ok
|
||||
# 1200 = … + 1250 + 1260 сошлось только с учётом строки 1250 и шаблонной 1260
|
||||
assert [f for f in result.checks.relations_failed if f.relation.startswith("1200")] == []
|
||||
|
||||
# без шаблонной строки 1260 сумма не сходится: соотношение нарушено с ключом колонки БД
|
||||
without_template = build_stored_form(
|
||||
make_document(effective),
|
||||
make_meta(organization_name="ООО"),
|
||||
[column],
|
||||
[p for p in pairs if p[0] is not l1260],
|
||||
[],
|
||||
)
|
||||
failed = [f for f in ENGINE.recompute(without_template).checks.relations_failed if f.relation.startswith("1200")]
|
||||
assert [(f.column_key, f.expected, f.actual) for f in failed] == [(key, Decimal(428471), Decimal(428461))]
|
||||
|
||||
|
||||
def test_engine_version_reads_file(tmp_path: Path) -> None:
|
||||
assert ENGINE.version == statements_version()
|
||||
assert statements_version(tmp_path / "VERSION") is None
|
||||
|
||||
|
||||
def test_engine_rules_accessors() -> None:
|
||||
engine = ArmStatementsEngine(default_rules())
|
||||
assert engine.tolerance() == Decimal(1)
|
||||
assert engine.editions() == (Edition.RAS_66N, Edition.FSBU4)
|
||||
assert engine.code_order(Edition.FSBU4, DocumentType.DISCLOSURE, FormVariant.FULL) == ()
|
||||
assert "1200" in engine.code_order(Edition.FSBU4, DocumentType.BALANCE, FormVariant.FULL)
|
||||
assert engine.resolve_relative(PeriodKind.ABSOLUTE, datetime.date(2025, 9, 30)) is None
|
||||
assert engine.resolve_relative(PeriodKind.NONE, datetime.date(2025, 9, 30)) is None
|
||||
assert engine.resolve_relative(PeriodKind.PREV_YEAR_END, datetime.date(2025, 9, 30)) == datetime.date(2024, 12, 31)
|
||||
|
||||
|
||||
def test_fake_engine_records_form() -> None:
|
||||
fake = FakeEngine(empty_result(DocStatus.NEEDS_CHECK), document_status=DocStatus.NEEDS_CHECK, edition=None)
|
||||
form = build_stored_form(make_document(), make_meta(), [], [], [])
|
||||
assert fake.recompute(form).status is DocStatus.NEEDS_CHECK
|
||||
assert fake.forms == [form]
|
||||
assert fake.edition(DocumentType.BALANCE, [("1200", True)], None, None) is None
|
||||
assert fake.edition_calls[0][1] == [("1200", True)]
|
||||
assert fake.contract_document_status({}) is DocStatus.NEEDS_CHECK
|
||||
@ -33,3 +33,6 @@ Goals и Non-goals — см. `proposal.md`.
|
||||
| 2026-10-18 | **R-1 (Р-40).** `CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` пишутся только для кодов из ровно четырёх цифр; пятизначные подстроки Excel не проверяются. **P3-7 плана в этой части заменён** (запись на код документа сохраняется, пятизначные коды — нет) | решение владельца 18.10.2026 (Р-40) перед исполнением Б; закрывает O-3 (поток `info` у пятизначных) | пропущенный `info` у нестандартного пятизначного кода; восстановление — снять условие длины кода в маппере 2.0 и пересчёте |
|
||||
| 2026-10-18 | **R-2 (Р-34).** Подтверждение кода строки — флаг `is_confirmed`; `line_code_confidence` и `line_code_modified` не меняются (P3-16 части А) | решение владельца 18.10.2026; в пересчёте Б правило `is_confirmed ⇒ needs_review строки = false` берётся из P3-16 | нет — поведение уже реализовано в части А |
|
||||
| 2026-10-18 | **R-3.** Удаление копии словаря §7 в дельте оформлено как MODIFIED «Соотношения §7 по колонкам», а не REMOVED (см. D-4); «Ответ `GET …/statement`» и порядок 409 оформлены новыми ADDED-требованиями, так как в `openspec/specs/` и дельтах части А одноимённых нет | `openspec archive` требует, чтобы имя REMOVED/MODIFIED существовало; иначе архив упадёт | при архивации — правка имён заголовков дельт |
|
||||
| 2026-10-18 | **P3-25 (B1).** Тип формы во входе пересчёта — `document.document_type` (`BALANCE` / `INCOME` / `DISCLOSURE`), а не `meta.statement_type` (только `balance` / `income`); план писал «тип формы» без источника | `DISCLOSURE` не представим в `statement_type`; тип документа — единственный источник для всех трёх типов | форма `DISCLOSURE` собралась бы с ошибкой `KeyError` / потерей типа; правка — одна строка в `build_stored_form` |
|
||||
| 2026-10-18 | **P3-26 (B1, O-3 часть А).** Копия `statement-code-orders.yaml` в `api/contracts/` не заводится: перечень кодов читает пакет из своей байтовой копии `api/arm_statements/rules/statement_code_orders.yaml` (`loader._ORDERS`), ядро файла не открывает; идентичность копии пакета с `docs/contracts/statement-code-orders.yaml` проверяет `sync-statements --check`; тест идентичности контракта сверяет только `*.json`. Сейчас копия пакета побайтно равна файлу docs | второй экземпляр файла в ядре никто бы не читал и он мог бы разойтись с пакетом | нет; если sync-statements когда-либо перестанет сверять YAML — добавить сверку в `contract_identity.py` |
|
||||
| 2026-10-18 | **P3-27 (B1).** `edition_codes` берёт строки `ok` результата `2.0`, сортирует по `row_index` и помечает код уверенным при `line_code_confidence == 100` (ровно), `None` — неуверенный | как в разделе «Что ядро вызывает» и в `recompute` пакета (код правленый/подтверждённый там — `code_fixed`) | редакция приёма разойдётся с редакцией пересчёта; сверку делает `test_edition_codes_and_engine_edition` |
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user