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 несут только обороты, сальдо закрыто на