Итог ищется по точному «Итого» раньше «Итого …»; хвост — только числа в колонках таблицы (#47, AURORA-1664)

I-1: _find_total сначала берёт первую строку корневого уровня с текстом ровно «Итого»
(новый ключ total_row_exact в osv_headers.yaml) и лишь без неё — первую по total_row.
Контрагент «Итого-Сервис» на плоском листе без outline и отступов больше не обрезает таблицу.
O-3: предупреждение о хвосте считает числа только в колонках таблицы (text_col…right_edge
из TableHeader); контрольные суммы правее таблицы (rb/2024-Q1) его не дают, ослабление
в test_classify_real откачено. Синтетика s32: итог «Итого по счету 60» без точного «Итого»
(нужна тесту покрытия для запасного паттерна total_row).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-18 12:04:48 +04:00
parent 6efe79fec9
commit 195720c97b
8 changed files with 103 additions and 31 deletions

View File

@ -70,7 +70,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают. обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают.
- Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле - Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле
(`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой. (`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой.
- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 126 файлов: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 31 синтетический) — регрессия, а не - Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 127 файлов: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 32 синтетических) — регрессия, а не
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
проверяют property-тест и ручная сверка «Итого». проверяют property-тест и ручная сверка «Итого».
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не - Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не

View File

@ -42,6 +42,11 @@ service_columns:
total_row: total_row:
- '^итого\b' - '^итого\b'
# Итоговая строка, чей текст — ровно «Итого»: ищется первой, чтобы контрагент «Итого-Сервис» на плоском листе (без outline
# и отступов) не обрезал таблицу; нет такой — действует `total_row` (начало текста)
total_row_exact:
- '^итого$'
# Вторая итоговая строка после «Итого» («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная, первая из них # Вторая итоговая строка после «Итого» («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная, первая из них
total_row_secondary: total_row_secondary:
- '^итого\s+развернут' - '^итого\s+развернут'

View File

@ -118,7 +118,9 @@ def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: R
"""Строка «Итого»: первая строка таблицы, чей текст матчит `total_row`, на корневом уровне оформления. """Строка «Итого»: первая строка таблицы, чей текст матчит `total_row`, на корневом уровне оформления.
Таблица кончается на первой «Итого» (AURORA-1664): всё ниже — вне документа, даже если там снова строки «Итого …» Таблица кончается на первой «Итого» (AURORA-1664): всё ниже — вне документа, даже если там снова строки «Итого …»
(аналитик дописывает под ОСВ подпись и свои блоки ГК/ГСК с итогами). Суммы не требуются (R-13: пустая ОСВ — (аналитик дописывает под ОСВ подпись и свои блоки ГК/ГСК с итогами). Сначала ищется первая строка корневого уровня,
чей текст — ровно «Итого» (`total_row_exact`), и только если такой нет — первая по `total_row` («Итого …»; I-1:
иначе контрагент «Итого-Сервис» на плоском листе обрезал бы таблицу). Суммы не требуются (R-13: пустая ОСВ —
«Итого» без сумм). Выбор — до уровней (их каскад считает с уже известной строкой «Итого»), поэтому «корневой «Итого» без сумм). Выбор — до уровней (их каскад считает с уже известной строкой «Итого»), поэтому «корневой
уровень» проверяется по оформлению: outline level и отступ строки не больше, чем у любой другой строки таблицы. уровень» проверяется по оформлению: outline level и отступ строки не больше, чем у любой другой строки таблицы.
Так контрагент «Итого-Сервис» под счётом с outline/отступом — не TOTAL (T3.3), а настоящее «Итого» ниже него — Так контрагент «Итого-Сервис» под счётом с outline/отступом — не TOTAL (T3.3), а настоящее «Итого» ниже него —
@ -126,30 +128,42 @@ def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: R
Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — обычная «Итого» Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — обычная «Итого»
(в плоской форме она может стоять после «Итого развернутое»); без обычной «Итого» первая вторая строка служит (в плоской форме она может стоять после «Итого развернутое»); без обычной «Итого» первая вторая строка служит
единственной итоговой. единственной итоговой.
Известный риск: на листе без outline и отступов контрагент «Итого-…» на корневом уровне становится TOTAL; это ловит Остаточный риск: если точного «Итого» нет вовсе, а на листе нет outline и отступов, контрагент «Итого-…» на корневом
проверка «TOTAL = корень» (§1.4). уровне становится TOTAL; это ловит проверка «TOTAL = корень» (§1.4).
""" """
if not rows: if not rows:
return None return None
outline_floor = min(sheet.outline(row) for row in rows) 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) indent_floor = min(_text_cell_style(sheet, row, header.text_col)[1] for row in rows)
roots = [
row
for row in rows
if sheet.outline(row) == outline_floor and _text_cell_style(sheet, row, header.text_col)[1] == indent_floor
]
for patterns in (rules.headers.total_row_exact, rules.headers.total_row):
only_secondary: int | None = None only_secondary: int | None = None
for row in rows: for row in roots:
text = sheet.text_at(row, header.text_col) text = sheet.text_at(row, header.text_col)
if not any(pattern.search(text) for pattern in rules.headers.total_row): if not any(pattern.search(text) for pattern in patterns):
continue
_, indent = _text_cell_style(sheet, row, header.text_col)
if sheet.outline(row) != outline_floor or indent != indent_floor:
continue continue
if not _is_secondary_total(text, rules): if not _is_secondary_total(text, rules):
return row return row
only_secondary = only_secondary or row only_secondary = only_secondary or row
if only_secondary is not None:
return only_secondary return only_secondary
return None
def _numbers_below(sheet: Sheet, last_row: int) -> list[int]: def _numbers_below(sheet: Sheet, header: TableHeader, last_row: int) -> list[int]:
"""Строки ниже `last_row` (по всему листу, в любых колонках) с числовыми ячейками: хвост вне документа.""" """Строки ниже `last_row` с числовыми ячейками в колонках таблицы (от колонки текста до правого края сумм):
return sorted({row for (row, _), cell in sheet.cells.items() if row > last_row and cell.kind == "number"}) хвост вне документа. Числа правее таблицы (контрольные суммы) хвостом не считаются."""
return sorted(
{
row
for (row, col), cell in sheet.cells.items()
if row > last_row and cell.kind == "number" and header.text_col <= col <= header.right_edge
}
)
def _tail_warning(rows: Sequence[int]) -> str: def _tail_warning(rows: Sequence[int]) -> str:
@ -281,7 +295,7 @@ def collect_body_rows(
) )
if total_row is not None: if total_row is not None:
last = rows.index(_trailing_totals(sheet, header, rows, total_row, rules)) last = rows.index(_trailing_totals(sheet, header, rows, total_row, rules))
tail = _numbers_below(sheet, rows[last]) tail = _numbers_below(sheet, header, rows[last])
if tail: if tail:
warnings.append(_tail_warning(tail)) warnings.append(_tail_warning(tail))
rows = rows[: last + 1] rows = rows[: last + 1]

View File

@ -179,6 +179,7 @@ class OsvHeaderRules(_Model):
credit: list[Pattern] credit: list[Pattern]
service_columns: dict[str, list[Pattern]] service_columns: dict[str, list[Pattern]]
total_row: list[Pattern] total_row: list[Pattern]
total_row_exact: list[Pattern]
total_row_secondary: list[Pattern] total_row_secondary: list[Pattern]
column_captions: list[Pattern] column_captions: list[Pattern]
counterparty_inn_suffix: list[Pattern] counterparty_inn_suffix: list[Pattern]

View File

@ -25,8 +25,7 @@ def test_real_osv_has_exactly_one_total_as_last_root_row(osv_file: Path) -> None
total = totals[0] total = totals[0]
assert total is parsed.rows[-1] assert total is parsed.rows[-1]
assert (total.level, total.parent, total.body.row) == (0, None, parsed.total_row) assert (total.level, total.parent, total.body.row) == (0, None, parsed.total_row)
# Допустимо одно предупреждение: числа в хвосте под «Итого» (контрольные суммы правее таблицы, AURORA-1664) assert parsed.warnings == []
assert [warning for warning in parsed.warnings if "ниже таблицы" not in warning] == []
@pytest.mark.fixtures @pytest.mark.fixtures

View File

@ -2,10 +2,10 @@
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s31 — xls без CODEPAGE, «Вал»/«Кол-во» и раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во»,
строки ниже «Итого» (#47); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47); описание — `osv/synthetic/README.md`
(openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и
метки времени книги и zip постоянны, порядок записи фиксирован. детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован.
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и
@ -729,6 +729,15 @@ def _s31() -> list[SheetSpec]:
return [sheet] return [sheet]
def _s32() -> list[SheetSpec]:
"""Итоговая строка с подписью «Итого по счету 60» вместо точного «Итого» (I-1): правило `total_row` (начало текста)
действует, когда точного «Итого» на листе нет."""
sheet = build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree())
last = max(row for row, _ in sheet.cells)
sheet.cells[(last, 1)] = CellSpec("Итого по счету 60", bold=sheet.cells[(last, 1)].bold)
return [sheet]
def _s28() -> list[SheetSpec]: def _s28() -> list[SheetSpec]:
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится.""" """Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
tree = _inn_layout_tree() tree = _inn_layout_tree()
@ -832,6 +841,7 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp
"s29_xls_no_codepage.xls": (_s29, to_xls), "s29_xls_no_codepage.xls": (_s29, to_xls),
"s30_val_kol_vo_materials.xlsx": (_s30, to_xlsx), "s30_val_kol_vo_materials.xlsx": (_s30, to_xlsx),
"s31_rows_below_total.xlsx": (_s31, to_xlsx), "s31_rows_below_total.xlsx": (_s31, to_xlsx),
"s32_total_with_suffix.xlsx": (_s32, to_xlsx),
} }
FILE_NAMES = tuple(FILES) FILE_NAMES = tuple(FILES)
@ -850,7 +860,7 @@ def generate_all(directory: Path) -> list[Path]:
def main(argv: Sequence[str] | None = None) -> int: def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="python -m tests.synthetic.generate", prog="python -m tests.synthetic.generate",
description="Синтетические ОСВ s01…s31 (xlsx, один xlsb и один xls) в указанный каталог.", description="Синтетические ОСВ s01…s32 (xlsx, один xlsb и один xls) в указанный каталог.",
) )
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic") parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
args = parser.parse_args(argv) args = parser.parse_args(argv)

View File

@ -41,6 +41,7 @@ INN_BAD_CHECKSUM_FILE = "s28_inn_bad_checksum.xlsx"
XLS_NO_CODEPAGE_FILE = "s29_xls_no_codepage.xls" XLS_NO_CODEPAGE_FILE = "s29_xls_no_codepage.xls"
VAL_KOL_VO_FILE = "s30_val_kol_vo_materials.xlsx" VAL_KOL_VO_FILE = "s30_val_kol_vo_materials.xlsx"
ROWS_BELOW_TOTAL_FILE = "s31_rows_below_total.xlsx" ROWS_BELOW_TOTAL_FILE = "s31_rows_below_total.xlsx"
TOTAL_WITH_SUFFIX_FILE = "s32_total_with_suffix.xlsx"
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28 TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
EXPECTED_NAMES = [ EXPECTED_NAMES = [
"s01_variant2_header.xlsx", "s01_variant2_header.xlsx",
@ -67,6 +68,7 @@ EXPECTED_NAMES = [
XLS_NO_CODEPAGE_FILE, XLS_NO_CODEPAGE_FILE,
VAL_KOL_VO_FILE, VAL_KOL_VO_FILE,
ROWS_BELOW_TOTAL_FILE, ROWS_BELOW_TOTAL_FILE,
TOTAL_WITH_SUFFIX_FILE,
] ]
XLSB_NAME = "s13_no_outline_indent.xlsb" XLSB_NAME = "s13_no_outline_indent.xlsb"
XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)" XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)"
@ -140,6 +142,8 @@ EXPECTED: dict[str, Expect] = {
VAL_KOL_VO_FILE: Expect("outline", 2025, "Q2", False, variant=2), VAL_KOL_VO_FILE: Expect("outline", 2025, "Q2", False, variant=2),
# подпись и блок чисел под «Итого» # подпись и блок чисел под «Итого»
ROWS_BELOW_TOTAL_FILE: Expect("outline", 2025, "Q1", True), ROWS_BELOW_TOTAL_FILE: Expect("outline", 2025, "Q1", True),
# итог подписан «Итого по счету 60»: точного «Итого» нет
TOTAL_WITH_SUFFIX_FILE: Expect("outline", 2025, "Q1", True),
} }
@ -211,7 +215,7 @@ def tree(doc: OsvDocument) -> list[tuple[str, int, int | None, tuple[Decimal | N
def test_synthetic_set_is_complete(fixtures_dir: Path) -> None: def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
"""В `fixtures/osv/synthetic/` лежат файлы набора s01…s31 (s13 — xlsb; допустима деградация: нет только его).""" """В `fixtures/osv/synthetic/` лежат файлы набора s01…s32 (s13 — xlsb; допустима деградация: нет только его)."""
present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file()) present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file())
assert present == [name for name in EXPECTED_NAMES if name in present] assert present == [name for name in EXPECTED_NAMES if name in present]
missing = [name for name in EXPECTED_NAMES if name not in present] missing = [name for name in EXPECTED_NAMES if name not in present]
@ -664,3 +668,11 @@ def test_s31_rows_below_total_are_outside_the_document(fixtures_dir: Path) -> No
assert [row.group_type for row in doc.rows].count("TOTAL") == 1 assert [row.group_type for row in doc.rows].count("TOTAL") == 1
assert not {"Ответственный:", "ООО Альфа (ГК)", "Итого по ГК"} & set(rows_text(doc)) assert not {"Ответственный:", "ООО Альфа (ГК)", "Итого по ГК"} & set(rows_text(doc))
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_s32_total_with_suffix_is_total(fixtures_dir: Path) -> None:
"""I-1: нет точного «Итого» — итогом служит строка, начинающаяся с «Итого» («Итого по счету 60»)."""
doc = one_document(parse_named(fixtures_dir, TOTAL_WITH_SUFFIX_FILE))
assert doc.rows[-1].row_value == "Итого по счету 60"
assert doc.rows[-1].group_type == "TOTAL"

View File

@ -545,6 +545,19 @@ def test_second_total_then_tail_numbers(rules: Rules) -> None:
assert any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings) assert any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings)
def test_numbers_right_of_table_below_total_give_no_warning(rules: Rules) -> None:
"""Контрольные суммы правее таблицы под «Итого» — не хвост таблицы: предупреждения нет."""
sheet = osv(
r(0, "60", S100),
r(None, "Итого", S100),
[None, None, None, None, None, None, None, None, None, None, None, 375490642],
)
parsed = parse(sheet, rules)
assert not any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings), parsed.warnings
def test_column_filled_only_in_footer_is_not_an_extra_key(rules: Rules) -> None: def test_column_filled_only_in_footer_is_not_an_extra_key(rules: Rules) -> None:
"""T4.5: колонка, заполненная только в подвале, не становится ключом `extra` у строк таблицы.""" """T4.5: колонка, заполненная только в подвале, не становится ключом `extra` у строк таблицы."""
sheet = sheet_from_rows( sheet = sheet_from_rows(
@ -865,6 +878,24 @@ def test_counterparty_named_like_total_in_the_middle(rules: Rules) -> None:
assert [row.group_type for row in parsed.rows].count("TOTAL") == 1 assert [row.group_type for row in parsed.rows].count("TOTAL") == 1
def test_flat_counterparty_named_like_total_before_exact_total(rules: Rules) -> None:
"""I-1: плоский лист без outline и отступов; «Итого-Сервис» с суммами в середине — строка данных, а не конец
таблицы: TOTAL — точное «Итого», данных ниже контрагента не теряется, хвоста нет."""
sheet = osv(
r(None, "60", (110, None, 50, 30, 130, None)),
r(None, "Итого-Сервис", S100, inn="7701234567"),
r(None, "ООО Поставщик", (10, None, None, None, 10, None), inn="7707654321"),
r(None, "Итого", (110, None, 50, 30, 130, None)),
)
parsed = parse(sheet, rules)
assert [row.body.text for row in parsed.rows] == ["60", "Итого-Сервис", "ООО Поставщик", "Итого"]
assert parsed.total_row == parsed.one("Итого").body.row
assert parsed.one("Итого-Сервис").group_type != "TOTAL"
assert not any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings), parsed.warnings
def test_last_row_named_like_total_is_rejected_by_outline_level(rules: Rules) -> None: def test_last_row_named_like_total_is_rejected_by_outline_level(rules: Rules) -> None:
"""Последняя строка «Итого-Сервис» без настоящего «Итого»: outline глубже минимального — не TOTAL.""" """Последняя строка «Итого-Сервис» без настоящего «Итого»: outline глубже минимального — не TOTAL."""
sheet = osv( sheet = osv(
@ -879,9 +910,9 @@ def test_last_row_named_like_total_is_rejected_by_outline_level(rules: Rules) ->
def test_known_risk_last_counterparty_named_like_total_without_outline(rules: Rules) -> None: def test_known_risk_last_counterparty_named_like_total_without_outline(rules: Rules) -> None:
"""Известный риск (документирован): без outline и отступов последняя строка «Итого-Сервис» при отсутствии """Остаточный риск (документирован): только когда точного «Итого» нет вовсе, а на листе нет outline и отступов,
настоящего «Итого» неотличима от итоговой строки и становится TOTAL; контрольные суммы §1.4 (TOTAL = корень) первая строка «Итого-Сервис» на корневом уровне неотличима от итоговой строки и становится TOTAL; контрольные суммы
это поймают, если суммы не совпадают. §1.4 (TOTAL = корень) это поймают, если суммы не совпадают.
""" """
sheet = osv( sheet = osv(
r(None, "60", S100), r(None, "60", S100),