feat(parser): классификация строк по group_schema (§1.3)
classify.py: collect_body_rows — строки таблицы до «Итого» (пустые, из пробелов, повторы заголовка и строки правее таблицы пропускаются; подвал после «Итого» не данные), «Итого» по тексту и корневому оформлению до уровней, без требования сумм (R-13, T3.3), шесть сумм через to_money с номером строки в предупреждениях, свёрнутое сальдо по знаку (R-1); classify_rows — ACCOUNT по ACCOUNT_RE, позиция в схеме, OTHER за схемой, TOTAL, COUNTERPARTY с ИНН (inn_digits), Вал./Кол./код валюты -> data_type, extra без дубликатов ключей и без колонок подвала (T4.5). Fixtures-тесты иерархии (R-2) и классификации на всех 66 реальных ОСВ. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
663ba17716
commit
4a16f193af
333
api/src/parser/classify.py
Normal file
333
api/src/parser/classify.py
Normal file
@ -0,0 +1,333 @@
|
||||
"""Строки табличной части ОСВ и их классификация (spec «Классификация строк», design D-4; `05-domain-rules.md`
|
||||
§1.2–§1.4).
|
||||
|
||||
Два шага вокруг каскада иерархии (`hierarchy.resolve_levels`):
|
||||
|
||||
1. `collect_body_rows` — строки таблицы от первой строки данных до строки «Итого» включительно: текст строки, признак
|
||||
счёта, оформление и шесть сумм (по `ColumnMap`; свёрнутое сальдо раскладывается по знаку, ruling R-1). Строка
|
||||
«Итого» выбирается здесь, по тексту и положению, до уровней: каскаду она нужна, чтобы закрепить её на уровне 0.
|
||||
2. `classify_rows` — тип группировки строки по позиции в `group_schema`, счёт ветки, контрагент и ИНН, `data_type`,
|
||||
нераспознанные колонки таблицы (`extra`).
|
||||
|
||||
Позиции строк и колонок берутся только из `TableHeader` и содержимого ячеек; единственная регулярка в коде —
|
||||
`ACCOUNT_RE`: формат кода счёта — структура, а не синоним.
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from src.contract.common import DataType
|
||||
from src.contract.result import OsvGroup
|
||||
from src.parser.deadline import Deadline
|
||||
from src.parser.detect import GROUP_ORDER, ColumnMap, TableHeader, first_data_row, is_header_repeat
|
||||
from src.parser.grid import Sheet, column_letter
|
||||
from src.parser.hierarchy import BodyRow, Levels
|
||||
from src.parser.numbers import inn_digits, to_money
|
||||
from src.parser.rules import GroupType, Rules, first_match, normalize_text
|
||||
|
||||
__all__ = [
|
||||
"ACCOUNT_RE",
|
||||
"DEADLINE_CHECK_EVERY_ROWS",
|
||||
"SUM_FIELDS",
|
||||
"ClassifiedRow",
|
||||
"RowGroupType",
|
||||
"classify_rows",
|
||||
"collect_body_rows",
|
||||
]
|
||||
|
||||
# Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»), после запятой — название
|
||||
# («60.01, Расчеты с поставщиками»). Группа 1 — код как в файле (`account_code_raw`)
|
||||
ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.\d{1,2}){0,2})(?:,\s*(.*))?$")
|
||||
_SEGMENT_SEPARATOR = "."
|
||||
# Кооперативный дедлайн (design D-8): проверка каждые N строк листа
|
||||
DEADLINE_CHECK_EVERY_ROWS = 500
|
||||
# Шесть сумм строки в порядке полей `ColumnMap` и `OsvRow` контракта
|
||||
SUM_FIELDS = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit")
|
||||
# Позиция группировки в `group_schema`: индекс 0 — ACCOUNT, первая не-счётная группировка — индекс 1
|
||||
_ACCOUNT_POSITION = 0
|
||||
|
||||
RowGroupType = GroupType | Literal["TOTAL"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClassifiedRow:
|
||||
"""Строка `rows[]` контракта до сборки `OsvRow`: исходная строка, место в дереве и классификация."""
|
||||
|
||||
body: BodyRow
|
||||
level: int
|
||||
parent: int | None
|
||||
group_type: RowGroupType
|
||||
group_name: str | None
|
||||
account_code_raw: str | None # код ближайшего счёта ветки, как в файле
|
||||
counterparty_name: str | None
|
||||
counterparty_inn: str | None
|
||||
data_type: DataType | None
|
||||
extra: dict[str, str | None] | None
|
||||
|
||||
|
||||
# --- Строки таблицы ---
|
||||
|
||||
|
||||
def _in_table(sheet: Sheet, row: int, header: TableHeader) -> bool:
|
||||
"""В строке есть значение в пределах таблицы; строка лишь с ячейками правее правого края — не строка таблицы."""
|
||||
return any(col <= header.right_edge for col, _ in sheet.row_cells(row))
|
||||
|
||||
|
||||
def _table_rows(sheet: Sheet, header: TableHeader, rules: Rules, deadline: Deadline) -> list[int]:
|
||||
"""Непустые строки после блока заголовка, кроме повторов заголовка и строк вне таблицы (Review Focus 4, 5).
|
||||
|
||||
Строка из одних пробелов пустая: `Sheet` не хранит такие ячейки в строках, а повтор заголовка — не данные.
|
||||
"""
|
||||
found: list[int] = []
|
||||
for index, row in enumerate(range(first_data_row(sheet, header), sheet.n_rows + 1), start=1):
|
||||
if index % DEADLINE_CHECK_EVERY_ROWS == 0:
|
||||
deadline.check("строки таблицы")
|
||||
if sheet.is_row_empty(row) or not _in_table(sheet, row, header):
|
||||
continue
|
||||
if is_header_repeat(sheet, row, header, rules):
|
||||
continue
|
||||
found.append(row)
|
||||
return found
|
||||
|
||||
|
||||
def _text_cell_style(sheet: Sheet, row: int, col: int) -> tuple[bool, int]:
|
||||
cell = sheet.cell(*sheet.merged_origin(row, col))
|
||||
return cell.bold, cell.indent
|
||||
|
||||
|
||||
def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: Rules) -> int | None:
|
||||
"""Строка «Итого»: последняя строка таблицы, чей текст матчит `total_row`, на корневом уровне оформления.
|
||||
|
||||
Суммы не требуются (R-13: пустая ОСВ — «Итого» без сумм). Выбор — до уровней (их каскад считает с уже известной
|
||||
строкой «Итого»), поэтому «корневой уровень» проверяется по оформлению: outline level и отступ строки не больше,
|
||||
чем у любой другой строки таблицы. Так контрагент «Итого-Сервис» в середине данных не TOTAL (за ним есть настоящее
|
||||
«Итого»), а последний «Итого-Сервис» под счётом с outline/отступом — тоже не TOTAL (T3.3).
|
||||
Известный риск: на листе без 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)
|
||||
for row in reversed(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)
|
||||
is_root = sheet.outline(row) == outline_floor and indent == indent_floor
|
||||
return row if is_root else None
|
||||
return None
|
||||
|
||||
|
||||
def _split_collapsed(sums: dict[str, Decimal | None], columns: ColumnMap) -> None:
|
||||
"""Свёрнутое сальдо (одна колонка, значение в `*_debit`): отрицательное — в `*_credit` по модулю (R-1)."""
|
||||
for group in columns.collapsed:
|
||||
debit, credit = f"{group}_debit", f"{group}_credit"
|
||||
value = sums[debit]
|
||||
if value is not None and value < 0:
|
||||
sums[debit], sums[credit] = None, -value
|
||||
|
||||
|
||||
def _sums(sheet: Sheet, row: int, columns: ColumnMap, warnings: list[str]) -> tuple[Decimal | None, ...]:
|
||||
"""Шесть сумм в порядке `SUM_FIELDS`; нечисловое значение — `None` и предупреждение с номером строки."""
|
||||
sums: dict[str, Decimal | None] = dict.fromkeys(SUM_FIELDS)
|
||||
for name, col in columns.indexes().items():
|
||||
parsed = to_money(sheet.cell(row, col))
|
||||
if parsed.warning is not None:
|
||||
warnings.append(f"строка {row}: {parsed.warning}")
|
||||
sums[name] = parsed.value
|
||||
_split_collapsed(sums, columns)
|
||||
return tuple(sums.values())
|
||||
|
||||
|
||||
def _body_row(sheet: Sheet, row: int, header: TableHeader, warnings: list[str]) -> BodyRow:
|
||||
text = sheet.text_at(row, header.text_col)
|
||||
account = ACCOUNT_RE.match(text)
|
||||
bold, indent = _text_cell_style(sheet, row, header.text_col)
|
||||
return BodyRow(
|
||||
row=row,
|
||||
text=text,
|
||||
is_account=account is not None,
|
||||
account_rank=None if account is None else account.group(1).count(_SEGMENT_SEPARATOR) + 1,
|
||||
bold=bold,
|
||||
indent=indent,
|
||||
outline=sheet.outline(row),
|
||||
sums=_sums(sheet, row, header.columns, warnings),
|
||||
)
|
||||
|
||||
|
||||
def _collapsed_warning(columns: ColumnMap) -> str:
|
||||
groups = ", ".join(group for group in GROUP_ORDER if group in columns.collapsed)
|
||||
return (
|
||||
f"свёрнутое сальдо: {groups} — одна колонка без «Дебет/Кредит»: положительное значение отнесено в дебет, "
|
||||
"отрицательное (по модулю) — в кредит"
|
||||
)
|
||||
|
||||
|
||||
def collect_body_rows(
|
||||
sheet: Sheet, header: TableHeader, rules: Rules, deadline: Deadline
|
||||
) -> tuple[list[BodyRow], int | None, list[str]]:
|
||||
"""Строки данных листа (включая «Итого»), номер строки «Итого» (`None` — не найдена) и предупреждения.
|
||||
|
||||
Строки после «Итого» — подвал (подписи, числа правее таблицы): ни в данные, ни в `extra` они не попадают (T4.5).
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
if header.columns.collapsed:
|
||||
warnings.append(_collapsed_warning(header.columns))
|
||||
rows = _table_rows(sheet, header, rules, deadline)
|
||||
total_row = _find_total(sheet, header, rows, rules)
|
||||
if total_row is not None:
|
||||
rows = rows[: rows.index(total_row) + 1]
|
||||
body: list[BodyRow] = []
|
||||
for index, row in enumerate(rows, start=1):
|
||||
if index % DEADLINE_CHECK_EVERY_ROWS == 0:
|
||||
deadline.check("суммы строк")
|
||||
body.append(_body_row(sheet, row, header, warnings))
|
||||
return body, total_row, warnings
|
||||
|
||||
|
||||
# --- Классификация ---
|
||||
|
||||
|
||||
def _extra_keys(sheet: Sheet, header: TableHeader, rows: Sequence[BodyRow]) -> dict[int, str]:
|
||||
"""Колонки `extra` со значениями в строках таблицы и их ключи без повторов.
|
||||
|
||||
`header.extra_cols` собран по всем строкам листа, поэтому колонки, заполненные только в подвале, отбрасываются.
|
||||
Одинаковые заголовки двух колонок различаются буквой колонки в скобках: «Кол-во (B)», «Кол-во (C)»
|
||||
(Review Focus 1); заголовок без повторов — как в файле.
|
||||
"""
|
||||
used = {
|
||||
col: title for col, title in header.extra_cols.items() if any(sheet.text_at(item.row, col) for item in rows)
|
||||
}
|
||||
titles = list(used.values())
|
||||
return {col: f"{title} ({column_letter(col)})" if titles.count(title) > 1 else title for col, title in used.items()}
|
||||
|
||||
|
||||
def _extra(sheet: Sheet, row: int, keys: dict[int, str]) -> dict[str, str | None] | None:
|
||||
values = {key: sheet.text_at(row, col) or None for col, key in keys.items()}
|
||||
return values if any(value is not None for value in values.values()) else None
|
||||
|
||||
|
||||
def _row_data_type(text: str, rules: Rules, currency_codes: frozenset[str]) -> DataType | None:
|
||||
"""CURRENCY/QUANTITY по словарю строк («Вал.», «Кол.») или коду валюты ISO («USD», «usd»); иначе `None`."""
|
||||
found = first_match(rules.data_types.row, text)
|
||||
if found is not None:
|
||||
return found
|
||||
return "CURRENCY" if normalize_text(text).upper() in currency_codes else None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Branch:
|
||||
"""Контекст строки для её детей: счёт ветки и число не-счётных группировок после последнего счёта."""
|
||||
|
||||
account_code_raw: str | None
|
||||
depth: int
|
||||
|
||||
|
||||
_ROOT_BRANCH = _Branch(None, 0)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Kind:
|
||||
"""Классификация одной строки (без места в дереве и `extra`)."""
|
||||
|
||||
group_type: RowGroupType
|
||||
group_name: str | None
|
||||
account_code_raw: str | None
|
||||
data_type: DataType | None
|
||||
counterparty_name: str | None = None
|
||||
counterparty_inn: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Context:
|
||||
schema: Sequence[OsvGroup]
|
||||
header: TableHeader
|
||||
sheet: Sheet
|
||||
doc_data_type: DataType | None
|
||||
rules: Rules
|
||||
currency_codes: frozenset[str]
|
||||
|
||||
|
||||
def _schema_group(schema: Sequence[OsvGroup], position: int) -> tuple[GroupType, str | None]:
|
||||
if position >= len(schema):
|
||||
return "OTHER", None
|
||||
return schema[position].type, schema[position].name
|
||||
|
||||
|
||||
def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple[_Kind, _Branch]:
|
||||
"""Тип строки (кроме TOTAL) по признаку счёта, словарю строк-показателей и позиции в схеме; контекст для детей."""
|
||||
# ASSUMPTION(OQ-45): `data_type` шапки (в том числе НУ) — как есть
|
||||
doc_data_type = context.doc_data_type
|
||||
account = ACCOUNT_RE.match(item.text) if item.is_account else None
|
||||
if account is not None:
|
||||
code = account.group(1)
|
||||
_, name = _schema_group(context.schema, _ACCOUNT_POSITION)
|
||||
return _Kind("ACCOUNT", name, code, doc_data_type), _Branch(code, 0)
|
||||
code = inherited.account_code_raw
|
||||
row_data_type = _row_data_type(item.text, context.rules, context.currency_codes)
|
||||
if row_data_type is not None:
|
||||
return _Kind("OTHER", None, code, row_data_type), inherited
|
||||
position = inherited.depth + 1
|
||||
group_type, name = _schema_group(context.schema, position)
|
||||
branch = _Branch(code, position)
|
||||
if group_type != "COUNTERPARTY":
|
||||
return _Kind(group_type, name, code, doc_data_type), branch
|
||||
inn_col = context.header.inn_col
|
||||
inn = None if inn_col is None else inn_digits(context.sheet.cell(item.row, inn_col))
|
||||
return _Kind(group_type, name, code, doc_data_type, counterparty_name=item.text, counterparty_inn=inn), branch
|
||||
|
||||
|
||||
def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно
|
||||
rows: Sequence[BodyRow],
|
||||
levels: Levels,
|
||||
schema: list[OsvGroup],
|
||||
header: TableHeader,
|
||||
sheet: Sheet,
|
||||
doc_data_type: DataType | None,
|
||||
total_row: int | None,
|
||||
rules: Rules,
|
||||
) -> list[ClassifiedRow]:
|
||||
"""Классификация строк по дереву `levels` и схеме группировок.
|
||||
|
||||
- «Итого» (`total_row`) — TOTAL, уровень 0 без родителя; в `rows` остаётся (# ASSUMPTION(OQ-01): хранить ли
|
||||
«Итого», решает ядро);
|
||||
- счёт (`ACCOUNT_RE`) — ACCOUNT, `account_code_raw` — код как в файле; дети наследуют код ветки;
|
||||
- строка-показатель («Вал.», «Кол.», код валюты) — `data_type` CURRENCY/QUANTITY, группа OTHER: это не уровень
|
||||
группировки, а вид сумм родителя; позицию в схеме она не занимает;
|
||||
- прочая строка — `schema[позиция]`, позиция = число не-счётных предков после последнего счёта + 1; за схемой —
|
||||
OTHER. COUNTERPARTY получает `counterparty_name` (текст строки) и `counterparty_inn` (колонка ИНН).
|
||||
"""
|
||||
context = _Context(schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes))
|
||||
extra_keys = _extra_keys(sheet, header, rows)
|
||||
branches: dict[int, _Branch] = {}
|
||||
result: list[ClassifiedRow] = []
|
||||
for item in rows:
|
||||
if item.row == total_row:
|
||||
# ASSUMPTION(OQ-01): строка «Итого» остаётся в rows; хранить ли её, решает ядро
|
||||
kind, branch = _Kind("TOTAL", None, None, doc_data_type), _ROOT_BRANCH
|
||||
level, parent = 0, None
|
||||
else:
|
||||
parent = levels.parent[item.row]
|
||||
inherited = _ROOT_BRANCH if parent is None else branches.get(parent, _ROOT_BRANCH)
|
||||
kind, branch = _classify_row(item, inherited, context)
|
||||
level = levels.by_row[item.row]
|
||||
branches[item.row] = branch
|
||||
result.append(
|
||||
ClassifiedRow(
|
||||
body=item,
|
||||
level=level,
|
||||
parent=parent,
|
||||
group_type=kind.group_type,
|
||||
group_name=kind.group_name,
|
||||
account_code_raw=kind.account_code_raw,
|
||||
counterparty_name=kind.counterparty_name,
|
||||
counterparty_inn=kind.counterparty_inn,
|
||||
data_type=kind.data_type,
|
||||
extra=_extra(sheet, item.row, extra_keys),
|
||||
)
|
||||
)
|
||||
return result
|
||||
68
api/tests/fixtures/real_osv.py
vendored
Normal file
68
api/tests/fixtures/real_osv.py
vendored
Normal file
@ -0,0 +1,68 @@
|
||||
"""Разбор реального файла ОСВ до классифицированных строк — общий конвейер fixtures-тестов иерархии и классификации.
|
||||
|
||||
Тот же порядок, что у парсера: лист ОСВ → заголовок таблицы → шапка и схема группировок → строки → каскад иерархии →
|
||||
классификация. Результат кэшируется по пути: несколько тестов одного файла разбирают его один раз; `seconds` —
|
||||
время первого (полного) разбора, включая чтение книги.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from src.contract import FileType
|
||||
from src.contract.result import OsvGroup
|
||||
from src.parser.classify import ClassifiedRow, classify_rows, collect_body_rows
|
||||
from src.parser.deadline import Deadline
|
||||
from src.parser.detect import SheetKind, TableHeader, classify_sheet, find_table_header
|
||||
from src.parser.grid import Sheet
|
||||
from src.parser.header import build_group_schema, parse_doc_header
|
||||
from src.parser.hierarchy import BodyRow, Levels, resolve_levels
|
||||
from src.parser.readers import read_book
|
||||
from src.parser.rules import load_rules
|
||||
|
||||
PARSE_TIMEOUT_SECONDS = 120
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RealParse:
|
||||
sheet: Sheet
|
||||
header: TableHeader
|
||||
schema: list[OsvGroup]
|
||||
body: list[BodyRow]
|
||||
total_row: int | None
|
||||
levels: Levels
|
||||
rows: list[ClassifiedRow]
|
||||
warnings: list[str]
|
||||
seconds: float
|
||||
|
||||
@property
|
||||
def data_rows(self) -> list[ClassifiedRow]:
|
||||
"""Строки данных без «Итого»."""
|
||||
return [row for row in self.rows if row.group_type != "TOTAL"]
|
||||
|
||||
def by_text(self, text: str) -> list[ClassifiedRow]:
|
||||
return [row for row in self.rows if row.body.text == text]
|
||||
|
||||
def row(self, number: int) -> ClassifiedRow:
|
||||
return next(row for row in self.rows if row.body.row == number)
|
||||
|
||||
|
||||
@cache
|
||||
def parse_real(path: Path) -> RealParse:
|
||||
rules = load_rules()
|
||||
started = time.monotonic()
|
||||
sheets, _ = read_book(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper()))
|
||||
osv = [sheet for sheet in sheets if classify_sheet(sheet, rules) is SheetKind.OSV]
|
||||
assert len(osv) == 1, path.name
|
||||
sheet = osv[0]
|
||||
deadline = Deadline(PARSE_TIMEOUT_SECONDS)
|
||||
header = find_table_header(sheet, rules)
|
||||
doc = parse_doc_header(sheet, header, rules)
|
||||
schema, _ = build_group_schema(sheet, header, rules)
|
||||
body, total_row, warnings = collect_body_rows(sheet, header, rules, deadline)
|
||||
levels = resolve_levels(body, total_row, deadline)
|
||||
rows = classify_rows(body, levels, schema, header, sheet, doc.data_type, total_row, rules)
|
||||
seconds = time.monotonic() - started
|
||||
return RealParse(sheet, header, schema, body, total_row, levels, rows, warnings, seconds)
|
||||
81
api/tests/fixtures/test_classify_real.py
vendored
Normal file
81
api/tests/fixtures/test_classify_real.py
vendored
Normal file
@ -0,0 +1,81 @@
|
||||
"""Классификация строк на реальных ОСВ из ../fixtures (spec «Классификация строк», R-13, T4.5).
|
||||
|
||||
Свойства на всех файлах: ровно одна строка TOTAL — последняя, уровень 0 без родителя; строки без предупреждений
|
||||
о числах; подвал после «Итого» не попадает ни в строки, ни в `extra`. Конкретные строки — по сценариям спеки.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from real_osv import parse_real
|
||||
|
||||
from src.parser.detect import INN_LENGTHS
|
||||
|
||||
SCHASTL_62_Q3 = Path("osv/schastl/2024-Q3/Оборотносальдовая ведомость по счету 62 за 3 квартал 2024 г..xls")
|
||||
RB_66_FY = Path("osv/rb/2024-FY/ОСВ_66 за 2024.xls")
|
||||
RB_60_Q1_2024 = Path("osv/rb/2024-Q1/сч 60_ 1 кв 2024 г.xlsx") # после «Итого» — числа правее таблицы
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_real_osv_has_exactly_one_total_as_last_root_row(osv_file: Path) -> None:
|
||||
parsed = parse_real(osv_file)
|
||||
|
||||
totals = [row for row in parsed.rows if row.group_type == "TOTAL"]
|
||||
assert len(totals) == 1
|
||||
total = totals[0]
|
||||
assert total is parsed.rows[-1]
|
||||
assert (total.level, total.parent, total.body.row) == (0, None, parsed.total_row)
|
||||
assert parsed.warnings == []
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_real_osv_rows_are_classified_into_schema(osv_file: Path) -> None:
|
||||
parsed = parse_real(osv_file)
|
||||
|
||||
schema_types = {group.type for group in parsed.schema}
|
||||
for row in parsed.data_rows:
|
||||
assert row.group_type == "OTHER" or row.group_type in schema_types, row
|
||||
assert row.data_type == "BU" # в фикстурах нет строк «Вал.»/«Кол.»
|
||||
if row.counterparty_inn is not None:
|
||||
assert row.counterparty_inn.isdigit()
|
||||
assert len(row.counterparty_inn) in INN_LENGTHS
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_schastl_62_department_counterparty_with_and_without_inn(fixtures_dir: Path) -> None:
|
||||
"""Spec «Подразделение, контрагент с ИНН, физлицо»."""
|
||||
parsed = parse_real(fixtures_dir / SCHASTL_62_Q3)
|
||||
|
||||
assert {row.group_type for row in parsed.by_text("БП Аренда")} == {"DEPARTMENT"}
|
||||
poultry = parsed.by_text("МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО")
|
||||
assert poultry
|
||||
for row in poultry:
|
||||
assert row.group_type == "COUNTERPARTY"
|
||||
assert row.counterparty_name == "МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО"
|
||||
assert row.counterparty_inn is not None
|
||||
assert row.counterparty_inn.isdigit()
|
||||
assert len(row.counterparty_inn) == min(INN_LENGTHS)
|
||||
(payroll,) = parsed.by_text("Ведомость в счет з/платы")
|
||||
assert (payroll.group_type, payroll.counterparty_inn) == ("COUNTERPARTY", None)
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_rb_66_counterparty_inherits_subaccount(fixtures_dir: Path) -> None:
|
||||
"""Одноимённый контрагент под 66.03 и 66.04 различается счётом ветки (Review Focus 3)."""
|
||||
parsed = parse_real(fixtures_dir / RB_66_FY)
|
||||
|
||||
yug = parsed.by_text("Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ")
|
||||
assert [row.account_code_raw for row in yug] == ["66.03", "66.04"]
|
||||
assert {row.group_type for row in yug} == {"COUNTERPARTY"}
|
||||
assert yug[0].parent != yug[1].parent
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_rb_60_footer_after_total_is_not_data(fixtures_dir: Path) -> None:
|
||||
"""T4.5: числа правее таблицы после «Итого» не становятся строками и не попадают в `extra`."""
|
||||
parsed = parse_real(fixtures_dir / RB_60_Q1_2024)
|
||||
|
||||
assert parsed.rows[-1].group_type == "TOTAL"
|
||||
assert parsed.total_row is not None
|
||||
assert parsed.sheet.n_rows > parsed.total_row # подвал есть
|
||||
assert all(row.extra is None for row in parsed.rows)
|
||||
44
api/tests/fixtures/test_hierarchy_real.py
vendored
Normal file
44
api/tests/fixtures/test_hierarchy_real.py
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
"""Иерархия строк на реальных ОСВ из ../fixtures (ruling R-2, R-13; spec «Иерархия строк — каскад стратегий»).
|
||||
|
||||
Свойства без таблицы файлов: дерево непротиворечиво, «Итого» — уровень 0; у файла со строками данных — `outline`
|
||||
(у всех фикстур есть группировки Excel), у пустой ОСВ (только «Итого») — `trivial`. Разбор файла укладывается в 5 с.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from real_osv import parse_real
|
||||
|
||||
from src.parser.hierarchy import TRIVIAL_STRATEGY, is_consistent
|
||||
|
||||
MAX_SECONDS_PER_FILE = 5
|
||||
RB_66_FY = Path("osv/rb/2024-FY/ОСВ_66 за 2024.xls")
|
||||
YUG_FINANS = "Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ"
|
||||
COUNTERPARTY_LEVEL = 2 # счёт (0) → субсчёт (1) → контрагент (2)
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_real_osv_hierarchy(osv_file: Path) -> None:
|
||||
parsed = parse_real(osv_file)
|
||||
|
||||
assert is_consistent(parsed.body, parsed.levels.by_row, parsed.total_row)
|
||||
assert parsed.total_row is not None
|
||||
assert parsed.levels.by_row[parsed.total_row] == 0
|
||||
assert parsed.levels.parent[parsed.total_row] is None
|
||||
expected = "outline" if parsed.data_rows else TRIVIAL_STRATEGY
|
||||
assert parsed.levels.strategy == expected
|
||||
assert parsed.seconds < MAX_SECONDS_PER_FILE
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_rb_66_fy_outline_tree(fixtures_dir: Path) -> None:
|
||||
"""Spec «Outline level (реальные файлы)»: 66 (0) → 66.01 (1) → контрагент (2); «Юг-Финанс» — под 66.03."""
|
||||
parsed = parse_real(fixtures_dir / RB_66_FY)
|
||||
|
||||
assert parsed.levels.strategy == "outline"
|
||||
account, subaccount, counterparty = parsed.rows[:3]
|
||||
assert [(account.body.text, account.level), (subaccount.body.text, subaccount.level)] == [("66", 0), ("66.01", 1)]
|
||||
assert (counterparty.level, counterparty.parent) == (COUNTERPARTY_LEVEL, subaccount.body.row)
|
||||
first = parsed.by_text(YUG_FINANS)[0]
|
||||
assert first.parent is not None
|
||||
assert parsed.row(first.parent).body.text == "66.03"
|
||||
606
api/tests/unit/parser/test_classify.py
Normal file
606
api/tests/unit/parser/test_classify.py
Normal file
@ -0,0 +1,606 @@
|
||||
"""Классификация строк ОСВ (spec «Классификация строк», design D-4, `05-domain-rules.md` §1.2–§1.4).
|
||||
|
||||
Сетки собираются `gridbuilder`; конвейер теста — тот же, что у парсера: заголовок таблицы → схема группировок →
|
||||
`collect_body_rows` → каскад иерархии → `classify_rows`. Позиции колонок в сетках произвольные: классификатор знает их
|
||||
только из `TableHeader`.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from gridbuilder import C, L, M, sheet_from_rows
|
||||
|
||||
from src.contract.common import DataType
|
||||
from src.parser.classify import ACCOUNT_RE, ClassifiedRow, classify_rows, collect_body_rows
|
||||
from src.parser.deadline import Deadline
|
||||
from src.parser.detect import find_table_header
|
||||
from src.parser.errors import ParseTimeoutError
|
||||
from src.parser.grid import Sheet
|
||||
from src.parser.header import build_group_schema
|
||||
from src.parser.hierarchy import TRIVIAL_STRATEGY, Levels, resolve_levels
|
||||
from src.parser.rules import Rules, load_rules
|
||||
|
||||
TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
|
||||
OPENING = "Сальдо на начало периода"
|
||||
TURNOVER = "Обороты за период"
|
||||
CLOSING = "Сальдо на конец периода"
|
||||
TIMEOUT_SECONDS = 60
|
||||
EXPIRED_AFTER_SECONDS = 1000
|
||||
ACCOUNT_ROW = 7 # первая строка данных в сетке `osv(...)`: шапка 3 строки + блок заголовка 3 строки
|
||||
DUPLICATED_EXTRA_COLUMNS = 2
|
||||
|
||||
Sums = tuple[object, object, object, object, object, object]
|
||||
NO_SUMS: Sums = (None, None, None, None, None, None)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rules() -> Rules:
|
||||
return load_rules()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Parsed:
|
||||
rows: list[ClassifiedRow]
|
||||
warnings: list[str]
|
||||
levels: Levels
|
||||
total_row: int | None
|
||||
|
||||
def by_text(self, text: str) -> list[ClassifiedRow]:
|
||||
return [row for row in self.rows if row.body.text == text]
|
||||
|
||||
def one(self, text: str) -> ClassifiedRow:
|
||||
found = self.by_text(text)
|
||||
assert len(found) == 1, (text, [row.body.text for row in self.rows])
|
||||
return found[0]
|
||||
|
||||
|
||||
def parse(
|
||||
sheet: Sheet, rules: Rules, doc_data_type: DataType | None = "BU", deadline: Deadline | None = None
|
||||
) -> Parsed:
|
||||
deadline = deadline or Deadline(TIMEOUT_SECONDS)
|
||||
header = find_table_header(sheet, rules)
|
||||
schema, _ = build_group_schema(sheet, header, rules)
|
||||
body, total_row, warnings = collect_body_rows(sheet, header, rules, deadline)
|
||||
levels = resolve_levels(body, total_row, deadline)
|
||||
rows = classify_rows(body, levels, schema, header, sheet, doc_data_type, total_row, rules)
|
||||
return Parsed(rows, warnings, levels, total_row)
|
||||
|
||||
|
||||
# --- Сетка: «Счет | ИНН | КПП | начало Дт/Кт | обороты Дт/Кт | конец Дт/Кт», группировки Счет/Контрагенты/Договоры ---
|
||||
|
||||
|
||||
def _head() -> list[list[object]]:
|
||||
return [
|
||||
["ООО Ромашка"],
|
||||
[TITLE],
|
||||
[],
|
||||
["Счет", "ИНН", "КПП", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 4
|
||||
["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], # 5
|
||||
["Договоры"], # 6
|
||||
]
|
||||
|
||||
|
||||
def r(level: int | None, text: object, sums: Sums = NO_SUMS, inn: object = None, kpp: object = None) -> list[object]:
|
||||
"""Строка данных: outline `level` (`None` — без группировки Excel), текст, ИНН, КПП и шесть сумм."""
|
||||
cells: list[object] = [text, inn, kpp, *sums]
|
||||
return cells if level is None else [L(level), *cells]
|
||||
|
||||
|
||||
def osv(*rows: list[object]) -> Sheet:
|
||||
return sheet_from_rows([*_head(), *rows])
|
||||
|
||||
|
||||
S100: Sums = (100, None, 50, 30, 120, None)
|
||||
|
||||
|
||||
def _basic() -> Sheet:
|
||||
return osv(
|
||||
r(0, "60", S100), # 7
|
||||
r(1, "60.01", S100), # 8
|
||||
r(2, "ООО Поставщик", S100, inn="7701234567", kpp="770101001"), # 9
|
||||
r(3, "Договор № 1 от 01.02.2025", S100), # 10
|
||||
r(None, "Итого", S100), # 11
|
||||
)
|
||||
|
||||
|
||||
# --- Регулярка счёта ---
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "code"),
|
||||
[
|
||||
("60", "60"),
|
||||
("60.01", "60.01"),
|
||||
("76.07.1", "76.07.1"),
|
||||
("001", "001"),
|
||||
("60.1", "60.1"),
|
||||
("60.01, Расчеты с поставщиками", "60.01"),
|
||||
("60,Расчеты", "60"),
|
||||
],
|
||||
)
|
||||
def test_account_re_matches_account_codes(text: str, code: str) -> None:
|
||||
found = ACCOUNT_RE.match(text)
|
||||
|
||||
assert found is not None
|
||||
assert found.group(1) == code
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text", ["6", "6001", "60.001", "60.01.1.1", "ООО 60", "60 лет Октября", "Итого", "12.2025 г."]
|
||||
)
|
||||
def test_account_re_rejects_non_accounts(text: str) -> None:
|
||||
assert ACCOUNT_RE.match(text) is None
|
||||
|
||||
|
||||
# --- Группы по позиции в схеме ---
|
||||
|
||||
|
||||
def test_account_subaccount_counterparty_contract(rules: Rules) -> None:
|
||||
parsed = parse(_basic(), rules)
|
||||
|
||||
kinds = [(row.body.text, row.group_type, row.group_name, row.account_code_raw, row.level) for row in parsed.rows]
|
||||
assert kinds == [
|
||||
("60", "ACCOUNT", "Счет", "60", 0),
|
||||
("60.01", "ACCOUNT", "Счет", "60.01", 1),
|
||||
("ООО Поставщик", "COUNTERPARTY", "Контрагенты", "60.01", 2), # schema[1]
|
||||
("Договор № 1 от 01.02.2025", "CONTRACT", "Договоры", "60.01", 3), # schema[2]
|
||||
("Итого", "TOTAL", None, None, 0),
|
||||
]
|
||||
assert parsed.levels.strategy == "outline"
|
||||
|
||||
|
||||
def test_parents_follow_tree(rules: Rules) -> None:
|
||||
parsed = parse(_basic(), rules)
|
||||
|
||||
assert [row.parent for row in parsed.rows] == [None, 7, 8, 9, None]
|
||||
|
||||
|
||||
def test_beyond_schema_is_other(rules: Rules) -> None:
|
||||
sheet = osv(
|
||||
r(0, "60", S100),
|
||||
r(1, "ООО Поставщик", S100, inn="7701234567"),
|
||||
r(2, "Договор № 1", S100),
|
||||
r(3, "Спецификация 7", S100),
|
||||
r(None, "Итого", S100),
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
beyond = parsed.one("Спецификация 7")
|
||||
assert (beyond.group_type, beyond.group_name, beyond.account_code_raw) == ("OTHER", None, "60")
|
||||
assert parsed.one("ООО Поставщик").group_type == "COUNTERPARTY"
|
||||
assert parsed.one("Договор № 1").group_type == "CONTRACT"
|
||||
|
||||
|
||||
def test_account_with_name_after_comma(rules: Rules) -> None:
|
||||
sheet = osv(
|
||||
r(0, "60", S100),
|
||||
r(1, "60.01, Расчеты с поставщиками", S100),
|
||||
r(2, "ООО Поставщик", S100),
|
||||
r(None, "Итого", S100),
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
account = parsed.one("60.01, Расчеты с поставщиками")
|
||||
assert (account.group_type, account.account_code_raw) == ("ACCOUNT", "60.01")
|
||||
assert account.body.text == "60.01, Расчеты с поставщиками" # row_value — исходный текст
|
||||
assert parsed.one("ООО Поставщик").account_code_raw == "60.01"
|
||||
|
||||
|
||||
def test_same_counterparty_under_different_subaccounts(rules: Rules) -> None:
|
||||
"""Review Focus 3: одноимённые контрагенты различаются родителем и счётом ветки, а не именем."""
|
||||
sheet = osv(
|
||||
r(0, "66", S100),
|
||||
r(1, "66.03", (None, None, 40, 40, None, None)), # 8
|
||||
r(2, "Юг-Финанс", (None, None, 40, 40, None, None)), # 9
|
||||
r(1, "66.04", (100, None, 10, -10, 120, None)), # 10
|
||||
r(2, "Юг-Финанс", (100, None, 10, -10, 120, None)), # 11
|
||||
r(None, "Итого", S100),
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
first, second = parsed.by_text("Юг-Финанс")
|
||||
assert (first.parent, first.account_code_raw) == (8, "66.03")
|
||||
assert (second.parent, second.account_code_raw) == (10, "66.04")
|
||||
|
||||
|
||||
# --- Контрагент и ИНН ---
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("inn", "expected"),
|
||||
[
|
||||
("7701234567", "7701234567"),
|
||||
("ИНН 770123456789", "770123456789"),
|
||||
(Decimal("123456789"), "0123456789"), # число с потерянным ведущим нулём
|
||||
("№ 12 от 01.02.2025", None),
|
||||
("12345", None),
|
||||
(None, None),
|
||||
],
|
||||
)
|
||||
def test_counterparty_inn_from_inn_column(rules: Rules, inn: object, expected: str | None) -> None:
|
||||
sheet = osv(
|
||||
r(0, "60", S100),
|
||||
r(1, "ООО Поставщик", S100, inn=inn),
|
||||
r(1, "ООО Второй", (None, None, None, None, None, None), inn="7702222222"),
|
||||
r(1, "ООО Третий", (None, None, None, None, None, None), inn="7703333333"),
|
||||
r(None, "Итого", S100),
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
counterparty = parsed.one("ООО Поставщик")
|
||||
assert counterparty.group_type == "COUNTERPARTY"
|
||||
assert counterparty.counterparty_name == "ООО Поставщик"
|
||||
assert counterparty.counterparty_inn == expected
|
||||
|
||||
|
||||
def test_counterparty_fields_only_on_counterparty_rows(rules: Rules) -> None:
|
||||
parsed = parse(_basic(), rules)
|
||||
|
||||
assert [(row.counterparty_name, row.counterparty_inn) for row in parsed.rows] == [
|
||||
(None, None),
|
||||
(None, None),
|
||||
("ООО Поставщик", "7701234567"),
|
||||
(None, None),
|
||||
(None, None),
|
||||
]
|
||||
|
||||
|
||||
# --- data_type строк ---
|
||||
|
||||
|
||||
def test_currency_and_quantity_rows(rules: Rules) -> None:
|
||||
sheet = osv(
|
||||
r(0, "60", S100),
|
||||
r(1, "ООО Поставщик", S100, inn="7701234567"),
|
||||
r(2, "USD", (5, None, 1, None, 6, None)),
|
||||
r(2, "Кол.", (3, None, None, None, 3, None)),
|
||||
r(2, "Вал.", (5, None, 1, None, 6, None)),
|
||||
r(2, "eur", (5, None, 1, None, 6, None)),
|
||||
r(None, "Итого", S100),
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules, doc_data_type="NU")
|
||||
|
||||
types = {row.body.text: row.data_type for row in parsed.rows}
|
||||
assert types == {
|
||||
"60": "NU",
|
||||
"ООО Поставщик": "NU",
|
||||
"USD": "CURRENCY",
|
||||
"Кол.": "QUANTITY",
|
||||
"Вал.": "CURRENCY",
|
||||
"eur": "CURRENCY",
|
||||
"Итого": "NU",
|
||||
}
|
||||
usd = parsed.one("USD")
|
||||
assert usd.body.sums[0] == Decimal("5.00") # суммы остаются в шести полях
|
||||
# Строка-показатель — не группировка: OTHER, а не следующая группа схемы (CONTRACT)
|
||||
assert (usd.group_type, usd.group_name, usd.account_code_raw) == ("OTHER", None, "60")
|
||||
|
||||
|
||||
def test_doc_data_type_none_passes_through(rules: Rules) -> None:
|
||||
parsed = parse(_basic(), rules, doc_data_type=None)
|
||||
|
||||
assert {row.data_type for row in parsed.rows} == {None}
|
||||
|
||||
|
||||
# --- extra ---
|
||||
|
||||
|
||||
def test_extra_from_unmatched_columns(rules: Rules) -> None:
|
||||
parsed = parse(_basic(), rules)
|
||||
|
||||
assert parsed.one("ООО Поставщик").extra == {"КПП": "770101001"}
|
||||
assert parsed.one("60").extra is None # все значения пусты
|
||||
assert parsed.one("Итого").extra is None
|
||||
|
||||
|
||||
def test_account_row_without_sums_and_duplicate_extra_titles(rules: Rules) -> None:
|
||||
"""Review Focus 1: строка счёта со всеми null не падает; две колонки с одним заголовком — разные ключи extra."""
|
||||
sheet = sheet_from_rows(
|
||||
[
|
||||
["ООО Ромашка"],
|
||||
[TITLE],
|
||||
[],
|
||||
["Счет", "Кол-во", "Кол-во", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
|
||||
["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
|
||||
[L(0), "60"], # счёт без сумм
|
||||
[L(1), "ООО Поставщик", "1", "2", None, None, 10, None, 10, None],
|
||||
[L(1), "ООО Второй", None, "3"],
|
||||
["Итого", None, None, None, None, 10, None, 10, None],
|
||||
]
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
account = parsed.one("60")
|
||||
assert account.group_type == "ACCOUNT"
|
||||
assert account.body.sums == (None,) * 6
|
||||
assert account.extra is None
|
||||
first = parsed.one("ООО Поставщик").extra
|
||||
assert first is not None
|
||||
assert len(first) == len(set(first)) == DUPLICATED_EXTRA_COLUMNS
|
||||
assert sorted(value or "" for value in first.values()) == ["1", "2"]
|
||||
second = parsed.one("ООО Второй").extra
|
||||
assert second is not None
|
||||
assert set(second) == set(first)
|
||||
assert sorted(value or "" for value in second.values()) == ["", "3"]
|
||||
|
||||
|
||||
def test_footer_after_total_is_not_data_nor_extra(rules: Rules) -> None:
|
||||
"""T4.5: подвал после «Итого» (подписи, числа правее таблицы) не попадает ни в строки, ни в extra."""
|
||||
sheet = osv(
|
||||
r(0, "60", S100),
|
||||
r(1, "ООО Поставщик", S100, inn="7701234567"),
|
||||
r(None, "Итого", S100),
|
||||
[],
|
||||
["Главный бухгалтер", None, "Иванова"], # подпись в колонке КПП
|
||||
[None, None, None, None, None, None, None, None, None, None, 375490642], # правее таблицы
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
assert [row.body.text for row in parsed.rows] == ["60", "ООО Поставщик", "Итого"]
|
||||
assert all(row.extra is None for row in parsed.rows)
|
||||
assert parsed.total_row == parsed.one("Итого").body.row
|
||||
|
||||
|
||||
def test_column_filled_only_in_footer_is_not_an_extra_key(rules: Rules) -> None:
|
||||
"""T4.5: колонка, заполненная только в подвале, не становится ключом `extra` у строк таблицы."""
|
||||
sheet = sheet_from_rows(
|
||||
[
|
||||
["ООО Ромашка"],
|
||||
[TITLE],
|
||||
[],
|
||||
["Счет", "КПП", "Код", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
|
||||
["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
|
||||
[L(0), "60", None, None, 100, None, None, None, 100, None],
|
||||
[L(1), "ООО Поставщик", "770101001", None, 100, None, None, None, 100, None],
|
||||
["Итого", None, None, 100, None, None, None, 100, None],
|
||||
["Главный бухгалтер", None, "подпись"],
|
||||
]
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
assert parsed.one("ООО Поставщик").extra == {"КПП": "770101001"}
|
||||
|
||||
|
||||
def test_rows_with_values_only_right_of_table_are_skipped(rules: Rules) -> None:
|
||||
sheet = osv(
|
||||
r(0, "60", S100),
|
||||
[None, None, None, None, None, None, None, None, None, None, 777], # только правее таблицы
|
||||
r(1, "ООО Поставщик", S100, inn="7701234567"),
|
||||
r(None, "Итого", S100),
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
assert [row.body.text for row in parsed.rows] == ["60", "ООО Поставщик", "Итого"]
|
||||
|
||||
|
||||
# --- Пустые строки, пробелы, повтор заголовка ---
|
||||
|
||||
|
||||
def test_blank_and_whitespace_rows_do_not_break_tree(rules: Rules) -> None:
|
||||
"""Review Focus 5: пустая строка и строка из пробелов между блоками пропускаются, дерево не рвётся."""
|
||||
sheet = osv(
|
||||
r(0, "60", S100), # 7
|
||||
[], # 8
|
||||
[" ", " ", None, " "], # 9
|
||||
r(1, "ООО Поставщик", S100, inn="7701234567"), # 10
|
||||
r(None, "Итого", S100), # 11
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
assert [row.body.row for row in parsed.rows] == [7, 10, 11]
|
||||
assert parsed.one("ООО Поставщик").parent == ACCOUNT_ROW
|
||||
|
||||
|
||||
def test_header_repeat_is_skipped(rules: Rules) -> None:
|
||||
"""Review Focus 4: повтор заголовка внутри данных (печать по страницам) — не строка данных и не OTHER."""
|
||||
sheet = osv(
|
||||
r(0, "60", S100), # 7
|
||||
["Счет", "ИНН", "КПП", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 8
|
||||
["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], # 9
|
||||
r(1, "ООО Поставщик", S100, inn="7701234567"), # 10
|
||||
r(None, "Итого", S100),
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
assert [row.body.text for row in parsed.rows] == ["60", "ООО Поставщик", "Итого"]
|
||||
assert parsed.one("ООО Поставщик").parent == ACCOUNT_ROW
|
||||
|
||||
|
||||
# --- TOTAL ---
|
||||
|
||||
|
||||
def test_empty_osv_total_without_sums(rules: Rules) -> None:
|
||||
"""R-13: пустая ОСВ — только «Итого» без сумм: одна строка TOTAL, иерархия trivial."""
|
||||
parsed = parse(osv(r(None, "Итого")), rules)
|
||||
|
||||
assert [(row.group_type, row.level, row.parent) for row in parsed.rows] == [("TOTAL", 0, None)]
|
||||
assert parsed.levels.strategy == TRIVIAL_STRATEGY
|
||||
assert parsed.total_row == parsed.rows[0].body.row
|
||||
|
||||
|
||||
def test_total_without_sums_in_account_pattern_sheet_stays_root(rules: Rules) -> None:
|
||||
"""T6-pointer: «Итого» без сумм выбирается по тексту и положению до уровней — иначе account_pattern сделал бы
|
||||
его ребёнком последнего счёта.
|
||||
"""
|
||||
sheet = osv(
|
||||
r(None, "60", S100),
|
||||
r(None, "60.01", S100),
|
||||
r(None, "ООО Поставщик", S100),
|
||||
r(None, "Итого"),
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
assert parsed.levels.strategy == "account_pattern"
|
||||
total = parsed.one("Итого")
|
||||
assert (total.group_type, total.level, total.parent) == ("TOTAL", 0, None)
|
||||
|
||||
|
||||
def test_counterparty_named_like_total_in_the_middle(rules: Rules) -> None:
|
||||
"""T3.3: «Итого-Сервис» в середине данных — контрагент; TOTAL — последняя строка «Итого»."""
|
||||
sheet = osv(
|
||||
r(0, "60", S100),
|
||||
r(1, "Итого-Сервис", S100, inn="7701234567"),
|
||||
r(None, "Итого", S100),
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
assert parsed.one("Итого-Сервис").group_type == "COUNTERPARTY"
|
||||
assert parsed.one("Итого").group_type == "TOTAL"
|
||||
assert [row.group_type for row in parsed.rows].count("TOTAL") == 1
|
||||
|
||||
|
||||
def test_last_row_named_like_total_is_rejected_by_outline_level(rules: Rules) -> None:
|
||||
"""Последняя строка «Итого-Сервис» без настоящего «Итого»: outline глубже минимального — не TOTAL."""
|
||||
sheet = osv(
|
||||
r(0, "60", S100),
|
||||
r(1, "Итого-Сервис", S100, inn="7701234567"),
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
assert parsed.total_row is None
|
||||
assert parsed.one("Итого-Сервис").group_type == "COUNTERPARTY"
|
||||
|
||||
|
||||
def test_known_risk_last_counterparty_named_like_total_without_outline(rules: Rules) -> None:
|
||||
"""Известный риск (документирован): без outline и отступов последняя строка «Итого-Сервис» при отсутствии
|
||||
настоящего «Итого» неотличима от итоговой строки и становится TOTAL; контрольные суммы §1.4 (TOTAL = корень)
|
||||
это поймают, если суммы не совпадают.
|
||||
"""
|
||||
sheet = osv(
|
||||
r(None, "60", S100),
|
||||
r(None, "Итого-Сервис", (1, None, None, None, 1, None)),
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
assert parsed.one("Итого-Сервис").group_type == "TOTAL"
|
||||
|
||||
|
||||
# --- Свёрнутое сальдо (R-1) ---
|
||||
|
||||
|
||||
def test_collapsed_closing_split_by_sign(rules: Rules) -> None:
|
||||
"""Spec «ОСВ без сальдо на начало и со свёрнутым сальдо»: отрицательное свёрнутое — в closing_credit."""
|
||||
sheet = sheet_from_rows(
|
||||
[
|
||||
["ООО Ромашка"],
|
||||
[TITLE],
|
||||
[],
|
||||
["Счет", M(TURNOVER, 2), M(CLOSING, 1, 2)],
|
||||
["Контрагенты", "Дебет", "Кредит", None],
|
||||
[L(0), "60", 10, 30, -20],
|
||||
[L(1), "ООО Первый", 10, None, 10],
|
||||
[L(1), "ООО Второй", None, 30, -30],
|
||||
[L(1), "ООО Третий", None, None, 0],
|
||||
[L(1), "ООО Четвёртый"],
|
||||
["Итого", 10, 30, -20],
|
||||
]
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
sums = {row.body.text: row.body.sums for row in parsed.rows}
|
||||
two = Decimal("0.00")
|
||||
assert sums["ООО Первый"] == (None, None, Decimal("10.00"), None, Decimal("10.00"), None)
|
||||
assert sums["ООО Второй"] == (None, None, None, Decimal("30.00"), None, Decimal("30.00"))
|
||||
assert sums["ООО Третий"] == (None, None, None, None, two, None)
|
||||
assert sums["ООО Четвёртый"] == (None,) * 6
|
||||
assert sums["Итого"] == (None, None, Decimal("10.00"), Decimal("30.00"), None, Decimal("20.00"))
|
||||
collapsed = [warning for warning in parsed.warnings if "свёрнутое сальдо" in warning]
|
||||
assert len(collapsed) == 1
|
||||
assert "closing" in collapsed[0]
|
||||
|
||||
|
||||
def test_no_collapsed_warning_for_regular_sheet(rules: Rules) -> None:
|
||||
parsed = parse(_basic(), rules)
|
||||
|
||||
assert parsed.warnings == []
|
||||
|
||||
|
||||
# --- Суммы и предупреждения ---
|
||||
|
||||
|
||||
def test_sums_in_column_map_order_and_money_warnings_with_row(rules: Rules) -> None:
|
||||
sheet = osv(
|
||||
r(0, "60", ("1 234,50", "—", "(10,00)", "abc", Decimal("0.005"), None)), # 7
|
||||
r(None, "Итого"),
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
assert parsed.one("60").body.sums == (
|
||||
Decimal("1234.50"),
|
||||
None,
|
||||
Decimal("-10.00"),
|
||||
None,
|
||||
Decimal("0.01"),
|
||||
None,
|
||||
)
|
||||
assert len(parsed.warnings) == 1
|
||||
assert "строка 7" in parsed.warnings[0]
|
||||
assert "abc" in parsed.warnings[0]
|
||||
|
||||
|
||||
def test_body_row_style_fields(rules: Rules) -> None:
|
||||
sheet = osv(
|
||||
r(None, C("60", bold=True), S100),
|
||||
r(None, C("ООО Поставщик", indent=2), S100),
|
||||
r(None, "Итого", S100),
|
||||
)
|
||||
|
||||
header = find_table_header(sheet, rules)
|
||||
body, _, _ = collect_body_rows(sheet, header, rules, Deadline(TIMEOUT_SECONDS))
|
||||
|
||||
account, counterparty, _ = body
|
||||
assert (account.is_account, account.account_rank, account.bold) == (True, 1, True)
|
||||
assert (counterparty.is_account, counterparty.account_rank, counterparty.indent) == (False, None, 2)
|
||||
|
||||
|
||||
def test_account_rank_counts_segments(rules: Rules) -> None:
|
||||
sheet = osv(
|
||||
r(0, "76", S100),
|
||||
r(1, "76.07", S100),
|
||||
r(2, "76.07.1, Прочие", S100),
|
||||
r(None, "Итого", S100),
|
||||
)
|
||||
|
||||
header = find_table_header(sheet, rules)
|
||||
body, _, _ = collect_body_rows(sheet, header, rules, Deadline(TIMEOUT_SECONDS))
|
||||
|
||||
assert [row.account_rank for row in body] == [1, 2, 3, None]
|
||||
|
||||
|
||||
# --- Дедлайн ---
|
||||
|
||||
|
||||
def test_collect_body_rows_checks_deadline(rules: Rules) -> None:
|
||||
data = [r(1, f"ООО Поставщик {index}", S100) for index in range(1200)]
|
||||
sheet = osv(r(0, "60", S100), *data, r(None, "Итого", S100))
|
||||
header = find_table_header(sheet, rules)
|
||||
# Часы: 0 при создании дедлайна, дальше — далеко за лимитом; первая же проверка внутри обхода строк бросает
|
||||
readings = iter([0.0])
|
||||
deadline = Deadline(EXPIRED_AFTER_SECONDS, clock=lambda: next(readings, EXPIRED_AFTER_SECONDS * 2.0))
|
||||
|
||||
with pytest.raises(ParseTimeoutError):
|
||||
collect_body_rows(sheet, header, rules, deadline)
|
||||
|
||||
|
||||
def test_classified_rows_cover_every_body_row(rules: Rules) -> None:
|
||||
parsed = parse(_basic(), rules)
|
||||
|
||||
assert [row.level for row in parsed.rows] == [parsed.levels.by_row[row.body.row] for row in parsed.rows]
|
||||
@ -322,9 +322,9 @@ def classify_rows(rows: Sequence[BodyRow], levels: Levels, schema: list[OsvGroup
|
||||
```
|
||||
Правила: ACCOUNT по `ACCOUNT_RE` (`account_code_raw` = группа 1, как в файле); текущая ветка счёта наследуется детям (`account_code_raw` у не-счётных строк = код ближайшего предка-счёта); не-счёт — позиция = число не-счётных предков после последнего ACCOUNT → `schema[позиция]` (индекс 0 — ACCOUNT, поэтому первая не-счётная группа = `schema[1]`), выход за схему → OTHER; `group_name` = `OsvGroup.name`; COUNTERPARTY → `counterparty_name = text`, `counterparty_inn = inn_digits(cell(inn_col))`; `data_type`: текст матчит `data_types.row` или ∈ `currency_codes` (регистронезависимо) → CURRENCY/QUANTITY, иначе `doc_data_type` (`# ASSUMPTION(OQ-45)`); `total_row` → TOTAL с `level 0`, `parent None`; `extra` = `{имя: sheet.text_at(row, col) or None for col, имя in header.extra_cols}` или `None`, если все значения пусты.
|
||||
|
||||
- [ ] **Step 1: Тесты.** Юнит: 60 / 60.01 / контрагент / договор → ACCOUNT, ACCOUNT, COUNTERPARTY (`schema[1]`), CONTRACT; выход за схему → OTHER; «60.01, Расчеты с поставщиками» → `account_code_raw "60.01"`; «USD» и «Кол.» → CURRENCY/QUANTITY; ИНН-колонка; `extra` с КПП и `None` при пустых; строка счёта со всеми `null` суммами не падает и не попадает в `extra` дубликатами (Review Focus 1); пустые строки и `" "` пропущены (Review Focus 5); повтор заголовка пропущен. `test_classify_real.py` (`fixtures`): `schastl/2024-Q3/…62…xls` — «БП Аренда» DEPARTMENT, «МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» COUNTERPARTY с 10-значным ИНН, «Ведомость в счет з/платы» COUNTERPARTY с `None`; `rb/2024-FY/ОСВ_66 за 2024.xls` — «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» `account_code_raw "66.03"`; у всех 66 ровно одна строка TOTAL.
|
||||
- [ ] **Step 2–4:** падает → реализация → тесты, `make check`.
|
||||
- [ ] **Step 5: Commit** `feat(parser): классификация строк по group_schema (§1.3)`.
|
||||
- [x] **Step 1: Тесты.** Юнит: 60 / 60.01 / контрагент / договор → ACCOUNT, ACCOUNT, COUNTERPARTY (`schema[1]`), CONTRACT; выход за схему → OTHER; «60.01, Расчеты с поставщиками» → `account_code_raw "60.01"`; «USD» и «Кол.» → CURRENCY/QUANTITY; ИНН-колонка; `extra` с КПП и `None` при пустых; строка счёта со всеми `null` суммами не падает и не попадает в `extra` дубликатами (Review Focus 1); пустые строки и `" "` пропущены (Review Focus 5); повтор заголовка пропущен. `test_classify_real.py` (`fixtures`): `schastl/2024-Q3/…62…xls` — «БП Аренда» DEPARTMENT, «МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» COUNTERPARTY с 10-значным ИНН, «Ведомость в счет з/платы» COUNTERPARTY с `None`; `rb/2024-FY/ОСВ_66 за 2024.xls` — «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» `account_code_raw "66.03"`; у всех 66 ровно одна строка TOTAL.
|
||||
- [x] **Step 2–4:** падает → реализация → тесты, `make check`.
|
||||
- [x] **Step 5: Commit** `feat(parser): классификация строк по group_schema (§1.3)`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -36,7 +36,7 @@
|
||||
- [x] 2.3 `hierarchy.py`: стратегии `outline`, `indent`, `account_pattern`, `sums`, каскад с проверкой непротиворечивости,
|
||||
`parent_row_number`. Unit-тесты на сетках для каждой стратегии и для отказа стратегии (одинаковые уровни); `fixtures`-тест:
|
||||
`hierarchy=outline` у всех 66.
|
||||
- [ ] 2.4 `classify.py` + `numbers.py`: классификация (ACCOUNT по регулярке, позиция в `group_schema`, OTHER при выходе,
|
||||
- [x] 2.4 `classify.py` + `numbers.py`: классификация (ACCOUNT по регулярке, позиция в `group_schema`, OTHER при выходе,
|
||||
TOTAL, COUNTERPARTY с ИНН, Вал./Кол./код валюты → `data_type`), числа (Decimal, текстовые форматы, скобки, прочерки,
|
||||
экспонента), `extra`. Unit-тесты по §1.3 и §1.2; `fixtures`-тест: в `schastl …62 за 3 кв 2024` «БП Аренда» → DEPARTMENT,
|
||||
«МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» с ИНН, физлицо без ИНН.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user