From 6117201b490b64316462aefd2517d59172c0bbec Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 00:33:20 +0400 Subject: [PATCH 1/5] =?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`) -- 2.47.2 From e01f218138d49414b912bcc45e1a40ea07021ad3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 00:44:24 +0400 Subject: [PATCH 2/5] =?UTF-8?q?fix(osv):=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=20=C2=AB=D0=9F=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=C2=BB=20?= =?UTF-8?q?=D1=81=D0=B2=D0=B5=D1=80=D1=8F=D1=8E=D1=82=D1=81=D1=8F=20=D1=86?= =?UTF-8?q?=D0=B5=D0=BF=D0=BE=D1=87=D0=BA=D0=BE=D0=B9=20=D1=81=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=B4=D0=BE,=20=D0=B0=20=D0=BD=D0=B5=20=D1=81=D1=83?= =?UTF-8?q?=D0=BC=D0=BC=D0=BE=D0=B9=20(AURORA-1823,=20#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit period_groups в osv_groups.yaml, признак is_period у ClassifiedRow; родитель, все дети которого периоды: обороты = Σ, начало = начало первого, конец = конец последнего. НКЗ лист 58 убран из манифеста ожидаемых needs_check. Co-Authored-By: Claude Opus 5.5 --- api/rules/osv_groups.yaml | 5 +++ api/src/parser/checks.py | 23 ++++++++-- api/src/parser/classify.py | 7 ++- api/src/parser/rules.py | 6 +++ api/tests/osv_dkb_expectations.yaml | 3 -- api/tests/unit/parser/test_checks.py | 60 ++++++++++++++++++++++++++ api/tests/unit/parser/test_classify.py | 40 +++++++++++++++++ api/tests/unit/parser/test_rules.py | 20 +++++++++ openspec/specs/osv-parsing/spec.md | 9 ++++ 9 files changed, 166 insertions(+), 7 deletions(-) diff --git a/api/rules/osv_groups.yaml b/api/rules/osv_groups.yaml index 7468996..de09725 100644 --- a/api/rules/osv_groups.yaml +++ b/api/rules/osv_groups.yaml @@ -20,6 +20,11 @@ mapping: # строки валюты («Валюта USD», «В валюте») — не контрагенты и по плану счетов (R-18) - ['^(в\s+)?валют', OTHER] +# Группировки-периоды: помесячные строки «Обороты за <месяц>» под контрагентом (выгрузка 1С «Субсчета + Контрагенты», +# третья группировка шапки «Период», AURORA-1823). Тип остаётся OTHER; строки получают признак `is_period`, и сальдо +# родителя сверяется цепочкой периодов (начало первого, конец последнего), а не суммой сальдо периодов. +period_groups: ['^период$'] + # Расчётные счета, у которых аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван # (ОСВ 1С по счёту 60 без строки «Контрагенты»; AURORA-1661, OQ-158). Счёт сопоставляется по коду и субсчетам # («76» -> 76.2, 76.Н.1; не 760). 71 и 73 исключены (R-9): подотчётные лица и персонал — не контрагенты. diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 1129765..3280ecc 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -12,7 +12,10 @@ разными сторонами сальдо). Нетто допустимо и у развёрнутого родителя, если среди его прямых детей есть «нетто-ребёнок» — ребёнок, прошедший сверку только по нетто, или имеющий такого потомка (1С схлопнула Дт/Кт субсчёта в нетто, а в родителе они остались развёрнутыми, AURORA-1863); признак идёт вверх по дереву. Развёрнутый - родитель без нетто-ребёнка сверяется по колонкам; + родитель без нетто-ребёнка сверяется по колонкам. Родитель, все дети которого — периоды («Обороты за <месяц>», + группировка «Период», `is_period`), сверяется цепочкой: обороты = Σ оборотов периодов, сальдо начала = начало + первого периода, сальдо конца = конец последнего (сальдо месяцев — не слагаемые; AURORA-1823); смешанные дети — + обычная сумма; 3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2; то же для единственного корня, когда @@ -151,6 +154,14 @@ def _balance_by_net(parent: Sequence[Decimal], children: Sequence[Decimal]) -> b return _differs(parent[_OPENING], children[_OPENING]) or _differs(parent[_CLOSING], children[_CLOSING]) +def _period_chain(periods: Sequence[ClassifiedRow]) -> list[Decimal]: + """Суммы родителя, все дети которого — периоды («Обороты за <месяц>»): обороты — Σ оборотов периодов, сальдо + начала — начало первого периода, конца — конец последнего (в порядке строк): сальдо периодов — цепочка.""" + first, last = _values(periods[0]), _values(periods[-1]) + turnover = _sum_of(periods)[_TURNOVER] + return [*first[_OPENING], *turnover, *last[_CLOSING]] + + def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> list[int]: """Номера родителей, чьи суммы ≠ Σ детей того же `data_type` (±0.01): обороты — по колонкам, сальдо — по колонкам или по нетто при свёрнутом сальдо родителя (см. описание модуля). @@ -165,6 +176,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) + periods_of: dict[int, list[ClassifiedRow]] = defaultdict(list) has_subaccounts: set[int] = set() for item in rows: if item.parent is None or _is_indicator(item): @@ -176,6 +188,8 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No for index, value in enumerate(_values(item)): accumulated[index] += value children_of[item.parent].append(item.body.row) + if item.is_period: + periods_of[item.parent].append(item) if item.group_type == "ACCOUNT": has_subaccounts.add(item.parent) violations: set[int] = set() @@ -186,9 +200,12 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No 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): + children = totals[row] + if periods_of[row] and len(periods_of[row]) == len(children_of[row]): + children = _period_chain(periods_of[row]) + if not _sums_match(_values(item), children, 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])): + elif net_child or (with_balance and _balance_by_net(_values(item), children)): netted.add(row) return [row for row in by_row if row in violations] diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 79e94c9..70f0993 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -106,6 +106,8 @@ class ClassifiedRow: indicator: bool = False # строка-показатель (§1.3): вне проверок сумм, `data_type` — вид по метке # COUNTERPARTY получен по плану счетов (вид субконто в шапке не назван, #59, R-6), а не по схеме группировок counterparty_by_account: bool = False + # Строка группировки-периода («Период» схемы шапки, `period_groups`): помесячный разрез родителя (AURORA-1823) + is_period: bool = False # --- Строки таблицы --- @@ -414,6 +416,7 @@ class _Kind: counterparty_inn: str | None = None indicator: bool = False counterparty_by_account: bool = False + is_period: bool = False @dataclass(frozen=True) @@ -472,7 +475,8 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple if group_type == "OTHER" and name is None and item.role == "contract": group_type = "CONTRACT" # договор контрагента в блоке субсчёта с кодами контрагентов (§1.3, `code_blocks`) if group_type != "COUNTERPARTY": - return _Kind(group_type, name, code, own), branch + is_period = group_type == "OTHER" and context.rules.groups.is_period_group(name) + return _Kind(group_type, name, code, own, is_period=is_period), branch inn_col = context.header.inn_col column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules) # ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100) @@ -565,6 +569,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta extra=_extra(sheet, item.row, extra_keys), indicator=kind.indicator, counterparty_by_account=kind.counterparty_by_account, + is_period=kind.is_period, ) ) return result diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index f08bad0..61bc654 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -206,6 +206,12 @@ class OsvGroupRules(_Model): default: GroupType = "OTHER" # Расчётные счета: аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван (#59, R-6) counterparty_accounts: list[str] = [] + # Названия группировок-периодов («Период»): строки «Обороты за <месяц>» — цепочка сальдо, не слагаемые (AURORA-1823) + period_groups: list[Pattern] = [] + + def is_period_group(self, name: str | None) -> bool: + """Название группировки из схемы шапки относится к периодам.""" + return name is not None and any(pattern.search(name) for pattern in self.period_groups) @model_validator(mode="after") def _check_counterparty_accounts(self) -> "OsvGroupRules": diff --git a/api/tests/osv_dkb_expectations.yaml b/api/tests/osv_dkb_expectations.yaml index 041cd29..d3bdb4b 100644 --- a/api/tests/osv_dkb_expectations.yaml +++ b/api/tests/osv_dkb_expectations.yaml @@ -4,9 +4,6 @@ # Файлов и листов, которых здесь нет, тест ждёт `done` без нарушений. Причина — данные файла, а не дефект разбора # (иначе чинить парсер): запись удаляется, когда вопрос закрыт. needs_check: - "nkz/2025-Q3/НКЗ_ОСВ_58+66+67_Субсчета+Контрагенты_2025.09-01_24459.xls": - reason: "лист 58: сумма детей ≠ родитель (строки 12, 24), см. arm-tabular-parser#11 A11-5; листы 66, 67 сходятся" - sheets: {"sheet:58": [parent_sum]} "agrokompleks/2025-Q3/ОСВ_сч62_2025_9мес.xls": reason: "аналитика магазинов 62.04 без сальдо на конец (тот же класс, что OQ-132); Итого и родители сходятся" sheets: {"sheet:TDSheet": [balance_identity]} diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index 6dad73e..bf4cc45 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -6,6 +6,8 @@ from decimal import Decimal +import pytest + from src.contract.common import DataType from src.parser.checks import TOLERANCE, balance_identity, parent_sums, run_checks, total_matches_root from src.parser.classify import ClassifiedRow, RowGroupType @@ -27,6 +29,7 @@ def row( # noqa: PLR0913 - строка теста описывается по text: str = "строка", code: str | None = "60", indicator: bool = False, + is_period: bool = False, ) -> ClassifiedRow: body = BodyRow( row=number, @@ -50,6 +53,7 @@ def row( # noqa: PLR0913 - строка теста описывается по data_type=data_type, extra=None, indicator=indicator, + is_period=is_period, ) @@ -240,6 +244,62 @@ def test_netted_flag_propagates_up_through_column_matched_level() -> None: assert parent_sums(rows) == [] +def period_tree( + opening: str = "100", turnover: str = "30", closing: str = "70", *, second_is_period: bool = True +) -> list[ClassifiedRow]: + """Контрагент 12 с помесячными строками «Период» 13 (сальдо 100 → 90, оборот Кт 10) и 14 (90 → 70, Кт 20): сальдо + родителя — начало первого периода и конец последнего, обороты — сумма (AURORA-1823).""" + return [ + row(12, (opening, None, None, turnover, closing, None), level=2, parent=10, group_type="COUNTERPARTY"), + row(13, ("100", None, None, "10", "90", None), level=3, parent=12, group_type="OTHER", is_period=True), + row( + 14, ("90", None, None, "20", "70", None), level=3, parent=12, group_type="OTHER", is_period=second_is_period + ), + ] + + +def test_period_children_are_chained_not_summed() -> None: + assert parent_sums(period_tree()) == [] + + +@pytest.mark.parametrize( + "broken", + [ + {"turnover": "31"}, # ошибка оборота периода + {"opening": "101"}, # сальдо начала ≠ начало первого периода + {"closing": "71"}, # сальдо конца ≠ конец последнего периода + ], +) +def test_period_children_chain_violations(broken: dict[str, str]) -> None: + assert parent_sums(period_tree(**broken)) == [12] + + +def test_period_balance_is_compared_by_net_for_collapsed_parent() -> None: + """Сальдо периодов сверяется той же функцией, что и обычное: свёрнутый родитель — по нетто.""" + rows = [ + row(12, ("100", None, None, "30", "70", None), level=2, parent=10, group_type="COUNTERPARTY"), + row(13, ("120", "20", None, "10", "90", None), level=3, parent=12, group_type="OTHER", is_period=True), + row(14, ("90", None, None, "20", "70", None), level=3, parent=12, group_type="OTHER", is_period=True), + ] + + assert parent_sums(rows) == [] + + +def test_ordinary_children_keep_summing_balances() -> None: + """Те же числа, но дети — не периоды: сальдо складывается (100 + 90 ≠ 100).""" + assert parent_sums(period_tree(second_is_period=False)) == [12] + + +def test_mixed_period_and_ordinary_children_use_plain_sums() -> None: + rows = [ + row(12, ("190", None, None, "30", "160", None), level=2, parent=10, group_type="COUNTERPARTY"), + row(13, ("100", None, None, "10", "90", None), level=3, parent=12, group_type="OTHER", is_period=True), + row(14, ("90", None, None, "20", "70", None), level=3, parent=12, group_type="CONTRACT"), + ] + + 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/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index 467396b..79fafba 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -281,6 +281,46 @@ def test_account_subaccount_counterparty_contract(rules: Rules) -> None: assert parsed.levels.strategy == "outline" +def test_period_group_rows_are_flagged(rules: Rules) -> None: + """AURORA-1823: третья группировка «Период» (строки «Обороты за <месяц>») — тип OTHER, но с признаком `is_period`; + контрагент и счёт признака не несут.""" + # «Период» — третья строка блока заголовка (как в выгрузке 1С): подписи «Дебет/Кредит» объединены на две строки + debit, credit = M("Дебет", 1, 2), M("Кредит", 1, 2) + sheet = sheet_from_rows( + [ + ["ООО Ромашка"], + [TITLE], + [], + ["Счет", "ИНН", "КПП", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], + ["Контрагенты", None, None, debit, credit, debit, credit, debit, credit], + ["Период"], + r(0, "58", S100), + r(1, "58.03", S100), + r(2, "ООО Поставщик", S100, inn="7701234567"), + r(3, "Обороты за Январь 25", S100), + r(3, "Обороты за Февраль 25", S100), + r(None, "Итого", S100), + ] + ) + + parsed = parse(sheet, rules) + + assert [(row.body.text, row.group_type, row.group_name, row.is_period) for row in parsed.rows] == [ + ("58", "ACCOUNT", "Счет", False), + ("58.03", "ACCOUNT", "Счет", False), + ("ООО Поставщик", "COUNTERPARTY", "Контрагенты", False), + ("Обороты за Январь 25", "OTHER", "Период", True), + ("Обороты за Февраль 25", "OTHER", "Период", True), + ("Итого", "TOTAL", None, False), + ] + + +def test_contract_group_is_not_period(rules: Rules) -> None: + parsed = parse(_basic(), rules) + + assert not any(row.is_period for row in parsed.rows) + + def test_parents_follow_tree(rules: Rules) -> None: parsed = parse(_basic(), rules) diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index a17a5f6..c85fc3a 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -749,6 +749,26 @@ def test_counterparty_accounts_list_is_data(rules: Rules) -> None: assert rules.groups.counterparty_accounts == ["58", "60", "62", "66", "67", "75", "76", "80"] +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("Период", True), + ("период", True), + (" Период ", True), + ("Периодичность", False), + ("Договоры", False), + (None, False), + ], +) +def test_period_groups_are_data(rules: Rules, name: str | None, expected: bool) -> None: + """AURORA-1823: группировка «Период» (строки «Обороты за <месяц>») — из `period_groups` словаря, не из кода.""" + assert rules.groups.is_period_group(name) is expected + + +def test_period_groups_dictionary_key_is_loaded(rules: Rules) -> None: + assert [pattern.source for pattern in rules.groups.period_groups] == ["^период$"] + + @pytest.mark.parametrize("bad", ["", "abc", "60.", "6 0"]) def test_counterparty_account_code_must_be_digits_with_dots(tmp_path: Path, bad: str) -> None: target = _copy_rules(tmp_path) diff --git a/openspec/specs/osv-parsing/spec.md b/openspec/specs/osv-parsing/spec.md index 172666d..b138a68 100644 --- a/openspec/specs/osv-parsing/spec.md +++ b/openspec/specs/osv-parsing/spec.md @@ -311,6 +311,15 @@ balance_identity_violations, parent_sum_violations}`. Результат про AURORA-1863); обороты сверяются по колонкам, неверное нетто или расхождение оборотов — нарушение; развёрнутый родитель без нетто-ребёнка по-прежнему сверяется по колонкам (расхождение даже на 0,05 при равном нетто — нарушение) +#### Scenario: Периоды под контрагентом — цепочка сальдо +- **WHEN** все дети родителя — строки группировки «Период» (`period_groups` словаря `osv_groups.yaml`; в выгрузке 1С + «Субсчета + Контрагенты» это помесячные строки «Обороты за <месяц>»; тип группы остаётся `OTHER`, у строки признак + `is_period`) +- **THEN** обороты родителя сверяются с суммой оборотов периодов, сальдо начала — с сальдо начала первого периода, + сальдо конца — с сальдо конца последнего (в порядке строк; сальдо сверяется по колонкам либо по нетто, как обычно), + а не с суммой сальдо месяцев (AURORA-1823); расхождение в обороте, начале первого или конце последнего периода — + нарушение; у родителя со смешанными детьми (периоды и обычные строки) суммы считаются как прежде + #### Scenario: Счёт 91 — аналитика только с оборотами - **WHEN** разбирается ОСВ по счёту 91: строки аналитики 91.01, 91.02, 91.09 несут только обороты, сальдо закрыто на субсчёт закрытия (`fixtures/osv-dkb/tsimlyansky/2025-Q3/91_ 9мес.2025.xls`) -- 2.47.2 From ca7f64c3efe071619d73d71c9dd8ee52da4dfc07 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 01:03:02 +0400 Subject: [PATCH 3/5] =?UTF-8?q?fix(osv):=20=D0=B8=D0=B5=D1=80=D0=B0=D1=80?= =?UTF-8?q?=D1=85=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D0=BE=D1=82=D1=81=D1=82?= =?UTF-8?q?=D1=83=D0=BF=D0=B0=D0=BC=20=E2=80=94=20=D0=BF=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D0=BD=D1=8C=20=D1=81=D1=87=D1=91=D1=82?= =?UTF-8?q?=D0=B0,=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F,=20=D0=B2=D0=BB=D0=BE?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D1=81=D1=83=D0=BC=D0=BC=D0=B0=D0=BC=20(AURORA-1858,=20#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В strategy_indent: счёт, чей код продолжает код соседа того же отступа (68.01 → 68.01.1), — ребёнок; безымянная строка с оборотами счёта сразу за ним — его строка закрытия; аналитика на одном отступе вкладывается по суммам только внутри родителя с нарушением и только если он после этого сходится. Co-Authored-By: Claude Opus 5.5 --- api/src/parser/classify.py | 1 + api/src/parser/hierarchy.py | 214 +++++++++++++++++++- api/tests/synthetic/generate.py | 69 ++++++- api/tests/synthetic/test_generate.py | 4 +- api/tests/synthetic/test_synthetic_files.py | 5 + api/tests/unit/parser/test_hierarchy.py | 160 +++++++++++++++ openspec/specs/osv-parsing/spec.md | 12 ++ 7 files changed, 461 insertions(+), 4 deletions(-) diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 70f0993..0618bf1 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -314,6 +314,7 @@ def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строк row=row, text=text, is_account=account is not None, + account_code=None if account is None else account.group(1), account_rank=None if account is None else account.group(1).count(_SEGMENT_SEPARATOR) + 1, bold=bold, indent=indent, diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index 202ee87..81fe98b 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -44,7 +44,11 @@ MIN_ROWS_FOR_CASCADE = 2 TRIVIAL_STRATEGY = "trivial" # Кооперативный дедлайн внутри стратегии (design D-8): проверка каждые N строк или N шагов перебора поддеревьев DEADLINE_CHECK_EVERY = 500 +# Вложенность по суммам в `indent` ищет родителя среди не более чем стольких следующих соседей: аналитика одного +# родителя вложена на единицы строк, а без предела плоский лист в тысячи строк давал бы O(n²) перебора +NEST_SCAN_LIMIT = 50 _ZERO = Decimal(0) +_TURNOVER_PAIR = slice(2, 4) # обороты (дебет, кредит) в `BodyRow.sums` Role = Literal["counterparty", "contract"] @@ -67,6 +71,7 @@ class BodyRow: label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.») # Роль в блоке субсчёта с кодами контрагентов (`code_blocks`, §1.3): контрагент (код с суммами) или его договор role: Role | None = None + account_code: str | None = None # код счёта строки, как в файле («68.01.1»); у не-счёта — `None` @dataclass(frozen=True) @@ -135,11 +140,218 @@ def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) - `indent` приходит либо как `alignment.indent`, либо как число ведущих пробелов (T2.4), поэтому шаг отступа не предполагается: (0, 2, 4), (0, 1, 2) и (0, 3, 7) дают одинаковые уровни 0, 1, 2. `None`, если различных отступов меньше двух. + + Три поправки к рангам отступа (AURORA-1858), когда отступа не хватает: + - подуровень счёта на том же отступе (`68.01` → `68.01.1`) — ребёнок по коду (`_nest_subaccounts`); + - безымянная строка закрытия сразу за строкой счёта (отступ пустой ячейки не хранится) — её ребёнок + (`_attach_closing_rows`); + - аналитика, вложенная на два уровня при одном отступе, — вложенность по суммам, но только внутри родителя с + нарушением «сумма детей = родитель» и только если после перестройки он сходится (`_nest_by_sums`). """ ranks = _ranks([item.indent for item in _paced(rows, deadline, "иерархия (indent)")]) if ranks is None: return None - return {item.row: ranks[item.indent] for item in rows} + levels = {item.row: ranks[item.indent] for item in rows} + levels = _nest_subaccounts(rows, levels) + levels = _attach_closing_rows(rows, levels) + return _nest_by_sums(rows, levels, deadline) + + +def _nest_subaccounts(rows: Sequence[BodyRow], levels: dict[int, int]) -> dict[int, int]: + """Счёт, чей непосредственный родительский код (`68.01.1` → `68.01`) есть у более раннего счёта того же уровня в + том же блоке, — его ребёнок: уровень родителя + 1, поддерево (строки глубже по исходному уровню) сдвигается вместе + с ним. Так вкладываются `68.01.1` и `68.01.2` под `68.01`, стоящие на одном отступе. + + Не трогаем счёт, который уже стоит под счётом с кодом своего родителя (блок «76.09» с собственной аналитикой и + «76.09.1» рядом на одном отступе). Применяется, только если все такие пары на одном исходном уровне (одном + отступе): иначе соседние корни с похожими кодами могут быть намеренными, и дерево остаётся по отступам. + """ + parent_of: dict[int, int] = {} # строка счёта -> строка счёта-родителя по коду + pair_levels: set[int] = set() + open_accounts: list[tuple[int, str]] = [] # (уровень, код) счетов-предков по текущим уровням + blocks: dict[int, dict[str, BodyRow]] = {} # уровень -> код -> последний счёт с этим кодом в текущем блоке + seen_codes: dict[int, set[str]] = {} # уровень -> коды всех встреченных счетов (блоки их забывают) + ambiguous = False + for item in rows: + if not item.is_account or item.account_code is None: + # строка не-счёт на уровне блока или выше разрывает его: счёт после неё — не ребёнок счёта до неё + for closed in [known for known in blocks if known >= levels[item.row]]: + del blocks[closed] + continue + level, code = levels[item.row], item.account_code + while open_accounts and open_accounts[-1][0] >= level: + open_accounts.pop() + for deeper in [known for known in blocks if known > level]: + del blocks[deeper] + parent_code = code.rsplit(".", 1)[0] + candidate = blocks.get(level, {}).get(parent_code) + under_parent = bool(open_accounts) and open_accounts[-1][1] == parent_code + if candidate is not None and not under_parent: + parent_of[item.row] = candidate.row + pair_levels.add(level) + elif candidate is not None or parent_code in seen_codes.get(level, ()): + # родительский код на этом уровне уже был, но счёт к нему не прикрепить (рядом уже стоит под своим родителем + # или между ними строка другого блока): раскладка неоднозначна, по коду не перекладываем ничего + ambiguous = True + seen_codes.setdefault(level, set()).add(code) + blocks.setdefault(level, {})[code] = item + open_accounts.append((level, code)) + if not parent_of or ambiguous or len(pair_levels) > 1: + return levels + result = _shift_nested(rows, levels, parent_of) + # перекладка принимается для счёта-родителя, только если после неё сумма его детей сходится с ним: иначе (файл с + # неровными отступами, где похожий код — совпадение) этот родитель остаётся как был + kids: dict[int, tuple[Decimal, ...]] = {} + by_parent = parents(rows, result) + by_row = {item.row: item for item in rows} + for item in rows: + parent = by_parent[item.row] + if parent is not None and not item.indicator: + kids[parent] = _add(kids[parent], _vector(item)) if parent in kids else _vector(item) + broken = { + parent + for parent in set(parent_of.values()) + if parent in kids and _violates(_vector(by_row[parent]), kids[parent]) + } + if broken: + parent_of = {row: parent for row, parent in parent_of.items() if parent not in broken} + if not parent_of: + return levels + result = _shift_nested(rows, levels, parent_of) + return result + + +def _shift_nested(rows: Sequence[BodyRow], levels: dict[int, int], parent_of: dict[int, int]) -> dict[int, int]: + """Уровни, в которых счета из `parent_of` стали детьми указанных счетов (поддерево сдвинуто вместе со счётом).""" + result: dict[int, int] = {} + shifts: list[tuple[int, int]] = [] # (исходный уровень сдвинутого счёта, его сдвиг) — открытые поддеревья + for item in rows: + original = levels[item.row] + while shifts and shifts[-1][0] >= original: + shifts.pop() + level = original + (shifts[-1][1] if shifts else 0) + if item.row in parent_of: + level = result[parent_of[item.row]] + 1 + shifts.append((original, level - original)) + result[item.row] = level + return result + + +def _attach_closing_rows(rows: Sequence[BodyRow], levels: dict[int, int]) -> dict[int, int]: + """Безымянная строка с суммами (без текста и кода) сразу за строкой счёта — строка закрытия, ребёнок этого счёта. + + Читатель не хранит пустые ячейки, поэтому её отступ — 0, как у счёта; признак — пустой текст, а не позиция. + Строка закрытия повторяет обороты счёта (дебет и кредит равны оборотам счёта): иначе безымянная строка с суммами — + не закрытие, и дерево остаётся по отступам. + """ + result = dict(levels) + previous: BodyRow | None = None + for item in rows: + if ( + previous is not None + and previous.is_account + and not item.is_account + and not item.text.strip() + and any(value is not None for value in item.sums) + and _matches(_vector(item)[_TURNOVER_PAIR], _vector(previous)[_TURNOVER_PAIR]) + and result[item.row] <= result[previous.row] + ): + result[item.row] = result[previous.row] + 1 + previous = item + return result + + +def _violates(parent: tuple[Decimal, ...], children: tuple[Decimal, ...]) -> bool: + """«Сумма детей ≠ родитель» для решения о перестройке: по паре «дебет, кредит» (начало, обороты, конец); пара, в + которой у детей пусто (аналитика без сальдо), не сверяется, а сальдо свёрнутого родителя сверяется по нетто — как + в контрольных суммах §1.4, но мягче: окончательно решают они, а не эта функция.""" + for start, is_balance in ((0, True), (2, False), (4, True)): + pair_parent, pair_children = parent[start : start + 2], children[start : start + 2] + if all(abs(value) <= SUM_TOLERANCE for value in pair_children): + continue + if _matches(pair_parent, pair_children): + continue + collapsed = any(abs(value) <= SUM_TOLERANCE for value in pair_parent) + net_equal = abs((pair_parent[0] - pair_parent[1]) - (pair_children[0] - pair_children[1])) <= SUM_TOLERANCE + if not (is_balance and collapsed and net_equal): + return True + return False + + +def _nest_roots(vectors: Sequence[tuple[Decimal, ...]], pacer: _Pacer) -> list[int]: + """Глубина вложенности каждого из подряд идущих соседей по суммам (жадно, как `strategy_sums`): сосед — родитель + следующих соседей (с их поддеревьями), пока сумма их строк не совпадёт с его суммами. Перебор ограничен + `NEST_SCAN_LIMIT` соседей вперёд. Все нули — глубина 0 у всех.""" + count = len(vectors) + zero = tuple(_ZERO for _ in vectors[0]) if vectors else () + end = [index + 1 for index in range(count)] + for index in reversed(range(count)): + if all(value == _ZERO for value in vectors[index]): + continue + accumulated, position = zero, index + 1 + for _ in range(NEST_SCAN_LIMIT): + pacer.tick() + if position >= count: + break + accumulated = _add(accumulated, vectors[position]) + position = end[position] + if _matches(accumulated, vectors[index]): + end[index] = position + break + depths: list[int] = [] + open_ends: list[int] = [] + for index in range(count): + while open_ends and open_ends[-1] <= index: + open_ends.pop() + depths.append(len(open_ends)) + if end[index] > index + 1: + open_ends.append(end[index]) + return depths + + +def _nest_by_sums(rows: Sequence[BodyRow], levels: dict[int, int], deadline: Deadline | None) -> dict[int, int]: + """Вложенность аналитики по суммам внутри родителя с нарушением «сумма детей = родитель» (при одном отступе + уровни аналитики неразличимы, видны только суммы: 15 = 16 + 17). + + Только для родителя с нарушением, чьи дети — строки аналитики (не счета и не показатели) с суммами; перестройка + принимается, если после неё сумма прямых детей совпадает с родителем (`_violates`), иначе дерево родителя остаётся + исходным. Строки без нарушения не трогаются: равные суммы соседей (50 + 50 = 100) не делают одного ребёнком другого. + """ + pacer = _Pacer(deadline, "иерархия (indent): вложенность по суммам") + by_parent = parents(rows, levels) + kids: dict[int, list[int]] = {} + for index, item in enumerate(rows): + parent = by_parent[item.row] + if parent is not None: + kids.setdefault(parent, []).append(index) + position = {item.row: index for index, item in enumerate(rows)} + bump: dict[int, int] = {} + for parent_row, kid_indexes in kids.items(): + pacer.tick() + members = [rows[index] for index in kid_indexes] + if len(members) < MIN_DISTINCT_LEVELS or any(m.is_account or m.indicator for m in members): + continue + vectors = [_vector(member) for member in members] + total = vectors[0] + for vector in vectors[1:]: + total = _add(total, vector) + parent_vector = _vector(rows[position[parent_row]]) + if not _violates(parent_vector, total): + continue + depths = _nest_roots(vectors, pacer) + top = [vector for vector, depth in zip(vectors, depths, strict=True) if depth == 0] + if not any(depths) or _violates(parent_vector, tuple(sum(column, _ZERO) for column in zip(*top, strict=True))): + continue + for kid, depth in zip(kid_indexes, depths, strict=True): + if depth == 0: + continue + index = kid + bump[rows[index].row] = depth + index += 1 + while index < len(rows) and levels[rows[index].row] > levels[rows[kid].row]: + bump[rows[index].row] = depth + index += 1 + return {row: level + bump.get(row, 0) for row, level in levels.items()} def strategy_account_pattern(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None: diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 7be1702..ae25f81 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -5,7 +5,8 @@ раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во», строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты другим треком), s45 — счёт шапки «Счет Поставщика» | «60*» в соседних ячейках, s46 — выгрузка «по контрагентам» с -блоками субсчетов (#71); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен +блоками субсчетов (#71), s47 — иерархия по отступам: подуровень счёта, строка закрытия, вложенность по суммам (#78); +описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован. @@ -1037,6 +1038,71 @@ def _s46() -> list[SheetSpec]: return [sheet] +def _s47() -> list[SheetSpec]: + """Иерархия по отступам там, где отступа не хватает (AURORA-1858): три листа «Субконто» без вида аналитики в шапке. + Лист «68»: подуровень «68.01.1» на том же отступе, что «68.01». Лист «90»: безымянная строка закрытия сразу за + счётом (пустая ячейка отступа не хранит). Лист «91»: аналитика на одном отступе, а вложена по суммам + (100 = 60 + 40; 30 = 20 + 10; 40 ⊃ 40).""" + head = {1: 'ООО "Октябрьское"', 3: "Период: 9 месяцев 2024 г."} + + def sheet(account: str, rows: Sequence[FlatRow]) -> SheetSpec: + spec = _flat_sheet({**head, 2: f"Оборотно-сальдовая ведомость по счету {account}"}, 5, rows) + spec.name = f"{account} сч." + return spec + + tax = (None, None, "2333.00", "2624.00", None, "291.00") + tax_old = (None, "15687.00", "45332.00", "45332.00", None, "15687.00") + rows_68: list[FlatRow] = [ + ("68.01", tax, 0), + ("68.01.1", tax, 0), + ("Налог (взносы): начислено / уплачено", tax, 1), + ("68.10", tax_old, 0), + ("Налог (взносы): начислено / уплачено", tax_old, 1), + ("Итого", (None, "15687.00", "47665.00", "47956.00", None, "15978.00"), 0), + ] + income = (None, None, None, "300.00", None, "300.00") + expense = (None, None, "200.00", None, "200.00", None) + closing = (None, None, "500.00", "300.00", "200.00", None) + rows_90: list[FlatRow] = [ + ("90.01", income, 0), + ("90.01.1", income, 0), + ("Аренда", (None, None, None, "200.00", None, None), 1), + ("Прочие", (None, None, None, "100.00", None, None), 1), + ("90.07", expense, 0), + ("90.07.1", expense, 0), + ("Расходы по аренде", (None, None, "200.00", None, None, None), 1), + ("90.09", closing, 0), + ("", (None, None, "500.00", "300.00", None, None), 0), + ("Итого", (None, None, "700.00", "600.00", "400.00", "300.00"), 0), + ] + + def turn(debit: str | None, credit: str | None) -> tuple[str | None, ...]: + return (None, None, debit, credit, None, None) + + rows_91: list[FlatRow] = [ + ("91.01", (None, None, None, "100.00", None, "100.00"), 0), + ("Проценты к получению", turn(None, "70.00"), 1), + ("Проценты по вкладам", turn(None, "70.00"), 1), + ("Прочие доходы", turn(None, "30.00"), 1), + ("Пени", turn(None, "20.00"), 1), + ("Штрафы", turn(None, "10.00"), 1), + ("91.02", (None, None, "170.00", None, "170.00", None), 0), + ("91.02.1", (None, None, "170.00", None, "170.00", None), 0), + ("Проценты к уплате", turn("100.00", None), 1), + ("Проценты по займам", turn("60.00", None), 1), + ("Проценты по кредитам", turn("40.00", None), 1), + ("Прочие расходы", turn("30.00", None), 1), + ("Затраты на нужды", turn("20.00", None), 1), + ("Штрафы по договорам", turn("10.00", None), 1), + ("Операционные расходы", turn("40.00", None), 1), + ("Услуги банка", turn("40.00", None), 1), + ("91.09", (None, None, None, "70.00", None, "70.00"), 0), + ("", turn(None, "70.00"), 0), + ("Итого", (None, None, "170.00", "170.00", "170.00", "170.00"), 0), + ] + return [sheet("68", rows_68), sheet("90", rows_90), sheet("91", rows_91)] + + def _s28() -> list[SheetSpec]: """Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится.""" tree = _inn_layout_tree() @@ -1152,6 +1218,7 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp "s44_subkonto_unnamed_10.xlsx": (_s44, to_xlsx), "s45_account_label_value_cells.xlsx": (_s45, to_xlsx), "s46_counterparty_code_blocks.xlsx": (_s46, to_xlsx), + "s47_indent_hierarchy_code_sums.xlsx": (_s47, to_xlsx), } FILE_NAMES = tuple(FILES) diff --git a/api/tests/synthetic/test_generate.py b/api/tests/synthetic/test_generate.py index 83a9142..684c908 100644 --- a/api/tests/synthetic/test_generate.py +++ b/api/tests/synthetic/test_generate.py @@ -26,13 +26,13 @@ def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None: assert left.read_bytes() == right.read_bytes(), left.name -def test_generated_set_is_s01_to_s36_and_s40_to_s46_with_one_xlsb(tmp_path: Path) -> None: +def test_generated_set_is_s01_to_s36_and_s40_to_s47_with_one_xlsb(tmp_path: Path) -> None: names = [path.name for path in generate_all(tmp_path)] assert names == list(FILE_NAMES) assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names) # s33…s36 — строки-показатели (AURORA-1613, #58), плоская форма ОСВ 1С (#59) — s40…s44 - numbers = [*range(1, 37), *range(40, 47)] + numbers = [*range(1, 37), *range(40, 48)] assert [name[1:3] for name in names] == [f"{number:02d}" for number in numbers] assert [name for name in names if name.endswith(".xlsb")] == ["s13_no_outline_indent.xlsb"] assert sorted(path.name for path in tmp_path.iterdir()) == sorted(names) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index 7db7c77..1e21f8e 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -52,6 +52,7 @@ FLAT_FORM_FILES = ( "s44_subkonto_unnamed_10.xlsx", "s45_account_label_value_cells.xlsx", "s46_counterparty_code_blocks.xlsx", + "s47_indent_hierarchy_code_sums.xlsx", ) TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28 EXPECTED_NAMES = [ @@ -189,6 +190,10 @@ EXPECTED: dict[str, Expect] = { "s45_account_label_value_cells.xlsx": Expect("header_account", 2025, "Q2", True, data_type=None), # #71: выгрузка «по контрагентам» целиком: блоки субсчетов, коды контрагентов, договоры, «ИТОГ по …», «ИТОГ» "s46_counterparty_code_blocks.xlsx": Expect("code_blocks", 2025, "Q2", True, data_type=None), + # #78: листы 68/90/91 по отступам: подуровень «68.01.1», безымянная строка закрытия, вложенность по суммам + "s47_indent_hierarchy_code_sums.xlsx": Expect( + "indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"', documents=3 + ), } diff --git a/api/tests/unit/parser/test_hierarchy.py b/api/tests/unit/parser/test_hierarchy.py index 8b7db95..74244d2 100644 --- a/api/tests/unit/parser/test_hierarchy.py +++ b/api/tests/unit/parser/test_hierarchy.py @@ -54,6 +54,7 @@ def body( # noqa: PLR0913 - фабрика тестовых строк: все row=row, text=text, is_account=match is not None, + account_code=match.group(1) if match else None, account_rank=rank, bold=bold, indent=indent, @@ -498,3 +499,162 @@ def test_header_account_goes_before_sums_for_adjacent_rows_with_equal_sums() -> assert levels.strategy == "header_account" assert levels_list(rows, levels.by_row) == [0, 0] assert set(levels.parent.values()) == {None} + + +# --- Отступ: подуровень счёта, закрывающая строка, вложенность по суммам (AURORA-1858) --- + + +def turnover(value: str) -> tuple[Decimal | None, ...]: + return money(None, None, value, None, None, None) + + +def test_indent_subaccount_with_code_prefix_on_same_indent_is_nested() -> None: + """Подуровень «68.01.1» на том же отступе, что «68.01»: по коду он ребёнок, его аналитика — на уровень глубже.""" + rows = numbered( + ("68.01", {"indent": 0}), + ("68.01.1", {"indent": 0}), + ("Налог", {"indent": 1}), + ("68.10", {"indent": 0}), + ("Налог", {"indent": 1}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 0, 21: 1} + + +def test_indent_neighbour_accounts_without_code_prefix_stay_roots() -> None: + rows = numbered( + ("68.01", {"indent": 0}), + ("68.10", {"indent": 0}), + ("68.1.1", {"indent": 0}), + ("Налог", {"indent": 1}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 0, 19: 0, 20: 1} + + +def test_indent_code_prefix_nesting_only_on_a_single_indent() -> None: + """Пары «счёт — подуровень» на двух разных отступах — не признак вложенности (соседние корни намеренно).""" + rows = numbered( + ("60", {"indent": 0}), + ("60.01", {"indent": 0}), + ("Контрагент", {"indent": 1}), + ("70", {"indent": 1}), + ("70.01", {"indent": 1}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 0, 19: 1, 20: 1, 21: 1} + + +def test_indent_chain_of_subaccounts_on_same_indent_is_nested_step_by_step() -> None: + rows = numbered( + ("68", {"indent": 0}), + ("68.01", {"indent": 0}), + ("68.01.1", {"indent": 0}), + ("Налог", {"indent": 1}), + ("69", {"indent": 0}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 3, 21: 0} + + +def test_indent_unnamed_closing_row_after_account_is_its_child() -> None: + """Строка закрытия без текста и кода (reader не хранит пустые ячейки — отступ 0) — ребёнок предыдущего счёта.""" + rows = numbered( + ("90.01", {"indent": 0, "sums": turnover("10")}), + ("Аренда", {"indent": 1, "sums": turnover("10")}), + ("90.09", {"indent": 0, "sums": turnover("7")}), + ("", {"indent": 0, "sums": turnover("7")}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0, 20: 1} + + +def test_indent_unnamed_row_without_sums_or_after_non_account_is_not_attached() -> None: + rows = numbered( + ("90.01", {"indent": 0}), + ("Аренда", {"indent": 1, "sums": turnover("10")}), + ("", {"indent": 0, "sums": turnover("10")}), + ("90.09", {"indent": 0}), + ("", {"indent": 0}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0, 20: 0, 21: 0} + + +def flat_group(*kids: str, parent: str = "100") -> list[BodyRow]: + """Счёт с оборотом `parent` и аналитикой на одном отступе — по суммам значения `kids` (дебет оборота).""" + specs: list[tuple[str, dict[str, object]]] = [("91.02", {"indent": 0, "sums": turnover(parent)})] + specs += [(f"Статья {n}", {"indent": 1, "sums": turnover(value)}) for n, value in enumerate(kids)] + return numbered(*specs) + + +def test_indent_flat_analytics_is_nested_by_sums_inside_violating_parent() -> None: + """Сумма детей ≠ родитель, а вложенность видна только по суммам (60 = 40 + 20): строка с суммой её соседей — их + родитель.""" + rows = flat_group("60", "40", "20", "40") + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 2, 21: 1} + + +def test_indent_flat_analytics_is_untouched_without_violation() -> None: + """Родитель сходится с детьми (50 + 50 = 100): равные суммы соседей не склеиваются в родителя и ребёнка.""" + rows = flat_group("50", "50") + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 1} + + +def test_indent_nesting_by_sums_is_rolled_back_when_parent_still_violates() -> None: + """30 + 30 + 30 ≠ 100 и вложенность по суммам (30 ⊃ 30) родителя не лечит: дерево остаётся исходным.""" + rows = flat_group("30", "30", "30") + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 1, 20: 1} + + +def test_indent_nesting_by_sums_never_nests_accounts() -> None: + rows = numbered( + ("91", {"indent": 0, "sums": turnover("100")}), + ("91.01", {"indent": 1, "sums": turnover("60")}), + ("91.02", {"indent": 1, "sums": turnover("60")}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 1} + + +def test_indent_second_subaccount_of_same_parent_is_nested_too() -> None: + """«68.04.1» и «68.04.2» на одном отступе с «68.04»: оба — дети «68.04» (второй — не ребёнок первого).""" + rows = numbered( + ("68.04", {"indent": 0}), + ("68.04.1", {"indent": 0}), + ("Налог", {"indent": 1}), + ("68.04.2", {"indent": 0}), + ("68.07", {"indent": 0}), + ("Налог", {"indent": 1}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 1, 21: 0, 22: 1} + + +def test_indent_subaccount_already_under_parent_with_its_code_is_not_moved() -> None: + """«76.09» с собственной аналитикой и «76.09.1» рядом на одном отступе — уже дети счёта «76.09»; под соседа + «76.09» их перекладывать не нужно.""" + rows = numbered( + ("76", {"indent": 0}), + ("76.09", {"indent": 1}), + ("76.09", {"indent": 2}), + ("ООО А", {"indent": 3}), + ("76.09.1", {"indent": 2}), + ("ООО Б", {"indent": 3}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 3, 21: 2, 22: 3} + + +def test_indent_unnamed_row_with_other_turnover_is_not_a_closing_row() -> None: + """Безымянная строка с суммами, не повторяющая обороты счёта, закрытием не считается.""" + rows = numbered( + ("26.01", {"indent": 0, "sums": money(None, None, "100", "100", None, None)}), + ("Статья", {"indent": 1, "sums": turnover("100")}), + ("", {"indent": 0, "sums": money(None, None, None, "100", None, None)}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0} diff --git a/openspec/specs/osv-parsing/spec.md b/openspec/specs/osv-parsing/spec.md index b138a68..2c2360e 100644 --- a/openspec/specs/osv-parsing/spec.md +++ b/openspec/specs/osv-parsing/spec.md @@ -150,6 +150,18 @@ SHALL пропускаться с предупреждением файла `SHE - **THEN** `OSV_HIERARCHY_BY_INDENT`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по кодам счетов и жирности (`s03`) +#### Scenario: Отступ не различает подуровни счёта, строку закрытия и вложенную аналитику +- **WHEN** на листе уровни берутся по отступам (`s47`; ОСВ 1С «Субконто» без вида аналитики в шапке), но + (а) счёт `68.01.1` стоит на одном отступе с `68.01` (код продолжает код соседнего счёта того же блока); + (б) безымянная строка с суммами (пустая ячейка отступа не хранится) идёт сразу за строкой счёта и повторяет его обороты; + (в) аналитика родителя с нарушением «сумма детей = родитель» стоит на одном отступе, а вложенность видна только по + суммам (`100 = 60 + 40`) +- **THEN** (а) `68.01.1` — ребёнок `68.01` (уровень +1, его аналитика глубже), если все такие пары листа на одном + отступе, раскладка однозначна, а сумма детей перекладываемого родителя сходится с ним; (б) строка закрытия — ребёнок + счёта; (в) внутри такого родителя строка, чьи суммы равны сумме следующих соседей, становится их родителем (жадно), но + только если после перестройки родитель сходится, иначе дерево остаётся по отступам; родитель без нарушения не + перестраивается (равные суммы соседей `50 + 50 = 100` не делают одного ребёнком другого); листы `s47` — `done` + #### Scenario: Без outline и отступов - **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`) - **THEN** `OSV_HIERARCHY_BY_ACCOUNT_PATTERN`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт -- 2.47.2 From e1760c08f613c793ce875f63297f6c774bb41703 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 10:43:39 +0400 Subject: [PATCH 4/5] =?UTF-8?q?fix(osv):=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=BF=D0=BE=20=D1=81?= =?UTF-8?q?=D1=83=D0=BC=D0=BC=D0=B0=D0=BC=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=B1=D0=B5=D0=B7=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF,=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=98=D0=9D=D0=9D=20=D0=B8=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=BC=20=D0=BE=D1=82=D1=81?= =?UTF-8?q?=D1=82=D1=83=D0=BF=D0=B5;=20=D1=81=D0=B4=D0=B2=D0=B8=D0=B3?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BA=D0=BB=D0=B0=D0=B4=D1=8B=D0=B2=D0=B0=D1=8E?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20(AURORA-1858,=20#78,=20=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-1: nest_by_sums включается из osv.py, если схема шапки не называет группы после счёта; строка с ИНН не вкладывается и не становится родителем; прямые дети родителя должны быть на одном отступе. M-1: сдвиги складываются. M-2: код без разделителя сегментов не вкладывается. M-3: строка закрытия — только с ненулевыми оборотами. M-4: общий жадный перебор _greedy_ends для strategy_sums и вложенности, граница NEST_SCAN_LIMIT под тестом. Co-Authored-By: Claude Opus 5.5 --- api/src/parser/classify.py | 6 +- api/src/parser/hierarchy.py | 130 ++++++++++-------- api/src/parser/indicators.py | 13 +- api/src/parser/osv.py | 9 +- api/tests/unit/parser/test_hierarchy.py | 129 +++++++++++++++++ .../unit/parser/test_osv_indent_nesting.py | 97 +++++++++++++ 6 files changed, 322 insertions(+), 62 deletions(-) create mode 100644 api/tests/unit/parser/test_osv_indent_nesting.py diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 0618bf1..ade5b56 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -37,7 +37,7 @@ from src.parser.detect import ( table_kind_row, ) from src.parser.grid import Sheet, column_letter -from src.parser.hierarchy import BodyRow, Levels +from src.parser.hierarchy import ACCOUNT_SEGMENT_SEPARATOR, BodyRow, Levels from src.parser.indicators import is_indicator_total, read_label from src.parser.numbers import column_inn, split_inn_suffix, to_money from src.parser.rules import GroupType, Rules @@ -64,7 +64,7 @@ ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.(?:\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2})(?:, # Субсчёт с тремя цифрами в сегменте («60.111», «60.243»): счёт, только если код начинается со счёта шапки (§1.3, # AURORA-1714); без счёта шапки действует `ACCOUNT_RE` _WIDE_ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.\d{1,3}){1,2})(?:,\s*(.*))?$", re.ASCII | re.DOTALL) -_SEGMENT_SEPARATOR = "." +_SEGMENT_SEPARATOR = ACCOUNT_SEGMENT_SEPARATOR def match_account(text: str, header_account: str | None = None) -> re.Match[str] | None: @@ -310,6 +310,7 @@ def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строк # Единицы отступа разные (alignment.indent, пробелы, колонки) — иерархия сравнивает только ранги: колонка глубже # любого отступа ячейки indent += label_cols.index(label_col) * LABEL_COLUMN_INDENT_STEP + inn_in_column = header.inn_col is not None and column_inn(sheet.cell(row, header.inn_col), rules) is not None return BodyRow( row=row, text=text, @@ -323,6 +324,7 @@ def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строк label_col=label_col, indicator=label.indicator, label_data_type=label.data_type, + has_inn=inn_in_column or split_inn_suffix(text, rules)[1] is not None, ) diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index 81fe98b..54f171f 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -47,6 +47,10 @@ DEADLINE_CHECK_EVERY = 500 # Вложенность по суммам в `indent` ищет родителя среди не более чем стольких следующих соседей: аналитика одного # родителя вложена на единицы строк, а без предела плоский лист в тысячи строк давал бы O(n²) перебора NEST_SCAN_LIMIT = 50 +# Вложенность по суммам имеет смысл, только если у родителя есть кого вкладывать друг в друга: хотя бы два ребёнка +MIN_NESTABLE_CHILDREN = 2 +# Разделитель сегментов кода счёта («68.01.1»); `classify` берёт его отсюда же +ACCOUNT_SEGMENT_SEPARATOR = "." _ZERO = Decimal(0) _TURNOVER_PAIR = slice(2, 4) # обороты (дебет, кредит) в `BodyRow.sums` @@ -72,6 +76,7 @@ class BodyRow: # Роль в блоке субсчёта с кодами контрагентов (`code_blocks`, §1.3): контрагент (код с суммами) или его договор role: Role | None = None account_code: str | None = None # код счёта строки, как в файле («68.01.1»); у не-счёта — `None` + has_inn: bool = False # у строки есть ИНН: в колонке ИНН или хвостом названия (контрагент, не строка аналитики) @dataclass(frozen=True) @@ -134,7 +139,9 @@ def strategy_outline(rows: Sequence[BodyRow], deadline: Deadline | None = None) return {item.row: item.outline - base for item in rows} -def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None: +def strategy_indent( + rows: Sequence[BodyRow], deadline: Deadline | None = None, *, nest_by_sums: bool = True +) -> dict[int, int] | None: """Отступ ячейки текста: уровень — ранг отступа среди различных отступов листа. `indent` приходит либо как `alignment.indent`, либо как число ведущих пробелов (T2.4), поэтому шаг отступа @@ -146,7 +153,10 @@ def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) - - безымянная строка закрытия сразу за строкой счёта (отступ пустой ячейки не хранится) — её ребёнок (`_attach_closing_rows`); - аналитика, вложенная на два уровня при одном отступе, — вложенность по суммам, но только внутри родителя с - нарушением «сумма детей = родитель» и только если после перестройки он сходится (`_nest_by_sums`). + нарушением «сумма детей = родитель», только если все его прямые дети на одном отступе, ни вкладываемая строка, + ни новый родитель не несут ИНН, и только если после перестройки он сходится (`_nest_by_sums`). Вызывающий + отключает её (`nest_by_sums=False`), когда схема шапки называет группы аналитики после счёта: вложение + изменило бы `group_type`/`group_name` строки. """ ranks = _ranks([item.indent for item in _paced(rows, deadline, "иерархия (indent)")]) if ranks is None: @@ -154,7 +164,7 @@ def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) - levels = {item.row: ranks[item.indent] for item in rows} levels = _nest_subaccounts(rows, levels) levels = _attach_closing_rows(rows, levels) - return _nest_by_sums(rows, levels, deadline) + return _nest_by_sums(rows, levels, deadline) if nest_by_sums else levels def _nest_subaccounts(rows: Sequence[BodyRow], levels: dict[int, int]) -> dict[int, int]: @@ -183,13 +193,14 @@ def _nest_subaccounts(rows: Sequence[BodyRow], levels: dict[int, int]) -> dict[i open_accounts.pop() for deeper in [known for known in blocks if known > level]: del blocks[deeper] - parent_code = code.rsplit(".", 1)[0] - candidate = blocks.get(level, {}).get(parent_code) + # у кода без сегментов («60» и второй «60») родителя по коду нет: `parent_code` пуст + parent_code = code.rpartition(ACCOUNT_SEGMENT_SEPARATOR)[0] + candidate = blocks.get(level, {}).get(parent_code) if parent_code else None under_parent = bool(open_accounts) and open_accounts[-1][1] == parent_code if candidate is not None and not under_parent: parent_of[item.row] = candidate.row pair_levels.add(level) - elif candidate is not None or parent_code in seen_codes.get(level, ()): + elif parent_code and (candidate is not None or parent_code in seen_codes.get(level, ())): # родительский код на этом уровне уже был, но счёт к нему не прикрепить (рядом уже стоит под своим родителем # или между ними строка другого блока): раскладка неоднозначна, по коду не перекладываем ничего ambiguous = True @@ -241,8 +252,8 @@ def _attach_closing_rows(rows: Sequence[BodyRow], levels: dict[int, int]) -> dic """Безымянная строка с суммами (без текста и кода) сразу за строкой счёта — строка закрытия, ребёнок этого счёта. Читатель не хранит пустые ячейки, поэтому её отступ — 0, как у счёта; признак — пустой текст, а не позиция. - Строка закрытия повторяет обороты счёта (дебет и кредит равны оборотам счёта): иначе безымянная строка с суммами — - не закрытие, и дерево остаётся по отступам. + Строка закрытия повторяет обороты счёта (дебет и кредит равны оборотам счёта) и они не нулевые: иначе безымянная + строка с суммами — не закрытие, и дерево остаётся по отступам. """ result = dict(levels) previous: BodyRow | None = None @@ -252,7 +263,7 @@ def _attach_closing_rows(rows: Sequence[BodyRow], levels: dict[int, int]) -> dic and previous.is_account and not item.is_account and not item.text.strip() - and any(value is not None for value in item.sums) + and any(value != _ZERO for value in _vector(item)[_TURNOVER_PAIR]) and _matches(_vector(item)[_TURNOVER_PAIR], _vector(previous)[_TURNOVER_PAIR]) and result[item.row] <= result[previous.row] ): @@ -278,29 +289,44 @@ def _violates(parent: tuple[Decimal, ...], children: tuple[Decimal, ...]) -> boo return False -def _nest_roots(vectors: Sequence[tuple[Decimal, ...]], pacer: _Pacer) -> list[int]: - """Глубина вложенности каждого из подряд идущих соседей по суммам (жадно, как `strategy_sums`): сосед — родитель - следующих соседей (с их поддеревьями), пока сумма их строк не совпадёт с его суммами. Перебор ограничен - `NEST_SCAN_LIMIT` соседей вперёд. Все нули — глубина 0 у всех.""" +def _greedy_ends( + vectors: Sequence[tuple[Decimal, ...]], + pacer: _Pacer, + *, + scan_limit: int | None = None, + blocked: Sequence[bool] | None = None, +) -> list[int]: + """Граница поддерева каждой строки (`end[i]`, исключительно) по суммам, жадно и с конца: строка `i` перебирает + следующие поддеревья подряд и становится родителем на первом совпадении по всем колонкам. Строка без сумм + родителем не бывает. `scan_limit` — сколько следующих поддеревьев перебирать (`None` — до конца); + `blocked[i]` — строка не может быть ни родителем, ни ребёнком (перебор обрывается на ней).""" count = len(vectors) zero = tuple(_ZERO for _ in vectors[0]) if vectors else () end = [index + 1 for index in range(count)] for index in reversed(range(count)): - if all(value == _ZERO for value in vectors[index]): + pacer.tick() + target = vectors[index] + if all(value == _ZERO for value in target) or (blocked is not None and blocked[index]): continue - accumulated, position = zero, index + 1 - for _ in range(NEST_SCAN_LIMIT): - pacer.tick() - if position >= count: + accumulated, position, steps = zero, index + 1, 0 + while position < count and (scan_limit is None or steps < scan_limit): + if blocked is not None and blocked[position]: break + pacer.tick() accumulated = _add(accumulated, vectors[position]) position = end[position] - if _matches(accumulated, vectors[index]): + steps += 1 + if _matches(accumulated, target): end[index] = position break + return end + + +def _depths(end: Sequence[int]) -> list[int]: + """Глубина вложенности каждой строки по границам поддеревьев `end`.""" depths: list[int] = [] - open_ends: list[int] = [] - for index in range(count): + open_ends: list[int] = [] # границы поддеревьев открытых предков + for index in range(len(end)): while open_ends and open_ends[-1] <= index: open_ends.pop() depths.append(len(open_ends)) @@ -329,7 +355,11 @@ def _nest_by_sums(rows: Sequence[BodyRow], levels: dict[int, int], deadline: Dea for parent_row, kid_indexes in kids.items(): pacer.tick() members = [rows[index] for index in kid_indexes] - if len(members) < MIN_DISTINCT_LEVELS or any(m.is_account or m.indicator for m in members): + if ( + len(members) < MIN_NESTABLE_CHILDREN + or any(m.is_account or m.indicator for m in members) + or len({m.indent for m in members}) > 1 + ): continue vectors = [_vector(member) for member in members] total = vectors[0] @@ -338,7 +368,9 @@ def _nest_by_sums(rows: Sequence[BodyRow], levels: dict[int, int], deadline: Dea parent_vector = _vector(rows[position[parent_row]]) if not _violates(parent_vector, total): continue - depths = _nest_roots(vectors, pacer) + depths = _depths( + _greedy_ends(vectors, pacer, scan_limit=NEST_SCAN_LIMIT, blocked=[member.has_inn for member in members]) + ) top = [vector for vector, depth in zip(vectors, depths, strict=True) if depth == 0] if not any(depths) or _violates(parent_vector, tuple(sum(column, _ZERO) for column in zip(*top, strict=True))): continue @@ -346,10 +378,11 @@ def _nest_by_sums(rows: Sequence[BodyRow], levels: dict[int, int], deadline: Dea if depth == 0: continue index = kid - bump[rows[index].row] = depth + # сдвиги складываются: строка может лежать в поддереве вложенного ребёнка и сама вкладываться глубже + bump[rows[index].row] = bump.get(rows[index].row, 0) + depth index += 1 while index < len(rows) and levels[rows[index].row] > levels[rows[kid].row]: - bump[rows[index].row] = depth + bump[rows[index].row] = bump.get(rows[index].row, 0) + depth index += 1 return {row: level + bump.get(row, 0) for row, level in levels.items()} @@ -449,38 +482,11 @@ def strategy_sums(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> поддеревья до конца листа. Поэтому дедлайн проверяется во внутреннем цикле каждые `DEADLINE_CHECK_EVERY` шагов, и такой файл завершается `TIMEOUT`, а не висит. """ - count = len(rows) vectors = [_vector(item) for item in rows] - zero = tuple(_ZERO for _ in vectors[0]) if vectors else () - end = [index + 1 for index in range(count)] # граница (исключительно) поддерева строки - has_children = False - pacer = _Pacer(deadline, "иерархия (sums): перебор поддеревьев") - for index in reversed(range(count)): - pacer.tick() - target = vectors[index] - if all(value == _ZERO for value in target): - continue - accumulated = zero - position = index + 1 - while position < count: - pacer.tick() - accumulated = _add(accumulated, vectors[position]) - position = end[position] - if _matches(accumulated, target): - end[index] = position - has_children = True - break - if not has_children: + end = _greedy_ends(vectors, _Pacer(deadline, "иерархия (sums): перебор поддеревьев")) + if all(end[index] == index + 1 for index in range(len(rows))): return None - by_row: dict[int, int] = {} - open_ends: list[int] = [] # границы поддеревьев открытых предков - for index, item in enumerate(rows): - while open_ends and open_ends[-1] <= index: - open_ends.pop() - by_row[item.row] = len(open_ends) - if end[index] > index + 1: - open_ends.append(end[index]) - return by_row + return {item.row: depth for item, depth in zip(rows, _depths(end), strict=True)} STRATEGIES: tuple[tuple[str, Strategy], ...] = ( @@ -521,10 +527,18 @@ def parents(rows: Sequence[BodyRow], by_row: Mapping[int, int]) -> dict[int, int def resolve_levels( - rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline, *, header_account: bool = False + rows: Sequence[BodyRow], + total_row: int | None, + deadline: Deadline, + *, + header_account: bool = False, + nest_by_sums: bool = False, ) -> Levels: """Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`. + `nest_by_sums` — разрешить стратегии `indent` вложенность аналитики по суммам (AURORA-1858): только когда схема + шапки не называет группы аналитики после счёта (вложение не меняет `group_type`/`group_name` строки). + `header_account` — в шапке есть код счёта: тогда после `account_pattern` и перед `sums` пробуется `header_account` (плоский список под счётом шапки, #59). @@ -545,7 +559,9 @@ def resolve_levels( cascade.insert(before_sums, ("header_account", strategy_header_account)) for name, strategy in cascade: deadline.check(f"иерархия ({name})") - found = strategy(body, deadline) + found = ( + strategy_indent(body, deadline, nest_by_sums=nest_by_sums) if name == "indent" else strategy(body, deadline) + ) if found is None: continue by_row = {**found, **pinned} diff --git a/api/src/parser/indicators.py b/api/src/parser/indicators.py index 18031a9..315af1b 100644 --- a/api/src/parser/indicators.py +++ b/api/src/parser/indicators.py @@ -59,7 +59,12 @@ def is_indicator_total(sheet: Sheet, row: int, header: TableHeader, rules: Rules def levels_with_indicators( - rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline, *, header_account: bool = False + rows: Sequence[BodyRow], + total_row: int | None, + deadline: Deadline, + *, + header_account: bool = False, + nest_by_sums: bool = False, ) -> Levels: """Каскад иерархии без строк-показателей; показатель — ребёнок предыдущей строки данных, на уровень глубже (§1.3): так строки НУ/Кол./«В валюте» на одном outline-уровне со своей строкой не ломают дерево строк БУ. @@ -67,7 +72,11 @@ def levels_with_indicators( Итоговые строки-показатели и показатель без строки данных выше — уровень 0 без родителя. """ levels = resolve_levels( - [item for item in rows if not item.indicator], total_row, deadline, header_account=header_account + [item for item in rows if not item.indicator], + total_row, + deadline, + header_account=header_account, + nest_by_sums=nest_by_sums, ) by_row: dict[int, int] = {} parent: dict[int, int | None] = {} diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index d66e292..7dc57e4 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -217,7 +217,14 @@ class OsvParser: blocks = apply_code_blocks(body, total_row, rules, doc.account_code_raw) body = blocks.rows step = "иерархия строк" - levels = levels_with_indicators(body, total_row, deadline, header_account=bool(doc.account_code_raw)) + # Вложенность по суммам меняет group_type/group_name строки: только без названных групп после счёта + levels = levels_with_indicators( + body, + total_row, + deadline, + header_account=bool(doc.account_code_raw), + nest_by_sums=all(group.name is None for group in schema.groups[1:]), + ) step = "строки ИНН" glue = glue_inn_rows( body, diff --git a/api/tests/unit/parser/test_hierarchy.py b/api/tests/unit/parser/test_hierarchy.py index 74244d2..196f014 100644 --- a/api/tests/unit/parser/test_hierarchy.py +++ b/api/tests/unit/parser/test_hierarchy.py @@ -47,6 +47,7 @@ def body( # noqa: PLR0913 - фабрика тестовых строк: все indent: int = 0, bold: bool = False, sums: tuple[Decimal | None, ...] = NO_SUMS, + has_inn: bool = False, ) -> BodyRow: match = ACCOUNT.fullmatch(text) rank = match.group(1).count(".") + 1 if match else None @@ -60,6 +61,7 @@ def body( # noqa: PLR0913 - фабрика тестовых строк: все indent=indent, outline=outline, sums=sums, + has_inn=has_inn, ) @@ -658,3 +660,130 @@ def test_indent_unnamed_row_with_other_turnover_is_not_a_closing_row() -> None: ) assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0} + + +# --- Вложенность по суммам: защиты (ревью AURORA-1858, C-1) --- + + +def test_indent_nesting_by_sums_is_off_when_caller_forbids_it() -> None: + """Схема шапки называет группы аналитики после счёта: вложение изменило бы group_type/group_name строки.""" + rows = flat_group("60", "40", "20", "40") + + assert strategy_indent(rows, nest_by_sums=False) == {17: 0, 18: 1, 19: 1, 20: 1, 21: 1} + + +def test_indent_nesting_by_sums_needs_one_indent_among_children() -> None: + """Прямые дети родителя на разных отступах (2, 1, 1): отступ их уровни различает, суммы его не переопределяют.""" + rows = numbered( + ("91.02", {"indent": 0, "sums": turnover("100")}), + ("Статья 0", {"indent": 2, "sums": turnover("60")}), + ("Статья 1", {"indent": 1, "sums": turnover("40")}), + ("Статья 2", {"indent": 1, "sums": turnover("40")}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 2, 19: 1, 20: 1} + + +def inn_group(*, inn_on: set[int]) -> list[BodyRow]: + """91.02 = 100; аналитика 60, 40, 40 на одном отступе; `inn_on` — номера статей (с 0) с ИНН.""" + sums = ("60", "40", "40") + specs: list[tuple[str, dict[str, object]]] = [("91.02", {"indent": 0, "sums": turnover("100")})] + specs += [ + (f"Статья {n}", {"indent": 1, "sums": turnover(value), "has_inn": n in inn_on}) for n, value in enumerate(sums) + ] + return numbered(*specs) + + +def test_indent_nesting_by_sums_without_inn_nests_equal_neighbours() -> None: + assert strategy_indent(inn_group(inn_on=set())) == {17: 0, 18: 1, 19: 1, 20: 2} + + +def test_indent_nesting_by_sums_skips_row_with_inn_being_nested() -> None: + """Вкладываемая строка с ИНН: она контрагент, а не строка под соседом.""" + assert strategy_indent(inn_group(inn_on={2})) == {17: 0, 18: 1, 19: 1, 20: 1} + + +def test_indent_nesting_by_sums_skips_new_parent_with_inn() -> None: + """Новый родитель с ИНН: контрагент не становится родителем строки.""" + assert strategy_indent(inn_group(inn_on={1})) == {17: 0, 18: 1, 19: 1, 20: 1} + + +def test_resolve_levels_does_not_nest_by_sums_unless_asked() -> None: + rows = flat_group("60", "40", "20", "40") + flat = [1, 1, 1, 1] + nested = [1, 2, 2, 1] + + off = resolve_levels(rows, None, never_expires()) + on = resolve_levels(rows, None, never_expires(), nest_by_sums=True) + + assert levels_list(rows, off.by_row)[1:] == flat + assert levels_list(rows, on.by_row)[1:] == nested + + +def test_indent_nesting_by_sums_accumulates_depth_for_nested_child_of_nested_parent() -> None: + """91 = 100: X 40, Y 40, Z 60 на одном отступе (X ⊃ Y); под Y — K1 40, K2 40 глубже (K1 ⊃ K2). Поддерево Y + сдвигается вместе с Y (+1), а K2 ещё вкладывается в K1 (+1): сдвиги складываются, а не затирают друг друга.""" + rows = numbered( + ("91", {"indent": 0, "sums": turnover("100")}), + ("X", {"indent": 1, "sums": turnover("40")}), + ("Y", {"indent": 1, "sums": turnover("40")}), + ("K1", {"indent": 2, "sums": turnover("40")}), + ("K2", {"indent": 2, "sums": turnover("40")}), + ("Z", {"indent": 1, "sums": turnover("60")}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 3, 21: 4, 22: 1} + + +def test_indent_nesting_by_sums_keeps_depth_of_children_under_a_nested_row() -> None: + """Строка с детьми (глубже по отступу) вкладывается как поддерево: уровни детей растут на её глубину.""" + rows = numbered( + ("91", {"indent": 0, "sums": turnover("100")}), + ("Z", {"indent": 1, "sums": turnover("60")}), + ("Y", {"indent": 1, "sums": turnover("60")}), + ("K1", {"indent": 2, "sums": turnover("30")}), + ("K2", {"indent": 2, "sums": turnover("30")}), + ("W", {"indent": 1, "sums": turnover("40")}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 3, 21: 3, 22: 1} + + +NEST_SCAN_LIMIT_PLUS = 50 # столько соседей вперёд перебирает вложенность по суммам + + +@pytest.mark.parametrize(("count", "nested"), [(NEST_SCAN_LIMIT_PLUS, True), (NEST_SCAN_LIMIT_PLUS + 1, False)]) +def test_indent_nesting_by_sums_scan_limit_boundary(count: int, nested: bool) -> None: + """Родитель строки, чьи суммы дают сумма `count` следующих соседей: 50 соседей находятся, 51 — нет.""" + kids = [str(n) for n in range(1, count + 1)] + total = str(sum(range(1, count + 1))) + rows = flat_group(total, *kids, parent=total) + + levels = strategy_indent(rows) + + assert levels is not None + assert levels[FIRST + 1] == 1 + assert (levels[FIRST + 2] == levels[FIRST + 1] + 1) is nested + + +def test_indent_account_code_without_separator_is_not_nested() -> None: + """Второй счёт «60» после «60»: у кода нет разделителя сегментов, родителя по коду нет.""" + rows = numbered( + ("60", {"indent": 0}), + ("Контрагент", {"indent": 1}), + ("60", {"indent": 0}), + ("Контрагент 2", {"indent": 1}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0, 20: 1} + + +def test_indent_closing_row_needs_nonzero_turnover() -> None: + """Безымянная строка с нулевыми оборотами при нулевых оборотах счёта закрытием не считается.""" + rows = numbered( + ("90.09", {"indent": 0, "sums": money(None, None, "0", "0", None, None)}), + ("Статья", {"indent": 1, "sums": turnover("5")}), + ("", {"indent": 0, "sums": money(None, None, "0", "0", None, "7")}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0} diff --git a/api/tests/unit/parser/test_osv_indent_nesting.py b/api/tests/unit/parser/test_osv_indent_nesting.py new file mode 100644 index 0000000..2c8d6ab --- /dev/null +++ b/api/tests/unit/parser/test_osv_indent_nesting.py @@ -0,0 +1,97 @@ +"""Вложенность по суммам при иерархии по отступам (AURORA-1858, ревью C-1): защиты на уровне всего листа. + +Правило не должно прятать реальную ошибку и менять `group_type`/`group_name` строк: при названных группах аналитики в +шапке оно отключено, с ИНН строка не вкладывается и не становится родителем. +""" + +import pytest +from gridbuilder import C, M + +from src.contract.result import OsvDocument +from src.parser.deadline import Deadline +from src.parser.osv import OsvParser +from src.parser.rules import Rules, load_rules + +from gridbuilder import sheet_from_rows # isort: skip + +TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г." +TIMEOUT_SECONDS = 60 +Row = list[object] + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +def sums(value: int) -> Row: + return [None, None, value, None, value, None] + + +def line(indent: int, text: str, value: int, inn: str | None = None) -> Row: + return [C(text, indent=indent), inn, None, *sums(value)] + + +def parse(rules: Rules, head_group_rows: list[Row], body: list[Row]) -> OsvDocument: + rows: list[Row] = [ + ["ООО Ромашка"], + [TITLE], + [], + [ + "Счет", + "ИНН", + "КПП", + M("Сальдо на начало периода", 2), + M("Обороты за период", 2), + M("Сальдо на конец периода", 2), + ], + ["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + *head_group_rows, + *body, + ] + return OsvParser(rules).parse_sheet(sheet_from_rows(rows), Deadline(TIMEOUT_SECONDS)).document + + +def test_reviewer_scenario_with_named_groups_and_inn_stays_needs_check(rules: Rules) -> None: + """Счёт 60 = 100, но контрагенты 60 + 40 + 40: это ошибка файла, а не вложенность «Гамма под Бетой».""" + document = parse( + rules, + [["Договоры"]], + [ + line(0, "60", 100), + line(1, "ООО Альфа", 60, "7701234567"), + line(2, "Договор 1", 60), + line(1, "ООО Бета", 40, "7707654321"), + line(1, "ООО Гамма", 40, "5001234567"), + [C("Итого"), None, None, *sums(100)], + ], + ) + + by_text = {row.row_value: row for row in document.rows} + gamma = by_text["ООО Гамма"] + assert document.status == "needs_check" + assert gamma.group_type == "COUNTERPARTY" + assert gamma.counterparty_inn == "5001234567" + assert gamma.parent_row_number == by_text["60"].row_number + assert by_text["ООО Бета"].parent_row_number == by_text["60"].row_number + + +def test_named_analytic_group_in_header_disables_nesting_by_sums(rules: Rules) -> None: + """Без ИНН и с одним отступом правило бы вложило «Б» в «В» (40 ⊃ 40): шапка называет «Контрагенты», вложение + сменило бы group_type строки, поэтому дерево остаётся по отступам и лист на проверке.""" + document = parse( + rules, + [], + [ + line(0, "60", 100), + line(1, "Контрагент А", 60), + line(1, "Контрагент Б", 40), + line(1, "Контрагент В", 40), + [C("Итого"), None, None, *sums(100)], + ], + ) + + by_text = {row.row_value: row for row in document.rows} + assert document.status == "needs_check" + assert {by_text[name].group_type for name in ("Контрагент А", "Контрагент Б", "Контрагент В")} == {"COUNTERPARTY"} + assert {by_text[name].level for name in ("Контрагент А", "Контрагент Б", "Контрагент В")} == {1} -- 2.47.2 From 133b31a1f38501cf84203ce0c996db88359c1af1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 10:56:18 +0400 Subject: [PATCH 5/5] =?UTF-8?q?fix(osv):=20=D0=BD=D0=B5=D1=82=D1=82=D0=BE-?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=B7=D0=BD=D0=B0=D0=BA=20=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D1=86=D0=B5=D0=BF=D0=BE=D1=87=D0=BA=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BD=D0=B5=D1=82=D1=82=D0=BE-=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA,=20=D0=BD=D0=B5=D0=BF=D1=80=D0=B5=D1=80=D1=8B?= =?UTF-8?q?=D0=B2=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=BF=D0=B5=D1=80=D0=B8?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=B2,=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B8=20CLAUDE.md=20(AURORA-1863,=20AURORA-1823,=20#78,=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D1=8C=D1=8E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-2: в netted попадает только строка, сама сошедшаяся с детьми лишь по нетто; сошедшаяся по колонкам признак не передаёт. I-3: сальдо конца периода = сальдо начала следующего (по колонкам либо по нетто), разрыв — нарушение. M-6: сценарии спеки (защиты вложения по суммам, цепочка нетто-строк, непрерывность периодов); CLAUDE.md и докстринг checks.py ссылаются на решения владельца 28.10.2026. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 ++- api/src/parser/checks.py | 51 ++++++++++++++------- api/tests/unit/parser/test_checks.py | 66 ++++++++++++++++++++++++++-- openspec/specs/osv-parsing/spec.md | 26 +++++++---- 4 files changed, 120 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d9a2abc..d61ac68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,11 +79,13 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность проверяют property-тест и ручная сверка «Итого». - Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не - подгонять. + подгонять (решения владельца 28.10.2026 — AURORA-1858: уточнение иерархии по отступам А/Б/В с защитами, см. + `05-domain-rules.md` §1.3). **Нетривиальное правило §1.4 (R-21):** при сверке «сумма детей = родитель» обороты сравниваются по колонкам, а сальдо — по колонкам либо по нетто `дебет − кредит`, если у родителя сальдо свёрнуто (1С показывает счёт нетто, а субсчета и -контрагентов — развёрнуто). Это решение зафиксировано в design №31; не ослаблять и не расширять без обсуждения. +контрагентов — развёрнуто). Это решение зафиксировано в design №31; не ослаблять и не расширять без обсуждения (решение владельца 28.10.2026, AURORA-1863: узкое нетто для +развёрнутого родителя над нетто-ребёнком — только непрерывной цепочкой нетто-строк). **Счета без сальдо по аналитике (OQ-132, #11 п. 2):** список счетов (90, 91, 99) — данные `api/rules/osv_checks.yaml`, не код; тождество строки на них не проверяется, «сумма детей = родитель» (обороты всегда, сальдо — только корень → субсчета) и diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 3280ecc..5d06785 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -10,12 +10,13 @@ сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С показывает счёт свёрнутым сальдо, а субсчета и контрагентов — развёрнутым (так во всех реальных файлах, где у счёта есть субсчета с разными сторонами сальдо). Нетто допустимо и у развёрнутого родителя, если среди его прямых детей есть - «нетто-ребёнок» — ребёнок, прошедший сверку только по нетто, или имеющий такого потомка (1С схлопнула Дт/Кт - субсчёта в нетто, а в родителе они остались развёрнутыми, AURORA-1863); признак идёт вверх по дереву. Развёрнутый - родитель без нетто-ребёнка сверяется по колонкам. Родитель, все дети которого — периоды («Обороты за <месяц>», - группировка «Период», `is_period`), сверяется цепочкой: обороты = Σ оборотов периодов, сальдо начала = начало - первого периода, сальдо конца = конец последнего (сальдо месяцев — не слагаемые; AURORA-1823); смешанные дети — - обычная сумма; + «нетто-ребёнок» — ребёнок, сам сошедшийся с детьми по этой стороне сальдо (начало или конец) лишь по нетто (1С + схлопнула Дт/Кт субсчёта в нетто, а в родителе они остались развёрнутыми, AURORA-1863); признак передаётся вверх + только непрерывной цепочкой нетто-строк: строка, сошедшаяся по колонкам, его не передаёт. Развёрнутый родитель + без нетто-ребёнка сверяется по колонкам. Родитель, все дети которого — периоды («Обороты за <месяц>», группировка + «Период», `is_period`), сверяется цепочкой: обороты = Σ оборотов периодов, сальдо начала = начало первого периода, + сальдо конца = конец последнего, а конец каждого периода = начало следующего (по колонкам либо по нетто; сальдо + месяцев — не слагаемые; AURORA-1823); смешанные дети — обычная сумма; 3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2; то же для единственного корня, когда @@ -29,12 +30,15 @@ аналитикой сальдо детей пусто по определению); п. 3 («Итого») не меняется. Любое нарушение делает результат `needs_check` (решает оркестратор `osv.py`); парсер структуру под сходимость не -подгоняет. Номера строк — в `OsvChecks`, причины — тексты предупреждений. +подгоняет (решения владельца 28.10.2026: узкое нетто для нетто-ребёнка, AURORA-1863, выше; уточнение иерархии по +отступам с защитами, AURORA-1858, `hierarchy.strategy_indent`, `05-domain-rules.md` §1.3). Номера строк — в +`OsvChecks`, причины — тексты предупреждений. """ from collections import defaultdict from collections.abc import Iterable, Sequence from decimal import Decimal +from itertools import pairwise from src.contract.result import OsvChecks from src.parser.classify import SUM_FIELDS, ClassifiedRow @@ -141,7 +145,8 @@ def _sums_match( with_balance: bool = True, net_child: bool = False, ) -> bool: - """Суммы родителя = Σ детей; `with_balance=False` — только обороты (сальдо детей не несёт данных).""" + """Суммы родителя = Σ детей; `with_balance=False` — только обороты (сальдо детей не несёт данных). `net_child` — + среди детей есть нетто-ребёнок: сальдо (начало и конец) сверяется и по нетто.""" return ( (not with_balance or _balance_matches(parent[_OPENING], children[_OPENING], net_child=net_child)) and not _differs(parent[_TURNOVER], children[_TURNOVER]) @@ -149,9 +154,14 @@ def _sums_match( ) -def _balance_by_net(parent: Sequence[Decimal], children: Sequence[Decimal]) -> bool: - """Сальдо родителя (начало или конец) не равно Σ детей по колонкам: сверка прошла только по нетто.""" - return _differs(parent[_OPENING], children[_OPENING]) or _differs(parent[_CLOSING], children[_CLOSING]) +def _periods_continuous(periods: Sequence[ClassifiedRow]) -> bool: + """Сальдо конца каждого периода = сальдо начала следующей строки периода (по колонкам либо по нетто).""" + return all( + _balance_matches(closing, opening) or _balance_matches(opening, closing) + for closing, opening in ( + (_values(left)[_CLOSING], _values(right)[_OPENING]) for left, right in pairwise(periods) + ) + ) def _period_chain(periods: Sequence[ClassifiedRow]) -> list[Decimal]: @@ -193,19 +203,28 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No if item.group_type == "ACCOUNT": has_subaccounts.add(item.parent) violations: set[int] = set() - # «Свёрнутые в нетто» строки: прошли сверку с детьми только по нетто либо имеют такого потомка. Потомки идут по - # большим номерам строк, поэтому родители обходятся от больших номеров к меньшим. + # Нетто-строки: строка сама сошлась с детьми по сальдо (начало или конец) лишь по нетто, по колонкам не сошлось. + # Цепочка нетто-строк непрерывна: строка, сошедшаяся по колонкам, признак вверх не передаёт. Нетто допускается на + # обеих сторонах сальдо, а не только на той, где ребёнок свёрнут: схлопнутую пару 1С не показывает ни в начале, ни + # в конце (на реальных файлах родитель расходится с детьми симметрично на обеих сторонах). Потомки идут по большим + # номерам строк, поэтому родители обходятся от больших номеров к меньшим. 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]) children = totals[row] - if periods_of[row] and len(periods_of[row]) == len(children_of[row]): + chained = bool(periods_of[row]) and len(periods_of[row]) == len(children_of[row]) + if chained: children = _period_chain(periods_of[row]) - if not _sums_match(_values(item), children, with_balance=with_balance, net_child=net_child): + if (chained and with_balance and not _periods_continuous(periods_of[row])) or not _sums_match( + _values(item), children, with_balance=with_balance, net_child=net_child + ): violations.add(row) - elif net_child or (with_balance and _balance_by_net(_values(item), children)): + elif with_balance and ( + _differs(_values(item)[_OPENING], children[_OPENING]) + or _differs(_values(item)[_CLOSING], children[_CLOSING]) + ): netted.add(row) return [row for row in by_row if row in violations] diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index bf4cc45..a6e613e 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -229,9 +229,9 @@ def test_expanded_parent_without_netted_child_stays_violation_on_rounding_gap() assert parent_sums(rows) == [12] -def test_netted_flag_propagates_up_through_column_matched_level() -> None: - """Признак «есть свёрнутый потомок» идёт вверх: счёт 11 развёрнут, субсчёт 12 сошёлся по колонкам, но над ним - свёрнутый 13.""" +def test_netted_flag_does_not_pass_through_column_matched_level() -> None: + """Признак передаёт только строка, сама сошедшаяся с детьми по нетто (непрерывная цепочка нетто-строк): счёт 11 + развёрнут, субсчёт 12 сошёлся по колонкам (над свёрнутым 13) — признака у 12 нет, 11 сверяется по колонкам.""" 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"), @@ -241,6 +241,32 @@ def test_netted_flag_propagates_up_through_column_matched_level() -> None: row(16, ("70", "70", "0", "0", "70", "70"), level=2, parent=12, group_type="ACCOUNT"), ] + assert parent_sums(rows) == [11] + + +def test_netted_flag_continues_through_chain_of_net_rows() -> None: + """Цепочка нетто-строк: 12 сошёлся с 13 по нетто, 11 над ним — тоже по нетто.""" + rows = [ + row(11, ("260", "200", "10", "10", "260", "200"), 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"), + ] + + assert parent_sums(rows) == [] + + +def test_net_child_allows_net_on_both_balance_sides() -> None: + """R-9: ребёнок свёрнут в нетто по сальдо начала (на конец сошёлся по колонкам), а развёрнутый родитель на конец + расходится с ним симметрично: схлопнутую пару 1С не показывает ни в начале, ни в конце — нетто на обе стороны.""" + rows = [ + row(12, ("130", "70", "10", "10", "130", "70"), group_type="ACCOUNT"), + row(13, ("60", None, "10", "10", "60", None), level=1, parent=12, group_type="ACCOUNT"), + row(14, ("90", None, "10", None, "60", None), level=2, parent=13, group_type="ACCOUNT"), + row(15, (None, "30", None, "10", None, None), level=2, parent=13, group_type="ACCOUNT"), + ] + assert parent_sums(rows) == [] @@ -274,6 +300,40 @@ def test_period_children_chain_violations(broken: dict[str, str]) -> None: assert parent_sums(period_tree(**broken)) == [12] +def test_period_chain_gap_in_the_middle_is_violation() -> None: + """Начало и конец цепочки, обороты сходятся, но сальдо конца месяца ≠ сальдо начала следующего (90 → 95).""" + rows = [ + row(12, ("100", None, None, "30", "70", None), level=2, parent=10, group_type="COUNTERPARTY"), + row(13, ("100", None, None, "10", "90", None), level=3, parent=12, group_type="OTHER", is_period=True), + row(14, ("95", None, None, "10", "85", None), level=3, parent=12, group_type="OTHER", is_period=True), + row(15, ("85", None, None, "10", "70", None), level=3, parent=12, group_type="OTHER", is_period=True), + ] + + assert parent_sums(rows) == [12] + + +def test_period_chain_may_skip_months_but_must_be_continuous() -> None: + """Пропуск месяцев без строк допустим: сравниваются соседние строки периодов.""" + rows = [ + row(12, ("100", None, None, "30", "70", None), level=2, parent=10, group_type="COUNTERPARTY"), + row(13, ("100", None, None, "10", "90", None), level=3, parent=12, group_type="OTHER", is_period=True), + row(14, ("90", None, None, "20", "70", None), level=3, parent=12, group_type="OTHER", is_period=True), + ] + + assert parent_sums(rows) == [] + + +def test_period_chain_continuity_by_net_when_one_side_is_collapsed() -> None: + """Конец месяца развёрнут (120 Дт / 20 Кт), начало следующего свёрнуто (100 Дт): нетто совпадает.""" + rows = [ + row(12, ("100", None, None, "30", "70", None), level=2, parent=10, group_type="COUNTERPARTY"), + row(13, ("100", None, None, "10", "120", "20"), level=3, parent=12, group_type="OTHER", is_period=True), + row(14, ("100", None, None, "20", "70", None), level=3, parent=12, group_type="OTHER", is_period=True), + ] + + assert parent_sums(rows) == [] + + def test_period_balance_is_compared_by_net_for_collapsed_parent() -> None: """Сальдо периодов сверяется той же функцией, что и обычное: свёрнутый родитель — по нетто.""" rows = [ diff --git a/openspec/specs/osv-parsing/spec.md b/openspec/specs/osv-parsing/spec.md index 2c2360e..bfc1ce0 100644 --- a/openspec/specs/osv-parsing/spec.md +++ b/openspec/specs/osv-parsing/spec.md @@ -158,9 +158,17 @@ SHALL пропускаться с предупреждением файла `SHE суммам (`100 = 60 + 40`) - **THEN** (а) `68.01.1` — ребёнок `68.01` (уровень +1, его аналитика глубже), если все такие пары листа на одном отступе, раскладка однозначна, а сумма детей перекладываемого родителя сходится с ним; (б) строка закрытия — ребёнок - счёта; (в) внутри такого родителя строка, чьи суммы равны сумме следующих соседей, становится их родителем (жадно), но - только если после перестройки родитель сходится, иначе дерево остаётся по отступам; родитель без нарушения не - перестраивается (равные суммы соседей `50 + 50 = 100` не делают одного ребёнком другого); листы `s47` — `done` + счёта (оборотов не нулевые); (в) внутри такого родителя строка, чьи суммы равны сумме следующих соседей, становится + их родителем (жадно, не дальше `NEST_SCAN_LIMIT` = 50 следующих соседей), но только если после перестройки родитель + сходится, иначе дерево остаётся по отступам; родитель без нарушения не перестраивается (равные суммы соседей + `50 + 50 = 100` не делают одного ребёнком другого); листы `s47` — `done` +- Вложение по суммам (в) меняет `level` и `parent_row_number` строк в `rows[]` контракта, поэтому применяется только при + одновременном выполнении трёх защит (решение владельца 28.10.2026): (1) схема шапки не называет группы аналитики + после счёта («Субконто» без вида), то есть вложение не меняет `group_type`/`group_name` строки; (2) все прямые дети + родителя стоят на одном отступе; (3) ни вкладываемая строка, ни новый родитель не несут ИНН (колонка ИНН или хвост + названия). Иначе (шапка «Счет / Контрагенты / Договоры», контрагенты с ИНН) ошибка суммы остаётся нарушением, а лист — + `needs_check`: контрагент не становится строкой под соседом. Код без разделителя сегментов («60» и второй «60») + по (а) не вкладывается #### Scenario: Без outline и отступов - **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`) @@ -316,9 +324,10 @@ balance_identity_violations, parent_sum_violations}`. Результат про или расхождение оборотов — нарушение #### Scenario: Развёрнутый родитель над свёрнутым в нетто ребёнком -- **WHEN** сальдо родителя развёрнуто (обе стороны), а среди его прямых детей есть «нетто-ребёнок» — ребёнок, прошедший - сверку со своими детьми только по нетто, либо имеющий такого потомка (признак передаётся вверх по дереву), и нетто детей - равно нетто родителя +- **WHEN** сальдо родителя развёрнуто (обе стороны), а среди его прямых детей есть «нетто-ребёнок» — ребёнок, сам + сошедшийся со своими детьми по этой стороне сальдо (начало или конец) лишь по нетто, и нетто детей равно нетто + родителя; признак передаётся вверх только непрерывной цепочкой нетто-строк (строка, сошедшаяся по колонкам, его не + передаёт), отдельно для начала и конца - **THEN** нарушения по сальдо нет (1С схлопнула Дт/Кт субсчёта в нетто, а в родителе они остались развёрнутыми, AURORA-1863); обороты сверяются по колонкам, неверное нетто или расхождение оборотов — нарушение; развёрнутый родитель без нетто-ребёнка по-прежнему сверяется по колонкам (расхождение даже на 0,05 при равном нетто — нарушение) @@ -329,8 +338,9 @@ balance_identity_violations, parent_sum_violations}`. Результат про `is_period`) - **THEN** обороты родителя сверяются с суммой оборотов периодов, сальдо начала — с сальдо начала первого периода, сальдо конца — с сальдо конца последнего (в порядке строк; сальдо сверяется по колонкам либо по нетто, как обычно), - а не с суммой сальдо месяцев (AURORA-1823); расхождение в обороте, начале первого или конце последнего периода — - нарушение; у родителя со смешанными детьми (периоды и обычные строки) суммы считаются как прежде + а не с суммой сальдо месяцев (AURORA-1823); сальдо конца каждого периода SHALL равняться сальдо начала следующей строки + периода (по колонкам либо по нетто; месяцы без строк допустимы); расхождение в обороте, начале первого или конце + последнего периода либо разрыв цепочки — нарушение; у родителя со смешанными детьми (периоды и обычные строки) суммы считаются как прежде #### Scenario: Счёт 91 — аналитика только с оборотами - **WHEN** разбирается ОСВ по счёту 91: строки аналитики 91.01, 91.02, 91.09 несут только обороты, сальдо закрыто на -- 2.47.2