From e851445866b73d34c982498df39120647d4c0e85 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 14:28:49 +0400 Subject: [PATCH] =?UTF-8?q?fix(statements):=20=D1=86=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D1=81=D1=83=D1=82=D1=81=D1=82=D0=B2=D1=83=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B5=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D1=80=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D1=87=D0=B8=D0=B8=20=D1=81=D1=83=D0=BC=D0=BC?= =?UTF-8?q?=D1=8B;=20docstring=20=D0=BF=D0=BE=D1=80=D1=8F=D0=B4=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=20(M-1,=20M-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/domain/statement_checks.py | 22 ++++++++++++---------- api/src/repositories/statements.py | 3 ++- api/tests/test_domain_statement_checks.py | 9 +++++++++ 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/api/src/domain/statement_checks.py b/api/src/domain/statement_checks.py index f758e5b..81e97ca 100644 --- a/api/src/domain/statement_checks.py +++ b/api/src/domain/statement_checks.py @@ -1,10 +1,10 @@ """Логические проверки форм ББ/ОФР §7 в ядре (spec statement-checks; design D-4, D-5). -Перенос `tabular-parser/api/src/parser/statements/checks.py` по семантике: соотношение по каждой колонке, только если в -колонке есть его цель; отсутствующий код и пустая сумма — 0; правая часть считается как записано и с `abs_terms` по -модулю, выполнено, если хоть один вариант в пределах `tolerance`; жёсткое нарушение не больше -`rounding_tolerance_percent` от наибольшего по модулю числа соотношения — мягкое; «сумма пятизначных подстрок = -четырёхзначный родитель» — мягкая. Результат не хранится (DBML). +Перенос `tabular-parser/api/src/parser/statements/checks.py` по семантике: соотношение по каждой колонке, только если +в колонке есть его цель (есть сумма: пустая ячейка — цели нет); отсутствующий код и пустая сумма в правой части — 0; +правая часть считается как записано и с `abs_terms` по модулю, выполнено, если хоть один вариант в пределах +`tolerance`; жёсткое нарушение не больше `rounding_tolerance_percent` от наибольшего по модулю числа соотношения — +мягкое; «сумма пятизначных подстрок = четырёхзначный родитель» — мягкая. Результат не хранится (DBML). """ import functools @@ -136,8 +136,9 @@ def _holds(amounts: Mapping[str, Decimal], rule: RelationRule, tolerance: Decima def _subline_rules(codes: Sequence[str], form: str) -> list[RelationRule]: - """«1110 = 11101 + …» для четырёхзначных кодов формы с пятизначными подстроками (по - порядку строк, M-9 2b).""" + """«1110 = 11101 + …» для четырёхзначных кодов формы с пятизначными подстроками. Порядок строк в БД не + хранится (DBML порядка не знает): «по порядку» — это порядок кодов, в каком их отдаёт `statement_amounts` + (M-9 2b: подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами).""" low, high = CODE_RANGES[form] parents = {code for code in codes if len(code) == _PARENT_LEN} children: dict[str, list[str]] = {} @@ -177,14 +178,15 @@ def run_checks( column_indexes: Sequence[int], rules: CheckRules, ) -> ChecksReport: - """Проверки §7 формы `form` по колонкам `column_indexes`; `lines` — `(код, {колонка: - сумма})` по порядку строк.""" + """Проверки §7 формы `form` по колонкам `column_indexes`; `lines` — `(код, {колонка: сумма})` в порядке кодов + (порядок строк формы в БД не хранится; от него зависит только привязка пятизначных подстрок).""" explicit = list(rules.relations_for(form)) sublines = _subline_rules([code for code, _ in lines], form) if rules.subline_check else [] hard: list[FailedCheck] = [] soft: list[FailedCheck] = [] for column_index in column_indexes: - amounts = {code: (values.get(column_index) or _ZERO) for code, values in lines} + # код присутствует в колонке, только если в ней есть сумма (пустая ячейка — кода нет, как в tabular-parser) + amounts = {code: amount for code, values in lines if (amount := values.get(column_index)) is not None} for rule in [*explicit, *sublines]: if rule not in sublines and rule.target not in amounts: continue diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index ad303df..28c41d0 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -116,7 +116,8 @@ async def delete_file_statements(session: AsyncSession, file_id: uuid.UUID) -> N async def statement_amounts( session: AsyncSession, meta_id: uuid.UUID ) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]: - """Индексы колонок и `(код строки, {колонка: сумма})` по возрастанию кода — вход `run_checks`.""" + """Индексы колонок и `(код строки, {колонка: сумма})` по возрастанию кода — вход `run_checks`. Порядок строк + формы в БД не хранится (DBML не хранит), поэтому порядок строк — лексикографический порядок кодов.""" columns = ( await session.execute( select(FinancialStatementColumn.id, FinancialStatementColumn.column_index) diff --git a/api/tests/test_domain_statement_checks.py b/api/tests/test_domain_statement_checks.py index 4d4a18b..1838024 100644 --- a/api/tests/test_domain_statement_checks.py +++ b/api/tests/test_domain_statement_checks.py @@ -195,3 +195,12 @@ def test_broken_rules_file(tmp_path) -> None: bad.write_text("tolerance: 1\nrelations:\n - {form: BALANCE, relation: '16A0 = 1100', strict: true}\n", "utf-8") with pytest.raises(ValueError, match="statement_checks.yaml"): load_check_rules(bad) + + +def test_target_with_empty_cell_in_column_is_skipped_but_zero_is_checked() -> None: + """M-1: цель присутствует в колонке, только если в ней есть сумма: пустая ячейка (`None`) — цели нет, соотношение + пропущено (как в tabular-parser); явный 0 — цель есть, и при ненулевых слагаемых это нарушение.""" + terms = (("1100", {0: "300", 1: "300"}), ("1200", {0: "600", 1: "600"})) + empty = run_checks("BALANCE", _lines(*terms, ("1600", {0: None, 1: "0"})), [0, 1], RULES) + failed = _by_relation(empty.failed, "1600 = 1100 + 1200") + assert [(item.column_index, item.expected, item.actual) for item in failed] == [(1, D("0"), D("900"))]