fix(osv): нетто-признак только цепочкой нетто-строк, непрерывность периодов, спека и CLAUDE.md (AURORA-1863, AURORA-1823, #78, ревью)

I-2: в netted попадает только строка, сама сошедшаяся с детьми лишь по нетто; сошедшаяся по колонкам признак не
передаёт. I-3: сальдо конца периода = сальдо начала следующего (по колонкам либо по нетто), разрыв — нарушение.
M-6: сценарии спеки (защиты вложения по суммам, цепочка нетто-строк, непрерывность периодов); CLAUDE.md и докстринг
checks.py ссылаются на решения владельца 28.10.2026.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 10:56:18 +04:00
parent e1760c08f6
commit 133b31a1f3
4 changed files with 120 additions and 29 deletions

View File

@ -79,11 +79,13 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
проверяют property-тест и ручная сверка «Итого». проверяют property-тест и ручная сверка «Итого».
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не - Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не
подгонять. подгонять (решения владельца 28.10.2026 — AURORA-1858: уточнение иерархии по отступам А/Б/В с защитами, см.
`05-domain-rules.md` §1.3).
**Нетривиальное правило §1.4 (R-21):** при сверке «сумма детей = родитель» обороты сравниваются по колонкам, а сальдо — **Нетривиальное правило §1.4 (R-21):** при сверке «сумма детей = родитель» обороты сравниваются по колонкам, а сальдо —
по колонкам либо по нетто `дебет − кредит`, если у родителя сальдо свёрнуто (1С показывает счёт нетто, а субсчета и по колонкам либо по нетто `дебет − кредит`, если у родителя сальдо свёрнуто (1С показывает счёт нетто, а субсчета и
контрагентов — развёрнуто). Это решение зафиксировано в design №31; не ослаблять и не расширять без обсуждения. контрагентов — развёрнуто). Это решение зафиксировано в design №31; не ослаблять и не расширять без обсуждения (решение владельца 28.10.2026, AURORA-1863: узкое нетто для
развёрнутого родителя над нетто-ребёнком — только непрерывной цепочкой нетто-строк).
**Счета без сальдо по аналитике (OQ-132, #11 п. 2):** список счетов (90, 91, 99) — данные `api/rules/osv_checks.yaml`, не код; **Счета без сальдо по аналитике (OQ-132, #11 п. 2):** список счетов (90, 91, 99) — данные `api/rules/osv_checks.yaml`, не код;
тождество строки на них не проверяется, «сумма детей = родитель» (обороты всегда, сальдо — только корень → субсчета) и тождество строки на них не проверяется, «сумма детей = родитель» (обороты всегда, сальдо — только корень → субсчета) и

View File

@ -10,12 +10,13 @@
сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С показывает счёт свёрнутым сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С показывает счёт свёрнутым
сальдо, а субсчета и контрагентов — развёрнутым (так во всех реальных файлах, где у счёта есть субсчета с сальдо, а субсчета и контрагентов — развёрнутым (так во всех реальных файлах, где у счёта есть субсчета с
разными сторонами сальдо). Нетто допустимо и у развёрнутого родителя, если среди его прямых детей есть разными сторонами сальдо). Нетто допустимо и у развёрнутого родителя, если среди его прямых детей есть
«нетто-ребёнок» — ребёнок, прошедший сверку только по нетто, или имеющий такого потомка (1С схлопнула Дт/Кт «нетто-ребёнок» — ребёнок, сам сошедшийся с детьми по этой стороне сальдо (начало или конец) лишь по нетто (1С
субсчёта в нетто, а в родителе они остались развёрнутыми, AURORA-1863); признак идёт вверх по дереву. Развёрнутый схлопнула Дт/Кт субсчёта в нетто, а в родителе они остались развёрнутыми, AURORA-1863); признак передаётся вверх
родитель без нетто-ребёнка сверяется по колонкам. Родитель, все дети которого — периоды («Обороты за <месяц>», только непрерывной цепочкой нетто-строк: строка, сошедшаяся по колонкам, его не передаёт. Развёрнутый родитель
группировка «Период», `is_period`), сверяется цепочкой: обороты = Σ оборотов периодов, сальдо начала = начало без нетто-ребёнка сверяется по колонкам. Родитель, все дети которого — периоды («Обороты за <месяц>», группировка
первого периода, сальдо конца = конец последнего (сальдо месяцев — не слагаемые; AURORA-1823); смешанные дети — «Период», `is_period`), сверяется цепочкой: обороты = Σ оборотов периодов, сальдо начала = начало первого периода,
обычная сумма; сальдо конца = конец последнего, а конец каждого периода = начало следующего (по колонкам либо по нетто; сальдо
месяцев — не слагаемые; AURORA-1823); смешанные дети — обычная сумма;
3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их 3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их
несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо — несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо —
по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2; то же для единственного корня, когда по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2; то же для единственного корня, когда
@ -29,12 +30,15 @@
аналитикой сальдо детей пусто по определению); п. 3 («Итого») не меняется. аналитикой сальдо детей пусто по определению); п. 3 («Итого») не меняется.
Любое нарушение делает результат `needs_check` (решает оркестратор `osv.py`); парсер структуру под сходимость не Любое нарушение делает результат `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 import defaultdict
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Sequence
from decimal import Decimal from decimal import Decimal
from itertools import pairwise
from src.contract.result import OsvChecks from src.contract.result import OsvChecks
from src.parser.classify import SUM_FIELDS, ClassifiedRow from src.parser.classify import SUM_FIELDS, ClassifiedRow
@ -141,7 +145,8 @@ def _sums_match(
with_balance: bool = True, with_balance: bool = True,
net_child: bool = False, net_child: bool = False,
) -> bool: ) -> bool:
"""Суммы родителя = Σ детей; `with_balance=False` — только обороты (сальдо детей не несёт данных).""" """Суммы родителя = Σ детей; `with_balance=False` — только обороты (сальдо детей не несёт данных). `net_child` —
среди детей есть нетто-ребёнок: сальдо (начало и конец) сверяется и по нетто."""
return ( return (
(not with_balance or _balance_matches(parent[_OPENING], children[_OPENING], net_child=net_child)) (not with_balance or _balance_matches(parent[_OPENING], children[_OPENING], net_child=net_child))
and not _differs(parent[_TURNOVER], children[_TURNOVER]) 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: def _periods_continuous(periods: Sequence[ClassifiedRow]) -> bool:
"""Сальдо родителя (начало или конец) не равно Σ детей по колонкам: сверка прошла только по нетто.""" """Сальдо конца каждого периода = сальдо начала следующей строки периода (по колонкам либо по нетто)."""
return _differs(parent[_OPENING], children[_OPENING]) or _differs(parent[_CLOSING], children[_CLOSING]) 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]: 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": if item.group_type == "ACCOUNT":
has_subaccounts.add(item.parent) has_subaccounts.add(item.parent)
violations: set[int] = set() violations: set[int] = set()
# «Свёрнутые в нетто» строки: прошли сверку с детьми только по нетто либо имеют такого потомка. Потомки идут по # Нетто-строки: строка сама сошлась с детьми по сальдо (начало или конец) лишь по нетто, по колонкам не сошлось.
# большим номерам строк, поэтому родители обходятся от больших номеров к меньшим. # Цепочка нетто-строк непрерывна: строка, сошедшаяся по колонкам, признак вверх не передаёт. Нетто допускается на
# обеих сторонах сальдо, а не только на той, где ребёнок свёрнут: схлопнутую пару 1С не показывает ни в начале, ни
# в конце (на реальных файлах родитель расходится с детьми симметрично на обеих сторонах). Потомки идут по большим
# номерам строк, поэтому родители обходятся от больших номеров к меньшим.
netted: set[int] = set() netted: set[int] = set()
for row in sorted(totals, reverse=True): for row in sorted(totals, reverse=True):
item = by_row[row] item = by_row[row]
with_balance = row in has_subaccounts or not _no_analytic_balance(item, exempt) 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]) net_child = any(child in netted for child in children_of[row])
children = totals[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]) 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) 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) netted.add(row)
return [row for row in by_row if row in violations] return [row for row in by_row if row in violations]

View File

@ -229,9 +229,9 @@ def test_expanded_parent_without_netted_child_stays_violation_on_rounding_gap()
assert parent_sums(rows) == [12] assert parent_sums(rows) == [12]
def test_netted_flag_propagates_up_through_column_matched_level() -> None: def test_netted_flag_does_not_pass_through_column_matched_level() -> None:
"""Признак «есть свёрнутый потомок» идёт вверх: счёт 11 развёрнут, субсчёт 12 сошёлся по колонкам, но над ним """Признак передаёт только строка, сама сошедшаяся с детьми по нетто (непрерывная цепочка нетто-строк): счёт 11
свёрнутый 13.""" развёрнут, субсчёт 12 сошёлся по колонкам (над свёрнутым 13) — признака у 12 нет, 11 сверяется по колонкам."""
rows = [ rows = [
row(11, ("300", "240", "10", "10", "300", "240"), group_type="ACCOUNT", text="60"), 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(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"), 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) == [] 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] 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: def test_period_balance_is_compared_by_net_for_collapsed_parent() -> None:
"""Сальдо периодов сверяется той же функцией, что и обычное: свёрнутый родитель — по нетто.""" """Сальдо периодов сверяется той же функцией, что и обычное: свёрнутый родитель — по нетто."""
rows = [ rows = [

View File

@ -158,9 +158,17 @@ SHALL пропускаться с предупреждением файла `SHE
суммам (`100 = 60 + 40`) суммам (`100 = 60 + 40`)
- **THEN** (а) `68.01.1` — ребёнок `68.01` (уровень +1, его аналитика глубже), если все такие пары листа на одном - **THEN** (а) `68.01.1` — ребёнок `68.01` (уровень +1, его аналитика глубже), если все такие пары листа на одном
отступе, раскладка однозначна, а сумма детей перекладываемого родителя сходится с ним; (б) строка закрытия — ребёнок отступе, раскладка однозначна, а сумма детей перекладываемого родителя сходится с ним; (б) строка закрытия — ребёнок
счёта; (в) внутри такого родителя строка, чьи суммы равны сумме следующих соседей, становится их родителем (жадно), но счёта (оборотов не нулевые); (в) внутри такого родителя строка, чьи суммы равны сумме следующих соседей, становится
только если после перестройки родитель сходится, иначе дерево остаётся по отступам; родитель без нарушения не их родителем (жадно, не дальше `NEST_SCAN_LIMIT` = 50 следующих соседей), но только если после перестройки родитель
перестраивается (равные суммы соседей `50 + 50 = 100` не делают одного ребёнком другого); листы `s47` — `done` сходится, иначе дерево остаётся по отступам; родитель без нарушения не перестраивается (равные суммы соседей
`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 и отступов #### Scenario: Без outline и отступов
- **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`) - **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`)
@ -316,9 +324,10 @@ balance_identity_violations, parent_sum_violations}`. Результат про
или расхождение оборотов — нарушение или расхождение оборотов — нарушение
#### Scenario: Развёрнутый родитель над свёрнутым в нетто ребёнком #### Scenario: Развёрнутый родитель над свёрнутым в нетто ребёнком
- **WHEN** сальдо родителя развёрнуто (обе стороны), а среди его прямых детей есть «нетто-ребёнок» — ребёнок, прошедший - **WHEN** сальдо родителя развёрнуто (обе стороны), а среди его прямых детей есть «нетто-ребёнок» — ребёнок, сам
сверку со своими детьми только по нетто, либо имеющий такого потомка (признак передаётся вверх по дереву), и нетто детей сошедшийся со своими детьми по этой стороне сальдо (начало или конец) лишь по нетто, и нетто детей равно нетто
равно нетто родителя родителя; признак передаётся вверх только непрерывной цепочкой нетто-строк (строка, сошедшаяся по колонкам, его не
передаёт), отдельно для начала и конца
- **THEN** нарушения по сальдо нет (1С схлопнула Дт/Кт субсчёта в нетто, а в родителе они остались развёрнутыми, - **THEN** нарушения по сальдо нет (1С схлопнула Дт/Кт субсчёта в нетто, а в родителе они остались развёрнутыми,
AURORA-1863); обороты сверяются по колонкам, неверное нетто или расхождение оборотов — нарушение; развёрнутый родитель AURORA-1863); обороты сверяются по колонкам, неверное нетто или расхождение оборотов — нарушение; развёрнутый родитель
без нетто-ребёнка по-прежнему сверяется по колонкам (расхождение даже на 0,05 при равном нетто — нарушение) без нетто-ребёнка по-прежнему сверяется по колонкам (расхождение даже на 0,05 при равном нетто — нарушение)
@ -329,8 +338,9 @@ balance_identity_violations, parent_sum_violations}`. Результат про
`is_period`) `is_period`)
- **THEN** обороты родителя сверяются с суммой оборотов периодов, сальдо начала — с сальдо начала первого периода, - **THEN** обороты родителя сверяются с суммой оборотов периодов, сальдо начала — с сальдо начала первого периода,
сальдо конца — с сальдо конца последнего (в порядке строк; сальдо сверяется по колонкам либо по нетто, как обычно), сальдо конца — с сальдо конца последнего (в порядке строк; сальдо сверяется по колонкам либо по нетто, как обычно),
а не с суммой сальдо месяцев (AURORA-1823); расхождение в обороте, начале первого или конце последнего периода — а не с суммой сальдо месяцев (AURORA-1823); сальдо конца каждого периода SHALL равняться сальдо начала следующей строки
нарушение; у родителя со смешанными детьми (периоды и обычные строки) суммы считаются как прежде периода (по колонкам либо по нетто; месяцы без строк допустимы); расхождение в обороте, начале первого или конце
последнего периода либо разрыв цепочки — нарушение; у родителя со смешанными детьми (периоды и обычные строки) суммы считаются как прежде
#### Scenario: Счёт 91 — аналитика только с оборотами #### Scenario: Счёт 91 — аналитика только с оборотами
- **WHEN** разбирается ОСВ по счёту 91: строки аналитики 91.01, 91.02, 91.09 несут только обороты, сальдо закрыто на - **WHEN** разбирается ОСВ по счёту 91: строки аналитики 91.01, 91.02, 91.09 несут только обороты, сальдо закрыто на