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