ОСВ: ложное «Требует проверки» — периоды, нетто-ребёнок, иерархия по отступам (AURORA-1823, 1863, 1858) #79
@ -79,11 +79,13 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
|
||||
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
|
||||
проверяют property-тест и ручная сверка «Итого».
|
||||
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не
|
||||
подгонять.
|
||||
подгонять (решения владельца 28.10.2026 — AURORA-1858: уточнение иерархии по отступам А/Б/В с защитами, см.
|
||||
`05-domain-rules.md` §1.3).
|
||||
|
||||
**Нетривиальное правило §1.4 (R-21):** при сверке «сумма детей = родитель» обороты сравниваются по колонкам, а сальдо —
|
||||
по колонкам либо по нетто `дебет − кредит`, если у родителя сальдо свёрнуто (1С показывает счёт нетто, а субсчета и
|
||||
контрагентов — развёрнуто). Это решение зафиксировано в design №31; не ослаблять и не расширять без обсуждения.
|
||||
контрагентов — развёрнуто). Это решение зафиксировано в design №31; не ослаблять и не расширять без обсуждения (решение владельца 28.10.2026, AURORA-1863: узкое нетто для
|
||||
развёрнутого родителя над нетто-ребёнком — только непрерывной цепочкой нетто-строк).
|
||||
|
||||
**Счета без сальдо по аналитике (OQ-132, #11 п. 2):** список счетов (90, 91, 99) — данные `api/rules/osv_checks.yaml`, не код;
|
||||
тождество строки на них не проверяется, «сумма детей = родитель» (обороты всегда, сальдо — только корень → субсчета) и
|
||||
|
||||
@ -20,6 +20,11 @@ mapping:
|
||||
# строки валюты («Валюта USD», «В валюте») — не контрагенты и по плану счетов (R-18)
|
||||
- ['^(в\s+)?валют', OTHER]
|
||||
|
||||
# Группировки-периоды: помесячные строки «Обороты за <месяц>» под контрагентом (выгрузка 1С «Субсчета + Контрагенты»,
|
||||
# третья группировка шапки «Период», AURORA-1823). Тип остаётся OTHER; строки получают признак `is_period`, и сальдо
|
||||
# родителя сверяется цепочкой периодов (начало первого, конец последнего), а не суммой сальдо периодов.
|
||||
period_groups: ['^период$']
|
||||
|
||||
# Расчётные счета, у которых аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван
|
||||
# (ОСВ 1С по счёту 60 без строки «Контрагенты»; AURORA-1661, OQ-158). Счёт сопоставляется по коду и субсчетам
|
||||
# («76» -> 76.2, 76.Н.1; не 760). 71 и 73 исключены (R-9): подотчётные лица и персонал — не контрагенты.
|
||||
|
||||
@ -9,7 +9,14 @@
|
||||
Обороты сверяются по колонкам. Сальдо (начало, конец) — по колонкам (развёрнутое сальдо) либо, если у родителя
|
||||
сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С показывает счёт свёрнутым
|
||||
сальдо, а субсчета и контрагентов — развёрнутым (так во всех реальных файлах, где у счёта есть субсчета с
|
||||
разными сторонами сальдо);
|
||||
разными сторонами сальдо). Нетто допустимо и у развёрнутого родителя, если среди его прямых детей есть
|
||||
«нетто-ребёнок» — ребёнок, сам сошедшийся с детьми по этой стороне сальдо (начало или конец) лишь по нетто (1С
|
||||
схлопнула Дт/Кт субсчёта в нетто, а в родителе они остались развёрнутыми, AURORA-1863); признак передаётся вверх
|
||||
только непрерывной цепочкой нетто-строк: строка, сошедшаяся по колонкам, его не передаёт. Развёрнутый родитель
|
||||
без нетто-ребёнка сверяется по колонкам. Родитель, все дети которого — периоды («Обороты за <месяц>», группировка
|
||||
«Период», `is_period`), сверяется цепочкой: обороты = Σ оборотов периодов, сальдо начала = начало первого периода,
|
||||
сальдо конца = конец последнего, а конец каждого периода = начало следующего (по колонкам либо по нетто; сальдо
|
||||
месяцев — не слагаемые; AURORA-1823); смешанные дети — обычная сумма;
|
||||
3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их
|
||||
несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо —
|
||||
по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2; то же для единственного корня, когда
|
||||
@ -23,12 +30,15 @@
|
||||
аналитикой сальдо детей пусто по определению); п. 3 («Итого») не меняется.
|
||||
|
||||
Любое нарушение делает результат `needs_check` (решает оркестратор `osv.py`); парсер структуру под сходимость не
|
||||
подгоняет. Номера строк — в `OsvChecks`, причины — тексты предупреждений.
|
||||
подгоняет (решения владельца 28.10.2026: узкое нетто для нетто-ребёнка, AURORA-1863, выше; уточнение иерархии по
|
||||
отступам с защитами, AURORA-1858, `hierarchy.strategy_indent`, `05-domain-rules.md` §1.3). Номера строк — в
|
||||
`OsvChecks`, причины — тексты предупреждений.
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable, Sequence
|
||||
from decimal import Decimal
|
||||
from itertools import pairwise
|
||||
|
||||
from src.contract.result import OsvChecks
|
||||
from src.parser.classify import SUM_FIELDS, ClassifiedRow
|
||||
@ -119,23 +129,49 @@ def balance_identity(
|
||||
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):
|
||||
return True
|
||||
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:
|
||||
"""Суммы родителя = Σ детей; `with_balance=False` — только обороты (сальдо детей не несёт данных)."""
|
||||
def _sums_match(
|
||||
parent: Sequence[Decimal],
|
||||
children: Sequence[Decimal],
|
||||
*,
|
||||
with_balance: bool = True,
|
||||
net_child: bool = False,
|
||||
) -> bool:
|
||||
"""Суммы родителя = Σ детей; `with_balance=False` — только обороты (сальдо детей не несёт данных). `net_child` —
|
||||
среди детей есть нетто-ребёнок: сальдо (начало и конец) сверяется и по нетто."""
|
||||
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 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]:
|
||||
"""Номера родителей, чьи суммы ≠ Σ детей того же `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}
|
||||
totals: dict[int, list[Decimal]] = defaultdict(lambda: [_ZERO] * len(SUM_FIELDS))
|
||||
children_of: dict[int, list[int]] = defaultdict(list)
|
||||
periods_of: dict[int, list[ClassifiedRow]] = defaultdict(list)
|
||||
has_subaccounts: set[int] = set()
|
||||
for item in rows:
|
||||
if item.parent is None or _is_indicator(item):
|
||||
@ -159,18 +197,36 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No
|
||||
accumulated = totals[item.parent]
|
||||
for index, value in enumerate(_values(item)):
|
||||
accumulated[index] += value
|
||||
children_of[item.parent].append(item.body.row)
|
||||
if item.is_period:
|
||||
periods_of[item.parent].append(item)
|
||||
if item.group_type == "ACCOUNT":
|
||||
has_subaccounts.add(item.parent)
|
||||
return [
|
||||
row
|
||||
for row in by_row
|
||||
if row in totals
|
||||
and not _sums_match(
|
||||
_values(by_row[row]),
|
||||
totals[row],
|
||||
with_balance=row in has_subaccounts or not _no_analytic_balance(by_row[row], exempt),
|
||||
)
|
||||
]
|
||||
violations: set[int] = set()
|
||||
# Нетто-строки: строка сама сошлась с детьми по сальдо (начало или конец) лишь по нетто, по колонкам не сошлось.
|
||||
# Цепочка нетто-строк непрерывна: строка, сошедшаяся по колонкам, признак вверх не передаёт. Нетто допускается на
|
||||
# обеих сторонах сальдо, а не только на той, где ребёнок свёрнут: схлопнутую пару 1С не показывает ни в начале, ни
|
||||
# в конце (на реальных файлах родитель расходится с детьми симметрично на обеих сторонах). Потомки идут по большим
|
||||
# номерам строк, поэтому родители обходятся от больших номеров к меньшим.
|
||||
netted: set[int] = set()
|
||||
for row in sorted(totals, reverse=True):
|
||||
item = by_row[row]
|
||||
with_balance = row in has_subaccounts or not _no_analytic_balance(item, exempt)
|
||||
net_child = any(child in netted for child in children_of[row])
|
||||
children = totals[row]
|
||||
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:
|
||||
|
||||
@ -37,7 +37,7 @@ from src.parser.detect import (
|
||||
table_kind_row,
|
||||
)
|
||||
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.numbers import column_inn, split_inn_suffix, to_money
|
||||
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,
|
||||
# AURORA-1714); без счёта шапки действует `ACCOUNT_RE`
|
||||
_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:
|
||||
@ -106,6 +106,8 @@ class ClassifiedRow:
|
||||
indicator: bool = False # строка-показатель (§1.3): вне проверок сумм, `data_type` — вид по метке
|
||||
# COUNTERPARTY получен по плану счетов (вид субконто в шапке не назван, #59, R-6), а не по схеме группировок
|
||||
counterparty_by_account: bool = False
|
||||
# Строка группировки-периода («Период» схемы шапки, `period_groups`): помесячный разрез родителя (AURORA-1823)
|
||||
is_period: bool = False
|
||||
|
||||
|
||||
# --- Строки таблицы ---
|
||||
@ -308,10 +310,12 @@ def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строк
|
||||
# Единицы отступа разные (alignment.indent, пробелы, колонки) — иерархия сравнивает только ранги: колонка глубже
|
||||
# любого отступа ячейки
|
||||
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(
|
||||
row=row,
|
||||
text=text,
|
||||
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,
|
||||
bold=bold,
|
||||
indent=indent,
|
||||
@ -320,6 +324,7 @@ def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строк
|
||||
label_col=label_col,
|
||||
indicator=label.indicator,
|
||||
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
|
||||
indicator: bool = False
|
||||
counterparty_by_account: bool = False
|
||||
is_period: bool = False
|
||||
|
||||
|
||||
@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":
|
||||
group_type = "CONTRACT" # договор контрагента в блоке субсчёта с кодами контрагентов (§1.3, `code_blocks`)
|
||||
if group_type != "COUNTERPARTY":
|
||||
return _Kind(group_type, name, code, own), branch
|
||||
is_period = group_type == "OTHER" and context.rules.groups.is_period_group(name)
|
||||
return _Kind(group_type, name, code, own, is_period=is_period), branch
|
||||
inn_col = context.header.inn_col
|
||||
column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules)
|
||||
# ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100)
|
||||
@ -565,6 +572,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
|
||||
extra=_extra(sheet, item.row, extra_keys),
|
||||
indicator=kind.indicator,
|
||||
counterparty_by_account=kind.counterparty_by_account,
|
||||
is_period=kind.is_period,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
@ -44,7 +44,15 @@ MIN_ROWS_FOR_CASCADE = 2
|
||||
TRIVIAL_STRATEGY = "trivial"
|
||||
# Кооперативный дедлайн внутри стратегии (design D-8): проверка каждые N строк или N шагов перебора поддеревьев
|
||||
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)
|
||||
_TURNOVER_PAIR = slice(2, 4) # обороты (дебет, кредит) в `BodyRow.sums`
|
||||
|
||||
|
||||
Role = Literal["counterparty", "contract"]
|
||||
@ -67,6 +75,8 @@ class BodyRow:
|
||||
label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.»)
|
||||
# Роль в блоке субсчёта с кодами контрагентов (`code_blocks`, §1.3): контрагент (код с суммами) или его договор
|
||||
role: Role | None = None
|
||||
account_code: str | None = None # код счёта строки, как в файле («68.01.1»); у не-счёта — `None`
|
||||
has_inn: bool = False # у строки есть ИНН: в колонке ИНН или хвостом названия (контрагент, не строка аналитики)
|
||||
|
||||
|
||||
@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}
|
||||
|
||||
|
||||
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), поэтому шаг отступа
|
||||
не предполагается: (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)")])
|
||||
if ranks is 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:
|
||||
@ -237,38 +482,11 @@ def strategy_sums(rows: Sequence[BodyRow], deadline: Deadline | None = None) ->
|
||||
поддеревья до конца листа. Поэтому дедлайн проверяется во внутреннем цикле каждые `DEADLINE_CHECK_EVERY` шагов,
|
||||
и такой файл завершается `TIMEOUT`, а не висит.
|
||||
"""
|
||||
count = len(rows)
|
||||
vectors = [_vector(item) for item in rows]
|
||||
zero = tuple(_ZERO for _ in vectors[0]) if vectors else ()
|
||||
end = [index + 1 for index in range(count)] # граница (исключительно) поддерева строки
|
||||
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:
|
||||
end = _greedy_ends(vectors, _Pacer(deadline, "иерархия (sums): перебор поддеревьев"))
|
||||
if all(end[index] == index + 1 for index in range(len(rows))):
|
||||
return None
|
||||
by_row: dict[int, int] = {}
|
||||
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
|
||||
return {item.row: depth for item, depth in zip(rows, _depths(end), strict=True)}
|
||||
|
||||
|
||||
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(
|
||||
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:
|
||||
"""Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`.
|
||||
|
||||
`nest_by_sums` — разрешить стратегии `indent` вложенность аналитики по суммам (AURORA-1858): только когда схема
|
||||
шапки не называет группы аналитики после счёта (вложение не меняет `group_type`/`group_name` строки).
|
||||
|
||||
`header_account` — в шапке есть код счёта: тогда после `account_pattern` и перед `sums` пробуется `header_account`
|
||||
(плоский список под счётом шапки, #59).
|
||||
|
||||
@ -333,7 +559,9 @@ def resolve_levels(
|
||||
cascade.insert(before_sums, ("header_account", strategy_header_account))
|
||||
for name, strategy in cascade:
|
||||
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:
|
||||
continue
|
||||
by_row = {**found, **pinned}
|
||||
|
||||
@ -59,7 +59,12 @@ def is_indicator_total(sheet: Sheet, row: int, header: TableHeader, rules: Rules
|
||||
|
||||
|
||||
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:
|
||||
"""Каскад иерархии без строк-показателей; показатель — ребёнок предыдущей строки данных, на уровень глубже
|
||||
(§1.3): так строки НУ/Кол./«В валюте» на одном outline-уровне со своей строкой не ломают дерево строк БУ.
|
||||
@ -67,7 +72,11 @@ def levels_with_indicators(
|
||||
Итоговые строки-показатели и показатель без строки данных выше — уровень 0 без родителя.
|
||||
"""
|
||||
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] = {}
|
||||
parent: dict[int, int | None] = {}
|
||||
|
||||
@ -217,7 +217,14 @@ class OsvParser:
|
||||
blocks = apply_code_blocks(body, total_row, rules, doc.account_code_raw)
|
||||
body = blocks.rows
|
||||
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 = "строки ИНН"
|
||||
glue = glue_inn_rows(
|
||||
body,
|
||||
|
||||
@ -206,6 +206,12 @@ class OsvGroupRules(_Model):
|
||||
default: GroupType = "OTHER"
|
||||
# Расчётные счета: аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван (#59, R-6)
|
||||
counterparty_accounts: list[str] = []
|
||||
# Названия группировок-периодов («Период»): строки «Обороты за <месяц>» — цепочка сальдо, не слагаемые (AURORA-1823)
|
||||
period_groups: list[Pattern] = []
|
||||
|
||||
def is_period_group(self, name: str | None) -> bool:
|
||||
"""Название группировки из схемы шапки относится к периодам."""
|
||||
return name is not None and any(pattern.search(name) for pattern in self.period_groups)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_counterparty_accounts(self) -> "OsvGroupRules":
|
||||
|
||||
@ -4,9 +4,6 @@
|
||||
# Файлов и листов, которых здесь нет, тест ждёт `done` без нарушений. Причина — данные файла, а не дефект разбора
|
||||
# (иначе чинить парсер): запись удаляется, когда вопрос закрыт.
|
||||
needs_check:
|
||||
"nkz/2025-Q3/НКЗ_ОСВ_58+66+67_Субсчета+Контрагенты_2025.09-01_24459.xls":
|
||||
reason: "лист 58: сумма детей ≠ родитель (строки 12, 24), см. arm-tabular-parser#11 A11-5; листы 66, 67 сходятся"
|
||||
sheets: {"sheet:58": [parent_sum]}
|
||||
"agrokompleks/2025-Q3/ОСВ_сч62_2025_9мес.xls":
|
||||
reason: "аналитика магазинов 62.04 без сальдо на конец (тот же класс, что OQ-132); Итого и родители сходятся"
|
||||
sheets: {"sheet:TDSheet": [balance_identity]}
|
||||
|
||||
@ -5,7 +5,8 @@
|
||||
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во»,
|
||||
строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты
|
||||
другим треком), s45 — счёт шапки «Счет Поставщика» | «60*» в соседних ячейках, s46 — выгрузка «по контрагентам» с
|
||||
блоками субсчетов (#71); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен
|
||||
блоками субсчетов (#71), s47 — иерархия по отступам: подуровень счёта, строка закрытия, вложенность по суммам (#78);
|
||||
описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен
|
||||
(openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги
|
||||
и zip постоянны, порядок записи фиксирован.
|
||||
|
||||
@ -1037,6 +1038,71 @@ def _s46() -> list[SheetSpec]:
|
||||
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]:
|
||||
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
|
||||
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),
|
||||
"s45_account_label_value_cells.xlsx": (_s45, to_xlsx),
|
||||
"s46_counterparty_code_blocks.xlsx": (_s46, to_xlsx),
|
||||
"s47_indent_hierarchy_code_sums.xlsx": (_s47, to_xlsx),
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
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)]
|
||||
|
||||
assert names == list(FILE_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
|
||||
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 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)
|
||||
|
||||
@ -52,6 +52,7 @@ FLAT_FORM_FILES = (
|
||||
"s44_subkonto_unnamed_10.xlsx",
|
||||
"s45_account_label_value_cells.xlsx",
|
||||
"s46_counterparty_code_blocks.xlsx",
|
||||
"s47_indent_hierarchy_code_sums.xlsx",
|
||||
)
|
||||
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
|
||||
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),
|
||||
# #71: выгрузка «по контрагентам» целиком: блоки субсчетов, коды контрагентов, договоры, «ИТОГ по …», «ИТОГ»
|
||||
"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
|
||||
|
||||
import pytest
|
||||
|
||||
from src.contract.common import DataType
|
||||
from src.parser.checks import TOLERANCE, balance_identity, parent_sums, run_checks, total_matches_root
|
||||
from src.parser.classify import ClassifiedRow, RowGroupType
|
||||
@ -27,6 +29,7 @@ def row( # noqa: PLR0913 - строка теста описывается по
|
||||
text: str = "строка",
|
||||
code: str | None = "60",
|
||||
indicator: bool = False,
|
||||
is_period: bool = False,
|
||||
) -> ClassifiedRow:
|
||||
body = BodyRow(
|
||||
row=number,
|
||||
@ -50,6 +53,7 @@ def row( # noqa: PLR0913 - строка теста описывается по
|
||||
data_type=data_type,
|
||||
extra=None,
|
||||
indicator=indicator,
|
||||
is_period=is_period,
|
||||
)
|
||||
|
||||
|
||||
@ -186,6 +190,176 @@ def test_expanded_parent_balance_must_match_by_columns() -> None:
|
||||
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:
|
||||
rows = [
|
||||
account(12, ("16", None, "135", "112", "39", None)),
|
||||
|
||||
@ -281,6 +281,46 @@ def test_account_subaccount_counterparty_contract(rules: Rules) -> None:
|
||||
assert parsed.levels.strategy == "outline"
|
||||
|
||||
|
||||
def test_period_group_rows_are_flagged(rules: Rules) -> None:
|
||||
"""AURORA-1823: третья группировка «Период» (строки «Обороты за <месяц>») — тип OTHER, но с признаком `is_period`;
|
||||
контрагент и счёт признака не несут."""
|
||||
# «Период» — третья строка блока заголовка (как в выгрузке 1С): подписи «Дебет/Кредит» объединены на две строки
|
||||
debit, credit = M("Дебет", 1, 2), M("Кредит", 1, 2)
|
||||
sheet = sheet_from_rows(
|
||||
[
|
||||
["ООО Ромашка"],
|
||||
[TITLE],
|
||||
[],
|
||||
["Счет", "ИНН", "КПП", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
|
||||
["Контрагенты", None, None, debit, credit, debit, credit, debit, credit],
|
||||
["Период"],
|
||||
r(0, "58", S100),
|
||||
r(1, "58.03", S100),
|
||||
r(2, "ООО Поставщик", S100, inn="7701234567"),
|
||||
r(3, "Обороты за Январь 25", S100),
|
||||
r(3, "Обороты за Февраль 25", S100),
|
||||
r(None, "Итого", S100),
|
||||
]
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
assert [(row.body.text, row.group_type, row.group_name, row.is_period) for row in parsed.rows] == [
|
||||
("58", "ACCOUNT", "Счет", False),
|
||||
("58.03", "ACCOUNT", "Счет", False),
|
||||
("ООО Поставщик", "COUNTERPARTY", "Контрагенты", False),
|
||||
("Обороты за Январь 25", "OTHER", "Период", True),
|
||||
("Обороты за Февраль 25", "OTHER", "Период", True),
|
||||
("Итого", "TOTAL", None, False),
|
||||
]
|
||||
|
||||
|
||||
def test_contract_group_is_not_period(rules: Rules) -> None:
|
||||
parsed = parse(_basic(), rules)
|
||||
|
||||
assert not any(row.is_period for row in parsed.rows)
|
||||
|
||||
|
||||
def test_parents_follow_tree(rules: Rules) -> None:
|
||||
parsed = parse(_basic(), rules)
|
||||
|
||||
|
||||
@ -47,6 +47,7 @@ def body( # noqa: PLR0913 - фабрика тестовых строк: все
|
||||
indent: int = 0,
|
||||
bold: bool = False,
|
||||
sums: tuple[Decimal | None, ...] = NO_SUMS,
|
||||
has_inn: bool = False,
|
||||
) -> BodyRow:
|
||||
match = ACCOUNT.fullmatch(text)
|
||||
rank = match.group(1).count(".") + 1 if match else None
|
||||
@ -54,11 +55,13 @@ def body( # noqa: PLR0913 - фабрика тестовых строк: все
|
||||
row=row,
|
||||
text=text,
|
||||
is_account=match is not None,
|
||||
account_code=match.group(1) if match else None,
|
||||
account_rank=rank,
|
||||
bold=bold,
|
||||
indent=indent,
|
||||
outline=outline,
|
||||
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_list(rows, levels.by_row) == [0, 0]
|
||||
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"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "expected"),
|
||||
[
|
||||
("Период", True),
|
||||
("период", True),
|
||||
(" Период ", True),
|
||||
("Периодичность", False),
|
||||
("Договоры", False),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_period_groups_are_data(rules: Rules, name: str | None, expected: bool) -> None:
|
||||
"""AURORA-1823: группировка «Период» (строки «Обороты за <месяц>») — из `period_groups` словаря, не из кода."""
|
||||
assert rules.groups.is_period_group(name) is expected
|
||||
|
||||
|
||||
def test_period_groups_dictionary_key_is_loaded(rules: Rules) -> None:
|
||||
assert [pattern.source for pattern in rules.groups.period_groups] == ["^период$"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "abc", "60.", "6 0"])
|
||||
def test_counterparty_account_code_must_be_digits_with_dots(tmp_path: Path, bad: str) -> None:
|
||||
target = _copy_rules(tmp_path)
|
||||
|
||||
@ -150,6 +150,26 @@ SHALL пропускаться с предупреждением файла `SHE
|
||||
- **THEN** `OSV_HIERARCHY_BY_INDENT`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по
|
||||
кодам счетов и жирности (`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 и отступов
|
||||
- **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`)
|
||||
- **THEN** `OSV_HIERARCHY_BY_ACCOUNT_PATTERN`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт
|
||||
@ -303,6 +323,25 @@ balance_identity_violations, parent_sum_violations}`. Результат про
|
||||
- **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 — аналитика только с оборотами
|
||||
- **WHEN** разбирается ОСВ по счёту 91: строки аналитики 91.01, 91.02, 91.09 несут только обороты, сальдо закрыто на
|
||||
субсчёт закрытия (`fixtures/osv-dkb/tsimlyansky/2025-Q3/91_ 9мес.2025.xls`)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user