Плоская форма ОСВ 1С: счёт из шапки, «Итого развернутое», плоский список, контрагенты расчётных счетов (#59) #63

Merged
aep merged 16 commits from feat/59-osv-flat-form into main 2026-10-19 13:21:55 +03:00
7 changed files with 101 additions and 14 deletions
Showing only changes of commit 774141588e - Show all commits

View File

@ -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,

View File

@ -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):

View File

@ -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),

View File

@ -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)]

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)
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))

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.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()

View File

@ -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"