Строки-показатели: метка колонки «Показатели» при сборе строк (#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.detect import GROUP_ORDER, ColumnMap, TableHeader, first_data_row, is_header_repeat
from src.parser.grid import Sheet, column_letter from src.parser.grid import Sheet, column_letter
from src.parser.hierarchy import BodyRow, Levels 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.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__ = [ __all__ = [
"ACCOUNT_RE", "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: def _trailing_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], total: int, rules: Rules) -> int:
"""Номер последней из вторых итоговых строк, идущих в таблице сразу за основной «Итого» (она сама, если их нет).""" """Номер последней из вторых итоговых строк, идущих в таблице сразу за основной «Итого» (она сама, если их нет):
«Итого развернутое» и итоговые строки-показатели («Итого» с меткой «НУ», AURORA-1613)."""
last = total last = total
for row in rows[rows.index(total) + 1 :]: 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 break
last = row last = row
return last return last
@ -233,7 +236,9 @@ def _label_cols(sheet: Sheet, header: TableHeader, rows: Sequence[int]) -> tuple
found.update( found.update(
col col
for col, _ in sheet.row_cells(row) 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)) 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], "" 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_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) account = ACCOUNT_RE.match(text)
bold, indent = _text_cell_style(sheet, row, label_col) bold, indent = _text_cell_style(sheet, row, label_col)
# Единицы отступа разные (alignment.indent, пробелы, колонки) — иерархия сравнивает только ранги: колонка глубже # Единицы отступа разные (alignment.indent, пробелы, колонки) — иерархия сравнивает только ранги: колонка глубже
@ -264,6 +276,8 @@ def _body_row(sheet: Sheet, row: int, header: TableHeader, label_cols: Sequence[
outline=sheet.outline(row), outline=sheet.outline(row),
sums=_sums(sheet, row, header.columns, warnings), sums=_sums(sheet, row, header.columns, warnings),
label_col=label_col, 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): for index, row in enumerate(rows, start=1):
if index % DEADLINE_CHECK_EVERY_ROWS == 0: if index % DEADLINE_CHECK_EVERY_ROWS == 0:
deadline.check("суммы строк") 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 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 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) @dataclass(frozen=True)
class _Branch: class _Branch:
"""Контекст строки для её детей: счёт ветки и число не-счётных группировок после последнего счёта.""" """Контекст строки для её детей: счёт ветки и число не-счётных группировок после последнего счёта."""

View File

@ -14,6 +14,7 @@ from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
from typing import Protocol from typing import Protocol
from src.contract.common import DataType
from src.parser.deadline import Deadline from src.parser.deadline import Deadline
from src.parser.errors import InvalidFormatError from src.parser.errors import InvalidFormatError
@ -59,6 +60,8 @@ class BodyRow:
outline: int outline: int
sums: tuple[Decimal | None, ...] sums: tuple[Decimal | None, ...]
label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы) label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы)
indicator: bool = False # строка-показатель (§1.3): вид сумм предыдущей строки данных, не уровень аналитики
label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.»)
@dataclass(frozen=True) @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) parsed = parse(_basic(), rules)
assert [row.level for row in parsed.rows] == [parsed.levels.by_row[row.body.row] for row in parsed.rows] 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)