From 6fd7adde22ab2d9635c646caa1600bd05653b5d4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 12:50:35 +0400 Subject: [PATCH] =?UTF-8?q?feat(osv):=20=C2=AB=D0=98=D1=82=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=C2=BB=20=D1=81=D0=B2=D0=B5=D1=80=D1=8F=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D1=81=20=D1=81=D1=83=D0=BC=D0=BC=D0=BE=D0=B9=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D1=8F?= =?UTF-8?q?=200;=20=C2=AB=D0=98=D1=82=D0=BE=D0=B3=D0=BE=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B2=D0=B5=D1=80=D0=BD=D1=83=D1=82=D0=BE=D0=B5=C2=BB=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=D1=82=D0=BE=D1=80=D0=B0=D1=8F=20=D0=B8=D1=82?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=D0=B2=D0=B0=D1=8F=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- api/rules/osv_headers.yaml | 4 ++ api/src/parser/checks.py | 44 +++++++++++++++---- api/src/parser/classify.py | 39 +++++++++++++---- api/src/parser/hierarchy.py | 10 +++-- api/src/parser/rules.py | 1 + api/tests/unit/parser/test_checks.py | 58 ++++++++++++++++++++++++++ api/tests/unit/parser/test_classify.py | 46 ++++++++++++++++++++ 7 files changed, 182 insertions(+), 20 deletions(-) diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml index bb7acf5..23e31ee 100644 --- a/api/rules/osv_headers.yaml +++ b/api/rules/osv_headers.yaml @@ -49,6 +49,10 @@ service_columns: total_row: - '^итого\b' +# Вторая итоговая строка после «Итого» («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная, первая из них +total_row_secondary: + - '^итого\s+развернут' + # Метки шапки документа (область выше заголовка таблицы); ключи фиксированы header_markers: # «по счету 60 за …»: код счёта — сразу после совпадения diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 2090570..265da86 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -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]]: diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 3cb56ea..61dd630 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -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 diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index ab8310f..1ce8a1e 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -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) diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index 603aab5..00f0e3a 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -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") diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index 5910693..680dd41 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -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 diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index e310b52..1b1bf6d 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -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(