# Колонка «Показатели» и валютные строки ОСВ: план реализации > **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/`. Команды 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`), синтетику s37–s44. Этот план в `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` — только для новых s33–s36, отдельным коммитом в 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 `. - 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` модуля): ```python @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`: ```yaml # Колонка вида суммы строки 1С («Показатели», в шапке часто с переносом «Показа-тели»): метки БУ/НУ/Кол./Вал./ПР/ВР # (§1.3, AURORA-1613). Метки читает `data_types.yaml` — `indicator_column` indicator: - '^показа\s?тели$' ``` В `data_types.yaml`: в `row` добавить `- ['^в\s+валюте$', CURRENCY]` с комментарием «строка суммы в валюте под «Валюта <код>» (AURORA-1661); сама «Валюта USD» — обычная строка в рублях»; после `row` — новые разделы: ```yaml # Метка колонки «Показатели» (§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`: ```python 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: Синтетика s33–s36 и ожидания (модель: sonnet) **Files:** - Modify: `api/tests/synthetic/generate.py` - Modify: `api/tests/synthetic/test_synthetic_files.py` - Create: `$WT/fixtures/osv/synthetic/s33_indicator_column_bu_nu.xlsx`, `s34_indicator_column_qty_val_diff.xlsx`, `s35_currency_slices.xlsx`, `s36_no_data_type_header.xlsx` - [ ] **Step 1: Построитель листа «по сетке».** В `generate.py` после `form_sheet` добавить (константы `ORG`, `GROUP_TITLES`, `DEBIT`, `CREDIT`, `D` — уже есть): ```python 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: Четыре файла** (суммы согласованы: родитель БУ = Σ детей БУ, «Итого» = корень, тождество строк выполняется у всех строк): ```python def _s33() -> 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 _s34() -> 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 _s35() -> 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 _s36() -> 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`: ```python "s33_indicator_column_bu_nu.xlsx": (_s33, to_xlsx), "s34_indicator_column_qty_val_diff.xlsx": (_s34, to_xlsx), "s35_currency_slices.xlsx": (_s35, to_xlsx), "s36_no_data_type_header.xlsx": (_s36, to_xlsx), ``` и поправить описание CLI в `main` («s01…s36»). Номера s37–s44 заняты треком B (#59); рулинг владельца R-1. - [ ] **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` — `"s33_indicator_column_bu_nu.xlsx": Expect("outline", 2025, "Q3", False, data_type=None)`, `"s34_indicator_column_qty_val_diff.xlsx": Expect("outline", 2025, "Q3", False)`, `"s35_currency_slices.xlsx": Expect("outline", 2025, "Q3", False)`, `"s36_no_data_type_header.xlsx": Expect("outline", 2025, "Q3", False, data_type=None)`; `EXPECTED_NAMES` — четыре имени; докстринг `test_synthetic_set_is_complete` — «s01…s36»; - сценарии (по образцу `test_s30_…`, хелперы `one_document`, `parse_named`, `warnings_of`): ```python def test_s33_indicator_column_bu_nu(fixtures_dir: Path) -> None: """AURORA-1613: строки «НУ» колонки «Показатели» — показатели строки БУ; две «Итого»; проверки сходятся.""" outcome = parse_named(fixtures_dir, "s33_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_s34_indicator_column_quantity_currency_differences(fixtures_dir: Path) -> None: """«Кол.» — QUANTITY, «Вал.» — CURRENCY, «ПР»/«ВР»/неизвестная метка — null; одно предупреждение о метке.""" outcome = parse_named(fixtures_dir, "s34_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_s35_v_valyute_is_currency_of_previous_row(fixtures_dir: Path) -> None: """AURORA-1661: «В валюте» — CURRENCY, родитель — «Валюта …» над ней; «Валюта …» — обычные строки в рублях.""" outcome = parse_named(fixtures_dir, "s35_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_s36_no_data_type_in_header_rows_are_bu(fixtures_dir: Path) -> None: """Шапка без «Выводимые данные»: строки — BU, предупреждение одно, `header.data_type` — null.""" outcome = parse_named(fixtures_dir, "s36_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: Запустить — сценарии s33–s36 красные** (это и есть воспроизведение багов): `ENV_FILE=.env.test .venv/bin/python -m pytest tests/synthetic -q -k "s33 or s34 or s35 or s36"`. Записать в ledger, как именно падает каждый (до исправления). - [ ] **Step 6: Commit** в tabular-parser (`generate.py`, тесты; `make check` красный только на s33–s36 и покрытии словарей — допустимо с пометкой в ledger) и отдельный commit в fixtures (`git -C $WT/fixtures add osv/synthetic/s3[3-6]*.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`: ```python 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`, с умолчаниями — позиционные конструкторы в тестах не ломаются): ```python indicator: bool = False # строка-показатель (§1.3): вид сумм предыдущей строки данных, не уровень аналитики label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.») ``` (`DataType` — из `src.contract.common`.) - [ ] **Step 2: Модуль `indicators.py` — чтение метки:** ```python """Строки-показатели ОСВ (§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`** (сценарии s33–s36 ещё могут быть красными — отметить в 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`: ```python 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(...)`: ```python 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`): перед классификацией ```python 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: Синтетика s33–s36 зелёная:** `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/s33…s36.json` (новые, `make golden-update`) - [ ] **Step 1: `make golden-check`** из `$WT/tabular-parser`. Ожидание: diff только «нет снимка» для s33–s36. **Любой diff по реальным файлам или s01–s32 — стоп**: записать в ledger (файл, строки, что изменилось) и вернуть контроллеру; не перегенерировать. - [ ] **Step 2: `make golden-update`**, проверить `git -C $WT/fixtures status` — только четыре новых снимка; просмотреть их глазами (строки НУ/«В валюте» — показатели с верными родителями, две TOTAL в s33). Commit в fixtures «Golden s33–s36: колонка «Показатели», «В валюте» (#58)». - [ ] **Step 3: Покрытие словарей** — `ENV_FILE=.env.test .venv/bin/python -m pytest tests/fixtures/test_rules_coverage.py -q` зелёный (каждая новая запись срабатывает на s33–s35). - [ ] **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/*.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 синтетики: s33–s36 одной строкой каждый) - 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 — не трогать: статусы ведёт главная сессия.