ОСВ: ложное «Требует проверки» — периоды, нетто-ребёнок, иерархия по отступам (AURORA-1823, 1863, 1858) #79
@ -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`, не код;
|
||||||
тождество строки на них не проверяется, «сумма детей = родитель» (обороты всегда, сальдо — только корень → субсчета) и
|
тождество строки на них не проверяется, «сумма детей = родитель» (обороты всегда, сальдо — только корень → субсчета) и
|
||||||
|
|||||||
@ -20,6 +20,11 @@ mapping:
|
|||||||
# строки валюты («Валюта USD», «В валюте») — не контрагенты и по плану счетов (R-18)
|
# строки валюты («Валюта USD», «В валюте») — не контрагенты и по плану счетов (R-18)
|
||||||
- ['^(в\s+)?валют', OTHER]
|
- ['^(в\s+)?валют', OTHER]
|
||||||
|
|
||||||
|
# Группировки-периоды: помесячные строки «Обороты за <месяц>» под контрагентом (выгрузка 1С «Субсчета + Контрагенты»,
|
||||||
|
# третья группировка шапки «Период», AURORA-1823). Тип остаётся OTHER; строки получают признак `is_period`, и сальдо
|
||||||
|
# родителя сверяется цепочкой периодов (начало первого, конец последнего), а не суммой сальдо периодов.
|
||||||
|
period_groups: ['^период$']
|
||||||
|
|
||||||
# Расчётные счета, у которых аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван
|
# Расчётные счета, у которых аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван
|
||||||
# (ОСВ 1С по счёту 60 без строки «Контрагенты»; AURORA-1661, OQ-158). Счёт сопоставляется по коду и субсчетам
|
# (ОСВ 1С по счёту 60 без строки «Контрагенты»; AURORA-1661, OQ-158). Счёт сопоставляется по коду и субсчетам
|
||||||
# («76» -> 76.2, 76.Н.1; не 760). 71 и 73 исключены (R-9): подотчётные лица и персонал — не контрагенты.
|
# («76» -> 76.2, 76.Н.1; не 760). 71 и 73 исключены (R-9): подотчётные лица и персонал — не контрагенты.
|
||||||
|
|||||||
@ -9,7 +9,14 @@
|
|||||||
Обороты сверяются по колонкам. Сальдо (начало, конец) — по колонкам (развёрнутое сальдо) либо, если у родителя
|
Обороты сверяются по колонкам. Сальдо (начало, конец) — по колонкам (развёрнутое сальдо) либо, если у родителя
|
||||||
сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С показывает счёт свёрнутым
|
сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С показывает счёт свёрнутым
|
||||||
сальдо, а субсчета и контрагентов — развёрнутым (так во всех реальных файлах, где у счёта есть субсчета с
|
сальдо, а субсчета и контрагентов — развёрнутым (так во всех реальных файлах, где у счёта есть субсчета с
|
||||||
разными сторонами сальдо);
|
разными сторонами сальдо). Нетто допустимо и у развёрнутого родителя, если среди его прямых детей есть
|
||||||
|
«нетто-ребёнок» — ребёнок, сам сошедшийся с детьми по этой стороне сальдо (начало или конец) лишь по нетто (1С
|
||||||
|
схлопнула Дт/Кт субсчёта в нетто, а в родителе они остались развёрнутыми, AURORA-1863); признак передаётся вверх
|
||||||
|
только непрерывной цепочкой нетто-строк: строка, сошедшаяся по колонкам, его не передаёт. Развёрнутый родитель
|
||||||
|
без нетто-ребёнка сверяется по колонкам. Родитель, все дети которого — периоды («Обороты за <месяц>», группировка
|
||||||
|
«Период», `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; то же для единственного корня, когда
|
||||||
@ -23,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
|
||||||
@ -119,23 +129,49 @@ def balance_identity(
|
|||||||
return violations
|
return violations
|
||||||
|
|
||||||
|
|
||||||
def _balance_matches(parent: Sequence[Decimal], children: Sequence[Decimal]) -> bool:
|
def _balance_matches(parent: Sequence[Decimal], children: Sequence[Decimal], *, net_child: bool = False) -> bool:
|
||||||
"""Пара «дебет, кредит» сальдо: равна по колонкам или, при свёрнутом сальдо родителя, по нетто."""
|
"""Пара «дебет, кредит» сальдо: равна по колонкам или по нетто — при свёрнутом сальдо родителя либо когда среди
|
||||||
|
его детей есть свёрнутый в нетто (`net_child`, AURORA-1863)."""
|
||||||
if not _differs(parent, children):
|
if not _differs(parent, children):
|
||||||
return True
|
return True
|
||||||
is_collapsed = any(abs(value) <= TOLERANCE for value in parent)
|
is_collapsed = any(abs(value) <= TOLERANCE for value in parent)
|
||||||
return is_collapsed and abs(_net(parent) - _net(children)) <= TOLERANCE
|
return (is_collapsed or net_child) and abs(_net(parent) - _net(children)) <= TOLERANCE
|
||||||
|
|
||||||
|
|
||||||
def _sums_match(parent: Sequence[Decimal], children: Sequence[Decimal], *, with_balance: bool = True) -> bool:
|
def _sums_match(
|
||||||
"""Суммы родителя = Σ детей; `with_balance=False` — только обороты (сальдо детей не несёт данных)."""
|
parent: Sequence[Decimal],
|
||||||
|
children: Sequence[Decimal],
|
||||||
|
*,
|
||||||
|
with_balance: bool = True,
|
||||||
|
net_child: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Суммы родителя = Σ детей; `with_balance=False` — только обороты (сальдо детей не несёт данных). `net_child` —
|
||||||
|
среди детей есть нетто-ребёнок: сальдо (начало и конец) сверяется и по нетто."""
|
||||||
return (
|
return (
|
||||||
(not with_balance or _balance_matches(parent[_OPENING], children[_OPENING]))
|
(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])
|
||||||
and (not with_balance or _balance_matches(parent[_CLOSING], children[_CLOSING]))
|
and (not with_balance or _balance_matches(parent[_CLOSING], children[_CLOSING], net_child=net_child))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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]:
|
||||||
|
"""Суммы родителя, все дети которого — периоды («Обороты за <месяц>»): обороты — Σ оборотов периодов, сальдо
|
||||||
|
начала — начало первого периода, конца — конец последнего (в порядке строк): сальдо периодов — цепочка."""
|
||||||
|
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]:
|
def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> list[int]:
|
||||||
"""Номера родителей, чьи суммы ≠ Σ детей того же `data_type` (±0.01): обороты — по колонкам, сальдо — по колонкам
|
"""Номера родителей, чьи суммы ≠ Σ детей того же `data_type` (±0.01): обороты — по колонкам, сальдо — по колонкам
|
||||||
или по нетто при свёрнутом сальдо родителя (см. описание модуля).
|
или по нетто при свёрнутом сальдо родителя (см. описание модуля).
|
||||||
@ -149,6 +185,8 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No
|
|||||||
"""
|
"""
|
||||||
by_row = {item.body.row: item for item in rows}
|
by_row = {item.body.row: item for item in rows}
|
||||||
totals: dict[int, list[Decimal]] = defaultdict(lambda: [_ZERO] * len(SUM_FIELDS))
|
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()
|
has_subaccounts: set[int] = set()
|
||||||
for item in rows:
|
for item in rows:
|
||||||
if item.parent is None or _is_indicator(item):
|
if item.parent is None or _is_indicator(item):
|
||||||
@ -159,18 +197,36 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No
|
|||||||
accumulated = totals[item.parent]
|
accumulated = totals[item.parent]
|
||||||
for index, value in enumerate(_values(item)):
|
for index, value in enumerate(_values(item)):
|
||||||
accumulated[index] += value
|
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":
|
if item.group_type == "ACCOUNT":
|
||||||
has_subaccounts.add(item.parent)
|
has_subaccounts.add(item.parent)
|
||||||
return [
|
violations: set[int] = set()
|
||||||
row
|
# Нетто-строки: строка сама сошлась с детьми по сальдо (начало или конец) лишь по нетто, по колонкам не сошлось.
|
||||||
for row in by_row
|
# Цепочка нетто-строк непрерывна: строка, сошедшаяся по колонкам, признак вверх не передаёт. Нетто допускается на
|
||||||
if row in totals
|
# обеих сторонах сальдо, а не только на той, где ребёнок свёрнут: схлопнутую пару 1С не показывает ни в начале, ни
|
||||||
and not _sums_match(
|
# в конце (на реальных файлах родитель расходится с детьми симметрично на обеих сторонах). Потомки идут по большим
|
||||||
_values(by_row[row]),
|
# номерам строк, поэтому родители обходятся от больших номеров к меньшим.
|
||||||
totals[row],
|
netted: set[int] = set()
|
||||||
with_balance=row in has_subaccounts or not _no_analytic_balance(by_row[row], exempt),
|
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]
|
||||||
|
chained = bool(periods_of[row]) and len(periods_of[row]) == len(children_of[row])
|
||||||
|
if chained:
|
||||||
|
children = _period_chain(periods_of[row])
|
||||||
|
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 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]
|
||||||
|
|
||||||
|
|
||||||
def _total(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern] = ()) -> ClassifiedRow | None:
|
def _total(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern] = ()) -> ClassifiedRow | None:
|
||||||
|
|||||||
@ -37,7 +37,7 @@ from src.parser.detect import (
|
|||||||
table_kind_row,
|
table_kind_row,
|
||||||
)
|
)
|
||||||
from src.parser.grid import Sheet, column_letter
|
from src.parser.grid import Sheet, column_letter
|
||||||
from src.parser.hierarchy import BodyRow, Levels
|
from src.parser.hierarchy import ACCOUNT_SEGMENT_SEPARATOR, BodyRow, Levels
|
||||||
from src.parser.indicators import is_indicator_total, read_label
|
from src.parser.indicators import is_indicator_total, read_label
|
||||||
from src.parser.numbers import column_inn, split_inn_suffix, to_money
|
from src.parser.numbers import column_inn, split_inn_suffix, to_money
|
||||||
from src.parser.rules import GroupType, Rules
|
from src.parser.rules import GroupType, Rules
|
||||||
@ -64,7 +64,7 @@ ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.(?:\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2})(?:,
|
|||||||
# Субсчёт с тремя цифрами в сегменте («60.111», «60.243»): счёт, только если код начинается со счёта шапки (§1.3,
|
# Субсчёт с тремя цифрами в сегменте («60.111», «60.243»): счёт, только если код начинается со счёта шапки (§1.3,
|
||||||
# AURORA-1714); без счёта шапки действует `ACCOUNT_RE`
|
# AURORA-1714); без счёта шапки действует `ACCOUNT_RE`
|
||||||
_WIDE_ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.\d{1,3}){1,2})(?:,\s*(.*))?$", re.ASCII | re.DOTALL)
|
_WIDE_ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.\d{1,3}){1,2})(?:,\s*(.*))?$", re.ASCII | re.DOTALL)
|
||||||
_SEGMENT_SEPARATOR = "."
|
_SEGMENT_SEPARATOR = ACCOUNT_SEGMENT_SEPARATOR
|
||||||
|
|
||||||
|
|
||||||
def match_account(text: str, header_account: str | None = None) -> re.Match[str] | None:
|
def match_account(text: str, header_account: str | None = None) -> re.Match[str] | None:
|
||||||
@ -106,6 +106,8 @@ class ClassifiedRow:
|
|||||||
indicator: bool = False # строка-показатель (§1.3): вне проверок сумм, `data_type` — вид по метке
|
indicator: bool = False # строка-показатель (§1.3): вне проверок сумм, `data_type` — вид по метке
|
||||||
# COUNTERPARTY получен по плану счетов (вид субконто в шапке не назван, #59, R-6), а не по схеме группировок
|
# COUNTERPARTY получен по плану счетов (вид субконто в шапке не назван, #59, R-6), а не по схеме группировок
|
||||||
counterparty_by_account: bool = False
|
counterparty_by_account: bool = False
|
||||||
|
# Строка группировки-периода («Период» схемы шапки, `period_groups`): помесячный разрез родителя (AURORA-1823)
|
||||||
|
is_period: bool = False
|
||||||
|
|
||||||
|
|
||||||
# --- Строки таблицы ---
|
# --- Строки таблицы ---
|
||||||
@ -308,10 +310,12 @@ def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строк
|
|||||||
# Единицы отступа разные (alignment.indent, пробелы, колонки) — иерархия сравнивает только ранги: колонка глубже
|
# Единицы отступа разные (alignment.indent, пробелы, колонки) — иерархия сравнивает только ранги: колонка глубже
|
||||||
# любого отступа ячейки
|
# любого отступа ячейки
|
||||||
indent += label_cols.index(label_col) * LABEL_COLUMN_INDENT_STEP
|
indent += label_cols.index(label_col) * LABEL_COLUMN_INDENT_STEP
|
||||||
|
inn_in_column = header.inn_col is not None and column_inn(sheet.cell(row, header.inn_col), rules) is not None
|
||||||
return BodyRow(
|
return BodyRow(
|
||||||
row=row,
|
row=row,
|
||||||
text=text,
|
text=text,
|
||||||
is_account=account is not None,
|
is_account=account is not None,
|
||||||
|
account_code=None if account is None else account.group(1),
|
||||||
account_rank=None if account is None else account.group(1).count(_SEGMENT_SEPARATOR) + 1,
|
account_rank=None if account is None else account.group(1).count(_SEGMENT_SEPARATOR) + 1,
|
||||||
bold=bold,
|
bold=bold,
|
||||||
indent=indent,
|
indent=indent,
|
||||||
@ -320,6 +324,7 @@ def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строк
|
|||||||
label_col=label_col,
|
label_col=label_col,
|
||||||
indicator=label.indicator,
|
indicator=label.indicator,
|
||||||
label_data_type=label.data_type,
|
label_data_type=label.data_type,
|
||||||
|
has_inn=inn_in_column or split_inn_suffix(text, rules)[1] is not None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -414,6 +419,7 @@ class _Kind:
|
|||||||
counterparty_inn: str | None = None
|
counterparty_inn: str | None = None
|
||||||
indicator: bool = False
|
indicator: bool = False
|
||||||
counterparty_by_account: bool = False
|
counterparty_by_account: bool = False
|
||||||
|
is_period: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -472,7 +478,8 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple
|
|||||||
if group_type == "OTHER" and name is None and item.role == "contract":
|
if group_type == "OTHER" and name is None and item.role == "contract":
|
||||||
group_type = "CONTRACT" # договор контрагента в блоке субсчёта с кодами контрагентов (§1.3, `code_blocks`)
|
group_type = "CONTRACT" # договор контрагента в блоке субсчёта с кодами контрагентов (§1.3, `code_blocks`)
|
||||||
if group_type != "COUNTERPARTY":
|
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
|
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)
|
column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules)
|
||||||
# ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100)
|
# ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100)
|
||||||
@ -565,6 +572,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
|
|||||||
extra=_extra(sheet, item.row, extra_keys),
|
extra=_extra(sheet, item.row, extra_keys),
|
||||||
indicator=kind.indicator,
|
indicator=kind.indicator,
|
||||||
counterparty_by_account=kind.counterparty_by_account,
|
counterparty_by_account=kind.counterparty_by_account,
|
||||||
|
is_period=kind.is_period,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@ -44,7 +44,15 @@ MIN_ROWS_FOR_CASCADE = 2
|
|||||||
TRIVIAL_STRATEGY = "trivial"
|
TRIVIAL_STRATEGY = "trivial"
|
||||||
# Кооперативный дедлайн внутри стратегии (design D-8): проверка каждые N строк или N шагов перебора поддеревьев
|
# Кооперативный дедлайн внутри стратегии (design D-8): проверка каждые N строк или N шагов перебора поддеревьев
|
||||||
DEADLINE_CHECK_EVERY = 500
|
DEADLINE_CHECK_EVERY = 500
|
||||||
|
# Вложенность по суммам в `indent` ищет родителя среди не более чем стольких следующих соседей: аналитика одного
|
||||||
|
# родителя вложена на единицы строк, а без предела плоский лист в тысячи строк давал бы O(n²) перебора
|
||||||
|
NEST_SCAN_LIMIT = 50
|
||||||
|
# Вложенность по суммам имеет смысл, только если у родителя есть кого вкладывать друг в друга: хотя бы два ребёнка
|
||||||
|
MIN_NESTABLE_CHILDREN = 2
|
||||||
|
# Разделитель сегментов кода счёта («68.01.1»); `classify` берёт его отсюда же
|
||||||
|
ACCOUNT_SEGMENT_SEPARATOR = "."
|
||||||
_ZERO = Decimal(0)
|
_ZERO = Decimal(0)
|
||||||
|
_TURNOVER_PAIR = slice(2, 4) # обороты (дебет, кредит) в `BodyRow.sums`
|
||||||
|
|
||||||
|
|
||||||
Role = Literal["counterparty", "contract"]
|
Role = Literal["counterparty", "contract"]
|
||||||
@ -67,6 +75,8 @@ class BodyRow:
|
|||||||
label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.»)
|
label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.»)
|
||||||
# Роль в блоке субсчёта с кодами контрагентов (`code_blocks`, §1.3): контрагент (код с суммами) или его договор
|
# Роль в блоке субсчёта с кодами контрагентов (`code_blocks`, §1.3): контрагент (код с суммами) или его договор
|
||||||
role: Role | None = None
|
role: Role | None = None
|
||||||
|
account_code: str | None = None # код счёта строки, как в файле («68.01.1»); у не-счёта — `None`
|
||||||
|
has_inn: bool = False # у строки есть ИНН: в колонке ИНН или хвостом названия (контрагент, не строка аналитики)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -129,17 +139,252 @@ def strategy_outline(rows: Sequence[BodyRow], deadline: Deadline | None = None)
|
|||||||
return {item.row: item.outline - base for item in rows}
|
return {item.row: item.outline - base for item in rows}
|
||||||
|
|
||||||
|
|
||||||
def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
|
def strategy_indent(
|
||||||
|
rows: Sequence[BodyRow], deadline: Deadline | None = None, *, nest_by_sums: bool = True
|
||||||
|
) -> dict[int, int] | None:
|
||||||
"""Отступ ячейки текста: уровень — ранг отступа среди различных отступов листа.
|
"""Отступ ячейки текста: уровень — ранг отступа среди различных отступов листа.
|
||||||
|
|
||||||
`indent` приходит либо как `alignment.indent`, либо как число ведущих пробелов (T2.4), поэтому шаг отступа
|
`indent` приходит либо как `alignment.indent`, либо как число ведущих пробелов (T2.4), поэтому шаг отступа
|
||||||
не предполагается: (0, 2, 4), (0, 1, 2) и (0, 3, 7) дают одинаковые уровни 0, 1, 2. `None`, если различных
|
не предполагается: (0, 2, 4), (0, 1, 2) и (0, 3, 7) дают одинаковые уровни 0, 1, 2. `None`, если различных
|
||||||
отступов меньше двух.
|
отступов меньше двух.
|
||||||
|
|
||||||
|
Три поправки к рангам отступа (AURORA-1858), когда отступа не хватает:
|
||||||
|
- подуровень счёта на том же отступе (`68.01` → `68.01.1`) — ребёнок по коду (`_nest_subaccounts`);
|
||||||
|
- безымянная строка закрытия сразу за строкой счёта (отступ пустой ячейки не хранится) — её ребёнок
|
||||||
|
(`_attach_closing_rows`);
|
||||||
|
- аналитика, вложенная на два уровня при одном отступе, — вложенность по суммам, но только внутри родителя с
|
||||||
|
нарушением «сумма детей = родитель», только если все его прямые дети на одном отступе, ни вкладываемая строка,
|
||||||
|
ни новый родитель не несут ИНН, и только если после перестройки он сходится (`_nest_by_sums`). Вызывающий
|
||||||
|
отключает её (`nest_by_sums=False`), когда схема шапки называет группы аналитики после счёта: вложение
|
||||||
|
изменило бы `group_type`/`group_name` строки.
|
||||||
"""
|
"""
|
||||||
ranks = _ranks([item.indent for item in _paced(rows, deadline, "иерархия (indent)")])
|
ranks = _ranks([item.indent for item in _paced(rows, deadline, "иерархия (indent)")])
|
||||||
if ranks is None:
|
if ranks is None:
|
||||||
return None
|
return None
|
||||||
return {item.row: ranks[item.indent] for item in rows}
|
levels = {item.row: ranks[item.indent] for item in rows}
|
||||||
|
levels = _nest_subaccounts(rows, levels)
|
||||||
|
levels = _attach_closing_rows(rows, levels)
|
||||||
|
return _nest_by_sums(rows, levels, deadline) if nest_by_sums else levels
|
||||||
|
|
||||||
|
|
||||||
|
def _nest_subaccounts(rows: Sequence[BodyRow], levels: dict[int, int]) -> dict[int, int]:
|
||||||
|
"""Счёт, чей непосредственный родительский код (`68.01.1` → `68.01`) есть у более раннего счёта того же уровня в
|
||||||
|
том же блоке, — его ребёнок: уровень родителя + 1, поддерево (строки глубже по исходному уровню) сдвигается вместе
|
||||||
|
с ним. Так вкладываются `68.01.1` и `68.01.2` под `68.01`, стоящие на одном отступе.
|
||||||
|
|
||||||
|
Не трогаем счёт, который уже стоит под счётом с кодом своего родителя (блок «76.09» с собственной аналитикой и
|
||||||
|
«76.09.1» рядом на одном отступе). Применяется, только если все такие пары на одном исходном уровне (одном
|
||||||
|
отступе): иначе соседние корни с похожими кодами могут быть намеренными, и дерево остаётся по отступам.
|
||||||
|
"""
|
||||||
|
parent_of: dict[int, int] = {} # строка счёта -> строка счёта-родителя по коду
|
||||||
|
pair_levels: set[int] = set()
|
||||||
|
open_accounts: list[tuple[int, str]] = [] # (уровень, код) счетов-предков по текущим уровням
|
||||||
|
blocks: dict[int, dict[str, BodyRow]] = {} # уровень -> код -> последний счёт с этим кодом в текущем блоке
|
||||||
|
seen_codes: dict[int, set[str]] = {} # уровень -> коды всех встреченных счетов (блоки их забывают)
|
||||||
|
ambiguous = False
|
||||||
|
for item in rows:
|
||||||
|
if not item.is_account or item.account_code is None:
|
||||||
|
# строка не-счёт на уровне блока или выше разрывает его: счёт после неё — не ребёнок счёта до неё
|
||||||
|
for closed in [known for known in blocks if known >= levels[item.row]]:
|
||||||
|
del blocks[closed]
|
||||||
|
continue
|
||||||
|
level, code = levels[item.row], item.account_code
|
||||||
|
while open_accounts and open_accounts[-1][0] >= level:
|
||||||
|
open_accounts.pop()
|
||||||
|
for deeper in [known for known in blocks if known > level]:
|
||||||
|
del blocks[deeper]
|
||||||
|
# у кода без сегментов («60» и второй «60») родителя по коду нет: `parent_code` пуст
|
||||||
|
parent_code = code.rpartition(ACCOUNT_SEGMENT_SEPARATOR)[0]
|
||||||
|
candidate = blocks.get(level, {}).get(parent_code) if parent_code else None
|
||||||
|
under_parent = bool(open_accounts) and open_accounts[-1][1] == parent_code
|
||||||
|
if candidate is not None and not under_parent:
|
||||||
|
parent_of[item.row] = candidate.row
|
||||||
|
pair_levels.add(level)
|
||||||
|
elif parent_code and (candidate is not None or parent_code in seen_codes.get(level, ())):
|
||||||
|
# родительский код на этом уровне уже был, но счёт к нему не прикрепить (рядом уже стоит под своим родителем
|
||||||
|
# или между ними строка другого блока): раскладка неоднозначна, по коду не перекладываем ничего
|
||||||
|
ambiguous = True
|
||||||
|
seen_codes.setdefault(level, set()).add(code)
|
||||||
|
blocks.setdefault(level, {})[code] = item
|
||||||
|
open_accounts.append((level, code))
|
||||||
|
if not parent_of or ambiguous or len(pair_levels) > 1:
|
||||||
|
return levels
|
||||||
|
result = _shift_nested(rows, levels, parent_of)
|
||||||
|
# перекладка принимается для счёта-родителя, только если после неё сумма его детей сходится с ним: иначе (файл с
|
||||||
|
# неровными отступами, где похожий код — совпадение) этот родитель остаётся как был
|
||||||
|
kids: dict[int, tuple[Decimal, ...]] = {}
|
||||||
|
by_parent = parents(rows, result)
|
||||||
|
by_row = {item.row: item for item in rows}
|
||||||
|
for item in rows:
|
||||||
|
parent = by_parent[item.row]
|
||||||
|
if parent is not None and not item.indicator:
|
||||||
|
kids[parent] = _add(kids[parent], _vector(item)) if parent in kids else _vector(item)
|
||||||
|
broken = {
|
||||||
|
parent
|
||||||
|
for parent in set(parent_of.values())
|
||||||
|
if parent in kids and _violates(_vector(by_row[parent]), kids[parent])
|
||||||
|
}
|
||||||
|
if broken:
|
||||||
|
parent_of = {row: parent for row, parent in parent_of.items() if parent not in broken}
|
||||||
|
if not parent_of:
|
||||||
|
return levels
|
||||||
|
result = _shift_nested(rows, levels, parent_of)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _shift_nested(rows: Sequence[BodyRow], levels: dict[int, int], parent_of: dict[int, int]) -> dict[int, int]:
|
||||||
|
"""Уровни, в которых счета из `parent_of` стали детьми указанных счетов (поддерево сдвинуто вместе со счётом)."""
|
||||||
|
result: dict[int, int] = {}
|
||||||
|
shifts: list[tuple[int, int]] = [] # (исходный уровень сдвинутого счёта, его сдвиг) — открытые поддеревья
|
||||||
|
for item in rows:
|
||||||
|
original = levels[item.row]
|
||||||
|
while shifts and shifts[-1][0] >= original:
|
||||||
|
shifts.pop()
|
||||||
|
level = original + (shifts[-1][1] if shifts else 0)
|
||||||
|
if item.row in parent_of:
|
||||||
|
level = result[parent_of[item.row]] + 1
|
||||||
|
shifts.append((original, level - original))
|
||||||
|
result[item.row] = level
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_closing_rows(rows: Sequence[BodyRow], levels: dict[int, int]) -> dict[int, int]:
|
||||||
|
"""Безымянная строка с суммами (без текста и кода) сразу за строкой счёта — строка закрытия, ребёнок этого счёта.
|
||||||
|
|
||||||
|
Читатель не хранит пустые ячейки, поэтому её отступ — 0, как у счёта; признак — пустой текст, а не позиция.
|
||||||
|
Строка закрытия повторяет обороты счёта (дебет и кредит равны оборотам счёта) и они не нулевые: иначе безымянная
|
||||||
|
строка с суммами — не закрытие, и дерево остаётся по отступам.
|
||||||
|
"""
|
||||||
|
result = dict(levels)
|
||||||
|
previous: BodyRow | None = None
|
||||||
|
for item in rows:
|
||||||
|
if (
|
||||||
|
previous is not None
|
||||||
|
and previous.is_account
|
||||||
|
and not item.is_account
|
||||||
|
and not item.text.strip()
|
||||||
|
and any(value != _ZERO for value in _vector(item)[_TURNOVER_PAIR])
|
||||||
|
and _matches(_vector(item)[_TURNOVER_PAIR], _vector(previous)[_TURNOVER_PAIR])
|
||||||
|
and result[item.row] <= result[previous.row]
|
||||||
|
):
|
||||||
|
result[item.row] = result[previous.row] + 1
|
||||||
|
previous = item
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _violates(parent: tuple[Decimal, ...], children: tuple[Decimal, ...]) -> bool:
|
||||||
|
"""«Сумма детей ≠ родитель» для решения о перестройке: по паре «дебет, кредит» (начало, обороты, конец); пара, в
|
||||||
|
которой у детей пусто (аналитика без сальдо), не сверяется, а сальдо свёрнутого родителя сверяется по нетто — как
|
||||||
|
в контрольных суммах §1.4, но мягче: окончательно решают они, а не эта функция."""
|
||||||
|
for start, is_balance in ((0, True), (2, False), (4, True)):
|
||||||
|
pair_parent, pair_children = parent[start : start + 2], children[start : start + 2]
|
||||||
|
if all(abs(value) <= SUM_TOLERANCE for value in pair_children):
|
||||||
|
continue
|
||||||
|
if _matches(pair_parent, pair_children):
|
||||||
|
continue
|
||||||
|
collapsed = any(abs(value) <= SUM_TOLERANCE for value in pair_parent)
|
||||||
|
net_equal = abs((pair_parent[0] - pair_parent[1]) - (pair_children[0] - pair_children[1])) <= SUM_TOLERANCE
|
||||||
|
if not (is_balance and collapsed and net_equal):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _greedy_ends(
|
||||||
|
vectors: Sequence[tuple[Decimal, ...]],
|
||||||
|
pacer: _Pacer,
|
||||||
|
*,
|
||||||
|
scan_limit: int | None = None,
|
||||||
|
blocked: Sequence[bool] | None = None,
|
||||||
|
) -> list[int]:
|
||||||
|
"""Граница поддерева каждой строки (`end[i]`, исключительно) по суммам, жадно и с конца: строка `i` перебирает
|
||||||
|
следующие поддеревья подряд и становится родителем на первом совпадении по всем колонкам. Строка без сумм
|
||||||
|
родителем не бывает. `scan_limit` — сколько следующих поддеревьев перебирать (`None` — до конца);
|
||||||
|
`blocked[i]` — строка не может быть ни родителем, ни ребёнком (перебор обрывается на ней)."""
|
||||||
|
count = len(vectors)
|
||||||
|
zero = tuple(_ZERO for _ in vectors[0]) if vectors else ()
|
||||||
|
end = [index + 1 for index in range(count)]
|
||||||
|
for index in reversed(range(count)):
|
||||||
|
pacer.tick()
|
||||||
|
target = vectors[index]
|
||||||
|
if all(value == _ZERO for value in target) or (blocked is not None and blocked[index]):
|
||||||
|
continue
|
||||||
|
accumulated, position, steps = zero, index + 1, 0
|
||||||
|
while position < count and (scan_limit is None or steps < scan_limit):
|
||||||
|
if blocked is not None and blocked[position]:
|
||||||
|
break
|
||||||
|
pacer.tick()
|
||||||
|
accumulated = _add(accumulated, vectors[position])
|
||||||
|
position = end[position]
|
||||||
|
steps += 1
|
||||||
|
if _matches(accumulated, target):
|
||||||
|
end[index] = position
|
||||||
|
break
|
||||||
|
return end
|
||||||
|
|
||||||
|
|
||||||
|
def _depths(end: Sequence[int]) -> list[int]:
|
||||||
|
"""Глубина вложенности каждой строки по границам поддеревьев `end`."""
|
||||||
|
depths: list[int] = []
|
||||||
|
open_ends: list[int] = [] # границы поддеревьев открытых предков
|
||||||
|
for index in range(len(end)):
|
||||||
|
while open_ends and open_ends[-1] <= index:
|
||||||
|
open_ends.pop()
|
||||||
|
depths.append(len(open_ends))
|
||||||
|
if end[index] > index + 1:
|
||||||
|
open_ends.append(end[index])
|
||||||
|
return depths
|
||||||
|
|
||||||
|
|
||||||
|
def _nest_by_sums(rows: Sequence[BodyRow], levels: dict[int, int], deadline: Deadline | None) -> dict[int, int]:
|
||||||
|
"""Вложенность аналитики по суммам внутри родителя с нарушением «сумма детей = родитель» (при одном отступе
|
||||||
|
уровни аналитики неразличимы, видны только суммы: 15 = 16 + 17).
|
||||||
|
|
||||||
|
Только для родителя с нарушением, чьи дети — строки аналитики (не счета и не показатели) с суммами; перестройка
|
||||||
|
принимается, если после неё сумма прямых детей совпадает с родителем (`_violates`), иначе дерево родителя остаётся
|
||||||
|
исходным. Строки без нарушения не трогаются: равные суммы соседей (50 + 50 = 100) не делают одного ребёнком другого.
|
||||||
|
"""
|
||||||
|
pacer = _Pacer(deadline, "иерархия (indent): вложенность по суммам")
|
||||||
|
by_parent = parents(rows, levels)
|
||||||
|
kids: dict[int, list[int]] = {}
|
||||||
|
for index, item in enumerate(rows):
|
||||||
|
parent = by_parent[item.row]
|
||||||
|
if parent is not None:
|
||||||
|
kids.setdefault(parent, []).append(index)
|
||||||
|
position = {item.row: index for index, item in enumerate(rows)}
|
||||||
|
bump: dict[int, int] = {}
|
||||||
|
for parent_row, kid_indexes in kids.items():
|
||||||
|
pacer.tick()
|
||||||
|
members = [rows[index] for index in kid_indexes]
|
||||||
|
if (
|
||||||
|
len(members) < MIN_NESTABLE_CHILDREN
|
||||||
|
or any(m.is_account or m.indicator for m in members)
|
||||||
|
or len({m.indent for m in members}) > 1
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
vectors = [_vector(member) for member in members]
|
||||||
|
total = vectors[0]
|
||||||
|
for vector in vectors[1:]:
|
||||||
|
total = _add(total, vector)
|
||||||
|
parent_vector = _vector(rows[position[parent_row]])
|
||||||
|
if not _violates(parent_vector, total):
|
||||||
|
continue
|
||||||
|
depths = _depths(
|
||||||
|
_greedy_ends(vectors, pacer, scan_limit=NEST_SCAN_LIMIT, blocked=[member.has_inn for member in members])
|
||||||
|
)
|
||||||
|
top = [vector for vector, depth in zip(vectors, depths, strict=True) if depth == 0]
|
||||||
|
if not any(depths) or _violates(parent_vector, tuple(sum(column, _ZERO) for column in zip(*top, strict=True))):
|
||||||
|
continue
|
||||||
|
for kid, depth in zip(kid_indexes, depths, strict=True):
|
||||||
|
if depth == 0:
|
||||||
|
continue
|
||||||
|
index = kid
|
||||||
|
# сдвиги складываются: строка может лежать в поддереве вложенного ребёнка и сама вкладываться глубже
|
||||||
|
bump[rows[index].row] = bump.get(rows[index].row, 0) + depth
|
||||||
|
index += 1
|
||||||
|
while index < len(rows) and levels[rows[index].row] > levels[rows[kid].row]:
|
||||||
|
bump[rows[index].row] = bump.get(rows[index].row, 0) + depth
|
||||||
|
index += 1
|
||||||
|
return {row: level + bump.get(row, 0) for row, level in levels.items()}
|
||||||
|
|
||||||
|
|
||||||
def strategy_account_pattern(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
|
def strategy_account_pattern(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
|
||||||
@ -237,38 +482,11 @@ def strategy_sums(rows: Sequence[BodyRow], deadline: Deadline | None = None) ->
|
|||||||
поддеревья до конца листа. Поэтому дедлайн проверяется во внутреннем цикле каждые `DEADLINE_CHECK_EVERY` шагов,
|
поддеревья до конца листа. Поэтому дедлайн проверяется во внутреннем цикле каждые `DEADLINE_CHECK_EVERY` шагов,
|
||||||
и такой файл завершается `TIMEOUT`, а не висит.
|
и такой файл завершается `TIMEOUT`, а не висит.
|
||||||
"""
|
"""
|
||||||
count = len(rows)
|
|
||||||
vectors = [_vector(item) for item in rows]
|
vectors = [_vector(item) for item in rows]
|
||||||
zero = tuple(_ZERO for _ in vectors[0]) if vectors else ()
|
end = _greedy_ends(vectors, _Pacer(deadline, "иерархия (sums): перебор поддеревьев"))
|
||||||
end = [index + 1 for index in range(count)] # граница (исключительно) поддерева строки
|
if all(end[index] == index + 1 for index in range(len(rows))):
|
||||||
has_children = False
|
|
||||||
pacer = _Pacer(deadline, "иерархия (sums): перебор поддеревьев")
|
|
||||||
for index in reversed(range(count)):
|
|
||||||
pacer.tick()
|
|
||||||
target = vectors[index]
|
|
||||||
if all(value == _ZERO for value in target):
|
|
||||||
continue
|
|
||||||
accumulated = zero
|
|
||||||
position = index + 1
|
|
||||||
while position < count:
|
|
||||||
pacer.tick()
|
|
||||||
accumulated = _add(accumulated, vectors[position])
|
|
||||||
position = end[position]
|
|
||||||
if _matches(accumulated, target):
|
|
||||||
end[index] = position
|
|
||||||
has_children = True
|
|
||||||
break
|
|
||||||
if not has_children:
|
|
||||||
return None
|
return None
|
||||||
by_row: dict[int, int] = {}
|
return {item.row: depth for item, depth in zip(rows, _depths(end), strict=True)}
|
||||||
open_ends: list[int] = [] # границы поддеревьев открытых предков
|
|
||||||
for index, item in enumerate(rows):
|
|
||||||
while open_ends and open_ends[-1] <= index:
|
|
||||||
open_ends.pop()
|
|
||||||
by_row[item.row] = len(open_ends)
|
|
||||||
if end[index] > index + 1:
|
|
||||||
open_ends.append(end[index])
|
|
||||||
return by_row
|
|
||||||
|
|
||||||
|
|
||||||
STRATEGIES: tuple[tuple[str, Strategy], ...] = (
|
STRATEGIES: tuple[tuple[str, Strategy], ...] = (
|
||||||
@ -309,10 +527,18 @@ def parents(rows: Sequence[BodyRow], by_row: Mapping[int, int]) -> dict[int, int
|
|||||||
|
|
||||||
|
|
||||||
def resolve_levels(
|
def resolve_levels(
|
||||||
rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline, *, header_account: bool = False
|
rows: Sequence[BodyRow],
|
||||||
|
total_row: int | None,
|
||||||
|
deadline: Deadline,
|
||||||
|
*,
|
||||||
|
header_account: bool = False,
|
||||||
|
nest_by_sums: bool = False,
|
||||||
) -> Levels:
|
) -> Levels:
|
||||||
"""Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`.
|
"""Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`.
|
||||||
|
|
||||||
|
`nest_by_sums` — разрешить стратегии `indent` вложенность аналитики по суммам (AURORA-1858): только когда схема
|
||||||
|
шапки не называет группы аналитики после счёта (вложение не меняет `group_type`/`group_name` строки).
|
||||||
|
|
||||||
`header_account` — в шапке есть код счёта: тогда после `account_pattern` и перед `sums` пробуется `header_account`
|
`header_account` — в шапке есть код счёта: тогда после `account_pattern` и перед `sums` пробуется `header_account`
|
||||||
(плоский список под счётом шапки, #59).
|
(плоский список под счётом шапки, #59).
|
||||||
|
|
||||||
@ -333,7 +559,9 @@ def resolve_levels(
|
|||||||
cascade.insert(before_sums, ("header_account", strategy_header_account))
|
cascade.insert(before_sums, ("header_account", strategy_header_account))
|
||||||
for name, strategy in cascade:
|
for name, strategy in cascade:
|
||||||
deadline.check(f"иерархия ({name})")
|
deadline.check(f"иерархия ({name})")
|
||||||
found = strategy(body, deadline)
|
found = (
|
||||||
|
strategy_indent(body, deadline, nest_by_sums=nest_by_sums) if name == "indent" else strategy(body, deadline)
|
||||||
|
)
|
||||||
if found is None:
|
if found is None:
|
||||||
continue
|
continue
|
||||||
by_row = {**found, **pinned}
|
by_row = {**found, **pinned}
|
||||||
|
|||||||
@ -59,7 +59,12 @@ def is_indicator_total(sheet: Sheet, row: int, header: TableHeader, rules: Rules
|
|||||||
|
|
||||||
|
|
||||||
def levels_with_indicators(
|
def levels_with_indicators(
|
||||||
rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline, *, header_account: bool = False
|
rows: Sequence[BodyRow],
|
||||||
|
total_row: int | None,
|
||||||
|
deadline: Deadline,
|
||||||
|
*,
|
||||||
|
header_account: bool = False,
|
||||||
|
nest_by_sums: bool = False,
|
||||||
) -> Levels:
|
) -> Levels:
|
||||||
"""Каскад иерархии без строк-показателей; показатель — ребёнок предыдущей строки данных, на уровень глубже
|
"""Каскад иерархии без строк-показателей; показатель — ребёнок предыдущей строки данных, на уровень глубже
|
||||||
(§1.3): так строки НУ/Кол./«В валюте» на одном outline-уровне со своей строкой не ломают дерево строк БУ.
|
(§1.3): так строки НУ/Кол./«В валюте» на одном outline-уровне со своей строкой не ломают дерево строк БУ.
|
||||||
@ -67,7 +72,11 @@ def levels_with_indicators(
|
|||||||
Итоговые строки-показатели и показатель без строки данных выше — уровень 0 без родителя.
|
Итоговые строки-показатели и показатель без строки данных выше — уровень 0 без родителя.
|
||||||
"""
|
"""
|
||||||
levels = resolve_levels(
|
levels = resolve_levels(
|
||||||
[item for item in rows if not item.indicator], total_row, deadline, header_account=header_account
|
[item for item in rows if not item.indicator],
|
||||||
|
total_row,
|
||||||
|
deadline,
|
||||||
|
header_account=header_account,
|
||||||
|
nest_by_sums=nest_by_sums,
|
||||||
)
|
)
|
||||||
by_row: dict[int, int] = {}
|
by_row: dict[int, int] = {}
|
||||||
parent: dict[int, int | None] = {}
|
parent: dict[int, int | None] = {}
|
||||||
|
|||||||
@ -217,7 +217,14 @@ class OsvParser:
|
|||||||
blocks = apply_code_blocks(body, total_row, rules, doc.account_code_raw)
|
blocks = apply_code_blocks(body, total_row, rules, doc.account_code_raw)
|
||||||
body = blocks.rows
|
body = blocks.rows
|
||||||
step = "иерархия строк"
|
step = "иерархия строк"
|
||||||
levels = levels_with_indicators(body, total_row, deadline, header_account=bool(doc.account_code_raw))
|
# Вложенность по суммам меняет group_type/group_name строки: только без названных групп после счёта
|
||||||
|
levels = levels_with_indicators(
|
||||||
|
body,
|
||||||
|
total_row,
|
||||||
|
deadline,
|
||||||
|
header_account=bool(doc.account_code_raw),
|
||||||
|
nest_by_sums=all(group.name is None for group in schema.groups[1:]),
|
||||||
|
)
|
||||||
step = "строки ИНН"
|
step = "строки ИНН"
|
||||||
glue = glue_inn_rows(
|
glue = glue_inn_rows(
|
||||||
body,
|
body,
|
||||||
|
|||||||
@ -206,6 +206,12 @@ class OsvGroupRules(_Model):
|
|||||||
default: GroupType = "OTHER"
|
default: GroupType = "OTHER"
|
||||||
# Расчётные счета: аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван (#59, R-6)
|
# Расчётные счета: аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван (#59, R-6)
|
||||||
counterparty_accounts: list[str] = []
|
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")
|
@model_validator(mode="after")
|
||||||
def _check_counterparty_accounts(self) -> "OsvGroupRules":
|
def _check_counterparty_accounts(self) -> "OsvGroupRules":
|
||||||
|
|||||||
@ -4,9 +4,6 @@
|
|||||||
# Файлов и листов, которых здесь нет, тест ждёт `done` без нарушений. Причина — данные файла, а не дефект разбора
|
# Файлов и листов, которых здесь нет, тест ждёт `done` без нарушений. Причина — данные файла, а не дефект разбора
|
||||||
# (иначе чинить парсер): запись удаляется, когда вопрос закрыт.
|
# (иначе чинить парсер): запись удаляется, когда вопрос закрыт.
|
||||||
needs_check:
|
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":
|
"agrokompleks/2025-Q3/ОСВ_сч62_2025_9мес.xls":
|
||||||
reason: "аналитика магазинов 62.04 без сальдо на конец (тот же класс, что OQ-132); Итого и родители сходятся"
|
reason: "аналитика магазинов 62.04 без сальдо на конец (тот же класс, что OQ-132); Итого и родители сходятся"
|
||||||
sheets: {"sheet:TDSheet": [balance_identity]}
|
sheets: {"sheet:TDSheet": [balance_identity]}
|
||||||
|
|||||||
@ -5,7 +5,8 @@
|
|||||||
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во»,
|
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во»,
|
||||||
строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты
|
строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты
|
||||||
другим треком), s45 — счёт шапки «Счет Поставщика» | «60*» в соседних ячейках, s46 — выгрузка «по контрагентам» с
|
другим треком), s45 — счёт шапки «Счет Поставщика» | «60*» в соседних ячейках, s46 — выгрузка «по контрагентам» с
|
||||||
блоками субсчетов (#71); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен
|
блоками субсчетов (#71), s47 — иерархия по отступам: подуровень счёта, строка закрытия, вложенность по суммам (#78);
|
||||||
|
описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен
|
||||||
(openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги
|
(openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги
|
||||||
и zip постоянны, порядок записи фиксирован.
|
и zip постоянны, порядок записи фиксирован.
|
||||||
|
|
||||||
@ -1037,6 +1038,71 @@ def _s46() -> list[SheetSpec]:
|
|||||||
return [sheet]
|
return [sheet]
|
||||||
|
|
||||||
|
|
||||||
|
def _s47() -> list[SheetSpec]:
|
||||||
|
"""Иерархия по отступам там, где отступа не хватает (AURORA-1858): три листа «Субконто» без вида аналитики в шапке.
|
||||||
|
Лист «68»: подуровень «68.01.1» на том же отступе, что «68.01». Лист «90»: безымянная строка закрытия сразу за
|
||||||
|
счётом (пустая ячейка отступа не хранит). Лист «91»: аналитика на одном отступе, а вложена по суммам
|
||||||
|
(100 = 60 + 40; 30 = 20 + 10; 40 ⊃ 40)."""
|
||||||
|
head = {1: 'ООО "Октябрьское"', 3: "Период: 9 месяцев 2024 г."}
|
||||||
|
|
||||||
|
def sheet(account: str, rows: Sequence[FlatRow]) -> SheetSpec:
|
||||||
|
spec = _flat_sheet({**head, 2: f"Оборотно-сальдовая ведомость по счету {account}"}, 5, rows)
|
||||||
|
spec.name = f"{account} сч."
|
||||||
|
return spec
|
||||||
|
|
||||||
|
tax = (None, None, "2333.00", "2624.00", None, "291.00")
|
||||||
|
tax_old = (None, "15687.00", "45332.00", "45332.00", None, "15687.00")
|
||||||
|
rows_68: list[FlatRow] = [
|
||||||
|
("68.01", tax, 0),
|
||||||
|
("68.01.1", tax, 0),
|
||||||
|
("Налог (взносы): начислено / уплачено", tax, 1),
|
||||||
|
("68.10", tax_old, 0),
|
||||||
|
("Налог (взносы): начислено / уплачено", tax_old, 1),
|
||||||
|
("Итого", (None, "15687.00", "47665.00", "47956.00", None, "15978.00"), 0),
|
||||||
|
]
|
||||||
|
income = (None, None, None, "300.00", None, "300.00")
|
||||||
|
expense = (None, None, "200.00", None, "200.00", None)
|
||||||
|
closing = (None, None, "500.00", "300.00", "200.00", None)
|
||||||
|
rows_90: list[FlatRow] = [
|
||||||
|
("90.01", income, 0),
|
||||||
|
("90.01.1", income, 0),
|
||||||
|
("Аренда", (None, None, None, "200.00", None, None), 1),
|
||||||
|
("Прочие", (None, None, None, "100.00", None, None), 1),
|
||||||
|
("90.07", expense, 0),
|
||||||
|
("90.07.1", expense, 0),
|
||||||
|
("Расходы по аренде", (None, None, "200.00", None, None, None), 1),
|
||||||
|
("90.09", closing, 0),
|
||||||
|
("", (None, None, "500.00", "300.00", None, None), 0),
|
||||||
|
("Итого", (None, None, "700.00", "600.00", "400.00", "300.00"), 0),
|
||||||
|
]
|
||||||
|
|
||||||
|
def turn(debit: str | None, credit: str | None) -> tuple[str | None, ...]:
|
||||||
|
return (None, None, debit, credit, None, None)
|
||||||
|
|
||||||
|
rows_91: list[FlatRow] = [
|
||||||
|
("91.01", (None, None, None, "100.00", None, "100.00"), 0),
|
||||||
|
("Проценты к получению", turn(None, "70.00"), 1),
|
||||||
|
("Проценты по вкладам", turn(None, "70.00"), 1),
|
||||||
|
("Прочие доходы", turn(None, "30.00"), 1),
|
||||||
|
("Пени", turn(None, "20.00"), 1),
|
||||||
|
("Штрафы", turn(None, "10.00"), 1),
|
||||||
|
("91.02", (None, None, "170.00", None, "170.00", None), 0),
|
||||||
|
("91.02.1", (None, None, "170.00", None, "170.00", None), 0),
|
||||||
|
("Проценты к уплате", turn("100.00", None), 1),
|
||||||
|
("Проценты по займам", turn("60.00", None), 1),
|
||||||
|
("Проценты по кредитам", turn("40.00", None), 1),
|
||||||
|
("Прочие расходы", turn("30.00", None), 1),
|
||||||
|
("Затраты на нужды", turn("20.00", None), 1),
|
||||||
|
("Штрафы по договорам", turn("10.00", None), 1),
|
||||||
|
("Операционные расходы", turn("40.00", None), 1),
|
||||||
|
("Услуги банка", turn("40.00", None), 1),
|
||||||
|
("91.09", (None, None, None, "70.00", None, "70.00"), 0),
|
||||||
|
("", turn(None, "70.00"), 0),
|
||||||
|
("Итого", (None, None, "170.00", "170.00", "170.00", "170.00"), 0),
|
||||||
|
]
|
||||||
|
return [sheet("68", rows_68), sheet("90", rows_90), sheet("91", rows_91)]
|
||||||
|
|
||||||
|
|
||||||
def _s28() -> list[SheetSpec]:
|
def _s28() -> list[SheetSpec]:
|
||||||
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
|
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
|
||||||
tree = _inn_layout_tree()
|
tree = _inn_layout_tree()
|
||||||
@ -1152,6 +1218,7 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp
|
|||||||
"s44_subkonto_unnamed_10.xlsx": (_s44, to_xlsx),
|
"s44_subkonto_unnamed_10.xlsx": (_s44, to_xlsx),
|
||||||
"s45_account_label_value_cells.xlsx": (_s45, to_xlsx),
|
"s45_account_label_value_cells.xlsx": (_s45, to_xlsx),
|
||||||
"s46_counterparty_code_blocks.xlsx": (_s46, to_xlsx),
|
"s46_counterparty_code_blocks.xlsx": (_s46, to_xlsx),
|
||||||
|
"s47_indent_hierarchy_code_sums.xlsx": (_s47, to_xlsx),
|
||||||
}
|
}
|
||||||
FILE_NAMES = tuple(FILES)
|
FILE_NAMES = tuple(FILES)
|
||||||
|
|
||||||
|
|||||||
@ -26,13 +26,13 @@ def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None:
|
|||||||
assert left.read_bytes() == right.read_bytes(), left.name
|
assert left.read_bytes() == right.read_bytes(), left.name
|
||||||
|
|
||||||
|
|
||||||
def test_generated_set_is_s01_to_s36_and_s40_to_s46_with_one_xlsb(tmp_path: Path) -> None:
|
def test_generated_set_is_s01_to_s36_and_s40_to_s47_with_one_xlsb(tmp_path: Path) -> None:
|
||||||
names = [path.name for path in generate_all(tmp_path)]
|
names = [path.name for path in generate_all(tmp_path)]
|
||||||
|
|
||||||
assert names == list(FILE_NAMES)
|
assert names == list(FILE_NAMES)
|
||||||
assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names)
|
assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names)
|
||||||
# s33…s36 — строки-показатели (AURORA-1613, #58), плоская форма ОСВ 1С (#59) — s40…s44
|
# s33…s36 — строки-показатели (AURORA-1613, #58), плоская форма ОСВ 1С (#59) — s40…s44
|
||||||
numbers = [*range(1, 37), *range(40, 47)]
|
numbers = [*range(1, 37), *range(40, 48)]
|
||||||
assert [name[1:3] for name in names] == [f"{number:02d}" for number in numbers]
|
assert [name[1:3] for name in names] == [f"{number:02d}" for number in numbers]
|
||||||
assert [name for name in names if name.endswith(".xlsb")] == ["s13_no_outline_indent.xlsb"]
|
assert [name for name in names if name.endswith(".xlsb")] == ["s13_no_outline_indent.xlsb"]
|
||||||
assert sorted(path.name for path in tmp_path.iterdir()) == sorted(names)
|
assert sorted(path.name for path in tmp_path.iterdir()) == sorted(names)
|
||||||
|
|||||||
@ -52,6 +52,7 @@ FLAT_FORM_FILES = (
|
|||||||
"s44_subkonto_unnamed_10.xlsx",
|
"s44_subkonto_unnamed_10.xlsx",
|
||||||
"s45_account_label_value_cells.xlsx",
|
"s45_account_label_value_cells.xlsx",
|
||||||
"s46_counterparty_code_blocks.xlsx",
|
"s46_counterparty_code_blocks.xlsx",
|
||||||
|
"s47_indent_hierarchy_code_sums.xlsx",
|
||||||
)
|
)
|
||||||
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
|
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
|
||||||
EXPECTED_NAMES = [
|
EXPECTED_NAMES = [
|
||||||
@ -189,6 +190,10 @@ EXPECTED: dict[str, Expect] = {
|
|||||||
"s45_account_label_value_cells.xlsx": Expect("header_account", 2025, "Q2", True, data_type=None),
|
"s45_account_label_value_cells.xlsx": Expect("header_account", 2025, "Q2", True, data_type=None),
|
||||||
# #71: выгрузка «по контрагентам» целиком: блоки субсчетов, коды контрагентов, договоры, «ИТОГ по …», «ИТОГ»
|
# #71: выгрузка «по контрагентам» целиком: блоки субсчетов, коды контрагентов, договоры, «ИТОГ по …», «ИТОГ»
|
||||||
"s46_counterparty_code_blocks.xlsx": Expect("code_blocks", 2025, "Q2", True, data_type=None),
|
"s46_counterparty_code_blocks.xlsx": Expect("code_blocks", 2025, "Q2", True, data_type=None),
|
||||||
|
# #78: листы 68/90/91 по отступам: подуровень «68.01.1», безымянная строка закрытия, вложенность по суммам
|
||||||
|
"s47_indent_hierarchy_code_sums.xlsx": Expect(
|
||||||
|
"indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"', documents=3
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from src.contract.common import DataType
|
from src.contract.common import DataType
|
||||||
from src.parser.checks import TOLERANCE, balance_identity, parent_sums, run_checks, total_matches_root
|
from src.parser.checks import TOLERANCE, balance_identity, parent_sums, run_checks, total_matches_root
|
||||||
from src.parser.classify import ClassifiedRow, RowGroupType
|
from src.parser.classify import ClassifiedRow, RowGroupType
|
||||||
@ -27,6 +29,7 @@ def row( # noqa: PLR0913 - строка теста описывается по
|
|||||||
text: str = "строка",
|
text: str = "строка",
|
||||||
code: str | None = "60",
|
code: str | None = "60",
|
||||||
indicator: bool = False,
|
indicator: bool = False,
|
||||||
|
is_period: bool = False,
|
||||||
) -> ClassifiedRow:
|
) -> ClassifiedRow:
|
||||||
body = BodyRow(
|
body = BodyRow(
|
||||||
row=number,
|
row=number,
|
||||||
@ -50,6 +53,7 @@ def row( # noqa: PLR0913 - строка теста описывается по
|
|||||||
data_type=data_type,
|
data_type=data_type,
|
||||||
extra=None,
|
extra=None,
|
||||||
indicator=indicator,
|
indicator=indicator,
|
||||||
|
is_period=is_period,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -186,6 +190,176 @@ def test_expanded_parent_balance_must_match_by_columns() -> None:
|
|||||||
assert parent_sums(rows) == [12]
|
assert parent_sums(rows) == [12]
|
||||||
|
|
||||||
|
|
||||||
|
def netted_child_tree(parent_opening: tuple[str, str] = ("130", "70"), parent_turnover: tuple[str, str] = ("10", "10")):
|
||||||
|
"""Развёрнутый счёт 12 над свёрнутым субсчётом 13 (нетто 60 Дт), чьи внуки 14/15 развёрнуты (90 Дт / 30 Кт):
|
||||||
|
1С схлопнула Дт/Кт субсчёта в нетто, в родителе они остались развёрнутыми (AURORA-1863)."""
|
||||||
|
opening_debit, opening_credit = parent_opening
|
||||||
|
turnover_debit, turnover_credit = parent_turnover
|
||||||
|
return [
|
||||||
|
account(12, (opening_debit, opening_credit, turnover_debit, turnover_credit, opening_debit, opening_credit)),
|
||||||
|
row(13, ("60", None, "10", "10", "60", None), level=1, parent=12, group_type="ACCOUNT"),
|
||||||
|
row(14, ("90", None, "10", None, "90", None), level=2, parent=13, group_type="ACCOUNT"),
|
||||||
|
row(15, (None, "30", None, "10", None, "30"), level=2, parent=13, group_type="ACCOUNT"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_expanded_parent_over_netted_child_is_compared_by_net() -> None:
|
||||||
|
"""AURORA-1863: у ребёнка сальдо свёрнуто относительно его детей (прошёл только по нетто) — развёрнутый
|
||||||
|
родитель над ним тоже сверяется по нетто."""
|
||||||
|
assert parent_sums(netted_child_tree()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_expanded_parent_over_netted_child_with_wrong_net_is_violation() -> None:
|
||||||
|
assert parent_sums(netted_child_tree(parent_opening=("131", "70"))) == [12]
|
||||||
|
|
||||||
|
|
||||||
|
def test_expanded_parent_over_netted_child_turnovers_still_by_columns() -> None:
|
||||||
|
assert parent_sums(netted_child_tree(parent_turnover=("11", "11"))) == [12]
|
||||||
|
|
||||||
|
|
||||||
|
def test_expanded_parent_without_netted_child_stays_violation_on_rounding_gap() -> None:
|
||||||
|
"""Узость правила: расхождение 0.05 на обеих сторонах при равном нетто у развёрнутого родителя без свёрнутого
|
||||||
|
ребёнка остаётся нарушением."""
|
||||||
|
rows = [
|
||||||
|
account(12, ("9977809.90", "8829025.95", None, None, "9977809.90", "8829025.95")),
|
||||||
|
row(13, ("9977809.85", None, None, None, "9977809.85", None), level=1, parent=12, group_type="ACCOUNT"),
|
||||||
|
row(14, (None, "8829025.90", None, None, None, "8829025.90"), level=1, parent=12, group_type="ACCOUNT"),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert parent_sums(rows) == [12]
|
||||||
|
|
||||||
|
|
||||||
|
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"),
|
||||||
|
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"),
|
||||||
|
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) == []
|
||||||
|
|
||||||
|
|
||||||
|
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_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 = [
|
||||||
|
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:
|
def test_turnovers_are_compared_by_columns_even_when_net_matches() -> None:
|
||||||
rows = [
|
rows = [
|
||||||
account(12, ("16", None, "135", "112", "39", None)),
|
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"
|
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:
|
def test_parents_follow_tree(rules: Rules) -> None:
|
||||||
parsed = parse(_basic(), rules)
|
parsed = parse(_basic(), rules)
|
||||||
|
|
||||||
|
|||||||
@ -47,6 +47,7 @@ def body( # noqa: PLR0913 - фабрика тестовых строк: все
|
|||||||
indent: int = 0,
|
indent: int = 0,
|
||||||
bold: bool = False,
|
bold: bool = False,
|
||||||
sums: tuple[Decimal | None, ...] = NO_SUMS,
|
sums: tuple[Decimal | None, ...] = NO_SUMS,
|
||||||
|
has_inn: bool = False,
|
||||||
) -> BodyRow:
|
) -> BodyRow:
|
||||||
match = ACCOUNT.fullmatch(text)
|
match = ACCOUNT.fullmatch(text)
|
||||||
rank = match.group(1).count(".") + 1 if match else None
|
rank = match.group(1).count(".") + 1 if match else None
|
||||||
@ -54,11 +55,13 @@ def body( # noqa: PLR0913 - фабрика тестовых строк: все
|
|||||||
row=row,
|
row=row,
|
||||||
text=text,
|
text=text,
|
||||||
is_account=match is not None,
|
is_account=match is not None,
|
||||||
|
account_code=match.group(1) if match else None,
|
||||||
account_rank=rank,
|
account_rank=rank,
|
||||||
bold=bold,
|
bold=bold,
|
||||||
indent=indent,
|
indent=indent,
|
||||||
outline=outline,
|
outline=outline,
|
||||||
sums=sums,
|
sums=sums,
|
||||||
|
has_inn=has_inn,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -498,3 +501,289 @@ def test_header_account_goes_before_sums_for_adjacent_rows_with_equal_sums() ->
|
|||||||
assert levels.strategy == "header_account"
|
assert levels.strategy == "header_account"
|
||||||
assert levels_list(rows, levels.by_row) == [0, 0]
|
assert levels_list(rows, levels.by_row) == [0, 0]
|
||||||
assert set(levels.parent.values()) == {None}
|
assert set(levels.parent.values()) == {None}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Отступ: подуровень счёта, закрывающая строка, вложенность по суммам (AURORA-1858) ---
|
||||||
|
|
||||||
|
|
||||||
|
def turnover(value: str) -> tuple[Decimal | None, ...]:
|
||||||
|
return money(None, None, value, None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_subaccount_with_code_prefix_on_same_indent_is_nested() -> None:
|
||||||
|
"""Подуровень «68.01.1» на том же отступе, что «68.01»: по коду он ребёнок, его аналитика — на уровень глубже."""
|
||||||
|
rows = numbered(
|
||||||
|
("68.01", {"indent": 0}),
|
||||||
|
("68.01.1", {"indent": 0}),
|
||||||
|
("Налог", {"indent": 1}),
|
||||||
|
("68.10", {"indent": 0}),
|
||||||
|
("Налог", {"indent": 1}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 0, 21: 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_neighbour_accounts_without_code_prefix_stay_roots() -> None:
|
||||||
|
rows = numbered(
|
||||||
|
("68.01", {"indent": 0}),
|
||||||
|
("68.10", {"indent": 0}),
|
||||||
|
("68.1.1", {"indent": 0}),
|
||||||
|
("Налог", {"indent": 1}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert strategy_indent(rows) == {17: 0, 18: 0, 19: 0, 20: 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_code_prefix_nesting_only_on_a_single_indent() -> None:
|
||||||
|
"""Пары «счёт — подуровень» на двух разных отступах — не признак вложенности (соседние корни намеренно)."""
|
||||||
|
rows = numbered(
|
||||||
|
("60", {"indent": 0}),
|
||||||
|
("60.01", {"indent": 0}),
|
||||||
|
("Контрагент", {"indent": 1}),
|
||||||
|
("70", {"indent": 1}),
|
||||||
|
("70.01", {"indent": 1}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert strategy_indent(rows) == {17: 0, 18: 0, 19: 1, 20: 1, 21: 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_chain_of_subaccounts_on_same_indent_is_nested_step_by_step() -> None:
|
||||||
|
rows = numbered(
|
||||||
|
("68", {"indent": 0}),
|
||||||
|
("68.01", {"indent": 0}),
|
||||||
|
("68.01.1", {"indent": 0}),
|
||||||
|
("Налог", {"indent": 1}),
|
||||||
|
("69", {"indent": 0}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 3, 21: 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_unnamed_closing_row_after_account_is_its_child() -> None:
|
||||||
|
"""Строка закрытия без текста и кода (reader не хранит пустые ячейки — отступ 0) — ребёнок предыдущего счёта."""
|
||||||
|
rows = numbered(
|
||||||
|
("90.01", {"indent": 0, "sums": turnover("10")}),
|
||||||
|
("Аренда", {"indent": 1, "sums": turnover("10")}),
|
||||||
|
("90.09", {"indent": 0, "sums": turnover("7")}),
|
||||||
|
("", {"indent": 0, "sums": turnover("7")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0, 20: 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_unnamed_row_without_sums_or_after_non_account_is_not_attached() -> None:
|
||||||
|
rows = numbered(
|
||||||
|
("90.01", {"indent": 0}),
|
||||||
|
("Аренда", {"indent": 1, "sums": turnover("10")}),
|
||||||
|
("", {"indent": 0, "sums": turnover("10")}),
|
||||||
|
("90.09", {"indent": 0}),
|
||||||
|
("", {"indent": 0}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0, 20: 0, 21: 0}
|
||||||
|
|
||||||
|
|
||||||
|
def flat_group(*kids: str, parent: str = "100") -> list[BodyRow]:
|
||||||
|
"""Счёт с оборотом `parent` и аналитикой на одном отступе — по суммам значения `kids` (дебет оборота)."""
|
||||||
|
specs: list[tuple[str, dict[str, object]]] = [("91.02", {"indent": 0, "sums": turnover(parent)})]
|
||||||
|
specs += [(f"Статья {n}", {"indent": 1, "sums": turnover(value)}) for n, value in enumerate(kids)]
|
||||||
|
return numbered(*specs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_flat_analytics_is_nested_by_sums_inside_violating_parent() -> None:
|
||||||
|
"""Сумма детей ≠ родитель, а вложенность видна только по суммам (60 = 40 + 20): строка с суммой её соседей — их
|
||||||
|
родитель."""
|
||||||
|
rows = flat_group("60", "40", "20", "40")
|
||||||
|
|
||||||
|
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 2, 21: 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_flat_analytics_is_untouched_without_violation() -> None:
|
||||||
|
"""Родитель сходится с детьми (50 + 50 = 100): равные суммы соседей не склеиваются в родителя и ребёнка."""
|
||||||
|
rows = flat_group("50", "50")
|
||||||
|
|
||||||
|
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_nesting_by_sums_is_rolled_back_when_parent_still_violates() -> None:
|
||||||
|
"""30 + 30 + 30 ≠ 100 и вложенность по суммам (30 ⊃ 30) родителя не лечит: дерево остаётся исходным."""
|
||||||
|
rows = flat_group("30", "30", "30")
|
||||||
|
|
||||||
|
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 1, 20: 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_nesting_by_sums_never_nests_accounts() -> None:
|
||||||
|
rows = numbered(
|
||||||
|
("91", {"indent": 0, "sums": turnover("100")}),
|
||||||
|
("91.01", {"indent": 1, "sums": turnover("60")}),
|
||||||
|
("91.02", {"indent": 1, "sums": turnover("60")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_second_subaccount_of_same_parent_is_nested_too() -> None:
|
||||||
|
"""«68.04.1» и «68.04.2» на одном отступе с «68.04»: оба — дети «68.04» (второй — не ребёнок первого)."""
|
||||||
|
rows = numbered(
|
||||||
|
("68.04", {"indent": 0}),
|
||||||
|
("68.04.1", {"indent": 0}),
|
||||||
|
("Налог", {"indent": 1}),
|
||||||
|
("68.04.2", {"indent": 0}),
|
||||||
|
("68.07", {"indent": 0}),
|
||||||
|
("Налог", {"indent": 1}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 1, 21: 0, 22: 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_subaccount_already_under_parent_with_its_code_is_not_moved() -> None:
|
||||||
|
"""«76.09» с собственной аналитикой и «76.09.1» рядом на одном отступе — уже дети счёта «76.09»; под соседа
|
||||||
|
«76.09» их перекладывать не нужно."""
|
||||||
|
rows = numbered(
|
||||||
|
("76", {"indent": 0}),
|
||||||
|
("76.09", {"indent": 1}),
|
||||||
|
("76.09", {"indent": 2}),
|
||||||
|
("ООО А", {"indent": 3}),
|
||||||
|
("76.09.1", {"indent": 2}),
|
||||||
|
("ООО Б", {"indent": 3}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 3, 21: 2, 22: 3}
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_unnamed_row_with_other_turnover_is_not_a_closing_row() -> None:
|
||||||
|
"""Безымянная строка с суммами, не повторяющая обороты счёта, закрытием не считается."""
|
||||||
|
rows = numbered(
|
||||||
|
("26.01", {"indent": 0, "sums": money(None, None, "100", "100", None, None)}),
|
||||||
|
("Статья", {"indent": 1, "sums": turnover("100")}),
|
||||||
|
("", {"indent": 0, "sums": money(None, None, None, "100", None, None)}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Вложенность по суммам: защиты (ревью AURORA-1858, C-1) ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_nesting_by_sums_is_off_when_caller_forbids_it() -> None:
|
||||||
|
"""Схема шапки называет группы аналитики после счёта: вложение изменило бы group_type/group_name строки."""
|
||||||
|
rows = flat_group("60", "40", "20", "40")
|
||||||
|
|
||||||
|
assert strategy_indent(rows, nest_by_sums=False) == {17: 0, 18: 1, 19: 1, 20: 1, 21: 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_nesting_by_sums_needs_one_indent_among_children() -> None:
|
||||||
|
"""Прямые дети родителя на разных отступах (2, 1, 1): отступ их уровни различает, суммы его не переопределяют."""
|
||||||
|
rows = numbered(
|
||||||
|
("91.02", {"indent": 0, "sums": turnover("100")}),
|
||||||
|
("Статья 0", {"indent": 2, "sums": turnover("60")}),
|
||||||
|
("Статья 1", {"indent": 1, "sums": turnover("40")}),
|
||||||
|
("Статья 2", {"indent": 1, "sums": turnover("40")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert strategy_indent(rows) == {17: 0, 18: 2, 19: 1, 20: 1}
|
||||||
|
|
||||||
|
|
||||||
|
def inn_group(*, inn_on: set[int]) -> list[BodyRow]:
|
||||||
|
"""91.02 = 100; аналитика 60, 40, 40 на одном отступе; `inn_on` — номера статей (с 0) с ИНН."""
|
||||||
|
sums = ("60", "40", "40")
|
||||||
|
specs: list[tuple[str, dict[str, object]]] = [("91.02", {"indent": 0, "sums": turnover("100")})]
|
||||||
|
specs += [
|
||||||
|
(f"Статья {n}", {"indent": 1, "sums": turnover(value), "has_inn": n in inn_on}) for n, value in enumerate(sums)
|
||||||
|
]
|
||||||
|
return numbered(*specs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_nesting_by_sums_without_inn_nests_equal_neighbours() -> None:
|
||||||
|
assert strategy_indent(inn_group(inn_on=set())) == {17: 0, 18: 1, 19: 1, 20: 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_nesting_by_sums_skips_row_with_inn_being_nested() -> None:
|
||||||
|
"""Вкладываемая строка с ИНН: она контрагент, а не строка под соседом."""
|
||||||
|
assert strategy_indent(inn_group(inn_on={2})) == {17: 0, 18: 1, 19: 1, 20: 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_nesting_by_sums_skips_new_parent_with_inn() -> None:
|
||||||
|
"""Новый родитель с ИНН: контрагент не становится родителем строки."""
|
||||||
|
assert strategy_indent(inn_group(inn_on={1})) == {17: 0, 18: 1, 19: 1, 20: 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_levels_does_not_nest_by_sums_unless_asked() -> None:
|
||||||
|
rows = flat_group("60", "40", "20", "40")
|
||||||
|
flat = [1, 1, 1, 1]
|
||||||
|
nested = [1, 2, 2, 1]
|
||||||
|
|
||||||
|
off = resolve_levels(rows, None, never_expires())
|
||||||
|
on = resolve_levels(rows, None, never_expires(), nest_by_sums=True)
|
||||||
|
|
||||||
|
assert levels_list(rows, off.by_row)[1:] == flat
|
||||||
|
assert levels_list(rows, on.by_row)[1:] == nested
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_nesting_by_sums_accumulates_depth_for_nested_child_of_nested_parent() -> None:
|
||||||
|
"""91 = 100: X 40, Y 40, Z 60 на одном отступе (X ⊃ Y); под Y — K1 40, K2 40 глубже (K1 ⊃ K2). Поддерево Y
|
||||||
|
сдвигается вместе с Y (+1), а K2 ещё вкладывается в K1 (+1): сдвиги складываются, а не затирают друг друга."""
|
||||||
|
rows = numbered(
|
||||||
|
("91", {"indent": 0, "sums": turnover("100")}),
|
||||||
|
("X", {"indent": 1, "sums": turnover("40")}),
|
||||||
|
("Y", {"indent": 1, "sums": turnover("40")}),
|
||||||
|
("K1", {"indent": 2, "sums": turnover("40")}),
|
||||||
|
("K2", {"indent": 2, "sums": turnover("40")}),
|
||||||
|
("Z", {"indent": 1, "sums": turnover("60")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 3, 21: 4, 22: 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_nesting_by_sums_keeps_depth_of_children_under_a_nested_row() -> None:
|
||||||
|
"""Строка с детьми (глубже по отступу) вкладывается как поддерево: уровни детей растут на её глубину."""
|
||||||
|
rows = numbered(
|
||||||
|
("91", {"indent": 0, "sums": turnover("100")}),
|
||||||
|
("Z", {"indent": 1, "sums": turnover("60")}),
|
||||||
|
("Y", {"indent": 1, "sums": turnover("60")}),
|
||||||
|
("K1", {"indent": 2, "sums": turnover("30")}),
|
||||||
|
("K2", {"indent": 2, "sums": turnover("30")}),
|
||||||
|
("W", {"indent": 1, "sums": turnover("40")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 3, 21: 3, 22: 1}
|
||||||
|
|
||||||
|
|
||||||
|
NEST_SCAN_LIMIT_PLUS = 50 # столько соседей вперёд перебирает вложенность по суммам
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("count", "nested"), [(NEST_SCAN_LIMIT_PLUS, True), (NEST_SCAN_LIMIT_PLUS + 1, False)])
|
||||||
|
def test_indent_nesting_by_sums_scan_limit_boundary(count: int, nested: bool) -> None:
|
||||||
|
"""Родитель строки, чьи суммы дают сумма `count` следующих соседей: 50 соседей находятся, 51 — нет."""
|
||||||
|
kids = [str(n) for n in range(1, count + 1)]
|
||||||
|
total = str(sum(range(1, count + 1)))
|
||||||
|
rows = flat_group(total, *kids, parent=total)
|
||||||
|
|
||||||
|
levels = strategy_indent(rows)
|
||||||
|
|
||||||
|
assert levels is not None
|
||||||
|
assert levels[FIRST + 1] == 1
|
||||||
|
assert (levels[FIRST + 2] == levels[FIRST + 1] + 1) is nested
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_account_code_without_separator_is_not_nested() -> None:
|
||||||
|
"""Второй счёт «60» после «60»: у кода нет разделителя сегментов, родителя по коду нет."""
|
||||||
|
rows = numbered(
|
||||||
|
("60", {"indent": 0}),
|
||||||
|
("Контрагент", {"indent": 1}),
|
||||||
|
("60", {"indent": 0}),
|
||||||
|
("Контрагент 2", {"indent": 1}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0, 20: 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_closing_row_needs_nonzero_turnover() -> None:
|
||||||
|
"""Безымянная строка с нулевыми оборотами при нулевых оборотах счёта закрытием не считается."""
|
||||||
|
rows = numbered(
|
||||||
|
("90.09", {"indent": 0, "sums": money(None, None, "0", "0", None, None)}),
|
||||||
|
("Статья", {"indent": 1, "sums": turnover("5")}),
|
||||||
|
("", {"indent": 0, "sums": money(None, None, "0", "0", None, "7")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0}
|
||||||
|
|||||||
97
api/tests/unit/parser/test_osv_indent_nesting.py
Normal file
97
api/tests/unit/parser/test_osv_indent_nesting.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
"""Вложенность по суммам при иерархии по отступам (AURORA-1858, ревью C-1): защиты на уровне всего листа.
|
||||||
|
|
||||||
|
Правило не должно прятать реальную ошибку и менять `group_type`/`group_name` строк: при названных группах аналитики в
|
||||||
|
шапке оно отключено, с ИНН строка не вкладывается и не становится родителем.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from gridbuilder import C, M
|
||||||
|
|
||||||
|
from src.contract.result import OsvDocument
|
||||||
|
from src.parser.deadline import Deadline
|
||||||
|
from src.parser.osv import OsvParser
|
||||||
|
from src.parser.rules import Rules, load_rules
|
||||||
|
|
||||||
|
from gridbuilder import sheet_from_rows # isort: skip
|
||||||
|
|
||||||
|
TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
|
||||||
|
TIMEOUT_SECONDS = 60
|
||||||
|
Row = list[object]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def rules() -> Rules:
|
||||||
|
return load_rules()
|
||||||
|
|
||||||
|
|
||||||
|
def sums(value: int) -> Row:
|
||||||
|
return [None, None, value, None, value, None]
|
||||||
|
|
||||||
|
|
||||||
|
def line(indent: int, text: str, value: int, inn: str | None = None) -> Row:
|
||||||
|
return [C(text, indent=indent), inn, None, *sums(value)]
|
||||||
|
|
||||||
|
|
||||||
|
def parse(rules: Rules, head_group_rows: list[Row], body: list[Row]) -> OsvDocument:
|
||||||
|
rows: list[Row] = [
|
||||||
|
["ООО Ромашка"],
|
||||||
|
[TITLE],
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
"Счет",
|
||||||
|
"ИНН",
|
||||||
|
"КПП",
|
||||||
|
M("Сальдо на начало периода", 2),
|
||||||
|
M("Обороты за период", 2),
|
||||||
|
M("Сальдо на конец периода", 2),
|
||||||
|
],
|
||||||
|
["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
|
||||||
|
*head_group_rows,
|
||||||
|
*body,
|
||||||
|
]
|
||||||
|
return OsvParser(rules).parse_sheet(sheet_from_rows(rows), Deadline(TIMEOUT_SECONDS)).document
|
||||||
|
|
||||||
|
|
||||||
|
def test_reviewer_scenario_with_named_groups_and_inn_stays_needs_check(rules: Rules) -> None:
|
||||||
|
"""Счёт 60 = 100, но контрагенты 60 + 40 + 40: это ошибка файла, а не вложенность «Гамма под Бетой»."""
|
||||||
|
document = parse(
|
||||||
|
rules,
|
||||||
|
[["Договоры"]],
|
||||||
|
[
|
||||||
|
line(0, "60", 100),
|
||||||
|
line(1, "ООО Альфа", 60, "7701234567"),
|
||||||
|
line(2, "Договор 1", 60),
|
||||||
|
line(1, "ООО Бета", 40, "7707654321"),
|
||||||
|
line(1, "ООО Гамма", 40, "5001234567"),
|
||||||
|
[C("Итого"), None, None, *sums(100)],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
by_text = {row.row_value: row for row in document.rows}
|
||||||
|
gamma = by_text["ООО Гамма"]
|
||||||
|
assert document.status == "needs_check"
|
||||||
|
assert gamma.group_type == "COUNTERPARTY"
|
||||||
|
assert gamma.counterparty_inn == "5001234567"
|
||||||
|
assert gamma.parent_row_number == by_text["60"].row_number
|
||||||
|
assert by_text["ООО Бета"].parent_row_number == by_text["60"].row_number
|
||||||
|
|
||||||
|
|
||||||
|
def test_named_analytic_group_in_header_disables_nesting_by_sums(rules: Rules) -> None:
|
||||||
|
"""Без ИНН и с одним отступом правило бы вложило «Б» в «В» (40 ⊃ 40): шапка называет «Контрагенты», вложение
|
||||||
|
сменило бы group_type строки, поэтому дерево остаётся по отступам и лист на проверке."""
|
||||||
|
document = parse(
|
||||||
|
rules,
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
line(0, "60", 100),
|
||||||
|
line(1, "Контрагент А", 60),
|
||||||
|
line(1, "Контрагент Б", 40),
|
||||||
|
line(1, "Контрагент В", 40),
|
||||||
|
[C("Итого"), None, None, *sums(100)],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
by_text = {row.row_value: row for row in document.rows}
|
||||||
|
assert document.status == "needs_check"
|
||||||
|
assert {by_text[name].group_type for name in ("Контрагент А", "Контрагент Б", "Контрагент В")} == {"COUNTERPARTY"}
|
||||||
|
assert {by_text[name].level for name in ("Контрагент А", "Контрагент Б", "Контрагент В")} == {1}
|
||||||
@ -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"]
|
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"])
|
@pytest.mark.parametrize("bad", ["", "abc", "60.", "6 0"])
|
||||||
def test_counterparty_account_code_must_be_digits_with_dots(tmp_path: Path, bad: str) -> None:
|
def test_counterparty_account_code_must_be_digits_with_dots(tmp_path: Path, bad: str) -> None:
|
||||||
target = _copy_rules(tmp_path)
|
target = _copy_rules(tmp_path)
|
||||||
|
|||||||
@ -150,6 +150,26 @@ SHALL пропускаться с предупреждением файла `SHE
|
|||||||
- **THEN** `OSV_HIERARCHY_BY_INDENT`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по
|
- **THEN** `OSV_HIERARCHY_BY_INDENT`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по
|
||||||
кодам счетов и жирности (`s03`)
|
кодам счетов и жирности (`s03`)
|
||||||
|
|
||||||
|
#### Scenario: Отступ не различает подуровни счёта, строку закрытия и вложенную аналитику
|
||||||
|
- **WHEN** на листе уровни берутся по отступам (`s47`; ОСВ 1С «Субконто» без вида аналитики в шапке), но
|
||||||
|
(а) счёт `68.01.1` стоит на одном отступе с `68.01` (код продолжает код соседнего счёта того же блока);
|
||||||
|
(б) безымянная строка с суммами (пустая ячейка отступа не хранится) идёт сразу за строкой счёта и повторяет его обороты;
|
||||||
|
(в) аналитика родителя с нарушением «сумма детей = родитель» стоит на одном отступе, а вложенность видна только по
|
||||||
|
суммам (`100 = 60 + 40`)
|
||||||
|
- **THEN** (а) `68.01.1` — ребёнок `68.01` (уровень +1, его аналитика глубже), если все такие пары листа на одном
|
||||||
|
отступе, раскладка однозначна, а сумма детей перекладываемого родителя сходится с ним; (б) строка закрытия — ребёнок
|
||||||
|
счёта (оборотов не нулевые); (в) внутри такого родителя строка, чьи суммы равны сумме следующих соседей, становится
|
||||||
|
их родителем (жадно, не дальше `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 и отступов
|
#### Scenario: Без outline и отступов
|
||||||
- **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`)
|
- **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`)
|
||||||
- **THEN** `OSV_HIERARCHY_BY_ACCOUNT_PATTERN`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт
|
- **THEN** `OSV_HIERARCHY_BY_ACCOUNT_PATTERN`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт
|
||||||
@ -303,6 +323,25 @@ balance_identity_violations, parent_sum_violations}`. Результат про
|
|||||||
- **THEN** нарушения «сумма детей = родитель» по сальдо нет; обороты при этом сверяются по колонкам, а неверное нетто
|
- **THEN** нарушения «сумма детей = родитель» по сальдо нет; обороты при этом сверяются по колонкам, а неверное нетто
|
||||||
или расхождение оборотов — нарушение
|
или расхождение оборотов — нарушение
|
||||||
|
|
||||||
|
#### Scenario: Развёрнутый родитель над свёрнутым в нетто ребёнком
|
||||||
|
- **WHEN** сальдо родителя развёрнуто (обе стороны), а среди его прямых детей есть «нетто-ребёнок» — ребёнок, сам
|
||||||
|
сошедшийся со своими детьми по этой стороне сальдо (начало или конец) лишь по нетто, и нетто детей равно нетто
|
||||||
|
родителя; признак передаётся вверх только непрерывной цепочкой нетто-строк (строка, сошедшаяся по колонкам, его не
|
||||||
|
передаёт), отдельно для начала и конца
|
||||||
|
- **THEN** нарушения по сальдо нет (1С схлопнула Дт/Кт субсчёта в нетто, а в родителе они остались развёрнутыми,
|
||||||
|
AURORA-1863); обороты сверяются по колонкам, неверное нетто или расхождение оборотов — нарушение; развёрнутый родитель
|
||||||
|
без нетто-ребёнка по-прежнему сверяется по колонкам (расхождение даже на 0,05 при равном нетто — нарушение)
|
||||||
|
|
||||||
|
#### Scenario: Периоды под контрагентом — цепочка сальдо
|
||||||
|
- **WHEN** все дети родителя — строки группировки «Период» (`period_groups` словаря `osv_groups.yaml`; в выгрузке 1С
|
||||||
|
«Субсчета + Контрагенты» это помесячные строки «Обороты за <месяц>»; тип группы остаётся `OTHER`, у строки признак
|
||||||
|
`is_period`)
|
||||||
|
- **THEN** обороты родителя сверяются с суммой оборотов периодов, сальдо начала — с сальдо начала первого периода,
|
||||||
|
сальдо конца — с сальдо конца последнего (в порядке строк; сальдо сверяется по колонкам либо по нетто, как обычно),
|
||||||
|
а не с суммой сальдо месяцев (AURORA-1823); сальдо конца каждого периода SHALL равняться сальдо начала следующей строки
|
||||||
|
периода (по колонкам либо по нетто; месяцы без строк допустимы); расхождение в обороте, начале первого или конце
|
||||||
|
последнего периода либо разрыв цепочки — нарушение; у родителя со смешанными детьми (периоды и обычные строки) суммы считаются как прежде
|
||||||
|
|
||||||
#### Scenario: Счёт 91 — аналитика только с оборотами
|
#### Scenario: Счёт 91 — аналитика только с оборотами
|
||||||
- **WHEN** разбирается ОСВ по счёту 91: строки аналитики 91.01, 91.02, 91.09 несут только обороты, сальдо закрыто на
|
- **WHEN** разбирается ОСВ по счёту 91: строки аналитики 91.01, 91.02, 91.09 несут только обороты, сальдо закрыто на
|
||||||
субсчёт закрытия (`fixtures/osv-dkb/tsimlyansky/2025-Q3/91_ 9мес.2025.xls`)
|
субсчёт закрытия (`fixtures/osv-dkb/tsimlyansky/2025-Q3/91_ 9мес.2025.xls`)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user