OpenSpec change osv-indicator-column и план (#58)

Колонка «Показатели», строки-показатели, «В валюте», BU по умолчанию (AURORA-1613, AURORA-1661 — валютная часть).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 11:03:13 +04:00
parent aef2d3557b
commit 656486a7d2
6 changed files with 935 additions and 0 deletions

View File

@ -0,0 +1,551 @@
# Колонка «Показатели» и валютные строки ОСВ: план реализации
> **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` модуля):
```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: Синтетика 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` — уже есть):
```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 _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`:
```python
"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`):
```python
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`:
```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`** (сценарии 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`:
```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: Синтетика 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 — не трогать: статусы ведёт главная сессия.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-19

View File

@ -0,0 +1,107 @@
# Design: колонка «Показатели» и валютные строки ОСВ
Правило — `05-domain-rules.md` §1.3 (абзацы «строки-показатели», «Колонка «Показатели»»), §1.4 (сверка родителя),
ветка docs `feat/58-osv-indicators`.
## Как выглядит файл (реальный образец контура, в git не кладётся)
`result_dir.zip`, `files/3da66219-….xlsx` — ОСВ по счёту 02, шапка без «Выводимые данные» («Параметры: Валюта БУ, НУ:
Руб»):
```
r7 Счет | Показа-\nтели | Сальдо на начало | Обороты за период | Сальдо на конец
r8 Основные средства | | Дебет | Кредит | Дебет | Кредит | Дебет | Кредит
r10 02 | БУ | | 480896,82 | 45754,41 | 46336,70 | | 481479,11 outline 0
r11 | НУ | | 480896,82 | 45754,41 | 46336,71 | | 481479,12 outline 0
r12 Интерактивная … | БУ | … outline 1
r13 | НУ | … outline 1
r16 Машина … (A16:D17 объединены) | БУ | … outline 1
r17 (продолжение объединения) | НУ | … outline 1
r30 Итого (A30:D31 объединены) | БУ | … outline 0
r31 (продолжение) | НУ | … outline 0
```
`files/270b41da-….xlsx` (плоская форма, трек B) — пары строк «Валюта CNY» (рубли) / «В валюте» (сумма в валюте) под
субсчётом 76.55.
Сейчас: колонка «Показа-тели» попадает в `extra_cols` и в `_label_cols` (r11, r13 — пустой текст → текст «НУ» из
колонки E, отступ +1000 → ребёнок строки БУ); r17 — текст объединённой ячейки (повтор названия), `data_type` всех строк
`null` (шапка молчит); r31 — не «Итого развернутое», `_trailing_totals` обрывается, строка уходит в подвал с
предупреждением «ниже таблицы есть числа».
## Решения
### Признак строки-показателя — поле строки, не значение `data_type`
Сейчас «показатель» = `data_type ∈ {CURRENCY, QUANTITY}` (`checks._INDICATOR_DATA_TYPES`). NU в этот набор добавить
нельзя: документ с «Выводимые данные: НУ» (s12) целиком состоит из строк NU, и все его проверки пропали бы. Поэтому
вводится признак `indicator: bool` у `BodyRow` (вычисляется при сборе строк) и у `ClassifiedRow`; `checks._is_indicator`
читает признак. Строка-показатель — если:
1. в листе есть колонка «Показатели», ячейка строки в ней непуста и метка ≠ «БУ» (словарь
`data_types.indicator_column`); `data_type` — из словаря; «ПР», «ВР» — `null` (словарь `indicator_column_untyped`);
метка не из обоих словарей — `null` + предупреждение `OSV_ROWS` «строка N: метка «X» в колонке «Показатели» не
распознана — строка не участвует в проверках»;
2. иначе — текст строки матчит `data_types.row` («Вал.», «Кол.», «Кол-во», **«В валюте»**) или равен коду валюты
(как сейчас, `row_data_type`).
Метка «БУ» в колонке даёт строке `data_type = BU` явно (даже если шапка молчит или называет НУ — колонка точнее шапки).
### Текст строки с меткой колонки — метка
У строки-показателя из колонки текст в колонке текста либо пуст, либо повторяет объединённую ячейку строки БУ (r17).
`BodyRow.text` = метка («НУ»), `is_account = False`. Так `row_value` не дублирует название объекта, а ИНН-логика и
классификатор не принимают строку за счёт или контрагента. Колонка «Показатели» исключается из `_label_cols` (как колонка
ИНН) и остаётся в `extra` под своим заголовком.
### Иерархия: показатели вне каскада, родитель — предыдущая строка данных
Новый модуль `src/parser/indicators.py` (чтобы не пересекаться с треком B в `hierarchy.py`):
`attach_indicators(rows, levels, total_row) -> Levels`. В `OsvParser.parse_sheet` каскад `resolve_levels` получает
строки без показателей; затем каждому показателю (до «Итого») назначается родитель — ближайшая строка выше, которая не
показатель, — и уровень `level(родителя) + 1`; показатель в начале таблицы без строки данных выше — уровень 0 без
родителя. Итоговые строки-показатели — уровень 0 без родителя (как «Итого»). Стратегия иерархии (`Levels.strategy`) —
стратегия каскада.
Для строк «Вал.»/«Кол.» под контрагентом (s04, s30, реальные файлы) результат совпадает с прежним: 1С печатает
показатель сразу под строкой, к которой он относится. Golden-проверка это подтверждает; любое расхождение golden на
реальных файлах — стоп и вопрос владельцу (не перегенерировать молча).
### Итоговые строки-показатели
`_trailing_totals` продолжает серию итоговых строк и через строки с меткой показателя в колонке «Показатели»
(r31 после r30): они остаются в таблице, классифицируются TOTAL с `data_type` метки и `indicator = True`;
`checks._total` берёт первую итоговую строку **не показатель**. Трек B меняет `_trailing_totals`/`_total` под
«Итого развернутое» перед «Итого» — при слиянии второй трек добавляет своё условие к версии первого.
### Вид данных по умолчанию
Если шапка не называет вид данных (`doc.data_type is None`), строки без собственного вида (не показатели и без метки
«БУ») получают `BU`; один раз на лист — предупреждение `OSV_HEADER` «вид данных в шапке не указан — строки без метки
приняты как БУ». Если все строки данных размечены колонкой «Показатели», предупреждения нет (умолчание не
применялось). `header.data_type` остаётся `null`; существующее предупреждение шапки «тип выводимых данных … не найден»
сохраняется.
### Что не меняется
- `balance_identity` проверяет все строки, включая показатели (как сейчас у «Вал.»/«Кол.»): у НУ/количества тождество
«начало + обороты = конец» тоже выполняется.
- «Валюта <код>» — обычная строка OTHER (рубли): `row_data_type("Валюта USD")` = `None` уже сейчас; закрепляется тестом.
- Контракт, DBML, arm-core.
## Decisions made without discussion
| Дата | Решение | Обоснование | Что стоит ошибка |
|---|---|---|---|
| 19.10.2026 | «ПР», «ВР» и метка не из словаря — `data_type = null`, строка-показатель | В контракте нет значения для разниц; `null` ядро не берёт в свод, проверки строку не видят | Разницы не видны как отдельный вид данных до AURORA-1669; переразметка — правка словаря |
| 19.10.2026 | Признак показателя — поле `indicator`, а не набор значений `data_type` | Документ «Выводимые данные: НУ» не должен терять проверки | Лишнее поле в двух dataclass; контракт не меняется |
| 19.10.2026 | `row_value` строки с меткой колонки — метка, а не текст колонки текста | Текст там пуст или повтор объединённой ячейки строки БУ | Если 1С когда-то печатает в строке НУ своё название — оно теряется (не встречалось в 31 файле контура) |
| 19.10.2026 | Колонка «Показатели» остаётся в `extra` | Первичка не теряется, ядро `extra` хранит как есть | Дублирование метки в `extra` и `data_type` |
| 19.10.2026 | Родитель показателя — предыдущая строка данных для всех показателей, включая «Вал.»/«Кол.» в колонке текста | Один механизм для колонки, «В валюте» и старых меток; каскад не видит строк, которые ломают дерево | Если показатель стоит не сразу под своей строкой, родитель будет неверным; ловит golden |
| 19.10.2026 | Умолчание BU — только строкам без собственного вида; `header.data_type` остаётся `null` | Шапка действительно молчит; ядру нужен вид строки, не шапки | Предупреждение на листах без «Выводимые данные» (были без него) |
| 19.10.2026 | Синтетика s40–s43 (s33–s39 оставлены треку B) | Параллельные треки не должны конфликтовать по именам файлов набора | Пропуск номеров в наборе |
| 19.10.2026 | Новый модуль `indicators.py` вместо правки `hierarchy.resolve_levels` | Трек B меняет каскад иерархии параллельно | Ещё один модуль конвейера |
Решения владельца 19.10.2026 (чат): п. 1 колонка «Показатели» как метки §1.3, НУ без своих проверок; п. 2 «В валюте» →
CURRENCY, «Валюта <код>» — строка OTHER в рублях, код валюты не хранится; п. 3 BU по умолчанию с предупреждением — по
рекомендации агента, владелец принял без возражений («ошибки давай исправлять сейчас»).

View File

@ -0,0 +1,51 @@
# Колонка «Показатели» и валютные строки ОСВ (AURORA-1613, AURORA-1661 — валютная часть)
Issue: RSHB/arm-tabular-parser#58. Ветки `feat/58-osv-indicators` в tabular-parser, docs, fixtures.
## Why
Прогон выгрузки тестового контура (AURORA-1639, свод `admin/1639-contour-run/`, находки O-7, O-8) и тестирование
AURORA-1534 показали два дефекта одного механизма — вида данных строки ОСВ:
- **AURORA-1613.** 1С печатает вид суммы строки отдельной колонкой «Показатели» (БУ / НУ / Кол. / Вал. / ПР / ВР;
31 файл контура). Парсер о колонке не знает: метка уходит в `extra`, `data_type` строк НУ/Кол. — `null` (шапка без
«Выводимые данные»); строка НУ с пустым текстом получает текст из колонки «Показатели» и отступ «колонка глубже»,
становится ребёнком строки БУ — «дети = родитель» и «Итого» не сходятся, документ `needs_check`, на экране оборот
удваивается. Вторая строка «Итого» (по НУ) обрезается как подвал с предупреждением «ниже таблицы есть числа».
- **AURORA-1661, валютная часть.** Строки «В валюте» не распознаются как валютные: суммы в валюте (69,6 млн USD)
записываются как рублёвые и попадают в свод.
- Строки без вида данных (шапка без «Выводимые данные», нет метки) получают `data_type = null`, а ядро берёт в свод
только BU/SUM — строки молча пропадают из сводной ОСВ.
Решения владельца 19.10.2026 — минимальное исправление: строки, которые не являются БУ, изолируются от данных БУ.
Глубокая аналитика НУ и валют вынесена в AURORA-1669 и AURORA-1670 (бэклог аналитики).
## What Changes
- Колонка «Показатели» определяется по заголовку (словарь `service_columns.indicator`); метка строки читается по
новому словарю `data_types.indicator_column`: «БУ» → BU (обычная строка), «НУ» → NU, «Кол.» → QUANTITY, «Вал.» →
CURRENCY, «ПР»/«ВР»/метка не из словаря → `data_type = null` (метка не из словаря — с предупреждением).
- Понятие **строки-показателя** (`indicator`) — признак строки, а не значение `data_type`: строки-показатели в колонке
текста («Вал.», «Кол.», «Кол-во», код валюты, **новое** «В валюте») и строки с меткой колонки «Показатели», кроме
«БУ». Строки-показатели не передаются в каскад иерархии; родитель — предыдущая строка данных, уровень на один
глубже. В проверки «дети = родитель» и «Итого» не входят (как сейчас CURRENCY/QUANTITY); тождество строки — как у всех.
- `row_value` строки с меткой колонки — метка (а не повтор текста объединённой ячейки строки БУ).
- Итоговая строка с меткой показателя сразу за «Итого» — TOTAL, в подвал не уходит, в сверку не входит.
- Строки без вида данных при шапке без «Выводимые данные» — `data_type = BU` с предупреждением; `header.data_type`
остаётся `null` (шапка его не называет).
- «Валюта <код>» — обычная строка (OTHER, рубли): поведение не меняется, закрепляется тестом.
- §1.3/§1.4 `05-domain-rules.md` — отдельным PR в arm-ka-docs (ветка `feat/58-osv-indicators`).
- Синтетика s40–s43 и golden — PR в arm-ka-fixtures.
Затронутые репозитории: arm-tabular-parser (код, словари, тесты, спека `osv-parsing`), arm-ka-docs (§1.3, §1.4),
arm-ka-fixtures (синтетика, golden). Таблицы DBML v5.19 не меняются (`osv_entry.data_type` уже включает NU, CURRENCY,
QUANTITY, NULL); контракт `recognition-result` не меняется. Срез: поддержка после Среза 2a (ошибки контура).
## Non-goals
- Собственные проверки по НУ, итоги по видам данных, хранение «ПР»/«ВР» отдельными значениями — AURORA-1669.
- Код валюты строки, мультивалютные контрагенты, пересчёт; пересмотр «USD» как CURRENCY (в ТЗ — разрез с рублями) —
AURORA-1670.
- Плоская форма 1С (шапка «по счету: X», плоский список без строки счёта, «Итого развернутое» перед «Итого»,
контрагенты на расчётных счетах) — отдельный трек AURORA-1661 (`.wt/osv-flat/`).
- Изменения arm-core: ядро уже берёт в свод только BU/SUM и пишет `data_type` как пришёл.

View File

@ -0,0 +1,192 @@
## ADDED Requirements
### Requirement: Строки-показатели и колонка «Показатели»
Строка-показатель — строка, которая несёт вид сумм предыдущей строки данных, а не уровень аналитики. Парсер SHALL считать
строку показателем, если:
1. в таблице есть колонка «Показатели» (заголовок блока по словарю `osv_headers.yaml` — `service_columns.indicator`,
«Показатели», «Показа-тели»), ячейка строки в ней непуста и метка не «БУ»: «НУ» → `data_type = NU`, «Кол.» →
QUANTITY, «Вал.» → CURRENCY (словарь `data_types.yaml` — `indicator_column`); «ПР», «ВР» (`indicator_column_untyped`)
→ `data_type = null`; метка не из словарей → `data_type = null` и предупреждение `OSV_ROWS` «строка N: метка «X» в
колонке «Показатели» не распознана — строка не участвует в проверках»;
2. иначе — текст строки матчит `data_types.yaml` — `row` («Вал.», «Кол.», «Кол-во», «В валюте» → CURRENCY/QUANTITY) или
равен коду валюты из `currency_codes`.
Строка «Валюта <код>» («Валюта USD») MUST NOT считаться показателем: это строка аналитики с рублёвыми суммами; код валюты
не хранится. Колонка «Показатели» MUST NOT быть колонкой текста строки (как колонка ИНН) и SHALL оставаться в `extra`
строки под своим заголовком. `row_value` строки с меткой колонки — сама метка.
Строки-показатели MUST NOT передаваться в каскад иерархии. Родитель строки-показателя — ближайшая строка выше, которая
сама не показатель; уровень — на один глубже родителя; показатель без строки данных выше — уровень 0 без родителя.
Строки-показатели MUST NOT входить в сверку «сумма детей = родитель» (ни как дети, ни как родители) и в сверку «Итого»;
тождество строки (начало + обороты = конец) проверяется у них, как у всех строк. Строки NU хранятся как пришли
(`ASSUMPTION(OQ-45)`); собственных проверок по NU нет (AURORA-1669).
Итоговые строки, следующие сразу за основной «Итого» и помеченные в колонке «Показатели» меткой показателя, SHALL
оставаться в таблице как TOTAL (`data_type` по метке) и MUST NOT уходить в подвал или давать предупреждение «ниже
таблицы есть числа»; сверка «Итого = корень» берёт первую итоговую строку, которая не показатель.
#### Scenario: Колонка «Показатели» БУ/НУ
- **WHEN** разбирается синтетическая ОСВ `s40` (счёт 02, шапка без «Выводимые данные», колонка «Показа-тели», на каждую
строку — «БУ» и «НУ», название объединено на две строки, «Итого» БУ и НУ)
- **THEN** строки «БУ» — `data_type = BU`, строки «НУ» — `data_type = NU`, `row_value = "НУ"`, `group_type = OTHER`,
родитель — строка «БУ» над ней; `parent_sum_violations = []`, `total_row_matches_root = true`, две строки TOTAL
(BU и NU), предупреждения «ниже таблицы» и «вид данных … приняты как БУ» нет, статус `done`
#### Scenario: Количество, валюта и разницы в колонке «Показатели»
- **WHEN** разбирается синтетическая ОСВ `s41` (колонка «Показатели» с метками «Кол.», «Вал.», «ПР», «ВР» и одной
неизвестной меткой)
- **THEN** «Кол.» — QUANTITY, «Вал.» — CURRENCY, «ПР»/«ВР»/неизвестная — `data_type = null`, все — показатели вне
сверки родителя; одно предупреждение `OSV_ROWS` о нераспознанной метке; статус `done`
#### Scenario: «В валюте» под «Валюта <код>»
- **WHEN** разбирается синтетическая ОСВ `s42` (под контрагентом пары «Валюта USD» / «В валюте», «Валюта EUR» /
«В валюте»)
- **THEN** «В валюте» — CURRENCY, родитель — «Валюта …» над ней; контрагент = Σ строк «Валюта …» по рублям;
статус `done`
#### Scenario: Строки «Вал.»/«Кол.» в колонке текста не меняются
- **WHEN** разбираются s04, s30 и все реальные фикстуры `../fixtures/osv/**`
- **THEN** golden-снимки совпадают с прежними (родитель показателя — строка над ним, как и было)
## MODIFIED Requirements
### Requirement: Классификация строк
Классификация SHALL выполняться над деревом, из которого уже убраны склеенные строки ИНН (требование «Строки ИНН рядом с
контрагентом»). Каждая строка данных SHALL получить `group_type`: ACCOUNT — текст матчит `ACCOUNT_RE` (регистр
учитывается, IGNORECASE нет): две-три цифры и до двух сегментов субсчёта через точку, где сегмент — `\d{1,2}` **или** 1–3
заглавные буквы кириллицы/латиницы («76.АВ», «76.ВА», «76.ОТ», R-16), далее опционально запятая и название:
`^\d{2,3}(\.(\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2}(,\s*.*)?$` с флагами `re.ASCII | re.DOTALL` (после запятой допустим любой
пробел или его отсутствие, R-18; цифры — только 0–9; название после запятой может занимать несколько строк ячейки — M-1);
`account_code_raw` — код как в файле, без названия после запятой. Не-счёт — группировка из `group_schema` по позиции
(число не-счётных предков после последнего ACCOUNT + 1), при выходе за схему — OTHER; строка ИНН без пары — OTHER;
последняя строка уровня 0 с текстом из словаря «Итого» — TOTAL.
Для COUNTERPARTY `counterparty_name` SHALL быть текстом строки без ИНН в конце и разделителя перед ним (`row_value` —
исходный текст; OQ-100), а `counterparty_inn` — первым найденным из:
1. колонки ИНН: цифры текстовой ячейки («ИНН 352819549855» → «352819549855», R-15), склеенная «ИНН/КПП» (`inn_kpp_cell`)
→ ИНН; результат только при 10 или 12 цифрах, иначе `null`; число из 9/11 цифр (ИНН регионов 01–09 с потерянным
ведущим нулём) дополняется нулём слева (R-24);
2. строки ИНН, склеенной с этой строкой (требование «Строки ИНН рядом с контрагентом»);
3. конца текста названия: 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»
(`counterparty_inn_suffix`; «Полиграф, 3528315710», «ООО Альфа (ИНН 7701234567)»).
Контрольная сумма ИНН из колонки и из названия MUST NOT проверяться. Строки-показатели (требование «Строки-показатели
и колонка «Показатели»») SHALL получать `group_type = OTHER`, `data_type` по метке, MUST NOT занимать позицию в
`group_schema` и MUST NOT наследовать `counterparty_name`/`counterparty_inn` (`account_code_raw` наследуется от ветки
родителя, R-19); их суммы остаются в шести полях. Строка с меткой «БУ» в колонке «Показатели» получает `data_type = BU`.
Остальные строки получают `data_type` шапки, а если шапка вид данных не называет — `BU` и одно предупреждение `OSV_HEADER`
на лист «вид данных в шапке не указан — строки без метки приняты как БУ» (`header.data_type` остаётся `null`). Все строки, включая TOTAL, SHALL присутствовать в `rows`
(`ASSUMPTION(OQ-01)`: хранить ли «Итого», решает ядро); склеенные строки ИНН в `rows` MUST NOT попадать.
#### Scenario: Подразделение, контрагент с ИНН, физлицо
- **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls`
- **THEN** «БП Аренда» → DEPARTMENT; «МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» → COUNTERPARTY с `counterparty_inn` из 10 цифр;
«Ведомость в счет з/платы» → COUNTERPARTY с `counterparty_inn = null`
#### Scenario: ИНН в конце названия
- **WHEN** разбирается синтетическая ОСВ `s17` (ИНН в тексте названия с разными разделителями)
- **THEN** у контрагентов `counterparty_name` без ИНН, `counterparty_inn` — ИНН из текста, `row_value` — исходный текст
#### Scenario: «ИНН/КПП» в одной ячейке колонки ИНН
- **WHEN** разбирается синтетическая ОСВ `s19`
- **THEN** `counterparty_inn` — 10 цифр до «/»
#### Scenario: Название субсчёта в строке счёта
- **WHEN** в синтетической ОСВ строка «60.01, Расчеты с поставщиками»
- **THEN** `group_type = ACCOUNT`, `account_code_raw = "60.01"`, `row_value` — исходный текст
#### Scenario: Буквенный субсчёт
- **WHEN** в ОСВ 76 строка «76.АВ» (реальные файлы РБ и Счастливочной) либо «60.АВ, …» (синтетика)
- **THEN** `group_type = ACCOUNT`, строка наследуется детьми как счёт ветки; «76.ав» (строчные) счётом не считается
#### Scenario: Строки Вал./Кол.
- **WHEN** в синтетической ОСВ под контрагентом строки «USD» и «Кол.»
- **THEN** `group_type = OTHER`, `data_type = CURRENCY` и `QUANTITY`, `counterparty_name`/`counterparty_inn` равны `null`,
`account_code_raw` совпадает с родителем, строки исключены из проверки «сумма детей = родитель», статус `done`
#### Scenario: Шапка без вида данных
- **WHEN** разбирается синтетическая ОСВ `s43` (шапка без «Выводимые данные», колонки «Показатели» нет)
- **THEN** `header.data_type = null`, у всех строк данных и «Итого» `data_type = BU`, в `warnings` одно `OSV_HEADER` «вид
данных в шапке не указан — строки без метки приняты как БУ», статус `done`
#### Scenario: «Валюта <код>» — обычная строка
- **WHEN** в синтетической ОСВ `s42` под контрагентом строка «Валюта USD» с рублёвыми суммами
- **THEN** `group_type = OTHER`, `data_type` шапки, строка участвует в сверке «сумма детей = родитель»
### Requirement: Контрольные суммы определяют статус
Для каждого документа парсер SHALL проверить: по каждой строке `opening_debit − opening_credit + turnover_debit −
turnover_credit = closing_debit − closing_credit` (±0.01, `null` = 0); по каждому родителю суммы = Σ детей с тем же
`data_type` (±0.01), причём **обороты** сверяются по колонкам, а **сальдо** (начало, конец) — по колонкам либо, если у
родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто «дебет − кредит» (R-21: 1С показывает счёт нетто,
а субсчета и контрагентов — развёрнуто; остаточные риски перечислены ниже); строки-показатели (требование «Строки-показатели и колонка
«Показатели»») в суммирование не входят и сами родителями не проверяются; строка TOTAL = корневая строка ACCOUNT
(первая строка уровня 0); итоговая строка-показатель («Итого» с меткой «НУ», «Кол.») в эту сверку не входит. Если корня нет (пустая ОСВ,
R-13), «Итого» SHALL быть пустой или нулевой. Если строка «Итого» не найдена, `total_row_matches_root = false` и в
`warnings` — «строка «Итого» не найдена» (R-20). Результат — `checks{total_row_matches_root,
balance_identity_violations, parent_sum_violations}`. Результат проверки SHALL записываться в документ листа (контракт v2.0): `status` листа — `done`, когда контрольные суммы
§1.4 сошлись (и строки ИНН без нарушений), иначе `needs_check` (прежние условия §1.6, применённые к листу); любое
нарушение → `status = needs_check` документа и запись причины в `warnings[]` документа. Предупреждения листа SHALL быть
структурами `{code, severity, message}` с `severity = info` и кодом с префиксом `OSV_` (`OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`,
`OSV_HEADER`, `OSV_ROWS`, `OSV_INN_ROWS`, `OSV_NO_DATA_ROWS`, `OSV_BALANCE_IDENTITY`, `OSV_PARENT_SUM`, `OSV_TOTAL`,
`OSV_XLSB_NO_OUTLINE`); строк вида `sheet=<имя> <текст>` в `metrics.warnings` больше нет. Парсер MUST NOT подгонять
структуру под сходимость.
Исключение — **счета без сальдо по аналитике** (решение владельца 07.10.2026, OQ-132): у 91 и 99 строки аналитики несут
только обороты, сальдо закрыто на субсчёт закрытия (91.09). Список счетов — данные `rules/osv_checks.yaml`
(`accounts_without_analytic_balance`, начальный состав `91`, `99`; 90 не входит). Строка относится к такому счёту, если
код ближайшего счёта её ветки (`account_code_raw`) совпадает с записью списка либо является её субсчётом
(`91`, `91.01`, но не `910`). Для таких строк тождество строки SHALL NOT проверяться; сверка «сумма детей = родитель»
сохраняется: обороты — всегда по колонкам, сальдо — только у родителя с дочерними субсчетами (корень → субсчета), а у
родителя с одной лишь аналитикой сальдо не сверяется (сальдо аналитики пусто по определению); «Итого» сверяется как обычно.
Остаточные риски (известные слепые зоны, не ловятся проверками §1.4):
- RR-1 — у свёрнутого родителя сверяется только нетто сальдо: распределение сальдо детей между дебетом и кредитом
(перестановка сторон при совпавшем нетто) не проверяется.
- RR-2 — «свёрнутость» определяется по самому родителю: развёрнутое сальдо родителя, у которого в файле потеряна одна
сторона, неотличимо от нетто-показа 1С и проходит, если нетто совпало.
- RR-3 — нетто допускается на любом уровне дерева (счёт, субсчёт, контрагент, договор), а не только на уровне «счёт →
субсчета», где его показывает 1С: ошибка распределения сальдо между договорами под свёрнутым контрагентом не ловится.
- RR-4 — тождество строки сверяет только нетто строки, «Итого = корень» — только корень: ошибка, сохраняющая нетто
строки, ловится лишь сверкой с родителем по колонкам, а под свёрнутым родителем — ничем (см. слепую зону ниже).
- Слепая зона: обе стороны сальдо (на начало или на конец) развёрнутого ребёнка увеличены на одно и то же X, а его
непосредственный родитель свёрнут — не ловится ничем: достаточно свёрнутого прямого родителя (сверка с ним — по нетто,
а нетто ребёнка не изменилось; значения родителя и выше не затронуты; тождество строки и «Итого = корень» сохраняются).
- Родитель с сальдо (0, 0) при детях (X Дт, X Кт) принимается: нулевое сальдо — свёрнутое, нетто детей X − X = 0
(закреплено тестом как осознанное поведение R-21).
- Вне R-21: текст вида «12.ООО» (2–3 цифры, точка, до трёх заглавных букв) принимается за счёт (R-16) и меняет
`group_type` строк ветки; суммы и проверки это не затрагивает.
#### Scenario: Все реальные файлы сходятся
- **WHEN** разбирается каждый файл `fixtures/osv/**`
- **THEN** `status = done`, `checks.total_row_matches_root = true`, оба списка нарушений пусты
#### Scenario: Испорченная сумма
- **WHEN** в синтетической ОСВ у одного контрагента `closing_debit` увеличен на 1
- **THEN** `status = needs_check` документа, `balance_identity_violations` содержит его `row_number`, `parent_sum_violations` —
`row_number` родителя, в `warnings[]` документа — `OSV_BALANCE_IDENTITY` / `OSV_PARENT_SUM` (`info`) с номерами строк
#### Scenario: Сальдо родителя свёрнуто нетто
- **WHEN** у родителя сальдо показано одной стороной (нетто), а у детей — обеими сторонами, и нетто детей равно нетто родителя
- **THEN** нарушения «сумма детей = родитель» по сальдо нет; обороты при этом сверяются по колонкам, а неверное нетто
или расхождение оборотов — нарушение
#### Scenario: Счёт 91 — аналитика только с оборотами
- **WHEN** разбирается ОСВ по счёту 91: строки аналитики 91.01, 91.02, 91.09 несут только обороты, сальдо закрыто на
субсчёт закрытия (`fixtures/osv-dkb/tsimlyansky/2025-Q3/91_ 9мес.2025.xls`)
- **THEN** `status = done`, оба списка нарушений пусты; обороты аналитики при этом сходятся с оборотами субсчёта, а
расхождение оборотов, сальдо субсчетов с корнем или «Итого» осталось бы нарушением
#### Scenario: «Итого» не найдена
- **WHEN** в таблице нет строки с текстом из словаря «Итого»
- **THEN** `total_row_matches_root = false`, `status = needs_check`, в `warnings[]` — `OSV_TOTAL` (`info`) «строка «Итого» не найдена»
#### Scenario: Пустая ОСВ
- **WHEN** разбираются реальные пустые ОСВ (в фикстурах две: `schastl/2025-Q2/…58 за 2 квартал 2025 г..xlsx` и `…66 за 2
квартал 2025 г..xlsx`: заголовок и сразу «Итого») либо любой файл, где кроме «Итого» строк данных нет
- **THEN** `status = done` документа, `rows` содержит только строку TOTAL, `metrics.rows = 0` (строки данных без TOTAL),
`hierarchy=trivial`, `checks.total_row_matches_root = true` (корня нет, суммы TOTAL пусты), предупреждение
`OSV_NO_DATA_ROWS` «нет строк данных»
#### Scenario: Предупреждение ОСВ
- **WHEN** на листе ОСВ есть строка без родителя
- **THEN** у документа ОСВ предупреждение `{code: "OSV_…", severity: "info", message}`, статус документа по §1.6;
в `metrics.warnings` конверта строки `sheet=…` нет

View File

@ -0,0 +1,32 @@
# Tasks: колонка «Показатели» и валютные строки ОСВ
План исполнения — `docs/superpowers/plans/2026-10-19-osv-indicator-column.md` (задачи 1–8, модели исполнителей там).
## 1. Словари
- [ ] 1.1 `api/rules/osv_headers.yaml` — `service_columns.indicator`; `api/rules/data_types.yaml` — `indicator_column`,
`indicator_column_untyped`, `row: «В валюте»`; `src/parser/rules.py` — поля `DataTypeRules`; тесты `test_rules.py`
## 2. Синтетика
- [ ] 2.1 `tests/synthetic/generate.py` — s40–s43 (`_grid_osv`); `tests/synthetic/test_synthetic_files.py` — ожидания
и сценарии (сначала красные); файлы в `../fixtures/osv/synthetic/`
## 3. Колонка «Показатели» в заголовке
- [ ] 3.1 `src/parser/detect.py` — `TableHeader.indicator_col`; тест `test_detect.py`
## 4. Строки-показатели при сборе строк
- [ ] 4.1 `src/parser/hierarchy.py` (`BodyRow`), новый `src/parser/indicators.py` (`read_label`), `src/parser/classify.py`
(`collect_body_rows`, `_label_cols`, `_trailing_totals`); тесты `test_classify.py`, `test_indicators.py`
## 5. Иерархия
- [ ] 5.1 `indicators.levels_with_indicators`, `osv.py` (вызов), `inn_rows._kind`; тест `test_indicators.py`
## 6. Классификация и проверки
- [ ] 6.1 `classify.py` (`_Kind`, `ClassifiedRow.indicator`, вид данных строки), `checks.py` (`_is_indicator`, `_total`),
`osv.py` (предупреждение «приняты как БУ»); тесты `test_classify.py`, `test_checks.py`
## 7. Регрессия и реальные файлы
- [ ] 7.1 Синтетика зелёная; `make golden-check` — снимки реальных файлов и s01–s32 без изменений; `make golden-update`
только для s40–s43; локальная сверка файлов контура (не в git)
## 8. Документация и проверки
- [ ] 8.1 README (раздел «Парсер ОСВ»), `../docs/context/06-test-data.md` §7 (s40–s43), `make check-full`