diff --git a/CLAUDE.md b/CLAUDE.md index d147799..0fbfd67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` — только по контрольным суммам, структуру под сходимость не diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml index a5ac32c..85d65dd 100644 --- a/api/rules/osv_headers.yaml +++ b/api/rules/osv_headers.yaml @@ -42,6 +42,11 @@ service_columns: total_row: - '^итого\b' +# Итоговая строка, чей текст — ровно «Итого»: ищется первой, чтобы контрагент «Итого-Сервис» на плоском листе (без outline +# и отступов) не обрезал таблицу; нет такой — действует `total_row` (начало текста) +total_row_exact: + - '^итого$' + # Вторая итоговая строка после «Итого» («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная, первая из них total_row_secondary: - '^итого\s+развернут' diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index c39bdcc..e105256 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -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] diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index e54b43f..1261bae 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -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] diff --git a/api/tests/fixtures/test_classify_real.py b/api/tests/fixtures/test_classify_real.py index 632e98f..85b79fc 100644 --- a/api/tests/fixtures/test_classify_real.py +++ b/api/tests/fixtures/test_classify_real.py @@ -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 diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 136b4c5..cb415b2 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -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) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index b1dd329..728c08f 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -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" diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index 4936a97..1aaa77a 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -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),