diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py new file mode 100644 index 0000000..e6cf810 --- /dev/null +++ b/api/src/parser/classify.py @@ -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 diff --git a/api/tests/fixtures/real_osv.py b/api/tests/fixtures/real_osv.py new file mode 100644 index 0000000..228feb4 --- /dev/null +++ b/api/tests/fixtures/real_osv.py @@ -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) diff --git a/api/tests/fixtures/test_classify_real.py b/api/tests/fixtures/test_classify_real.py new file mode 100644 index 0000000..4978786 --- /dev/null +++ b/api/tests/fixtures/test_classify_real.py @@ -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) diff --git a/api/tests/fixtures/test_hierarchy_real.py b/api/tests/fixtures/test_hierarchy_real.py new file mode 100644 index 0000000..349ad71 --- /dev/null +++ b/api/tests/fixtures/test_hierarchy_real.py @@ -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" diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py new file mode 100644 index 0000000..e7f3712 --- /dev/null +++ b/api/tests/unit/parser/test_classify.py @@ -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] diff --git a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md index e8b8f6b..3b578f4 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md +++ b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md @@ -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)`. --- diff --git a/openspec/changes/slice-2a-osv-parser/tasks.md b/openspec/changes/slice-2a-osv-parser/tasks.md index 84af963..f5f389f 100644 --- a/openspec/changes/slice-2a-osv-parser/tasks.md +++ b/openspec/changes/slice-2a-osv-parser/tasks.md @@ -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, «МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» с ИНН, физлицо без ИНН.