arm-tabular-parser/api/tests/fixtures/test_detect_real.py
Andrey Epifancev a833945669 feat(parser): детекторы листов ОСВ, заголовка таблицы и колонок по содержимому
detect.py: classify_sheet (ОСВ / форма ББ-ОФР / прочее по словарю), find_table_header
(строка с ≥ 2 группами сумм, «Дебет/Кредит» под группой в пределах merged-диапазона
или до следующей группы, заголовок в 1–4 строки, в т.ч. «Нач. сальдо Дт» в одной
ячейке; без «начала»; свёрнутое сальдо в *_debit + collapsed; свёрнутые «Обороты» и
половинчатая пара Дт/Кт — InvalidFormatError с названием группы), text_col, inn_col
(заголовок «ИНН» или ≥ 80 % значений из 10/12 цифр), right_edge, extra_cols,
group_label_rows, first_data_row, is_header_repeat (две группы или копия строки блока).

Pattern.search_normalized — ячейка нормализуется один раз (T3.6). osv_groups: известные
группировки OTHER перечислены явно, чтобы строка-метка «Основные средства» в
неслитом заголовке была частью блока. Хелпер тестов gridbuilder (Sheet из «картинки»).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 12:54:11 +04:00

104 lines
4.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Детекторы на реальных ОСВ из ../fixtures: общие свойства на всех 66 файлах и две диагностики из спеки.
Фикстуры — регрессия, не оракул: проверяются свойства, верные для любой ОСВ (лист ОСВ найден, шесть сумм, текст левее
сумм, ИНН между текстом и суммами), и сценарий спеки «Плавающие колонки двух организаций». Буквы колонок сравниваются
только здесь, в тестах, через `ColumnMap.as_diagnostics()` / `column_letter`.
"""
import time
from pathlib import Path
from typing import cast
import pytest
from src.contract import FileType
from src.parser.detect import SheetKind, classify_sheet, find_table_header, first_data_row, is_header_repeat
from src.parser.grid import Sheet, column_letter
from src.parser.readers import read_book
from src.parser.rules import Rules, load_rules
DETECT_SECONDS_LIMIT = 5.0
RB_60 = "osv/rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx"
SCHASTL_62 = "osv/schastl/2024-Q3/Оборотносальдовая ведомость по счету 62 за 3 квартал 2024 г..xls"
SCHASTL_62_LABEL_ROWS = 3 # Счет / Подразделение / Контрагенты (+ ИНН)
SUM_COLUMNS = 6
HEADER_MAX_ROWS = 4
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
def _sheets(path: Path) -> tuple[Sheet, ...]:
sheets, _ = read_book(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper()))
return sheets
@pytest.mark.fixtures
def test_every_real_osv_has_a_full_table_header(osv_file: Path, rules: Rules) -> None:
sheets = _sheets(osv_file)
started = time.perf_counter()
osv_sheets = [sheet for sheet in sheets if classify_sheet(sheet, rules) is SheetKind.OSV]
assert len(osv_sheets) == 1
sheet = osv_sheets[0]
header = find_table_header(sheet, rules)
data_start = first_data_row(sheet, header)
repeats = [row for row in range(data_start, sheet.n_rows + 1) if is_header_repeat(sheet, row, header, rules)]
assert time.perf_counter() - started < DETECT_SECONDS_LIMIT
sums = header.columns.indexes()
assert len(sums) == SUM_COLUMNS, header.columns.as_diagnostics()
assert len(set(sums.values())) == SUM_COLUMNS
assert header.columns.collapsed == frozenset()
columns = header.columns
for debit, credit in (
(columns.opening_debit, columns.opening_credit),
(columns.turnover_debit, columns.turnover_credit),
(columns.closing_debit, columns.closing_credit),
):
assert debit is not None and credit is not None and debit < credit
assert header.text_col < min(sums.values())
assert header.right_edge >= max(sums.values())
if header.inn_col is not None:
assert header.text_col < header.inn_col < min(sums.values())
assert all(header.text_col < col <= header.right_edge for col in header.extra_cols)
assert header.first_row <= header.last_row < header.first_row + HEADER_MAX_ROWS
assert header.group_label_rows and header.group_label_rows[0] == header.first_row
assert data_start > header.last_row
# Первая строка данных — не метка заголовка: у неё есть сумма, либо это «Итого» пустой ведомости
first_values = {col for col, _ in sheet.row_cells(data_start)}
assert first_values & set(sums.values()) or any(
pattern.search(sheet.text_at(data_start, header.text_col)) for pattern in rules.headers.total_row
), sheet.text_at(data_start, header.text_col)
assert repeats == []
@pytest.mark.fixtures
def test_rb_60_floating_columns(fixtures_dir: Path, rules: Rules) -> None:
sheet = _sheets(fixtures_dir / RB_60)[0]
header = find_table_header(sheet, rules)
diagnostics = header.columns.as_diagnostics()
assert [diagnostics[name] for name in header.columns.indexes()] == ["E", "H", "J", "L", "O", "Q"]
assert header.inn_col is None
assert column_letter(header.text_col) == "A"
@pytest.mark.fixtures
def test_schastl_62_floating_columns_inn_and_three_label_rows(fixtures_dir: Path, rules: Rules) -> None:
sheet = _sheets(fixtures_dir / SCHASTL_62)[0]
header = find_table_header(sheet, rules)
diagnostics = header.columns.as_diagnostics()
assert [diagnostics[name] for name in header.columns.indexes()] == ["G", "J", "K", "N", "Q", "S"]
assert header.inn_col is not None
assert column_letter(header.inn_col) == "E"
assert len(header.group_label_rows) == SCHASTL_62_LABEL_ROWS
# Мусор в T/U (spec «Мусор правее таблицы») — правее границы таблицы
assert column_letter(header.right_edge) == "S"
assert header.extra_cols == {}