Строки-показатели: метка колонки «Показатели» при сборе строк (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
43653f1b6f
commit
ef07ced609
@ -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:
|
||||
"""Контекст строки для её детей: счёт ветки и число не-счётных группировок после последнего счёта."""
|
||||
|
||||
@ -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)
|
||||
|
||||
55
api/src/parser/indicators.py
Normal file
55
api/src/parser/indicators.py
Normal 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"
|
||||
@ -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
|
||||
|
||||
82
api/tests/unit/parser/test_indicators.py
Normal file
82
api/tests/unit/parser/test_indicators.py
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user