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:
Andrey Epifancev 2026-10-05 14:07:33 +04:00
parent 663ba17716
commit 4a16f193af
7 changed files with 1136 additions and 4 deletions

333
api/src/parser/classify.py Normal file
View 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
View 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)

View 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)

View 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"

View 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]

View File

@ -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)`.
---

View File

@ -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,
«МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» с ИНН, физлицо без ИНН.