Строки-показатели: метка колонки «Показатели» при сборе строк (#58)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 11:55:05 +04:00
parent 43653f1b6f
commit ef07ced609
5 changed files with 197 additions and 14 deletions

View File

@ -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:
"""Контекст строки для её детей: счёт ветки и число не-счётных группировок после последнего счёта."""

View File

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

View File

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

View File

@ -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

View File

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