feat(osv): «Итого» сверяется с суммой строк уровня 0; «Итого развернутое» — вторая итоговая строка

- checks: «Итого» = первый корень ACCOUNT либо сумма всех строк уровня 0 (лист без строки корневого счёта,
  верхний уровень 58.01/58.03; OQ-106, OQ-133). Прежнее правило «= первому корню» остаётся достаточным;
  строки-показатели в сумму не входят; основная «Итого» — первая TOTAL листа.
- classify/hierarchy: вторые итоговые строки сразу за «Итого» (osv_headers.yaml: total_row_secondary) остаются
  в таблице как TOTAL уровня 0, а не уходят в OTHER; без обычной «Итого» вторая служит единственной итоговой.

Refs #11

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
Andrey Epifancev 2026-10-06 12:50:35 +04:00
parent 30f78c42e7
commit 6fd7adde22
7 changed files with 182 additions and 20 deletions

View File

@ -49,6 +49,10 @@ service_columns:
total_row:
- '^итого\b'
# Вторая итоговая строка после «Итого» («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная, первая из них
total_row_secondary:
- '^итого\s+развернут'
# Метки шапки документа (область выше заголовка таблицы); ключи фиксированы
header_markers:
# «по счету 60 за …»: код счёта — сразу после совпадения

View File

@ -9,8 +9,10 @@
по колонкам (развёрнутое сальдо) либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто
`дебет − кредит`: 1С показывает счёт свёрнутым сальдо, а субсчета и контрагентов — развёрнутым (так во всех
реальных файлах, где у счёта есть субсчета с разными сторонами сальдо);
3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0); корня нет (пустая ОСВ, R-13) — «Итого»
должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна, результат `false`.
3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их
несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133); корня нет
(пустая ОСВ, R-13) — «Итого» должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна,
результат `false`. Строки-показатели (валюта, количество) в сумму уровня 0 не входят: это другие единицы.
Любое нарушение делает результат `needs_check` (решает оркестратор `osv.py`); парсер структуру под сходимость не
подгоняет. Номера строк — в `OsvChecks`, причины — тексты предупреждений.
@ -111,7 +113,8 @@ def parent_sums(rows: Sequence[ClassifiedRow]) -> list[int]:
def _total(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
return next((item for item in reversed(rows) if item.group_type == "TOTAL"), None)
"""Основная «Итого» листа — первая строка TOTAL (вторая итоговая строка идёт за ней и в сверку не входит)."""
return next((item for item in rows if item.group_type == "TOTAL"), None)
def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
@ -121,10 +124,28 @@ def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
)
def total_matches_root(rows: Sequence[ClassifiedRow]) -> bool:
"""«Итого» = первая корневая строка ACCOUNT по шести колонкам (±0.01).
def _level_zero(rows: Sequence[ClassifiedRow]) -> list[ClassifiedRow]:
"""Строки уровня 0 без родителя, кроме «Итого» и строк-показателей, в порядке листа."""
return [
item
for item in rows
if item.level == _ROOT_LEVEL and item.parent is None and item.group_type != "TOTAL" and not _is_indicator(item)
]
Нет «Итого» — `False` (R-20); нет корня — `True`, только если все суммы «Итого» пусты или нулевые (R-13).
def _sum_of(rows: Iterable[ClassifiedRow]) -> tuple[Decimal, ...]:
totals = [_ZERO] * len(SUM_FIELDS)
for item in rows:
for index, value in enumerate(_values(item)):
totals[index] += value
return tuple(totals)
def total_matches_root(rows: Sequence[ClassifiedRow]) -> bool:
"""«Итого» = первая корневая строка ACCOUNT или сумма строк уровня 0 по шести колонкам (±0.01).
Нет «Итого» — `False` (R-20); нет корня — `True`, только если все суммы «Итого» пусты или нулевые (R-13). Сумма
уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов, «Итого» равно их сумме.
"""
total = _total(rows)
if total is None:
@ -132,7 +153,10 @@ def total_matches_root(rows: Sequence[ClassifiedRow]) -> bool:
root = _root(rows)
if root is None:
return all(abs(value) <= TOLERANCE for value in _values(total))
return not _differs(_values(total), _values(root))
if not _differs(_values(total), _values(root)):
return True
roots = _level_zero(rows)
return len(roots) > 1 and not _differs(_values(total), _sum_of(roots))
def _rows_text(rows: Iterable[int]) -> str:
@ -150,7 +174,11 @@ def _total_warning(rows: Sequence[ClassifiedRow]) -> str:
return TOTAL_NOT_FOUND_WARNING
if root is None:
return f"§1.4: строка «Итого» (строка {total.body.row}) не пуста, а корневого счёта нет"
return f"§1.4: строка «Итого» (строка {total.body.row}) ≠ корневому счёту (строка {root.body.row})"
message = f"§1.4: строка «Итого» (строка {total.body.row}) ≠ корневому счёту (строка {root.body.row})"
level_zero = _level_zero(rows)
if len(level_zero) > 1:
return f"{message} и ≠ сумме {len(level_zero)} строк уровня 0"
return message
def run_checks(rows: Sequence[ClassifiedRow]) -> tuple[OsvChecks, list[str]]:

View File

@ -112,6 +112,8 @@ def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: R
строкой «Итого»), поэтому «корневой уровень» проверяется по оформлению: outline level и отступ строки не больше,
чем у любой другой строки таблицы. Так контрагент «Итого-Сервис» в середине данных не TOTAL (за ним есть настоящее
«Итого»), а последний «Итого-Сервис» под счётом с outline/отступом — тоже не TOTAL (T3.3).
Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — «Итого» перед ней;
без обычной «Итого» вторая строка служит единственной итоговой.
Известный риск: на листе без outline и отступов последняя строка-контрагент «Итого-…» при отсутствии настоящего
«Итого» станет TOTAL; это ловит проверка «TOTAL = корень» (§1.4).
"""
@ -119,14 +121,33 @@ def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: R
return None
outline_floor = min(sheet.outline(row) for row in rows)
indent_floor = min(_text_cell_style(sheet, row, header.text_col)[1] for row in rows)
only_secondary: int | None = None
for row in reversed(rows):
text = sheet.text_at(row, header.text_col)
if not any(pattern.search(text) for pattern in rules.headers.total_row):
continue
_, indent = _text_cell_style(sheet, row, header.text_col)
is_root = sheet.outline(row) == outline_floor and indent == indent_floor
return row if is_root else None
return None
if sheet.outline(row) != outline_floor or indent != indent_floor:
return only_secondary
if _is_secondary_total(text, rules):
only_secondary = only_secondary or row
continue
return row
return only_secondary
def _is_secondary_total(text: str, rules: Rules) -> bool:
return any(pattern.search(text) for pattern in rules.headers.total_row_secondary)
def _trailing_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], total: int, rules: Rules) -> int:
"""Номер последней из вторых итоговых строк, идущих в таблице сразу за основной «Итого» (она сама, если их нет)."""
last = total
for row in rows[rows.index(total) + 1 :]:
if not _is_secondary_total(sheet.text_at(row, header.text_col), rules):
break
last = row
return last
def _split_collapsed(sums: dict[str, Decimal | None], columns: ColumnMap) -> None:
@ -179,7 +200,8 @@ def collect_body_rows(
) -> tuple[list[BodyRow], int | None, list[str]]:
"""Строки данных листа (включая «Итого»), номер строки «Итого» (`None` — не найдена) и предупреждения.
Строки после «Итого» — подвал (подписи, числа правее таблицы): ни в данные, ни в `extra` они не попадают (T4.5).
Строки после «Итого» — подвал (подписи, числа правее таблицы): ни в данные, ни в `extra` они не попадают (T4.5);
исключение — вторые итоговые строки («Итого развернутое») сразу за «Итого»: они остаются в таблице как TOTAL.
"""
warnings: list[str] = []
if header.columns.collapsed:
@ -187,7 +209,7 @@ def collect_body_rows(
rows = _table_rows(sheet, header, rules, deadline)
total_row = _find_total(sheet, header, rows, rules)
if total_row is not None:
rows = rows[: rows.index(total_row) + 1]
rows = rows[: rows.index(_trailing_totals(sheet, header, rows, total_row, rules)) + 1]
body: list[BodyRow] = []
for index, row in enumerate(rows, start=1):
if index % DEADLINE_CHECK_EVERY_ROWS == 0:
@ -300,8 +322,8 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
) -> list[ClassifiedRow]:
"""Классификация строк по дереву `levels` и схеме группировок.
- «Итого» (`total_row`) — TOTAL, уровень 0 без родителя; в `rows` остаётся (# ASSUMPTION(OQ-01): хранить ли
«Итого», решает ядро);
- «Итого» (`total_row`) и вторые итоговые строки после неё («Итого развернутое») — TOTAL, уровень 0 без родителя;
в `rows` остаются (# ASSUMPTION(OQ-01): хранить ли «Итого», решает ядро);
- счёт (`ACCOUNT_RE`) — ACCOUNT, `account_code_raw` — код как в файле; дети наследуют код ветки;
- строка-показатель («Вал.», «Кол.», код валюты) — `data_type` CURRENCY/QUANTITY, группа OTHER: это не уровень
группировки, а вид сумм родителя; позицию в схеме она не занимает;
@ -313,7 +335,8 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
branches: dict[int, _Branch] = {}
result: list[ClassifiedRow] = []
for item in rows:
if item.row == total_row:
if total_row is not None and item.row >= total_row:
# Всё от основной «Итого» и ниже — итоговые строки (подвал `collect_body_rows` уже отрезал)
# ASSUMPTION(OQ-01): строка «Итого» остаётся в rows; хранить ли её, решает ядро
kind, branch = _Kind("TOTAL", None, None, doc_data_type), _ROOT_BRANCH
level, parent = 0, None

View File

@ -268,11 +268,13 @@ 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) -> Levels:
"""Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`.
«Итого» (`total_row`) в стратегии не передаётся и получает уровень 0: это контрольная сумма документа, а не
ребёнок последнего счёта. Меньше двух строк, кроме «Итого», — тривиальное дерево корней `TRIVIAL_STRATEGY`.
«Итого» (`total_row`) и строки после неё (вторые итоговые) в стратегии не передаются и получают уровень 0: это
контрольная сумма документа, а не ребёнок последнего счёта. Меньше двух строк, кроме «Итого», — тривиальное дерево корней `TRIVIAL_STRATEGY`.
"""
body = [item for item in rows if item.row != total_row]
pinned = {} if total_row is None else {total_row: ROOT_LEVEL}
# «Итого» и вторые итоговые строки за ней («Итого развернутое») — не данные: уровень 0 без участия стратегий
totals = [] if total_row is None else [item for item in rows if item.row >= total_row]
body = [item for item in rows if total_row is None or item.row < total_row]
pinned = {item.row: ROOT_LEVEL for item in totals}
if len(body) < MIN_ROWS_FOR_CASCADE:
by_row = {item.row: ROOT_LEVEL for item in rows}
return Levels(by_row=by_row, parent=parents(rows, by_row), strategy=TRIVIAL_STRATEGY)

View File

@ -136,6 +136,7 @@ class OsvHeaderRules(_Model):
credit: list[Pattern]
service_columns: dict[str, list[Pattern]]
total_row: list[Pattern]
total_row_secondary: list[Pattern]
header_markers: dict[HeaderMarker, list[Pattern]]
@model_validator(mode="after")

View File

@ -228,6 +228,64 @@ def test_total_matches_first_root_account() -> None:
assert total_matches_root(rows) is False
# Лист без строки корневого счёта (вариант 2, issue #11 п. 3, OQ-106/OQ-133): верхний уровень — субсчета 58.01, 58.03;
# «Итого» равно сумме строк уровня 0
SUB_A: Sums = ("100", None, "10", "0", "110", None)
SUB_B: Sums = ("40", None, "30", "20", "50", None)
SUB_SUM: Sums = ("140", None, "40", "20", "160", None)
def multi_root_tree(total_sums: Sums = SUB_SUM) -> list[ClassifiedRow]:
return [
account(7, SUB_A),
row(8, SUB_A, level=1, parent=7),
account(9, SUB_B),
row(10, SUB_B, level=1, parent=9),
total(11, total_sums),
]
def test_total_matches_sum_of_level_zero_rows_when_there_are_several_roots() -> None:
rows = multi_root_tree()
checks, warnings = run_checks(rows)
assert total_matches_root(rows) is True
assert checks.total_row_matches_root is True
assert warnings == []
def test_total_that_equals_neither_first_root_nor_sum_of_roots_mismatches() -> None:
rows = multi_root_tree(("140", None, "40", "20", "161", None))
checks, warnings = run_checks(rows)
assert checks.total_row_matches_root is False
assert any("Итого" in warning and "11" in warning and "сумме 2 строк уровня 0" in warning for warning in warnings)
def test_sum_of_roots_ignores_indicator_rows_and_total_itself() -> None:
rows = multi_root_tree()
rows.insert(2, row(20, ("5", None, None, None, "5", None), group_type="OTHER", data_type="CURRENCY"))
assert total_matches_root(rows) is True
def test_total_still_matches_single_root_even_if_other_level_zero_rows_exist() -> None:
"""Прежнее поведение сохраняется: «Итого» = первый корень достаточно, лишние строки уровня 0 статус не портят."""
rows = tree()
rows.insert(3, row(9, ("1", None, None, None, "1", None), group_type="OTHER"))
assert total_matches_root(rows) is True
def test_second_total_row_does_not_replace_the_first_one() -> None:
"""Вторая итоговая строка («Итого развернутое») в сверку с корнем не входит: берётся первая TOTAL листа."""
rows = [*tree(), total(11, ("1", None, None, None, "1", None))]
assert total_matches_root(rows) is True
def test_empty_osv_without_root_matches_when_total_is_empty_or_zero() -> None:
assert total_matches_root([total(8, EMPTY)]) is True
assert total_matches_root([total(8, ("0", None, "0.00", None, None, "0"))]) is True

View File

@ -567,6 +567,52 @@ def test_total_without_sums_in_account_pattern_sheet_stays_root(rules: Rules) ->
assert unpinned.by_row[total.body.row] > 0
def test_second_total_row_after_total_is_total_too(rules: Rules) -> None:
"""Issue #11 п. 5: «Итого развернутое» после «Итого» — вторая итоговая строка (TOTAL, уровень 0), не OTHER."""
sheet = osv(
r(0, "60.01", S100),
r(1, "ООО Поставщик", S100, inn="7701234567"),
r(None, "Итого", S100),
r(None, "Итого развернутое", S100),
[],
["Главный бухгалтер", None, "Иванова"],
)
parsed = parse(sheet, rules)
assert [row.body.text for row in parsed.rows] == ["60.01", "ООО Поставщик", "Итого", "Итого развернутое"]
assert parsed.total_row == parsed.one("Итого").body.row # основная — первая из подряд идущих итоговых
for text in ("Итого", "Итого развернутое"):
total = parsed.one(text)
assert (total.group_type, total.level, total.parent) == ("TOTAL", 0, None)
def test_second_total_row_alone_serves_as_total(rules: Rules) -> None:
"""Если обычной «Итого» нет, «Итого развернутое» — единственная итоговая строка и сверяется с корнем."""
sheet = osv(
r(0, "60", S100),
r(1, "ООО Поставщик", S100, inn="7701234567"),
r(None, "Итого развернутое", S100),
)
parsed = parse(sheet, rules)
assert parsed.total_row == parsed.one("Итого развернутое").body.row
assert parsed.one("Итого развернутое").group_type == "TOTAL"
def test_text_after_total_that_is_not_a_second_total_stays_footer(rules: Rules) -> None:
sheet = osv(
r(0, "60", S100),
r(None, "Итого", S100),
r(None, "Руководитель", S100),
)
parsed = parse(sheet, rules)
assert [row.body.text for row in parsed.rows] == ["60", "Итого"]
def test_counterparty_named_like_total_in_the_middle(rules: Rules) -> None:
"""T3.3: «Итого-Сервис» в середине данных — контрагент; TOTAL — последняя строка «Итого»."""
sheet = osv(