diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index e105256..6d47ab1 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -31,8 +31,9 @@ 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.indicators import is_indicator_total, read_label, row_data_type from src.parser.numbers import column_inn, split_inn_suffix, to_money -from src.parser.rules import GroupType, Rules, first_match, normalize_text +from src.parser.rules import GroupType, Rules __all__ = [ "ACCOUNT_RE", @@ -184,10 +185,12 @@ def _is_secondary_total(text: str, rules: Rules) -> bool: def _trailing_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], total: int, rules: Rules) -> int: - """Номер последней из вторых итоговых строк, идущих в таблице сразу за основной «Итого» (она сама, если их нет).""" + """Номер последней из вторых итоговых строк, идущих в таблице сразу за основной «Итого» (она сама, если их нет): + «Итого развернутое» и итоговые строки-показатели («Итого» с меткой «НУ», AURORA-1613).""" last = total for row in rows[rows.index(total) + 1 :]: - if not _is_secondary_total(sheet.text_at(row, header.text_col), rules): + secondary = _is_secondary_total(sheet.text_at(row, header.text_col), rules) + if not (secondary or is_indicator_total(sheet, row, header, rules)): break last = row return last @@ -233,7 +236,9 @@ def _label_cols(sheet: Sheet, header: TableHeader, rows: Sequence[int]) -> tuple found.update( col for col, _ in sheet.row_cells(row) - if header.text_col < col < first_sum and col != header.inn_col and col <= header.right_edge + if header.text_col < col < first_sum + and col not in (header.inn_col, header.indicator_col) + and col <= header.right_edge ) return (header.text_col, *sorted(found)) @@ -247,8 +252,15 @@ def _label_of(sheet: Sheet, row: int, label_cols: Sequence[int]) -> tuple[int, s return label_cols[0], "" -def _body_row(sheet: Sheet, row: int, header: TableHeader, label_cols: Sequence[int], warnings: list[str]) -> BodyRow: +def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строке, приходит явно + sheet: Sheet, row: int, header: TableHeader, label_cols: Sequence[int], warnings: list[str], rules: Rules +) -> BodyRow: label_col, text = _label_of(sheet, row, label_cols) + label = read_label(sheet, row, text, header, rules) + if label.text is not None: + text = label.text # у строки с меткой колонки «Показатели» текст — метка, не название + if label.warning is not None: + warnings.append(label.warning) account = ACCOUNT_RE.match(text) bold, indent = _text_cell_style(sheet, row, label_col) # Единицы отступа разные (alignment.indent, пробелы, колонки) — иерархия сравнивает только ранги: колонка глубже @@ -264,6 +276,8 @@ def _body_row(sheet: Sheet, row: int, header: TableHeader, label_cols: Sequence[ outline=sheet.outline(row), sums=_sums(sheet, row, header.columns, warnings), label_col=label_col, + indicator=label.indicator, + label_data_type=label.data_type, ) @@ -304,7 +318,7 @@ def collect_body_rows( for index, row in enumerate(rows, start=1): if index % DEADLINE_CHECK_EVERY_ROWS == 0: deadline.check("суммы строк") - body.append(_body_row(sheet, row, header, label_cols, warnings)) + body.append(_body_row(sheet, row, header, label_cols, warnings, rules)) return body, total_row, warnings @@ -333,14 +347,6 @@ def _extra(sheet: Sheet, row: int, keys: dict[int, str]) -> dict[str, str | None 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: """Контекст строки для её детей: счёт ветки и число не-счётных группировок после последнего счёта.""" diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index 96a7185..af1836a 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -14,6 +14,7 @@ from dataclasses import dataclass from decimal import Decimal from typing import Protocol +from src.contract.common import DataType from src.parser.deadline import Deadline from src.parser.errors import InvalidFormatError @@ -59,6 +60,8 @@ class BodyRow: outline: int sums: tuple[Decimal | None, ...] label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы) + indicator: bool = False # строка-показатель (§1.3): вид сумм предыдущей строки данных, не уровень аналитики + label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.») @dataclass(frozen=True) diff --git a/api/src/parser/indicators.py b/api/src/parser/indicators.py new file mode 100644 index 0000000..af351b8 --- /dev/null +++ b/api/src/parser/indicators.py @@ -0,0 +1,55 @@ +"""Строки-показатели ОСВ (§1.3, AURORA-1613, AURORA-1661): метка вида суммы строки и привязка к строке данных.""" + +from dataclasses import dataclass + +from src.contract.common import DataType +from src.parser.detect import TableHeader +from src.parser.grid import Sheet +from src.parser.rules import Rules, first_match, normalize_text + +__all__ = ["RowLabel", "column_label", "is_indicator_total", "read_label", "row_data_type"] + + +@dataclass(frozen=True) +class RowLabel: + """Вид строки по метке: показатель ли она, `data_type` метки, текст строки взамен исходного, предупреждение.""" + + indicator: bool + data_type: DataType | None + text: str | None = None # для метки колонки «Показатели» — сама метка (`row_value`) + warning: str | None = 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 + + +def column_label(sheet: Sheet, row: int, header: TableHeader) -> str: + """Метка строки в колонке «Показатели» (пусто — колонки нет или ячейка пуста).""" + return "" if header.indicator_col is None else sheet.text_at(row, header.indicator_col) + + +def read_label(sheet: Sheet, row: int, text: str, header: TableHeader, rules: Rules) -> RowLabel: + """Метка колонки «Показатели» важнее текста строки: «БУ» — строка данных BU, прочие — показатели; без метки — + показатель по тексту («Вал.», «Кол.», «Кол-во», «В валюте», код валюты), иначе обычная строка без своего вида.""" + label = column_label(sheet, row, header) + if label: + data_type = first_match(rules.data_types.indicator_column, label) + if data_type == "BU": + return RowLabel(indicator=False, data_type="BU") + if data_type is not None or any(p.search(label) for p in rules.data_types.indicator_column_untyped): + return RowLabel(indicator=True, data_type=data_type, text=label) + warning = f"строка {row}: метка «{label}» в колонке «Показатели» не распознана — строка вне проверок" + return RowLabel(indicator=True, data_type=None, text=label, warning=warning) + data_type = row_data_type(text, rules, frozenset(rules.data_types.currency_codes)) + return RowLabel(indicator=data_type is not None, data_type=data_type) + + +def is_indicator_total(sheet: Sheet, row: int, header: TableHeader, rules: Rules) -> bool: + """Итоговая строка-показатель за «Итого»: метка колонки «Показатели» не «БУ».""" + label = column_label(sheet, row, header) + return bool(label) and first_match(rules.data_types.indicator_column, label) != "BU" diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index 1aaa77a..cef06c3 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -1037,3 +1037,40 @@ 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] + + +# --- Колонка «Показатели» (#58) --- + +INDICATOR_TOTAL_ROW = 7 + + +def _indicator_sheet() -> Sheet: + return sheet_from_rows( + [ + [TITLE], + ["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], + ["Контрагенты", "Показатели", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + [L(0), "60", "БУ", 100, None, 50, 30, 120, None], # 4 + [L(1), M("ООО Поставщик", 1, 2), "БУ", 100, None, 50, 30, 120, None], # 5 + [L(1), None, "НУ", 90, None, 40, 30, 100, None], # 6 + [L(0), "Итого", "БУ", 100, None, 50, 30, 120, None], # 7 + [L(0), None, "НУ", 90, None, 40, 30, 100, None], # 8 + ] + ) + + +def test_indicator_column_rows_are_labelled_indicators(rules: Rules) -> None: + sheet = _indicator_sheet() + header = find_table_header(sheet, rules) + + body, total_row, warnings = collect_body_rows(sheet, header, rules, Deadline(TIMEOUT_SECONDS)) + + by_row = {item.row: item for item in body} + assert sorted(by_row) == [4, 5, 6, 7, 8] + assert total_row == INDICATOR_TOTAL_ROW + assert not any(TAIL_NUMBERS_MARK in warning for warning in warnings) + assert [by_row[row].indicator for row in (4, 5, 6, 7, 8)] == [False, False, True, False, True] + assert [by_row[row].text for row in (6, 8)] == ["НУ", "НУ"] + assert [by_row[row].label_data_type for row in (4, 5, 6, 7, 8)] == ["BU", "BU", "NU", "BU", "NU"] + assert by_row[5].text == "ООО Поставщик" + assert not by_row[6].is_account diff --git a/api/tests/unit/parser/test_indicators.py b/api/tests/unit/parser/test_indicators.py new file mode 100644 index 0000000..43eb0ef --- /dev/null +++ b/api/tests/unit/parser/test_indicators.py @@ -0,0 +1,82 @@ +"""Метки строк-показателей (§1.3, #58): колонка «Показатели» и текст строки.""" + +import pytest +from gridbuilder import L, M, at, sheet_from_rows + +from src.parser.detect import TableHeader, find_table_header +from src.parser.grid import Sheet +from src.parser.indicators import RowLabel, is_indicator_total, read_label +from src.parser.rules import Rules, load_rules + +TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г." +LABEL_COL = 2 +FIRST_ROW = 4 + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +def _sheet(*labels: str, text: str = "60") -> tuple[Sheet, TableHeader]: + rows: list[list[object]] = [ + [TITLE], + ["Счет", None, M("Сальдо на начало периода", 2), M("Обороты за период", 2), M("Сальдо на конец периода", 2)], + ["Счет", "Показатели", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + ] + rows.extend([L(0), *at({1: text, LABEL_COL: label, 3: 10, 8: 10})] for label in labels) + sheet = sheet_from_rows(rows) + return sheet, find_table_header(sheet, load_rules()) + + +def _read(label: str, rules: Rules, text: str = "60") -> tuple[RowLabel, int]: + sheet, header = _sheet(label, text=text) + return read_label(sheet, FIRST_ROW, text, header, rules), FIRST_ROW + + +def test_bu_label_is_data_row(rules: Rules) -> None: + result, _ = _read("БУ", rules) + assert (result.indicator, result.data_type, result.text) == (False, "BU", None) + + +def test_nu_label_is_indicator_with_label_text(rules: Rules) -> None: + result, _ = _read("НУ", rules) + assert (result.indicator, result.data_type, result.text, result.warning) == (True, "NU", "НУ", None) + + +def test_untyped_label_has_no_data_type_and_no_warning(rules: Rules) -> None: + result, _ = _read("ПР", rules) + assert (result.indicator, result.data_type, result.text, result.warning) == (True, None, "ПР", None) + + +def test_unknown_label_warns_with_row_number(rules: Rules) -> None: + result, row = _read("Ед.", rules) + assert (result.indicator, result.data_type) == (True, None) + assert result.warning is not None + assert f"строка {row}" in result.warning + assert "Ед." in result.warning + + +def test_text_currency_label_without_column(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + [TITLE], + ["Счет", M("Сальдо на начало периода", 2), M("Обороты за период", 2), M("Сальдо на конец периода", 2)], + ["Счет", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + [L(0), *at({1: "В валюте", 2: 10})], + [L(0), *at({1: "Валюта USD", 2: 10})], + ] + ) + header = find_table_header(sheet, rules) + assert header.indicator_col is None + in_currency = read_label(sheet, 4, "В валюте", header, rules) + assert (in_currency.indicator, in_currency.data_type, in_currency.text) == (True, "CURRENCY", None) + plain = read_label(sheet, 5, "Валюта USD", header, rules) + assert (plain.indicator, plain.data_type) == (False, None) + + +def test_is_indicator_total(rules: Rules) -> None: + sheet, header = _sheet("НУ", "БУ", "") + assert is_indicator_total(sheet, FIRST_ROW, header, rules) + assert not is_indicator_total(sheet, FIRST_ROW + 1, header, rules) + assert not is_indicator_total(sheet, FIRST_ROW + 2, header, rules)