arm-tabular-parser/docs/superpowers/plans/2026-10-19-osv-indicator-column.md
Andrey Epifancev 656486a7d2 OpenSpec change osv-indicator-column и план (#58)
Колонка «Показатели», строки-показатели, «В валюте», BU по умолчанию (AURORA-1613, AURORA-1661 — валютная часть).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:03:13 +04:00

45 KiB
Raw Blame History

Колонка «Показатели» и валютные строки ОСВ: план реализации

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Строки ОСВ, которые не являются данными БУ (метки колонки «Показатели» — НУ, Кол., Вал., ПР, ВР; строка «В валюте»), не ломают дерево и проверки строк БУ и не попадают в свод; строки без вида данных при молчащей шапке получают BU (AURORA-1613, AURORA-1661 — валютная часть).

Architecture: Признак строки-показателя indicator вычисляется при сборе строк (collect_body_rows → новый модуль indicators.py: метка колонки «Показатели» по словарю, иначе текст строки по data_types.row) и живёт в BodyRow и ClassifiedRow. Каскад иерархии получает строки без показателей; показатель привязывается к предыдущей строке данных (indicators.levels_with_indicators). Проверки читают признак вместо набора data_type. Вид данных строки: метка колонки → иначе шапка → иначе BU с предупреждением.

Tech Stack: Python 3.11, pytest, ruff, mypy; openpyxl (генератор синтетики); OpenSpec.

Spec: tabular-parser/openspec/changes/osv-indicator-column/ (proposal.md, design.md, specs/osv-parsing/spec.md). Правило — docs/context/05-domain-rules.md §1.3, §1.4 (ветка docs feat/58-osv-indicators, уже закоммичено). Issue RSHB/arm-tabular-parser#58. Jira AURORA-1613, AURORA-1661 (валютная часть).

Worktree: /Users/andrey.epifancev/projects/awp-ca/.wt/osv-indicators/ — tabular-parser/, docs/, fixtures/ на ветке feat/58-osv-indicators; CLAUDE.md, .claude/, reference-old-arm/ — симлинки. Ниже $WT = этот путь. Git — только git -C $WT/<repo>. Команды Python — из $WT/tabular-parser/api с ENV_FILE=.env.test .venv/bin/python -m pytest …; make — из $WT/tabular-parser (сначала один раз make venv). codegraph: codegraph_explore с projectPath = $WT/tabular-parser.

Параллельный трек B (/Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/, плоская форма AURORA-1661) правит header.py (_account_code, _organization), hierarchy.py (стратегии, resolve_levels), classify.py (_find_total, _trailing_totals), checks.py (_total), синтетику s33–s39. Этот план в hierarchy.py добавляет только поля BodyRow, в _trailing_totals и _total — по одному условию. Его файлы не трогать сверх этого; при конфликте слияния второй вливающийся трек добавляет своё условие к версии первого.

Global Constraints

  • Язык комментариев, докстрингов, предупреждений, коммитов — русский; идентификаторы — английские.
  • Никаких координат в src/parser/** (кроме grid.py, readers/): AST-линтер tests/unit/parser/test_no_coordinates.py.
  • Словари — данные (api/rules/*.yaml); каждая новая запись срабатывает хотя бы на одном файле (tests/fixtures/test_rules_coverage.py). Паттерны без дефисов, «ё» и переносов (текст нормализуется: дефис и перенос → пробел).
  • Контракт результата и DBML не меняются. data_type строки — только значения контракта (BU, NU, SUM, CURRENCY, QUANTITY, null).
  • Деньги — Decimal.
  • Golden-снимки реальных файлов и s01–s32 MUST NOT измениться. Любой diff по ним — стоп, отчёт контроллеру, вопрос владельцу. make golden-update — только для новых s40–s43, отдельным коммитом в fixtures.
  • Файлы контура (/Users/andrey.epifancev/projects/awp-ca/result_dir.zip) в git MUST NOT попадать; распаковка — только во временный каталог вне репозиториев (mktemp -d).
  • Каждое решение без обсуждения — строкой в openspec/changes/osv-indicator-column/design.md, раздел ## Decisions made without discussion. Маркеры находок T/C/I/M/R/O — по ~/.claude/CLAUDE.md.
  • Перед каждым коммитом в tabular-parser — make check из $WT/tabular-parser зелёный. Коммиты заканчиваются строкой Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
  • Python-файлы правятся инструментами Edit/Write, не sed/heredoc через Bash.

Review Focus

  1. Документ «Выводимые данные: НУ» (s12) — все строки NU, проверки работают как раньше (признак показателя — не data_type).
  2. Строки «Вал.»/«Кол.»/код валюты в колонке текста (s04, s30, реальные файлы) — родитель и уровень те же, golden без diff.
  3. Строка «НУ» под объединённой ячейкой названия — row_value = "НУ", а не повтор названия; не контрагент, не счёт.
  4. Вторая «Итого» с меткой «НУ» — TOTAL, без предупреждения «ниже таблицы», сверка «Итого = корень» по строке БУ.
  5. Умолчание BU — только строкам без своего вида; header.data_type остаётся null; предупреждение одно на лист и только если умолчание применилось.

Task 1: Словари (модель: haiku)

Files:

  • Modify: api/rules/osv_headers.yaml (service_columns)

  • Modify: api/rules/data_types.yaml

  • Modify: api/src/parser/rules.py (DataTypeRules)

  • Test: api/tests/unit/parser/test_rules.py

  • Step 1: Тест загрузки и совпадений — дописать в test_rules.py (по образцу соседних тестов словарей, фикстура rules модуля):

@pytest.mark.parametrize(
    ("label", "expected"),
    [("БУ", "BU"), ("НУ", "NU"), ("Кол.", "QUANTITY"), ("Кол", "QUANTITY"), ("Вал.", "CURRENCY")],
)
def test_indicator_column_labels(rules: Rules, label: str, expected: str) -> None:
    assert first_match(rules.data_types.indicator_column, label) == expected


@pytest.mark.parametrize("label", ["ПР", "ВР"])
def test_indicator_column_untyped_labels(rules: Rules, label: str) -> None:
    assert first_match(rules.data_types.indicator_column, label) is None
    assert any(pattern.search(label) for pattern in rules.data_types.indicator_column_untyped)


def test_v_valyute_row_is_currency(rules: Rules) -> None:
    assert first_match(rules.data_types.row, "В валюте") == "CURRENCY"
    assert first_match(rules.data_types.row, "Валюта USD") is None


@pytest.mark.parametrize("title", ["Показа-\nтели", "Показатели", "ПОКАЗАТЕЛИ"])
def test_indicator_service_column(rules: Rules, title: str) -> None:
    assert any(pattern.search(title) for pattern in rules.headers.service_columns["indicator"])
  • Step 2: Запустить — падает (AttributeError: indicator_column, KeyError: 'indicator').

Run: cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit/parser/test_rules.py -q

  • Step 3: Словари. В osv_headers.yaml, service_columns, после inn:
  # Колонка вида суммы строки 1С («Показатели», в шапке часто с переносом «Показа-тели»): метки БУ/НУ/Кол./Вал./ПР/ВР
  # (§1.3, AURORA-1613). Метки читает `data_types.yaml` — `indicator_column`
  indicator:
    - '^показа\s?тели$'

В data_types.yaml: в row добавить - ['^в\s+валюте$', CURRENCY] с комментарием «строка суммы в валюте под «Валюта <код>» (AURORA-1661); сама «Валюта USD» — обычная строка в рублях»; после row — новые разделы:

# Метка колонки «Показатели» (§1.3, AURORA-1613): БУ — обычная строка с data_type BU; остальные — строки-показатели
# (вид сумм предыдущей строки данных, вне проверок «дети = родитель» и «Итого»)
indicator_column:
  - ['^бу$', BU]
  - ['^ну$', NU]
  - ['^кол\.?$', QUANTITY]
  - ['^вал\.?$', CURRENCY]

# Метки-показатели без значения data_type в контракте (постоянные и временные разницы): data_type = null.
# Прикладной сценарий НУ и разниц — аналитика AURORA-1669
indicator_column_untyped:
  - '^пр$'
  - '^вр$'
  • Step 4: rules.py. В DataTypeRules после row:
    indicator_column: list[tuple[Pattern, DataType]]
    indicator_column_untyped: list[Pattern]

Проверить, что OsvHeaderRules не требует обязательных ключей кроме inn (новый ключ indicator читается кодом через service_columns.get("indicator", []) — Task 3).

  • Step 5: Тест зелёный, затем make check (тест покрытия словарей упадёт на новых записях — это ожидаемо до Task 2; если make check красный только из-за test_rules_coverage, коммит допустим с пометкой в ledger, покрытие закрывает Task 7).
  • Step 6: Commit git -C $WT/tabular-parser commit -am "Словари: колонка «Показатели», «В валюте» (#58)".

Task 2: Синтетика s40–s43 и ожидания (модель: sonnet)

Files:

  • Modify: api/tests/synthetic/generate.py

  • Modify: api/tests/synthetic/test_synthetic_files.py

  • Create: $WT/fixtures/osv/synthetic/s40_indicator_column_bu_nu.xlsx, s41_indicator_column_qty_val_diff.xlsx, s42_currency_slices.xlsx, s43_no_data_type_header.xlsx

  • Step 1: Построитель листа «по сетке». В generate.py после form_sheet добавить (константы ORG, GROUP_TITLES, DEBIT, CREDIT, D — уже есть):

MERGED = "<объединено>"  # ячейка текста — продолжение объединения строки выше
INDICATOR_TITLE = "Показа-\nтели"


@dataclass(frozen=True)
class GridRow:
    """Строка листа «по сетке»: текст (None — пусто, MERGED — объединение с ячейкой выше), метка колонки «Показатели»,
    шесть сумм (начало Д/К, обороты Д/К, конец Д/К; строки — `Decimal` в файле) и outline level."""

    text: str | None
    sums: tuple[str | None, str | None, str | None, str | None, str | None, str | None]
    label: str | None = None
    outline: int = 0


def _grid_osv(  # noqa: PLR0913 - лист описывается полностью одним вызовом
    *,
    account: str,
    group_label: str,
    rows: Sequence[GridRow],
    data_type_line: str | None,
    indicator_column: bool,
    name: str = "Лист_1",
) -> SheetSpec:
    """ОСВ, заданная строками напрямую (не деревом `Node`): колонка «Показатели», объединённые названия, строки-соседи."""
    sheet = SheetSpec(name)
    sheet.put(1, 1, ORG)
    sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {account}")
    sheet.put(3, 1, "Период: 3 квартал 2025 г.")
    if data_type_line is not None:
        sheet.put(4, 1, data_type_line)
    sheet.put(5, 1, "Параметры:")
    sheet.put(5, 3, "Валюта БУ, НУ: Руб")
    label_col, first_sum = 4, 5
    top = 7
    sheet.put(top, 1, "Счет")
    if indicator_column:
        sheet.put(top, label_col, INDICATOR_TITLE)
        sheet.merge(top, label_col, top + 1, label_col)
    for index, key in enumerate(("opening", "turnover", "closing")):
        col = first_sum + 2 * index
        sheet.put(top, col, GROUP_TITLES[key])
        sheet.merge(top, col, top, col + 1)
        sheet.put(top + 1, col, DEBIT)
        sheet.put(top + 1, col + 1, CREDIT)
    sheet.put(top + 1, 1, group_label)
    for row, item in enumerate(rows, start=top + 2):
        if item.text == MERGED:
            sheet.merge(row - 1, 1, row, 1)
        elif item.text:
            sheet.put(row, 1, item.text)
        if item.label is not None:
            sheet.put(row, label_col, item.label)
        if item.outline:
            sheet.outline[row] = item.outline
        for offset, value in enumerate(item.sums):
            if value is not None:
                sheet.put(row, first_sum + offset, D(value))
    return sheet

(Если Sequence не импортирован в generate.py — добавить в импорт collections.abc.)

  • Step 2: Четыре файла (суммы согласованы: родитель БУ = Σ детей БУ, «Итого» = корень, тождество строк выполняется у всех строк):
def _s40() -> list[SheetSpec]:
    """Колонка «Показатели» БУ/НУ (AURORA-1613): счёт 02, шапка без «Выводимые данные», название объединено на строки
    БУ и НУ, «Итого» по БУ и по НУ. Строки НУ — показатели строки БУ над ними."""
    rows = [
        GridRow("02", (None, "300000.00", "20000.00", "30000.00", None, "310000.00"), "БУ"),
        GridRow(None, (None, "300000.02", "20000.00", "30000.03", None, "310000.05"), "НУ"),
        GridRow("Печь подовая", (None, "100000.00", "20000.00", "10000.00", None, "90000.00"), "БУ", 1),
        GridRow(MERGED, (None, "100000.02", "20000.00", "10000.01", None, "90000.03"), "НУ", 1),
        GridRow("Плита индукционная", (None, "200000.00", None, "20000.00", None, "220000.00"), "БУ", 1),
        GridRow(None, (None, "200000.00", None, "20000.02", None, "220000.02"), "НУ", 1),
        GridRow("Итого", (None, "300000.00", "20000.00", "30000.00", None, "310000.00"), "БУ"),
        GridRow(MERGED, (None, "300000.02", "20000.00", "30000.03", None, "310000.05"), "НУ"),
    ]
    return [_grid_osv(account="02", group_label="Основные средства", rows=rows, data_type_line=None, indicator_column=True)]


def _s41() -> list[SheetSpec]:
    """Колонка «Показатели»: «Вал.», «Кол.», «ПР», «ВР» и неизвестная метка «Ед.» — показатели вне сверки родителя."""
    rows = [
        GridRow("60", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00"), "БУ"),
        GridRow("ООО Альфа", ("1000.00", None, "500.00", "300.00", "1200.00", None), "БУ", 1),
        GridRow(None, ("12.50", None, "6.00", "3.00", "15.50", None), "Вал.", 1),
        GridRow(None, ("3", None, None, None, "3", None), "Кол.", 1),
        GridRow("ООО Бета", (None, "400.00", "100.00", None, None, "300.00"), "БУ", 1),
        GridRow(None, (None, None, "10.00", None, "10.00", None), "ПР", 1),
        GridRow(None, (None, "5.00", None, None, None, "5.00"), "ВР", 1),
        GridRow(None, ("1", None, None, None, "1", None), "Ед.", 1),
        GridRow("Итого", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00"), "БУ"),
    ]
    line = "Выводимые данные: БУ (данные бухгалтерского учета)"
    return [_grid_osv(account="60", group_label="Контрагенты", rows=rows, data_type_line=line, indicator_column=True)]


def _s42() -> list[SheetSpec]:
    """«Валюта <код>» — строка в рублях, «В валюте» — показатель (AURORA-1661): пары на одном outline-уровне."""
    rows = [
        GridRow("60", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00")),
        GridRow("60.21", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00"), outline=1),
        GridRow("ООО Альфа", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00"), outline=2),
        GridRow("Валюта USD", ("9000.00", None, "1000.00", None, "10000.00", None), outline=3),
        GridRow("В валюте", ("100.00", None, "11.00", None, "111.00", None), outline=3),
        GridRow("Валюта EUR", (None, "2000.00", None, "500.00", None, "2500.00"), outline=3),
        GridRow("В валюте", (None, "20.00", None, "5.00", None, "25.00"), outline=3),
        GridRow("Итого", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00")),
    ]
    line = "Выводимые данные: БУ (данные бухгалтерского учета)"
    return [_grid_osv(account="60", group_label="Контрагенты", rows=rows, data_type_line=line, indicator_column=False)]


def _s43() -> list[SheetSpec]:
    """Шапка без «Выводимые данные», колонки «Показатели» нет: строки — BU по умолчанию с предупреждением."""
    rows = [
        GridRow("60", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00")),
        GridRow("ООО Альфа", ("1000.00", None, "500.00", "300.00", "1200.00", None), outline=1),
        GridRow("ООО Бета", (None, "400.00", "100.00", None, None, "300.00"), outline=1),
        GridRow("Итого", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00")),
    ]
    return [_grid_osv(account="60", group_label="Контрагенты", rows=rows, data_type_line=None, indicator_column=False)]

Добавить в FILES:

    "s40_indicator_column_bu_nu.xlsx": (_s40, to_xlsx),
    "s41_indicator_column_qty_val_diff.xlsx": (_s41, to_xlsx),
    "s42_currency_slices.xlsx": (_s42, to_xlsx),
    "s43_no_data_type_header.xlsx": (_s43, to_xlsx),

и поправить описание CLI в main («s01…s43»). Номера s33–s39 оставлены треку B.

  • Step 3: Сгенерировать в fixtures: cd $WT/tabular-parser/api && .venv/bin/python -m tests.synthetic.generate $WT/fixtures/osv/synthetic — проверить git -C $WT/fixtures status: изменены только четыре новых файла (s01–s32 побайтно те же; если нет — стоп, отчёт).

  • Step 4: Ожидания и сценарии в test_synthetic_files.py:

    • Expect.data_type: str | None = "BU";
    • EXPECTED — "s40_indicator_column_bu_nu.xlsx": Expect("outline", 2025, "Q3", False, data_type=None), "s41_indicator_column_qty_val_diff.xlsx": Expect("outline", 2025, "Q3", False), "s42_currency_slices.xlsx": Expect("outline", 2025, "Q3", False), "s43_no_data_type_header.xlsx": Expect("outline", 2025, "Q3", False, data_type=None); EXPECTED_NAMES — четыре имени; докстринг test_synthetic_set_is_complete — «s01…s43»;
    • сценарии (по образцу test_s30_…, хелперы one_document, parse_named, warnings_of):
def test_s40_indicator_column_bu_nu(fixtures_dir: Path) -> None:
    """AURORA-1613: строки «НУ» колонки «Показатели» — показатели строки БУ; две «Итого»; проверки сходятся."""
    outcome = parse_named(fixtures_dir, "s40_indicator_column_bu_nu.xlsx")
    doc = one_document(outcome)
    by_row = {row.row_number: row for row in doc.rows}
    nu = [row for row in doc.rows if row.data_type == "NU"]
    assert [row.row_value for row in nu] == ["НУ"] * 4
    assert all(row.group_type in {"OTHER", "TOTAL"} for row in nu)
    for row in nu:
        if row.group_type != "TOTAL":
            parent = by_row[row.parent_row_number]
            assert parent.data_type == "BU" and parent.row_number == row.row_number - 1
            assert row.level == parent.level + 1
    assert [row.data_type for row in doc.rows if row.group_type == "TOTAL"] == ["BU", "NU"]
    assert {row.data_type for row in doc.rows if row.row_value != "НУ"} == {"BU"}
    assert doc.header.data_type is None
    texts = warnings_of(outcome)
    assert not any("ниже таблицы" in text for text in texts), texts
    assert not any("приняты как БУ" in text for text in texts), texts
    assert outcome.status is ResultStatus.DONE


def test_s41_indicator_column_quantity_currency_differences(fixtures_dir: Path) -> None:
    """«Кол.» — QUANTITY, «Вал.» — CURRENCY, «ПР»/«ВР»/неизвестная метка — null; одно предупреждение о метке."""
    outcome = parse_named(fixtures_dir, "s41_indicator_column_qty_val_diff.xlsx")
    doc = one_document(outcome)
    types = {row.row_value: row.data_type for row in doc.rows if row.row_value in {"Вал.", "Кол.", "ПР", "ВР", "Ед."}}
    assert types == {"Вал.": "CURRENCY", "Кол.": "QUANTITY", "ПР": None, "ВР": None, "Ед.": None}
    unknown = [text for text in warnings_of(outcome) if "«Ед.»" in text]
    assert len(unknown) == 1, warnings_of(outcome)
    assert outcome.status is ResultStatus.DONE


def test_s42_v_valyute_is_currency_of_previous_row(fixtures_dir: Path) -> None:
    """AURORA-1661: «В валюте» — CURRENCY, родитель — «Валюта …» над ней; «Валюта …» — обычные строки в рублях."""
    outcome = parse_named(fixtures_dir, "s42_currency_slices.xlsx")
    doc = one_document(outcome)
    by_row = {row.row_number: row for row in doc.rows}
    in_currency = [row for row in doc.rows if row.row_value == "В валюте"]
    assert [row.data_type for row in in_currency] == ["CURRENCY", "CURRENCY"]
    assert [by_row[row.parent_row_number].row_value for row in in_currency] == ["Валюта USD", "Валюта EUR"]
    slices = [row for row in doc.rows if row.row_value.startswith("Валюта ")]
    assert {(row.group_type, row.data_type) for row in slices} == {("OTHER", "BU")}
    assert outcome.status is ResultStatus.DONE


def test_s43_no_data_type_in_header_rows_are_bu(fixtures_dir: Path) -> None:
    """Шапка без «Выводимые данные»: строки — BU, предупреждение одно, `header.data_type` — null."""
    outcome = parse_named(fixtures_dir, "s43_no_data_type_header.xlsx")
    doc = one_document(outcome)
    assert doc.header.data_type is None
    assert {row.data_type for row in doc.rows} == {"BU"}
    assert len([text for text in warnings_of(outcome) if "приняты как БУ" in text]) == 1
    assert outcome.status is ResultStatus.DONE

Если имена хелперов в модуле другие — взять существующие (one_document, parse_named, warnings_of, ResultStatus уже используются в s30/s31).

  • Step 5: Запустить — сценарии s40–s43 красные (это и есть воспроизведение багов): ENV_FILE=.env.test .venv/bin/python -m pytest tests/synthetic -q -k "s40 or s41 or s42 or s43". Записать в ledger, как именно падает каждый (до исправления).
  • Step 6: Commit в tabular-parser (generate.py, тесты; make check красный только на s40–s43 и покрытии словарей — допустимо с пометкой в ledger) и отдельный commit в fixtures (git -C $WT/fixtures add osv/synthetic/s4*.xlsx).

Task 3: Колонка «Показатели» в заголовке (модель: haiku)

Files:

  • Modify: api/src/parser/detect.py (TableHeader, find_table_header)

  • Test: api/tests/unit/parser/test_detect.py

  • Step 1: Тест — сетка gridbuilder по образцу тестов inn_col в test_detect.py: заголовок «Счет | Показа-\nтели | Сальдо на начало …» → header.indicator_col = колонка «Показа-тели», header.text_col не меняется, колонка «Показатели» есть в header.extra_cols; без такой колонки — indicator_col is None.

  • Step 2: Запустить — падает.

  • Step 3: Реализация. TableHeader — последнее поле indicator_col: int | None = None (комментарий: «колонка «Показатели» — метка вида суммы строки, §1.3»). В find_table_header после inn_col:

    indicator_col = _service_col(sheet, range(group_row, last_row + 1), text_col, first_sum, rules, "indicator")

где _service_col — первая колонка между колонкой текста и суммами, чей текст в блоке заголовка матчит rules.headers.service_columns.get(key, []) (вынести общий цикл из _inn_col по заголовку, поведение _inn_col не менять). В occupied колонку не добавлять (остаётся в extra_cols). Передать indicator_col=indicator_col в итоговый TableHeader.

  • Step 4: Тест зелёный, make check, commit «Заголовок ОСВ: колонка «Показатели» (#58)».

Task 4: Строки-показатели при сборе строк (модель: sonnet)

Files:

  • Modify: api/src/parser/hierarchy.py (BodyRow — только поля)

  • Create: api/src/parser/indicators.py

  • Modify: api/src/parser/classify.py (_label_cols, _body_row, _trailing_totals, collect_body_rows)

  • Test: api/tests/unit/parser/test_indicators.py, api/tests/unit/parser/test_classify.py

  • Step 1: Поля BodyRow (после label_col, с умолчаниями — позиционные конструкторы в тестах не ломаются):

    indicator: bool = False  # строка-показатель (§1.3): вид сумм предыдущей строки данных, не уровень аналитики
    label_data_type: DataType | None = None  # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.»)

(DataType — из src.contract.common.)

  • Step 2: Модуль indicators.py — чтение метки:
"""Строки-показатели ОСВ (§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


@dataclass(frozen=True)
class RowLabel:
    """Вид строки по метке: показатель ли она, `data_type` метки, текст строки взамен исходного, предупреждение."""

    indicator: bool
    data_type: DataType | None
    text: str | None = None  # для метки колонки «Показатели» — сама метка (`row_value`)
    warning: str | None = 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"

row_data_type сейчас живёт в classify.py, а classify.py будет импортировать indicators.py — перенести row_data_type в indicators.py и в classify.py оставить реэкспорт (from src.parser.indicators import row_data_type), чтобы не ломать импорт в inn_rows.py и тестах; цикла импортов быть не должно (indicators.py не импортирует classify).

  • Step 3: Тесты test_indicators.py (сетки gridbuilder; TableHeader — через find_table_header): «БУ» → indicator False, data_type BU; «НУ» → True, NU, text "НУ"; «ПР» → True, None, без предупреждения; «Ед.» → True, None, предупреждение с номером строки; без колонки, текст «В валюте» → True, CURRENCY; текст «Валюта USD» → False, None; is_indicator_total для «НУ» — True, для «БУ» — False.
  • Step 4: classify.py:
    • _label_cols: условие col != header.inn_col дополнить and col != header.indicator_col;
    • _body_row(sheet, row, header, label_cols, warnings, rules): после _label_of — label = read_label(sheet, row, text, header, rules); если label.text is not None — text = label.text (у строки с меткой колонки текст — метка; account считать по новому тексту → не счёт); label.warning — в warnings; в BodyRow — indicator=label.indicator, label_data_type=label.data_type;
    • _trailing_totals: строка продолжает серию, если _is_secondary_total(...) или is_indicator_total(sheet, row, header, rules); докстринг — «и итоговые строки-показатели («Итого» с меткой «НУ», AURORA-1613)»;
    • collect_body_rows передаёт rules в _body_row.
  • Step 5: Тест в test_classify.py — сетка с колонкой «Показатели» (строки БУ/НУ, объединённое название, «Итого» БУ+НУ): collect_body_rows — строки «НУ» с indicator True, text == "НУ", label_data_type == "NU"; вторая «Итого» в body, предупреждения «ниже таблицы» нет.
  • Step 6: make check (сценарии s40–s43 ещё могут быть красными — отметить в ledger), commit «Строки-показатели: метка колонки «Показатели» при сборе строк (#58)».

Task 5: Иерархия — показатели вне каскада (модель: sonnet)

Files:

  • Modify: api/src/parser/indicators.py (levels_with_indicators)

  • Modify: api/src/parser/osv.py (parse_sheet)

  • Modify: api/src/parser/inn_rows.py (_kind)

  • Test: api/tests/unit/parser/test_indicators.py

  • Step 1: Тесты на levels_with_indicators (строки BodyRow вручную, Deadline с большим запасом, по образцу test_hierarchy.py):

    1. счёт (outline 0), строка «НУ»-показатель (outline 0), объект (outline 1), «НУ» (outline 1), «Итого», «Итого»-показатель → показатель счёта: родитель — счёт, уровень 1; показатель объекта: родитель — объект, уровень 2; объект — ребёнок счёта (каскад не видел показателей); обе итоговые — уровень 0 без родителя; стратегия outline;
    2. показатель первой строкой таблицы — уровень 0 без родителя;
    3. порядок ключей by_row и parent — порядок строк листа.
  • Step 2: Реализация в indicators.py:

def levels_with_indicators(rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline) -> Levels:
    """Каскад иерархии без строк-показателей; показатель — ребёнок предыдущей строки данных, на уровень глубже
    (§1.3): так строки НУ/Кол./«В валюте» на одном outline-уровне со своей строкой не ломают дерево строк БУ.

    Итоговые строки-показатели и показатель без строки данных выше — уровень 0 без родителя.
    """
    levels = resolve_levels([item for item in rows if not item.indicator], total_row, deadline)
    by_row: dict[int, int] = {}
    parent: dict[int, int | None] = {}
    previous: int | None = None
    for item in rows:
        if not item.indicator:
            by_row[item.row], parent[item.row] = levels.by_row[item.row], levels.parent[item.row]
            if total_row is None or item.row < total_row:
                previous = item.row
            continue
        if previous is None or (total_row is not None and item.row >= total_row):
            by_row[item.row], parent[item.row] = ROOT_LEVEL, None
        else:
            by_row[item.row], parent[item.row] = by_row[previous] + 1, previous
    return Levels(by_row=by_row, parent=parent, strategy=levels.strategy)

(ROOT_LEVEL, Levels, resolve_levels, BodyRow — из hierarchy.py; Deadline — из deadline.py.)

  • Step 3: osv.py — в parse_sheet levels = resolve_levels(body, total_row, deadline) заменить на levels = levels_with_indicators(body, total_row, deadline) (импорт resolve_levels убрать, если больше не нужен).
  • Step 4: inn_rows._kind — if row_data_type(item.text, rules, currency) is not None: заменить на if item.indicator: (параметр currency убрать, если больше не используется, вместе с вызывающим кодом).
  • Step 5: make check — все прежние тесты (в том числе s04, s30, test_inn_rows*.py) зелёные; commit «Строки-показатели вне каскада иерархии (#58)».

Task 6: Классификация, проверки, вид данных по умолчанию (модель: sonnet)

Files:

  • Modify: api/src/parser/classify.py (ClassifiedRow, _Kind, _classify_row, classify_rows)

  • Modify: api/src/parser/checks.py (_INDICATOR_DATA_TYPES, _is_indicator, _total)

  • Modify: api/src/parser/osv.py (предупреждение умолчания)

  • Test: api/tests/unit/parser/test_classify.py, api/tests/unit/parser/test_checks.py

  • Step 1: Тесты:

    • test_checks.py: строка ClassifiedRow с data_type="NU", indicator=True под родителем BU не входит в сумму детей; документ из одних строк NU без признака (как «Выводимые данные: НУ») проверяется как обычно; _total/total_matches_root берёт первую итоговую строку без признака, даже если перед ней стоит итоговая строка-показатель;
    • test_classify.py: строка с label_data_type="BU" при doc_data_type="NU" → BU; строка без метки при doc_data_type=None → BU; показатель → OTHER, data_type метки, indicator=True, account_code_raw ветки родителя, counterparty_* — None; итоговая строка-показатель → TOTAL c data_type метки и indicator=True.
  • Step 2: Реализация:

    • ClassifiedRow: поле indicator: bool = False (последним, с умолчанием);
    • _Kind: поле indicator: bool = False;
    • _classify_row: вместо row_data_type(...):
    if item.indicator:
        return _Kind("OTHER", None, code, item.label_data_type, indicator=True), inherited
    own = item.label_data_type or doc_data_type

и далее везде, где строка получала doc_data_type, — own (ACCOUNT, строка ИНН без пары, схема, COUNTERPARTY);

  • classify_rows: параметр doc_data_type — уже с умолчанием (см. osv.py); для итоговых строк _Kind("TOTAL", None, None, item.label_data_type or doc_data_type, indicator=item.indicator); в ClassifiedRow(...) передать indicator=kind.indicator; докстринг — пункт про строки-показатели переписать по §1.3;
  • checks.py: удалить _INDICATOR_DATA_TYPES; _is_indicator(item) → return item.indicator; _total — первая строка TOTAL с not item.indicator; докстринги parent_sums/_level_zero — «строки-показатели»;
  • osv.py (parse_sheet): перед классификацией
            data_type = doc.data_type or DEFAULT_DATA_TYPE
            defaulted = doc.data_type is None and any(
                not item.indicator and item.label_data_type is None for item in body
            )

classify_rows(..., data_type, ...); после классификации, если defaulted, — notes.append(OsvNote(OSV_HEADER, DEFAULT_DATA_TYPE_WARNING)) сразу после заметок шапки; константы в osv.py: DEFAULT_DATA_TYPE: DataType = "BU", DEFAULT_DATA_TYPE_WARNING = "вид данных в шапке не указан — строки без метки приняты как БУ". header.data_type (_header(doc)) не трогать.

  • Step 3: Синтетика s40–s43 зелёная: ENV_FILE=.env.test .venv/bin/python -m pytest tests/synthetic -q.
  • Step 4: make check, commit «Классификация и проверки по признаку строки-показателя, BU по умолчанию (#58)».

Task 7: Регрессия, golden, файлы контура (модель: sonnet)

Files:

  • Modify: $WT/fixtures/golden/expected_osv_parse/synthetic/s40…s43.json (новые, make golden-update)

  • Step 1: make golden-check из $WT/tabular-parser. Ожидание: diff только «нет снимка» для s40–s43. Любой diff по реальным файлам или s01–s32 — стоп: записать в ledger (файл, строки, что изменилось) и вернуть контроллеру; не перегенерировать.

  • Step 2: make golden-update, проверить git -C $WT/fixtures status — только четыре новых снимка; просмотреть их глазами (строки НУ/«В валюте» — показатели с верными родителями, две TOTAL в s40). Commit в fixtures «Golden s40–s43: колонка «Показатели», «В валюте» (#58)».

  • Step 3: Покрытие словарей — ENV_FILE=.env.test .venv/bin/python -m pytest tests/fixtures/test_rules_coverage.py -q зелёный (каждая новая запись срабатывает на s40–s42).

  • Step 4: Файлы контура (не в git). TMP=$(mktemp -d); извлечь из /Users/andrey.epifancev/projects/awp-ca/result_dir.zip файлы files/3da66219-*.xlsx, files/86353387-*.xls и все прочие файлы, где в заголовке таблицы есть «Показа» (найти перебором через python -I, без записи в репозитории), а также files/270b41da-*.xlsx, files/738bfcf5-*.xlsx. Для каждого: cd $WT/tabular-parser/api && .venv/bin/python -m src.parser.cli "$TMP/<файл>" на ветке и на origin/main (второй worktree не создавать: сравнить с выдачей admin/1639-contour-run/parser-out/<id>*.json, если она есть). Свести в ledger таблицу: файл, статус до/после, parent_sum_violations до/после, число строк NU/QUANTITY/CURRENCY, оборот Дт корня по BU. Ожидание по AURORA-1613: оборот по дебету БУ без удвоения. Для 270b41da/738bfcf5 (плоская форма, трек B) — только что строки «В валюте» стали CURRENCY; структура плоской формы — не цель этого плана. rm -rf "$TMP" в конце.

  • Step 5: если на файлах контура найден случай, который план не покрывает (новая метка, иная раскладка), — не чинить, записать находкой O-n в ledger и вернуть контроллеру.


Task 8: Документация и итоговые проверки (модель: sonnet)

Files:

  • Modify: README.md (раздел «Парсер ОСВ»: строки-показатели, колонка «Показатели», BU по умолчанию)

  • Modify: openspec/changes/osv-indicator-column/tasks.md (отметить выполненное)

  • Modify: $WT/docs/context/06-test-data.md (§7 синтетики: s40–s43 одной строкой каждый)

  • Modify: CLAUDE.md tabular-parser (одна строка в «Модули»: indicators.py — строки-показатели)

  • Step 1: README и CLAUDE.md — коротко, по образцу соседних абзацев; 06-test-data.md — commit в docs (ветка feat/58-osv-indicators, там уже правка §1.3/§1.4).

  • Step 2: openspec validate osv-indicator-column --strict — valid.

  • Step 3: make check-full из $WT/tabular-parser — зелёный; вывод — в ledger.

  • Step 4: commit «Документация: строки-показатели ОСВ (#58)».

После плана (контроллер)

Финальное ревью ветки — opus; затем finishing-a-development-branch: PR в tabular-parser (Closes #58), docs, fixtures (fixtures сливается первым, затем tabular-parser, затем docs). Список «Rulings I made» и таблица находок — в описание PR tp. Jira AURORA-1613/1661 — не трогать: статусы ведёт главная сессия.