fix(osv): «Итого развернутое» перед «Итого» — итоговая строка, сверка по основной «Итого» (#59)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8dd8a50868
commit
774141588e
@ -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,
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user