From 774141588eaf4c2556e16dc6d9bac5de5034fc81 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 12:08:58 +0400 Subject: [PATCH] =?UTF-8?q?fix(osv):=20=C2=AB=D0=98=D1=82=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B0=D0=B7=D0=B2=D0=B5=D1=80=D0=BD=D1=83=D1=82?= =?UTF-8?q?=D0=BE=D0=B5=C2=BB=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20=C2=AB?= =?UTF-8?q?=D0=98=D1=82=D0=BE=D0=B3=D0=BE=C2=BB=20=E2=80=94=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,=20=D1=81=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=C2=AB=D0=98=D1=82=D0=BE=D0=B3=D0=BE=C2=BB=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- api/src/parser/checks.py | 35 ++++++++++++++------- api/src/parser/classify.py | 14 +++++++++ api/src/parser/osv.py | 2 +- api/tests/synthetic/generate.py | 2 +- api/tests/synthetic/test_synthetic_files.py | 14 +++++++++ api/tests/unit/parser/test_checks.py | 18 ++++++++++- api/tests/unit/parser/test_classify.py | 30 ++++++++++++++++++ 7 files changed, 101 insertions(+), 14 deletions(-) diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 3d85650..4b9f399 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -32,7 +32,7 @@ from decimal import Decimal from src.contract.result import OsvChecks from src.parser.classify import SUM_FIELDS, ClassifiedRow from src.parser.osv_warnings import OSV_BALANCE_IDENTITY, OSV_PARENT_SUM, OSV_TOTAL, OsvNote -from src.parser.rules import OsvCheckRules +from src.parser.rules import OsvCheckRules, Pattern __all__ = [ "TOLERANCE", @@ -145,9 +145,16 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No ] -def _total(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None: - """Основная «Итого» листа — первая строка TOTAL без признака показателя (следующие за ней в сверку не входят).""" - return next((item for item in rows if item.group_type == "TOTAL" and not item.indicator), None) +def _total(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern] = ()) -> ClassifiedRow | None: + """Основная «Итого» листа — первая строка TOTAL без признака показателя, чей текст не матчит вторые итоговые. + + Строки-показатели (НУ, Кол., Вал. под «Итого») отсекаются первыми (#58). Вторая итоговая строка («Итого + развернутое», `secondary`) в сверку не входит, даже если стоит перед «Итого» (#59); если других TOTAL нет — + служит единственной итоговой. + """ + totals = [item for item in rows if item.group_type == "TOTAL" and not item.indicator] + main = next((item for item in totals if not any(p.search(item.body.text) for p in secondary)), None) + return main or next(iter(totals), None) def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None: @@ -174,13 +181,13 @@ def _sum_of(rows: Iterable[ClassifiedRow]) -> tuple[Decimal, ...]: return tuple(totals) -def total_matches_root(rows: Sequence[ClassifiedRow]) -> bool: +def total_matches_root(rows: Sequence[ClassifiedRow], secondary_totals: Sequence[Pattern] = ()) -> bool: """«Итого» = первая корневая строка ACCOUNT или сумма строк уровня 0 по шести колонкам (±0.01). Нет «Итого» — `False` (R-20); нет корня — `True`, только если все суммы «Итого» пусты или нулевые (R-13). Сумма уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов, «Итого» равно их сумме. """ - total = _total(rows) + total = _total(rows, secondary_totals) if total is None: return False root = _root(rows) @@ -202,8 +209,8 @@ def _rows_text(rows: Iterable[int]) -> str: return shown -def _total_warning(rows: Sequence[ClassifiedRow]) -> str: - total = _total(rows) +def _total_warning(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern] = ()) -> str: + total = _total(rows, secondary) root = _root(rows) if total is None: return TOTAL_NOT_FOUND_WARNING @@ -216,14 +223,20 @@ def _total_warning(rows: Sequence[ClassifiedRow]) -> str: return message -def run_checks(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> tuple[OsvChecks, list[OsvNote]]: +def run_checks( + rows: Sequence[ClassifiedRow], + exempt: OsvCheckRules | None = None, + *, + secondary_totals: Sequence[Pattern] = (), +) -> tuple[OsvChecks, list[OsvNote]]: """Три проверки §1.4 и заметки с кодами `OSV_*` и номерами строк (пустой список — всё сошлось). `exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью. + `secondary_totals` — вторые итоговые строки («Итого развернутое»): «Итого» для сверки — не они (#59). """ identity = balance_identity(rows, exempt) parents = parent_sums(rows, exempt) - matches = total_matches_root(rows) + matches = total_matches_root(rows, secondary_totals) warnings: list[OsvNote] = [] if identity: text = f"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±{TOLERANCE}): строки {_rows_text(identity)}" @@ -232,7 +245,7 @@ def run_checks(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = Non text = f"§1.4: суммы родителя ≠ сумме детей (±{TOLERANCE}): строки {_rows_text(parents)}" warnings.append(OsvNote(OSV_PARENT_SUM, text)) if not matches: - warnings.append(OsvNote(OSV_TOTAL, _total_warning(rows))) + warnings.append(OsvNote(OSV_TOTAL, _total_warning(rows, secondary_totals))) checks = OsvChecks( total_row_matches_root=matches, balance_identity_violations=identity, diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index eddb1a6..ae122d9 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -196,6 +196,19 @@ def _trailing_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], tot return last +def _leading_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], total: int, rules: Rules) -> int: + """Номер самой верхней из вторых итоговых строк сразу перед основной «Итого» (она сама, если их нет). + + Плоская форма 1С (#59): «Итого развернутое» стоит перед «Итого» — вместе они итоговый блок. + """ + first = total + for row in reversed(rows[: rows.index(total)]): + if not _is_secondary_total(sheet.text_at(row, header.text_col), rules): + break + first = row + return first + + def _split_collapsed(sums: dict[str, Decimal | None], columns: ColumnMap) -> None: """Свёрнутое сальдо (одна колонка, значение в `*_debit`): отрицательное — в `*_credit` по модулю (R-1).""" for group in columns.collapsed: @@ -313,6 +326,7 @@ def collect_body_rows( if tail: warnings.append(_tail_warning(tail)) rows = rows[: last + 1] + total_row = _leading_totals(sheet, header, rows, total_row, rules) label_cols = _label_cols(sheet, header, rows) body: list[BodyRow] = [] for index, row in enumerate(rows, start=1): diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index 3c0809c..9ed7de9 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -219,7 +219,7 @@ class OsvParser: glue = _check_inn_owners(glue, classified) doc = _with_table_account(doc, classified) deadline.check(f"лист «{sheet.name}»: контрольные суммы") - checks, check_notes = run_checks(classified, rules.checks) + checks, check_notes = run_checks(classified, rules.checks, secondary_totals=rules.headers.total_row_secondary) notes = [ hierarchy_note(levels.strategy), *notes_of(OSV_HEADER, doc.warnings), diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 7893503..b441dd9 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -938,7 +938,7 @@ def _s42() -> list[SheetSpec]: ("76.5", (None, "300.00", "700.00", "600.00", None, "200.00"), 0), ("76.Н", (None, None, "5000.00", "5000.00", None, None), 0), ("76.Н.1", (None, None, "5000.00", "5000.00", None, None), 0), - ("Итого развернутое", ("1000.00", "300.00", None, None, None, "200.00"), 0), + ("Итого развернутое", ("1000.00", "300.00", "5700.00", "6600.00", None, "200.00"), 0), ("Итого", ("700.00", None, "5700.00", "6600.00", None, "200.00"), 0), ] return [_flat_sheet(texts, 8, rows)] diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index 17bbd70..5a68d0c 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -704,6 +704,20 @@ def test_s31_rows_below_total_are_outside_the_document(fixtures_dir: Path) -> No assert any("ниже таблицы" in warning for warning in warnings_of(outcome)), warnings_of(outcome) +def test_s42_secondary_total_before_main_total(fixtures_dir: Path) -> None: + """#59: «Итого развернутое» перед «Итого» — обе TOTAL, сверка по основной «Итого», статус done.""" + outcome = parse_named(fixtures_dir, "s42_subaccounts_totals_reversed.xlsx") + doc = one_document(outcome) + + assert outcome.status is ResultStatus.DONE + assert doc.status == "done" + assert [row.group_type for row in doc.rows[-2:]] == ["TOTAL", "TOTAL"] + assert doc.checks is not None + assert doc.checks.total_row_matches_root is True + assert doc.checks.parent_sum_violations == [] + assert doc.checks.balance_identity_violations == [] + + def test_s32_total_with_suffix_is_total(fixtures_dir: Path) -> None: """I-1: нет точного «Итого» — итогом служит строка, начинающаяся с «Итого» («Итого по счету 60»).""" doc = one_document(parse_named(fixtures_dir, TOTAL_WITH_SUFFIX_FILE)) diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index f90f4bd..2571c78 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -10,7 +10,7 @@ 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 from src.parser.hierarchy import BodyRow -from src.parser.rules import OsvCheckRules +from src.parser.rules import OsvCheckRules, Pattern Sums = tuple[str | None, str | None, str | None, str | None, str | None, str | None] EMPTY: Sums = (None, None, None, None, None, None) @@ -241,6 +241,22 @@ def test_rows_without_children_are_not_parents() -> None: assert parent_sums(rows) == [] +def test_total_check_uses_main_total_when_secondary_comes_first() -> None: + """Плоская форма (#59): «Итого развернутое» перед «Итого» — сверка по основной «Итого».""" + secondary = [Pattern.compile(r"^итого\s+развернут")] + rows = [ + account(7, ROOT), + row(8, ("1", None, None, None, None, "1"), group_type="TOTAL", text="Итого развернутое"), + total(9, ROOT), + ] + + assert total_matches_root(rows, secondary) is True + assert total_matches_root(rows) is False + checks, notes = run_checks(rows, secondary_totals=secondary) + assert checks.total_row_matches_root is True + assert not any(note.code == "OSV_TOTAL" for note in notes) + + def test_total_matches_first_root_account() -> None: rows = tree() diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index e120156..a1d780c 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -1112,3 +1112,33 @@ def test_row_without_label_takes_document_data_type(rules: Rules) -> None: "60": "NU", "ООО Поставщик": "NU", } + + +def test_secondary_total_before_main_total_is_in_total_block(rules: Rules) -> None: + """Плоская форма (#59): «Итого развернутое» стоит перед «Итого» — обе строки итоговый блок, `total_row` — первая.""" + sheet = osv( + r(0, "60.01", S100), + r(0, "60.02", S100), + r(None, "Итого развернутое", (1, None, 2, 3, 4, None)), + r(None, "Итого", (200, None, 100, 60, 240, None)), + ) + + parsed = parse(sheet, rules) + + secondary, main = parsed.one("Итого развернутое"), parsed.one("Итого") + assert parsed.total_row == secondary.body.row + assert [row.group_type for row in (secondary, main)] == ["TOTAL", "TOTAL"] + assert [row.level for row in (secondary, main)] == [0, 0] + assert [row.parent for row in (secondary, main)] == [None, None] + assert parsed.one("60.02").group_type != "TOTAL" + + +def test_only_secondary_total_is_the_total(rules: Rules) -> None: + """Review Focus 3: без обычной «Итого» «Итого развернутое» — единственная итоговая строка.""" + sheet = osv(r(0, "60", S100), r(None, "Итого развернутое", S100)) + + parsed = parse(sheet, rules) + + secondary = parsed.one("Итого развернутое") + assert parsed.total_row == secondary.body.row + assert secondary.group_type == "TOTAL"