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:
parent
e1760c08f6
commit
133b31a1f3
@ -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`, не код;
|
||||
тождество строки на них не проверяется, «сумма детей = родитель» (обороты всегда, сальдо — только корень → субсчета) и
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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 несут только обороты, сальдо закрыто на
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user