ОСВ: ложное «Требует проверки» — периоды, нетто-ребёнок, иерархия по отступам (AURORA-1823, 1863, 1858) #79

Merged
aep merged 5 commits from feat/78-osv-false-check into main 2026-10-28 10:07:44 +03:00
9 changed files with 166 additions and 7 deletions
Showing only changes of commit e01f218138 - Show all commits

View File

@ -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): подотчётные лица и персонал — не контрагенты.

View File

@ -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]

View File

@ -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

View File

@ -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":

View File

@ -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]}

View File

@ -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)),

View File

@ -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)

View File

@ -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)

View File

@ -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`)