diff --git a/docs/superpowers/plans/2026-10-19-osv-indicator-column.md b/docs/superpowers/plans/2026-10-19-osv-indicator-column.md new file mode 100644 index 0000000..d85b527 --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-osv-indicator-column.md @@ -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/`. Команды 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 `. +- 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/*.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 — не трогать: статусы ведёт главная сессия. diff --git a/openspec/changes/osv-indicator-column/.openspec.yaml b/openspec/changes/osv-indicator-column/.openspec.yaml new file mode 100644 index 0000000..c1bc902 --- /dev/null +++ b/openspec/changes/osv-indicator-column/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-19 diff --git a/openspec/changes/osv-indicator-column/design.md b/openspec/changes/osv-indicator-column/design.md new file mode 100644 index 0000000..fb5954a --- /dev/null +++ b/openspec/changes/osv-indicator-column/design.md @@ -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 по умолчанию с предупреждением — по +рекомендации агента, владелец принял без возражений («ошибки давай исправлять сейчас»). diff --git a/openspec/changes/osv-indicator-column/proposal.md b/openspec/changes/osv-indicator-column/proposal.md new file mode 100644 index 0000000..891f737 --- /dev/null +++ b/openspec/changes/osv-indicator-column/proposal.md @@ -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` как пришёл. diff --git a/openspec/changes/osv-indicator-column/specs/osv-parsing/spec.md b/openspec/changes/osv-indicator-column/specs/osv-parsing/spec.md new file mode 100644 index 0000000..118af58 --- /dev/null +++ b/openspec/changes/osv-indicator-column/specs/osv-parsing/spec.md @@ -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=…` нет diff --git a/openspec/changes/osv-indicator-column/tasks.md b/openspec/changes/osv-indicator-column/tasks.md new file mode 100644 index 0000000..8b31a91 --- /dev/null +++ b/openspec/changes/osv-indicator-column/tasks.md @@ -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`