552 lines
45 KiB
Markdown
552 lines
45 KiB
Markdown
# Колонка «Показатели» и валютные строки ОСВ: план реализации
|
||
|
||
> **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`), синтетику 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 <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: Синтетика 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/<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 синтетики: 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 — не трогать: статусы ведёт главная сессия.
|