Итог ищется по точному «Итого» раньше «Итого …»; хвост — только числа в колонках таблицы (#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` и под линтер не попадают.
- Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле
(`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`. Корректность
проверяют property-тест и ручная сверка «Итого».
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не

View File

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

View File

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

View File

@ -179,6 +179,7 @@ class OsvHeaderRules(_Model):
credit: list[Pattern]
service_columns: dict[str, list[Pattern]]
total_row: list[Pattern]
total_row_exact: list[Pattern]
total_row_secondary: list[Pattern]
column_captions: 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]
assert total is parsed.rows[-1]
assert (total.level, total.parent, total.body.row) == (0, None, parsed.total_row)
# Допустимо одно предупреждение: числа в хвосте под «Итого» (контрольные суммы правее таблицы, AURORA-1664)
assert [warning for warning in parsed.warnings if "ниже таблицы" not in warning] == []
assert parsed.warnings == []
@pytest.mark.fixtures

View File

@ -2,10 +2,10 @@
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s31 — xls без CODEPAGE, «Вал»/«Кол-во» и
строки ниже «Итого» (#47); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен
(openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты —
метки времени книги и zip постоянны, порядок записи фиксирован.
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во»,
строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47); описание — `osv/synthetic/README.md`
в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и
детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован.
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и
@ -729,6 +729,15 @@ def _s31() -> list[SheetSpec]:
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]:
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
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),
"s30_val_kol_vo_materials.xlsx": (_s30, to_xlsx),
"s31_rows_below_total.xlsx": (_s31, to_xlsx),
"s32_total_with_suffix.xlsx": (_s32, to_xlsx),
}
FILE_NAMES = tuple(FILES)
@ -850,7 +860,7 @@ def generate_all(directory: Path) -> list[Path]:
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
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")
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"
VAL_KOL_VO_FILE = "s30_val_kol_vo_materials.xlsx"
ROWS_BELOW_TOTAL_FILE = "s31_rows_below_total.xlsx"
TOTAL_WITH_SUFFIX_FILE = "s32_total_with_suffix.xlsx"
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
EXPECTED_NAMES = [
"s01_variant2_header.xlsx",
@ -67,6 +68,7 @@ EXPECTED_NAMES = [
XLS_NO_CODEPAGE_FILE,
VAL_KOL_VO_FILE,
ROWS_BELOW_TOTAL_FILE,
TOTAL_WITH_SUFFIX_FILE,
]
XLSB_NAME = "s13_no_outline_indent.xlsb"
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),
# подпись и блок чисел под «Итого»
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:
"""В `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())
assert present == [name for name in EXPECTED_NAMES if name 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 not {"Ответственный:", "ООО Альфа (ГК)", "Итого по ГК"} & set(rows_text(doc))
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)
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:
"""T4.5: колонка, заполненная только в подвале, не становится ключом `extra` у строк таблицы."""
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
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:
"""Последняя строка «Итого-Сервис» без настоящего «Итого»: outline глубже минимального — не TOTAL."""
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:
"""Известный риск (документирован): без outline и отступов последняя строка «Итого-Сервис» при отсутствии
настоящего «Итого» неотличима от итоговой строки и становится TOTAL; контрольные суммы §1.4 (TOTAL = корень)
это поймают, если суммы не совпадают.
"""Остаточный риск (документирован): только когда точного «Итого» нет вовсе, а на листе нет outline и отступов,
первая строка «Итого-Сервис» на корневом уровне неотличима от итоговой строки и становится TOTAL; контрольные суммы
§1.4 (TOTAL = корень) это поймают, если суммы не совпадают.
"""
sheet = osv(
r(None, "60", S100),