From 6117201b490b64316462aefd2517d59172c0bbec Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 00:33:20 +0400 Subject: [PATCH] =?UTF-8?q?fix(osv):=20=D1=80=D0=B0=D0=B7=D0=B2=D1=91?= =?UTF-8?q?=D1=80=D0=BD=D1=83=D1=82=D1=8B=D0=B9=20=D1=80=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=20=D0=BD=D0=B0=D0=B4=20=D1=81=D0=B2?= =?UTF-8?q?=D1=91=D1=80=D0=BD=D1=83=D1=82=D1=8B=D0=BC=20=D0=B2=20=D0=BD?= =?UTF-8?q?=D0=B5=D1=82=D1=82=D0=BE=20=D1=80=D0=B5=D0=B1=D1=91=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=20=D1=81=D0=B2=D0=B5=D1=80=D1=8F=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=20=D0=BD=D0=B5=D1=82=D1=82=D0=BE=20(AUROR?= =?UTF-8?q?A-1863,=20#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Узкий вариант: нетто сальдо допустимо, если среди прямых детей есть нетто-ребёнок (прошёл сверку только по нетто либо имеет такого потомка); обороты — по колонкам. Co-Authored-By: Claude Opus 5.5 --- api/src/parser/checks.py | 54 +++++++++++++++++++--------- api/tests/unit/parser/test_checks.py | 54 ++++++++++++++++++++++++++++ openspec/specs/osv-parsing/spec.md | 8 +++++ 3 files changed, 99 insertions(+), 17 deletions(-) diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index a50a55a..1129765 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -9,7 +9,10 @@ Обороты сверяются по колонкам. Сальдо (начало, конец) — по колонкам (развёрнутое сальдо) либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С показывает счёт свёрнутым сальдо, а субсчета и контрагентов — развёрнутым (так во всех реальных файлах, где у счёта есть субсчета с - разными сторонами сальдо); + разными сторонами сальдо). Нетто допустимо и у развёрнутого родителя, если среди его прямых детей есть + «нетто-ребёнок» — ребёнок, прошедший сверку только по нетто, или имеющий такого потомка (1С схлопнула Дт/Кт + субсчёта в нетто, а в родителе они остались развёрнутыми, AURORA-1863); признак идёт вверх по дереву. Развёрнутый + родитель без нетто-ребёнка сверяется по колонкам; 3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2; то же для единственного корня, когда @@ -119,23 +122,35 @@ def balance_identity( return violations -def _balance_matches(parent: Sequence[Decimal], children: Sequence[Decimal]) -> bool: - """Пара «дебет, кредит» сальдо: равна по колонкам или, при свёрнутом сальдо родителя, по нетто.""" +def _balance_matches(parent: Sequence[Decimal], children: Sequence[Decimal], *, net_child: bool = False) -> bool: + """Пара «дебет, кредит» сальдо: равна по колонкам или по нетто — при свёрнутом сальдо родителя либо когда среди + его детей есть свёрнутый в нетто (`net_child`, AURORA-1863).""" if not _differs(parent, children): return True is_collapsed = any(abs(value) <= TOLERANCE for value in parent) - return is_collapsed and abs(_net(parent) - _net(children)) <= TOLERANCE + return (is_collapsed or net_child) and abs(_net(parent) - _net(children)) <= TOLERANCE -def _sums_match(parent: Sequence[Decimal], children: Sequence[Decimal], *, with_balance: bool = True) -> bool: +def _sums_match( + parent: Sequence[Decimal], + children: Sequence[Decimal], + *, + with_balance: bool = True, + net_child: bool = False, +) -> bool: """Суммы родителя = Σ детей; `with_balance=False` — только обороты (сальдо детей не несёт данных).""" return ( - (not with_balance or _balance_matches(parent[_OPENING], children[_OPENING])) + (not with_balance or _balance_matches(parent[_OPENING], children[_OPENING], net_child=net_child)) and not _differs(parent[_TURNOVER], children[_TURNOVER]) - and (not with_balance or _balance_matches(parent[_CLOSING], children[_CLOSING])) + and (not with_balance or _balance_matches(parent[_CLOSING], children[_CLOSING], net_child=net_child)) ) +def _balance_by_net(parent: Sequence[Decimal], children: Sequence[Decimal]) -> bool: + """Сальдо родителя (начало или конец) не равно Σ детей по колонкам: сверка прошла только по нетто.""" + return _differs(parent[_OPENING], children[_OPENING]) or _differs(parent[_CLOSING], children[_CLOSING]) + + def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> list[int]: """Номера родителей, чьи суммы ≠ Σ детей того же `data_type` (±0.01): обороты — по колонкам, сальдо — по колонкам или по нетто при свёрнутом сальдо родителя (см. описание модуля). @@ -149,6 +164,7 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No """ by_row = {item.body.row: item for item in rows} totals: dict[int, list[Decimal]] = defaultdict(lambda: [_ZERO] * len(SUM_FIELDS)) + children_of: dict[int, list[int]] = defaultdict(list) has_subaccounts: set[int] = set() for item in rows: if item.parent is None or _is_indicator(item): @@ -159,18 +175,22 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No accumulated = totals[item.parent] for index, value in enumerate(_values(item)): accumulated[index] += value + children_of[item.parent].append(item.body.row) if item.group_type == "ACCOUNT": has_subaccounts.add(item.parent) - return [ - row - for row in by_row - if row in totals - and not _sums_match( - _values(by_row[row]), - totals[row], - with_balance=row in has_subaccounts or not _no_analytic_balance(by_row[row], exempt), - ) - ] + violations: set[int] = set() + # «Свёрнутые в нетто» строки: прошли сверку с детьми только по нетто либо имеют такого потомка. Потомки идут по + # большим номерам строк, поэтому родители обходятся от больших номеров к меньшим. + netted: set[int] = set() + for row in sorted(totals, reverse=True): + item = by_row[row] + with_balance = row in has_subaccounts or not _no_analytic_balance(item, exempt) + net_child = any(child in netted for child in children_of[row]) + if not _sums_match(_values(item), totals[row], with_balance=with_balance, net_child=net_child): + violations.add(row) + elif net_child or (with_balance and _balance_by_net(_values(item), totals[row])): + netted.add(row) + return [row for row in by_row if row in violations] def _total(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern] = ()) -> ClassifiedRow | None: diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index 79c8c80..6dad73e 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -186,6 +186,60 @@ def test_expanded_parent_balance_must_match_by_columns() -> None: assert parent_sums(rows) == [12] +def netted_child_tree(parent_opening: tuple[str, str] = ("130", "70"), parent_turnover: tuple[str, str] = ("10", "10")): + """Развёрнутый счёт 12 над свёрнутым субсчётом 13 (нетто 60 Дт), чьи внуки 14/15 развёрнуты (90 Дт / 30 Кт): + 1С схлопнула Дт/Кт субсчёта в нетто, в родителе они остались развёрнутыми (AURORA-1863).""" + opening_debit, opening_credit = parent_opening + turnover_debit, turnover_credit = parent_turnover + return [ + account(12, (opening_debit, opening_credit, turnover_debit, turnover_credit, opening_debit, opening_credit)), + row(13, ("60", None, "10", "10", "60", None), level=1, parent=12, group_type="ACCOUNT"), + row(14, ("90", None, "10", None, "90", None), level=2, parent=13, group_type="ACCOUNT"), + row(15, (None, "30", None, "10", None, "30"), level=2, parent=13, group_type="ACCOUNT"), + ] + + +def test_expanded_parent_over_netted_child_is_compared_by_net() -> None: + """AURORA-1863: у ребёнка сальдо свёрнуто относительно его детей (прошёл только по нетто) — развёрнутый + родитель над ним тоже сверяется по нетто.""" + assert parent_sums(netted_child_tree()) == [] + + +def test_expanded_parent_over_netted_child_with_wrong_net_is_violation() -> None: + assert parent_sums(netted_child_tree(parent_opening=("131", "70"))) == [12] + + +def test_expanded_parent_over_netted_child_turnovers_still_by_columns() -> None: + assert parent_sums(netted_child_tree(parent_turnover=("11", "11"))) == [12] + + +def test_expanded_parent_without_netted_child_stays_violation_on_rounding_gap() -> None: + """Узость правила: расхождение 0.05 на обеих сторонах при равном нетто у развёрнутого родителя без свёрнутого + ребёнка остаётся нарушением.""" + rows = [ + account(12, ("9977809.90", "8829025.95", None, None, "9977809.90", "8829025.95")), + row(13, ("9977809.85", None, None, None, "9977809.85", None), level=1, parent=12, group_type="ACCOUNT"), + row(14, (None, "8829025.90", None, None, None, "8829025.90"), level=1, parent=12, group_type="ACCOUNT"), + ] + + assert parent_sums(rows) == [12] + + +def test_netted_flag_propagates_up_through_column_matched_level() -> None: + """Признак «есть свёрнутый потомок» идёт вверх: счёт 11 развёрнут, субсчёт 12 сошёлся по колонкам, но над ним + свёрнутый 13.""" + rows = [ + row(11, ("300", "240", "10", "10", "300", "240"), group_type="ACCOUNT", text="60"), + row(12, ("130", "70", "10", "10", "130", "70"), level=1, parent=11, group_type="ACCOUNT"), + row(13, ("60", None, "10", "10", "60", None), level=2, parent=12, group_type="ACCOUNT"), + row(14, ("90", None, "10", None, "90", None), level=3, parent=13, group_type="ACCOUNT"), + row(15, (None, "30", None, "10", None, "30"), level=3, parent=13, group_type="ACCOUNT"), + row(16, ("70", "70", "0", "0", "70", "70"), level=2, parent=12, group_type="ACCOUNT"), + ] + + assert parent_sums(rows) == [] + + def test_turnovers_are_compared_by_columns_even_when_net_matches() -> None: rows = [ account(12, ("16", None, "135", "112", "39", None)), diff --git a/openspec/specs/osv-parsing/spec.md b/openspec/specs/osv-parsing/spec.md index e3df309..172666d 100644 --- a/openspec/specs/osv-parsing/spec.md +++ b/openspec/specs/osv-parsing/spec.md @@ -303,6 +303,14 @@ balance_identity_violations, parent_sum_violations}`. Результат про - **THEN** нарушения «сумма детей = родитель» по сальдо нет; обороты при этом сверяются по колонкам, а неверное нетто или расхождение оборотов — нарушение +#### Scenario: Развёрнутый родитель над свёрнутым в нетто ребёнком +- **WHEN** сальдо родителя развёрнуто (обе стороны), а среди его прямых детей есть «нетто-ребёнок» — ребёнок, прошедший + сверку со своими детьми только по нетто, либо имеющий такого потомка (признак передаётся вверх по дереву), и нетто детей + равно нетто родителя +- **THEN** нарушения по сальдо нет (1С схлопнула Дт/Кт субсчёта в нетто, а в родителе они остались развёрнутыми, + AURORA-1863); обороты сверяются по колонкам, неверное нетто или расхождение оборотов — нарушение; развёрнутый родитель + без нетто-ребёнка по-прежнему сверяется по колонкам (расхождение даже на 0,05 при равном нетто — нарушение) + #### Scenario: Счёт 91 — аналитика только с оборотами - **WHEN** разбирается ОСВ по счёту 91: строки аналитики 91.01, 91.02, 91.09 несут только обороты, сальдо закрыто на субсчёт закрытия (`fixtures/osv-dkb/tsimlyansky/2025-Q3/91_ 9мес.2025.xls`)