ОСВ: ложное «Требует проверки» — периоды, нетто-ребёнок, иерархия по отступам (AURORA-1823, 1863, 1858) #79
@ -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): подотчётные лица и персонал — не контрагенты.
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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]}
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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`)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user