Итог ищется по точному «Итого» раньше «Итого …»; хвост — только числа в колонках таблицы (#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:
parent
6efe79fec9
commit
195720c97b
@ -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` — только по контрольным суммам, структуру под сходимость не
|
||||
|
||||
@ -42,6 +42,11 @@ service_columns:
|
||||
total_row:
|
||||
- '^итого\b'
|
||||
|
||||
# Итоговая строка, чей текст — ровно «Итого»: ищется первой, чтобы контрагент «Итого-Сервис» на плоском листе (без outline
|
||||
# и отступов) не обрезал таблицу; нет такой — действует `total_row` (начало текста)
|
||||
total_row_exact:
|
||||
- '^итого$'
|
||||
|
||||
# Вторая итоговая строка после «Итого» («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная, первая из них
|
||||
total_row_secondary:
|
||||
- '^итого\s+развернут'
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
3
api/tests/fixtures/test_classify_real.py
vendored
3
api/tests/fixtures/test_classify_real.py
vendored
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user