fix(osv): «Итого развернутое» перед «Итого» — итоговая строка, сверка по основной «Итого» (#59)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 12:08:58 +04:00
parent 8dd8a50868
commit 774141588e
7 changed files with 101 additions and 14 deletions

View File

@ -32,7 +32,7 @@ from decimal import Decimal
from src.contract.result import OsvChecks from src.contract.result import OsvChecks
from src.parser.classify import SUM_FIELDS, ClassifiedRow 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.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__ = [ __all__ = [
"TOLERANCE", "TOLERANCE",
@ -145,9 +145,16 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No
] ]
def _total(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None: def _total(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern] = ()) -> ClassifiedRow | None:
"""Основная «Итого» листа — первая строка TOTAL без признака показателя (следующие за ней в сверку не входят).""" """Основная «Итого» листа — первая строка TOTAL без признака показателя, чей текст не матчит вторые итоговые.
return next((item for item in rows if item.group_type == "TOTAL" and not item.indicator), None)
Строки-показатели (НУ, Кол., Вал. под «Итого») отсекаются первыми (#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: def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
@ -174,13 +181,13 @@ def _sum_of(rows: Iterable[ClassifiedRow]) -> tuple[Decimal, ...]:
return tuple(totals) 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). """«Итого» = первая корневая строка ACCOUNT или сумма строк уровня 0 по шести колонкам (±0.01).
Нет «Итого» — `False` (R-20); нет корня — `True`, только если все суммы «Итого» пусты или нулевые (R-13). Сумма Нет «Итого» — `False` (R-20); нет корня — `True`, только если все суммы «Итого» пусты или нулевые (R-13). Сумма
уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов, «Итого» равно их сумме. уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов, «Итого» равно их сумме.
""" """
total = _total(rows) total = _total(rows, secondary_totals)
if total is None: if total is None:
return False return False
root = _root(rows) root = _root(rows)
@ -202,8 +209,8 @@ def _rows_text(rows: Iterable[int]) -> str:
return shown return shown
def _total_warning(rows: Sequence[ClassifiedRow]) -> str: def _total_warning(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern] = ()) -> str:
total = _total(rows) total = _total(rows, secondary)
root = _root(rows) root = _root(rows)
if total is None: if total is None:
return TOTAL_NOT_FOUND_WARNING return TOTAL_NOT_FOUND_WARNING
@ -216,14 +223,20 @@ def _total_warning(rows: Sequence[ClassifiedRow]) -> str:
return message 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_*` и номерами строк (пустой список — всё сошлось). """Три проверки §1.4 и заметки с кодами `OSV_*` и номерами строк (пустой список — всё сошлось).
`exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью. `exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью.
`secondary_totals` — вторые итоговые строки («Итого развернутое»): «Итого» для сверки — не они (#59).
""" """
identity = balance_identity(rows, exempt) identity = balance_identity(rows, exempt)
parents = parent_sums(rows, exempt) parents = parent_sums(rows, exempt)
matches = total_matches_root(rows) matches = total_matches_root(rows, secondary_totals)
warnings: list[OsvNote] = [] warnings: list[OsvNote] = []
if identity: if identity:
text = f"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±{TOLERANCE}): строки {_rows_text(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)}" text = f"§1.4: суммы родителя ≠ сумме детей (±{TOLERANCE}): строки {_rows_text(parents)}"
warnings.append(OsvNote(OSV_PARENT_SUM, text)) warnings.append(OsvNote(OSV_PARENT_SUM, text))
if not matches: if not matches:
warnings.append(OsvNote(OSV_TOTAL, _total_warning(rows))) warnings.append(OsvNote(OSV_TOTAL, _total_warning(rows, secondary_totals)))
checks = OsvChecks( checks = OsvChecks(
total_row_matches_root=matches, total_row_matches_root=matches,
balance_identity_violations=identity, balance_identity_violations=identity,

View File

@ -196,6 +196,19 @@ def _trailing_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], tot
return last 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: def _split_collapsed(sums: dict[str, Decimal | None], columns: ColumnMap) -> None:
"""Свёрнутое сальдо (одна колонка, значение в `*_debit`): отрицательное — в `*_credit` по модулю (R-1).""" """Свёрнутое сальдо (одна колонка, значение в `*_debit`): отрицательное — в `*_credit` по модулю (R-1)."""
for group in columns.collapsed: for group in columns.collapsed:
@ -313,6 +326,7 @@ def collect_body_rows(
if tail: if tail:
warnings.append(_tail_warning(tail)) warnings.append(_tail_warning(tail))
rows = rows[: last + 1] rows = rows[: last + 1]
total_row = _leading_totals(sheet, header, rows, total_row, rules)
label_cols = _label_cols(sheet, header, rows) label_cols = _label_cols(sheet, header, rows)
body: list[BodyRow] = [] body: list[BodyRow] = []
for index, row in enumerate(rows, start=1): for index, row in enumerate(rows, start=1):

View File

@ -219,7 +219,7 @@ class OsvParser:
glue = _check_inn_owners(glue, classified) glue = _check_inn_owners(glue, classified)
doc = _with_table_account(doc, classified) doc = _with_table_account(doc, classified)
deadline.check(f"лист «{sheet.name}»: контрольные суммы") 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 = [ notes = [
hierarchy_note(levels.strategy), hierarchy_note(levels.strategy),
*notes_of(OSV_HEADER, doc.warnings), *notes_of(OSV_HEADER, doc.warnings),

View File

@ -938,7 +938,7 @@ def _s42() -> list[SheetSpec]:
("76.5", (None, "300.00", "700.00", "600.00", None, "200.00"), 0), ("76.5", (None, "300.00", "700.00", "600.00", None, "200.00"), 0),
("76.Н", (None, None, "5000.00", "5000.00", None, None), 0), ("76.Н", (None, None, "5000.00", "5000.00", None, None), 0),
("76.Н.1", (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), ("Итого", ("700.00", None, "5700.00", "6600.00", None, "200.00"), 0),
] ]
return [_flat_sheet(texts, 8, rows)] return [_flat_sheet(texts, 8, rows)]

View File

@ -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) 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: def test_s32_total_with_suffix_is_total(fixtures_dir: Path) -> None:
"""I-1: нет точного «Итого» — итогом служит строка, начинающаяся с «Итого» («Итого по счету 60»).""" """I-1: нет точного «Итого» — итогом служит строка, начинающаяся с «Итого» («Итого по счету 60»)."""
doc = one_document(parse_named(fixtures_dir, TOTAL_WITH_SUFFIX_FILE)) doc = one_document(parse_named(fixtures_dir, TOTAL_WITH_SUFFIX_FILE))

View File

@ -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.checks import TOLERANCE, balance_identity, parent_sums, run_checks, total_matches_root
from src.parser.classify import ClassifiedRow, RowGroupType from src.parser.classify import ClassifiedRow, RowGroupType
from src.parser.hierarchy import BodyRow 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] Sums = tuple[str | None, str | None, str | None, str | None, str | None, str | None]
EMPTY: Sums = (None, None, None, None, None, 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) == [] 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: def test_total_matches_first_root_account() -> None:
rows = tree() rows = tree()

View File

@ -1112,3 +1112,33 @@ def test_row_without_label_takes_document_data_type(rules: Rules) -> None:
"60": "NU", "60": "NU",
"ООО Поставщик": "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"