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:
parent
30f78c42e7
commit
6fd7adde22
@ -49,6 +49,10 @@ service_columns:
|
||||
total_row:
|
||||
- '^итого\b'
|
||||
|
||||
# Вторая итоговая строка после «Итого» («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная, первая из них
|
||||
total_row_secondary:
|
||||
- '^итого\s+развернут'
|
||||
|
||||
# Метки шапки документа (область выше заголовка таблицы); ключи фиксированы
|
||||
header_markers:
|
||||
# «по счету 60 за …»: код счёта — сразу после совпадения
|
||||
|
||||
@ -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]]:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user