arm-tabular-parser/docs/superpowers/plans/2026-10-19-osv-flat-form.md
Andrey Epifancev 7cd71677da docs(osv-flat-form): рулинги R-17…R-19, актуализация design, README, спеки (#59)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:20:00 +04:00

218 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ОСВ 1С в плоской форме (AURORA-1661, структура) Implementation Plan
> **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:** ОСВ 1С по одному счёту в плоской форме («по счету: X», колонка «Субконто», без строки счёта, «Итого развернутое» перед «Итого») разбирается без `INVALID_FORMAT`, со счётом и организацией из шапки, а контрагенты расчётных счетов без названия вида получают `COUNTERPARTY`.
**Architecture:** Изменения в конвейере листа ОСВ tabular-parser: шапка (`header.py`) — код счёта с двоеточием, строка вида субконто, «План счетов»; итоговый блок (`classify.collect_body_rows`, `checks._total`); новая стратегия каскада `header_account` (`hierarchy.py`) и счёт шапки у корня ветки (`classify_rows`); правило расчётных счетов — данные `osv_groups.yaml`. Контракт результата не меняется.
**Tech Stack:** Python 3.11, pydantic v2, openpyxl, pytest; OpenSpec 1.13.
**Spec:** `openspec/changes/osv-flat-form/` (proposal.md, design.md, specs/osv-parsing/spec.md)
**Ветка:** `feat/59-osv-flat-form` во всех трёх worktree: `/Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/{tabular-parser,fixtures,docs}`. Issue — RSHB/arm-tabular-parser#59. Jira — AURORA-1661 (структурная часть).
## Global Constraints
- Каждая shell-команда начинается с `cd /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/<repo> &&`; git — только `git -C /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/<repo>`; в основные checkout'ы (`/Users/andrey.epifancev/projects/awp-ca/tabular-parser` и т. п., ветка main) не заходить и не писать. Хук отказал — стоп, вернуться к контроллеру с текстом отказа.
- Код Python правится только Edit/Write, не `sed -i`/heredoc через Bash.
- Нет координат в `src/parser/**` (AST-линтер `tests/unit/parser/test_no_coordinates.py`): ни букв колонок, ни номеров строк, ни имён листов. Новые синонимы и списки — данные `api/rules/*.yaml`, не `if` в коде.
- Деньги — `Decimal`, `float` нет. `needs_check` — только по контрольным суммам; структуру под сходимость не подгонять.
- Контракт (`api/contracts/`, `src/contract/`) не меняется; `CONTRACT_VERSION` прежний.
- Синтетика этого плана — `s40`–`s44` (номера `s33`–`s39` — у параллельного трека A, не занимать).
- Golden (`../fixtures/golden/expected_osv_parse/`) правится только `make golden-update` с просмотром diff, коммит — в репозиторий fixtures отдельно от кода, сообщение с `(tabular-parser#59)`. Изменение снимка реального файла, не объяснённое задачей, — стоп и вопрос контроллеру.
- Реальные файлы контура (`/Users/andrey.epifancev/projects/awp-ca/result_dir.zip`, `/Users/andrey.epifancev/projects/awp-ca/ОСВ сч. 60 за 2 кв. 2026.xlsx`) в git не кладутся: только распаковка во временный каталог вне репозиториев для сверки.
- Трек A (`.wt/osv-indicators/`, AURORA-1613 + валюта 1661) владеет путём `data_type` (`rules/data_types.yaml`, `rules.RowDataType`, `classify.row_data_type`, `checks._INDICATOR_DATA_TYPES`, колонка «Показатели», умолчание BU) — эти места не трогать.
- Коммиты — по-русски, с `(#59)` в теме и строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Проверка задачи: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/tabular-parser && make check` зелёный (≈95 с; включает fixtures-тесты).
## Review Focus
1. «по счету:» без кода после двоеточия (код в соседней ячейке или нет вовсе) — `account_code_raw` пуст, работает фолбэк OQ-134 из строки таблицы с прежним предупреждением; тест в задаче 2.
2. Название и период в одной строке («…по счету 60 за 1 квартал 2025 г.») — строки вида субконто нет, организация по прежнему правилу (первая не-метка); тест в задаче 2.
3. На листе только «Итого развернутое», без обычной «Итого» — она единственная итоговая и сверяется как «Итого»; тест в задаче 3.
4. Счёт в шапке, строк-счетов нет, но есть outline (контрагент → договоры) — побеждает `outline`, `header_account` не используется, строки всё равно наследуют счёт шапки; тест в задаче 4.
5. Счёт ветки «760» или «600» при записи `76`/`60` в `counterparty_accounts` — не расчётный, строки OTHER; тест в задаче 5.
---
### Task 1: Синтетика s40–s44 и снимки текущего поведения
**Files:**
- Modify: `api/tests/synthetic/generate.py` (функции `_s40`…`_s44`, записи реестра `FILES`, docstring `main` — «s01…s44»)
- Create: `../fixtures/osv/synthetic/s40_flat_header_account.xlsx`, `s41_flat_chart_of_accounts.xlsx`, `s42_subaccounts_totals_reversed.xlsx`, `s43_subkonto_unnamed_60.xlsx`, `s44_subkonto_unnamed_10.xlsx`
- Create: снимки `../fixtures/golden/expected_osv_parse/synthetic/s40…s44.json` (`make golden-update`)
**Interfaces:**
- Produces: пять файлов синтетики; ожидания по ним задачи 2–5 проверяют разбором через `OsvParser`/`get_parser()`.
Содержимое листов (все — один лист, колонка A — текст, B–G — суммы «Сальдо на начало / Обороты за период / Сальдо на конец периода» × «Дебет | Кредит»; заголовок групп — объединения B:C, D:E, F:G; числа — `Decimal`; пусто — нет ячейки):
- `s40`: r2 «Оборотно-сальдовая ведомость по счету: 76.Н.1», r3 «Контрагенты», r4 «за 2025 г.», r5 «ООО "Товарное хозяйство"», r7 «Единица измерения: рубль (код по ОКЕИ 383)», r9 «Субконто» + группы, r10 подзаголовки; данные без outline и отступов: «Август - Агро ООО» 1000.00 | — | 500.00 | 300.00 | 1200.00 | —; «АВИС ООО» — | 200.00 | 100.00 | 400.00 | — | 500.00; «Агроаспект ООО» — | — | 50.00 | 50.00 | — | —; «Итого развернутое» 1000.00 | 200.00 | — | — | 1200.00 | 500.00; «Итого» 800.00 | — | 650.00 | 750.00 | 700.00 | —.
- `s41`: r2 «Оборотно-сальдовая ведомость по счету: 60», r3 «Организации», r4 «за 2 Квартал 2026 г. План счетов ООО Хреновская Нива», r5 «Субконто» + группы, r6 подзаголовки; «Автодор-Платные Дороги ООО» 820.21 | — | 5000.00 | 4991.00 | 829.21 | —; «АГРОПРОФИ ООО» — | 2763.79 | — | — | — | 2763.79; «Итого развернутое» 820.21 | 2763.79 | — | — | 829.21 | 2763.79; «Итого» — | 1943.58 | 5000.00 | 4991.00 | — | 1934.58.
- `s42`: r2 «Оборотно-сальдовая ведомость по счету: 76», r3 «за 2025 г.», r4 «ООО "Товарное хозяйство"», r6 «Единица измерения: рубль (код по ОКЕИ 383)», r8 «Субконто» + группы, r9 подзаголовки; «76.2» 1000.00 | — | — | 1000.00 | — | —; «76.5» — | 300.00 | 700.00 | 600.00 | — | 200.00; «76.Н» — | — | 5000.00 | 5000.00 | — | —; «76.Н.1» — | — | 5000.00 | 5000.00 | — | —; «Итого развернутое» 1000.00 | 300.00 | — | — | — | 200.00; «Итого» 700.00 | — | 5700.00 | 6600.00 | — | 200.00.
- `s43`: r1 «ООО "Октябрьское"», r2 «Оборотно-сальдовая ведомость по счету 60», r3 «Период: 9 месяцев 2024 г.», r5 «Субконто» + группы, r6 подзаголовки; «60.01» 34618000.00 | — | 4236500.00 | 38854500.00 | — | —; отступ 1: «ИП Иванов А.А.» 34618000.00 | — | — | 34618000.00 | — | —; отступ 1: «ООО "ЭСТЕТ ОМСК"» — | — | 4236500.00 | 4236500.00 | — | —; «Итого» и затем «Итого развернутое» — обе 34618000.00 | — | 4236500.00 | 38854500.00 | — | —.
- `s44`: как `s43`, но счёт 10: r2 «…по счету 10», «10.01» и строки «Мука пшеничная», «Сахар-песок» с теми же суммами.
- [ ] **Step 1: Добавить `_s40`…`_s44` и записи реестра**, по образцу `_s31`/`_s32` (листы через `SheetSpec`/`put`, отступ — `CellSpec(..., indent=1)`).
- [ ] **Step 2: Сгенерировать файлы**
Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/tabular-parser/api && .venv/bin/python -m tests.synthetic.generate ../../fixtures/osv/synthetic` (если `.venv` нет — `cd .. && make venv`).
Expected: в выводе пути `s40…s44`; `git -C /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/fixtures status` — пять новых файлов, прежние файлы синтетики без изменений (генератор детерминирован).
- [ ] **Step 3: Снимки текущего поведения**
Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/tabular-parser && make golden-update`
Expected: новые снимки только `synthetic/s40…s44.json`; у `s40`, `s41` — `status: "error"`, `INVALID_FORMAT` «иерархия строк не определена»; `s42` — `needs_check`, «Итого развернутое» — OTHER; `s43`, `s44` — контрагенты/материалы OTHER. Других изменений снимков нет.
- [ ] **Step 4: `make check` зелёный**
- [ ] **Step 5: Commit (два репозитория)**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/fixtures add osv/synthetic golden/expected_osv_parse/synthetic
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/fixtures commit -m "Синтетика s40–s44: плоская форма ОСВ 1С, снимки текущего поведения (tabular-parser#59)"
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/tabular-parser add api/tests/synthetic/generate.py
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/tabular-parser commit -m "test: синтетика s40–s44 — плоская форма ОСВ 1С (#59)"
```
---
### Task 2: Шапка — код счёта с двоеточием, строка вида субконто, «План счетов»
**Files:**
- Modify: `api/rules/osv_headers.yaml` (`header_markers.chart_of_accounts: ['\bплан\s+счетов\b']`)
- Modify: `api/src/parser/rules.py` (`HeaderMarker` + `"chart_of_accounts"`; `OsvGroupRules.known`)
- Modify: `api/src/parser/header.py` (`_account_code`, `_organization`, новый `_kind_row_cells`, `_variant1_groups`/`build_group_levels`)
- Test: `api/tests/unit/parser/test_header.py`, `api/tests/unit/parser/test_rules.py`
**Interfaces:**
- Produces: `OsvGroupRules.known(text: str) -> GroupType | None` — тип по явной записи `mapping`, `None` для `default`; `header._kind_row_cells(cells: list[_HeadCell], rules: Rules) -> list[_HeadCell]` — ячейки строго между строкой ячейки с меткой `account` и строкой периода (`_find_period`), если первая выше; иначе `[]`.
- [ ] **Step 1: Тесты (падают)** — в `test_header.py` через `sheet_from_rows`:
- `test_account_code_after_colon`: «Оборотно-сальдовая ведомость по счету: 76.Н.1» → `account_code_raw == "76.Н.1"`, `ACCOUNT_NOT_FOUND_WARNING` нет в `warnings`.
- `test_colon_without_code_falls_back_to_table_row` (Review Focus 1): «…по счету:» без кода → `account_code_raw == ""` и `ACCOUNT_NOT_FOUND_WARNING` в `warnings` (фолбэк делает `osv._with_table_account`).
- `test_kind_row_between_title_and_period_is_not_organization`: строки «…по счету: 76», «Контрагенты», «за 2025 г.», «ООО "Товарное хозяйство"» → `organization_name == 'ООО "Товарное хозяйство"'`.
- `test_organization_from_chart_of_accounts_tail`: «…по счету: 60», «Организации», «за 2 Квартал 2026 г. План счетов ООО Хреновская Нива» → `organization_name == "ООО Хреновская Нива"`, `period_quarter == "Q2"`, `period_year == 2026`.
- `test_title_and_period_in_one_row_keep_old_organization_rule` (Review Focus 2): существующая раскладка `_variant1` (`org` выше названия с периодом) → `organization_name == 'АО "Ромашка"'`.
- `test_known_kind_row_extends_single_account_schema`: шапка как у `s40`, заголовок «Субконто» → `build_group_schema(...)[0] == [ACCOUNT("Субконто"), COUNTERPARTY("Контрагенты")]`.
- `test_unknown_kind_row_is_not_added_to_schema`: строка «Материалы» → схема `[ACCOUNT("Субконто")]`.
- `test_organizations_kind_row_is_counterparty` (R-10): строка «Организации» → схема `[ACCOUNT("Субконто"), COUNTERPARTY("Организации")]`; в `osv_groups.yaml` добавить `['^организаци', COUNTERPARTY]` после `^контрагент`.
- в `test_rules.py`: `test_known_group_is_none_for_default` — `rules.groups.known("Контрагенты") == "COUNTERPARTY"`, `known("Материалы") is None`, `known("Организации") == "COUNTERPARTY"`, `known("Номенклатура") == "OTHER"`.
- [ ] **Step 2: Запуск** — `cd …/tabular-parser/api && .venv/bin/pytest tests/unit/parser/test_header.py tests/unit/parser/test_rules.py -q` → новые тесты FAIL.
- [ ] **Step 3: Реализация.**
- `_account_code`: хвост `_tail(...)` очистить от ведущих `:` и пробелов перед `_ACCOUNT_CODE.match`.
- `_organization`: сначала хвост метки `chart_of_accounts` в любой ячейке шапки (текст после совпадения, `strip(" ,;:")`, непустой); затем первая не-метка, исключая ячейки `_kind_row_cells`; затем `_title_tail_organization`. Ячейка периода, содержащая «План счетов», остаётся периодом (`_cut_period` режет на «г.»).
- `_is_structural_marker`: метка `chart_of_accounts` — структурная.
- Схема: если `_variant1_groups` дал ровно одну ACCOUNT, добавить по элементу текста `_kind_row_cells` (через запятую, `strip`), для которого `rules.groups.known(item)` не `None`: `_group(known, item)`; вариант 2 не меняется.
- [ ] **Step 4: Тесты PASS**, затем `make golden-update`: ожидаемо меняются только `synthetic/s40` и `s41` (если они ещё `error` — без изменений) и `s42` (`account_code_raw` «76» вместо «76.2», без предупреждения «взят из первой строки-счёта»). Изменение реального снимка — стоп (Global Constraints).
- [ ] **Step 5: `make check` зелёный; commit** — tabular-parser: `feat(osv): код счёта «по счету: X», строка вида субконто, «План счетов» в шапке (#59)`; fixtures (если снимки изменились): `Снимки s40–s42: шапка плоской формы (tabular-parser#59)`.
---
### Task 3: «Итого развернутое» перед «Итого»
**Files:**
- Modify: `api/src/parser/classify.py` (`collect_body_rows`, новый `_leading_totals`)
- Modify: `api/src/parser/checks.py` (`_total`, `total_matches_root`, `run_checks`)
- Modify: `api/src/parser/osv.py` (передать `rules.headers.total_row_secondary` в `run_checks`)
- Test: `api/tests/unit/parser/test_classify.py`, `api/tests/unit/parser/test_checks.py`
**Interfaces:**
- Produces: `collect_body_rows(...)` — тот же кортеж `(body, total_row, warnings)`, но `total_row` — **первая** строка итогового блока (вторые итоговые строки непосредственно перед основной «Итого» входят в блок); `run_checks(rows, exempt=None, *, secondary_totals: Sequence[Pattern] = ()) -> tuple[OsvChecks, list[OsvNote]]`; `total_matches_root(rows, secondary_totals: Sequence[Pattern] = ()) -> bool`.
- [ ] **Step 1: Тесты (падают)**
- `test_secondary_total_before_main_total_is_in_total_block` (`test_classify.py`): лист как `s42` → `total_row` = номер строки «Итого развернутое»; после `classify_rows` обе строки — `TOTAL`, `level == 0`, `parent is None`.
- `test_only_secondary_total_is_the_total` (Review Focus 3): на листе только «Итого развернутое» → она `TOTAL`, `total_row` — её номер.
- `test_total_check_uses_main_total_when_secondary_comes_first` (`test_checks.py`): строки TOTAL в порядке «Итого развернутое», «Итого»; суммы «Итого» = корень, «Итого развернутое» — нет → `total_matches_root(rows, secondary) is True`; без `secondary` (прежний вызов) — `False`.
- Разбор `s42` через `get_parser()`: `status == "done"`, `checks.parent_sum_violations == []`, `checks.balance_identity_violations == []`.
- [ ] **Step 2: Запуск — FAIL.**
- [ ] **Step 3: Реализация.** `_leading_totals(sheet, header, rows, total, rules) -> int` — идти от индекса основной «Итого» назад, пока текст строки (колонка текста) матчит `total_row_secondary`; вернуть номер самой верхней (или `total`). В `collect_body_rows` вернуть его как `total_row`; хвост — по-прежнему от последней из вторых строк после основной. `checks._total(rows, secondary)` — первая TOTAL, чей `body.text` не матчит ни один паттерн `secondary`, иначе первая TOTAL.
- [ ] **Step 4: PASS; `make golden-update`** — ожидаемо `synthetic/s42.json` (обе итоговые строки TOTAL, `status: done`); реальные снимки (пакет ДКБ с «Итого развернутое» после «Итого») не меняются.
- [ ] **Step 5: `make check`; commit** — `fix(osv): «Итого развернутое» перед «Итого» — итоговая строка, сверка по основной «Итого» (#59)`; fixtures — `Снимок s42: «Итого развернутое» перед «Итого» (tabular-parser#59)`.
---
### Task 4: Плоский список под счётом шапки
**Files:**
- Modify: `api/src/parser/hierarchy.py` (`strategy_header_account`, `resolve_levels`)
- Modify: `api/src/parser/osv_warnings.py` (`HIERARCHY_TEXTS["header_account"] = "плоский список под счётом шапки (header_account)"`)
- Modify: `api/src/parser/classify.py` (`classify_rows(..., header_account: str | None = None)`)
- Modify: `api/src/parser/checks.py` (`total_matches_root` без корня ACCOUNT)
- Modify: `api/src/parser/osv.py` (передать `header_account` в `resolve_levels` и `classify_rows`)
- Test: `api/tests/unit/parser/test_hierarchy.py`, `api/tests/unit/parser/test_checks.py`, Create `api/tests/unit/parser/test_osv_flat_form.py`
**Interfaces:**
- Consumes: `collect_body_rows` и `run_checks(..., secondary_totals=...)` задачи 3.
- Produces: `strategy_header_account(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None` — `{row: 0}` для всех строк, `None`, если хоть одна `is_account`; `resolve_levels(rows, total_row, deadline, *, header_account: bool = False) -> Levels` — `header_account` пробуется после `account_pattern` и перед `sums`, только при `header_account=True`; `classify_rows(..., *, inns=None, unpaired=frozenset(), header_account: str | None = None)` — корневая ветка `_Branch(header_account, 0)`.
- [ ] **Step 1: Тесты (падают)**
- `test_header_account_strategy_flat_rows` (`test_hierarchy.py`): три строки без outline/отступа/счёта, `header_account=True` → `strategy == "header_account"`, все уровни 0, родители `None`.
- `test_header_account_not_used_without_header_code`: те же строки, `header_account=False` → `InvalidFormatError` (прежнее поведение).
- `test_header_account_not_used_with_account_rows`: строки «60.01», «ООО А» без оформления, `header_account=True` → `account_pattern`.
- `test_total_without_account_root_matches_level_zero_sum` (`test_checks.py`): три корня OTHER/COUNTERPARTY без ACCOUNT, «Итого» = их сумма (сальдо «Итого» свёрнуто нетто) → `True`; одна сумма испорчена на 1 → `False`; только «Итого» с нулями → `True` (R-13 прежний).
- `test_osv_flat_form.py::test_flat_list_s40` (разбор `s40` через `get_parser()` или `OsvParser.parse_sheet` на листе из `sheet_from_rows`): `status == "done"`, предупреждение `OSV_HIERARCHY_BY_HEADER_ACCOUNT`, у каждой строки данных `level == 0`, `parent_row_number is None`, `account_code_raw == "76.Н.1"`, `organization_name == 'ООО "Товарное хозяйство"'`.
- `test_flat_list_s41`: `status == "done"`, `account_code_raw` строк `"60"`.
- `test_outline_wins_over_header_account_and_rows_inherit_header_code` (Review Focus 4): счёт в шапке «60», строк-счетов нет, outline: «ООО А» (0) → «Договор 1» (1) → `strategy == "outline"`, у обеих строк `account_code_raw == "60"`.
- [ ] **Step 2: Запуск — FAIL.**
- [ ] **Step 3: Реализация** по Interfaces; в `osv.py`: `resolve_levels(body, total_row, deadline, header_account=bool(doc.account_code_raw))`, `classify_rows(..., header_account=doc.account_code_raw or None)`. `total_matches_root`: нет корня ACCOUNT — если `_level_zero(rows)` не пуст, `_sums_match(_values(total), _sum_of(roots))`, иначе прежняя проверка нулей.
- [ ] **Step 4: PASS; `make golden-update`** — ожидаемо `s40`, `s41` (`done`, `header_account`); реальные снимки — только если у файла есть строки до первой строки-счёта (получат `account_code_raw` шапки, R-4) — каждое такое изменение перечислить в отчёте; иные изменения — стоп.
- [ ] **Step 5: `make check`; commit** — `feat(osv): стратегия header_account — плоский список под счётом шапки (#59)`; fixtures — `Снимки: плоский список под счётом шапки (tabular-parser#59)`.
---
### Task 5: Контрагенты расчётных счетов без названия вида
> Перед задачей: если трек A (`feat/*` в `.wt/osv-indicators/tabular-parser`, AURORA-1613) уже слит в main — `git -C …/tabular-parser fetch origin && git -C …/tabular-parser rebase origin/main`, `make golden-update`, разрешить конфликты в `classify._classify_row` (трек A меняет определение `data_type` в той же функции; сохранить обе правки). Не слит — работать как есть.
**Files:**
- Modify: `api/rules/osv_groups.yaml` (`counterparty_accounts: ['58', '60', '62', '66', '67', '75', '76', '80']` с комментарием про AURORA-1661, OQ-158 и R-9 (71/73 исключены))
- Modify: `api/src/parser/rules.py` (`OsvGroupRules.counterparty_accounts: list[str] = []`, проверка кодов как у `OsvCheckRules`, `is_counterparty_account`; общий хелпер сопоставления «код или его субсчёт» для обоих классов)
- Modify: `api/src/parser/classify.py` (`_classify_row`, `_Kind`, `ClassifiedRow.counterparty_by_account: bool = False`)
- Modify: `api/src/parser/osv.py` (заметка `OSV_ROWS`)
- Test: `api/tests/unit/parser/test_classify.py`, `api/tests/unit/parser/test_rules.py`, `api/tests/unit/parser/test_osv_flat_form.py`
**Interfaces:**
- Consumes: `classify_rows(..., header_account=...)` задачи 4.
- Produces: `OsvGroupRules.is_counterparty_account(account_code: str | None) -> bool` (`"76"` → `76`, `76.2`, `76.Н.1`; не `760`); `ClassifiedRow.counterparty_by_account` — строка получила COUNTERPARTY по плану счетов.
- [ ] **Step 1: Тесты (падают)**
- `test_rules.py::test_counterparty_accounts_match_code_and_subaccounts`: `is_counterparty_account("76.Н.1")`, `("60.01")` — `True`; `("760")`, `("600")`, `("10.01")`, `(None)` — `False` (Review Focus 5).
- `test_classify.py::test_unnamed_first_level_on_settlement_account_is_counterparty`: схема `[ACCOUNT("Субконто")]`, «60.01» → «ИП Иванов А.А.» (уровень 1) → COUNTERPARTY, `group_name is None`, `counterparty_name == "ИП Иванов А.А."`, `counterparty_by_account is True`.
- `test_unnamed_second_level_stays_other`: под таким контрагентом строка уровня 2 → OTHER.
- `test_named_other_group_is_not_reclassified`: схема `[ACCOUNT, OTHER("Работники организаций")]` на 71 → OTHER.
- `test_non_settlement_account_stays_other`: счёт «10.01» → OTHER, `counterparty_by_account is False`.
- `test_osv_flat_form.py::test_s43_counterparties_with_warning`: разбор `s43` → строки «ИП Иванов А.А.», «ООО "ЭСТЕТ ОМСК"» — COUNTERPARTY; в `warnings` ровно одно `OSV_ROWS` с текстом «вид субконто в шапке не назван: строки аналитики счёта 60.01 приняты за контрагентов по плану счетов — проверьте»; `status == "done"`.
- `test_s44_materials_stay_other`: разбор `s44` → строки OTHER, предупреждения «вид субконто…» нет.
- `test_s41_flat_counterparties`: разбор `s41` → строки данных COUNTERPARTY (по схеме с видом «Организации», R-10), `account_code_raw == "60"`, предупреждения «вид субконто…» нет.
- [ ] **Step 2: Запуск — FAIL.**
- [ ] **Step 3: Реализация.** В `_classify_row` после `_schema_group`: если `position == 1`, `position >= len(schema)` и `rules.groups.is_counterparty_account(code)` — COUNTERPARTY без имени, ИНН — по общей ветке COUNTERPARTY (колонка, строка ИНН, хвост названия); флаг `counterparty_by_account`. В `osv.py` — по одной заметке `OsvNote(OSV_ROWS, …)` на каждый различный `account_code_raw` строк с флагом, в порядке листа.
- [ ] **Step 4: PASS; `make golden-update`** — ожидаемо `s41`, `s43`; реальные снимки, где схема — одна ACCOUNT на расчётном счёте с не-счётными строками (пакет ДКБ `osv-dkb/**`), — OTHER → COUNTERPARTY и новое `OSV_ROWS`: перечислить файлы в отчёте; статусы снимков не должны меняться (если меняются — стоп).
- [ ] **Step 5: `make check`; commit** — `feat(osv): контрагенты расчётных счетов без названия вида — по плану счетов (#59)`; fixtures — `Снимки: контрагенты расчётных счетов по плану счетов (tabular-parser#59)`.
---
### Task 6: Правила, открытые вопросы, README, приёмка на реальных файлах
**Files:**
- Modify: `/Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/docs/context/05-domain-rules.md` (§1.1, §1.3, §1.4)
- Modify: `/Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/docs/context/07-open-questions.md`
- Modify: `README.md` (корень tabular-parser, раздел «Парсер ОСВ»: каскад стратегий + `header_account`), `CLAUDE.md` tabular-parser (строка каскада в «Модули» — `outline → indent → account_pattern → header_account → sums`)
- Modify: `openspec/changes/osv-flat-form/tasks.md` (отметить пункты), `design.md` (раздел «Decisions made without discussion» — дописать рулинги исполнения, если были)
- [ ] **Step 1: §1 правил (docs).** §1.1: код счёта после «по счету» — и через двоеточие; счёт шапки важнее строки таблицы; строка вида субконто между названием и периодом — не организация, известные виды — группировки после ACCOUNT; организация из «План счетов <орг>»; строка таблицы «Нормализация значений шапки» — без изменений. §1.3: стратегия `header_account` (условия, место в каскаде); счёт шапки у строк до первой строки-счёта; правило расчётных счетов (список — данные `osv_groups.yaml`, позиция 1, без имени, предупреждение; риск OQ-158). §1.4: «Итого развернутое» в любом порядке; «Итого» без корневой ACCOUNT = сумма уровня 0. Ссылки — AURORA-1661, tabular-parser#59, решения владельца 16.10/19.10.2026.
- [ ] **Step 2: Открытые вопросы (docs)** — следующие свободные номера OQ на момент правки (трек A может занять номера раньше — сверить с `origin/main` docs): «Учредители» на 80 (именованная OTHER, гудвилл §4 п. 2); сузить список расчётных счетов (71, 73); «Организации» в строке вида субконто — что это за вид в 1С. Каждый — «открыт», с допущением принятого поведения (R-8 остаётся OTHER, R-9 71/73 исключены, R-10 синоним COUNTERPARTY).
- [ ] **Step 3: Приёмка на реальных файлах контура (без коммита данных).**
Run:
```bash
T=$(mktemp -d) && cd "$T" && unzip -q /Users/andrey.epifancev/projects/awp-ca/result_dir.zip 'files/26e4c90d*' 'files/638ecb3d*' 'files/f2e0e04e*' 'files/7e49f8b5*' 'files/45e06f7d*' 'files/cb7e6eb0*' 'files/270b41da*' 'files/738bfcf5*' 'files/8c7e95d9*' && cp "/Users/andrey.epifancev/projects/awp-ca/ОСВ сч. 60 за 2 кв. 2026.xlsx" files/ && echo "$T"
cd /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/tabular-parser/api && for f in "$T"/files/*; do ENV_FILE=.env.test .venv/bin/python -m src.parser.cli "$f"; done
```
Expected (записать таблицей в отчёт задачи): ни одного `INVALID_FORMAT`; у плоских файлов (`26e4c90d`, `638ecb3d`, `f2e0e04e`, `7e49f8b5`, `45e06f7d`, `cb7e6eb0`, письмо) — `OSV_HIERARCHY_BY_HEADER_ACCOUNT`, `account_code_raw` из шапки («76.Н.1», «76.АВ», «76.2», «60»), организация «ООО "Товарное хозяйство"» / «ООО Хреновская Нива», строки — COUNTERPARTY; `270b41da`/`738bfcf5` — `account_code_raw = "76"`, «Итого развернутое» — TOTAL, нарушение остаётся только у валютного «76.55» (трек A); `8c7e95d9` лист «60 сч.» — контрагенты COUNTERPARTY с предупреждением. Письмо — COUNTERPARTY по схеме «Организации», без предупреждения «вид субконто…». Отклонение — описать, не чинить молча. После — `rm -rf "$T"`.
- [ ] **Step 4: `make check-full`** в tabular-parser (нужен стек compose на портах `.env.test`; если порты заняты демо-стендом — стоп и вопрос контроллеру); `make check` в docs.
- [ ] **Step 5: `openspec validate osv-flat-form --strict`** (tabular-parser) — без ошибок.
- [ ] **Step 6: Commit** — docs: `§1 правил ОСВ: плоская форма 1С — счёт из шапки, «Итого развернутое», header_account, контрагенты по плану счетов (tabular-parser#59, AURORA-1661)`; tabular-parser: `docs: README, CLAUDE.md и change osv-flat-form (#59)`.