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
104 lines
4.8 KiB
Python
104 lines
4.8 KiB
Python
"""Детекторы на реальных ОСВ из ../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 == {}
|