diff --git a/docs/superpowers/plans/2026-10-19-osv-flat-form.md b/docs/superpowers/plans/2026-10-19-osv-flat-form.md new file mode 100644 index 0000000..7a8c218 --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-osv-flat-form.md @@ -0,0 +1,216 @@ +# ОСВ 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/ &&`; git — только `git -C /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/`; в основные 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 `. +- Проверка задачи: `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_rules.py`: `test_known_group_is_none_for_default` — `rules.groups.known("Контрагенты") == "COUNTERPARTY"`, `known("Организации") is None`, `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', '71', '73', '75', '76', '80']` с комментарием про AURORA-1661 и OQ-158) +- 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, `account_code_raw == "60"`, предупреждение для счёта «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С. Каждый — «открыт», с допущением текущего поведения. +- [ ] **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 с предупреждением. Отклонение — описать, не чинить молча. После — `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)`. diff --git a/openspec/changes/osv-flat-form/.openspec.yaml b/openspec/changes/osv-flat-form/.openspec.yaml new file mode 100644 index 0000000..c1bc902 --- /dev/null +++ b/openspec/changes/osv-flat-form/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-19 diff --git a/openspec/changes/osv-flat-form/design.md b/openspec/changes/osv-flat-form/design.md new file mode 100644 index 0000000..f84541a --- /dev/null +++ b/openspec/changes/osv-flat-form/design.md @@ -0,0 +1,103 @@ +## Context + +Плоская форма ОСВ 1С по одному счёту (образцы — выгрузка контура `awp-ca/result_dir.zip` и файл письма +`awp-ca/ОСВ сч. 60 за 2 кв. 2026.xlsx`; в git не кладутся, только локальная сверка): + +``` +r2 Оборотно-сальдовая ведомость по счету: 76.Н.1 +r3 Контрагенты ← вид субконто (у письма — «Организации», у O-3 — «Материалы») +r4 за 2025 г. ← у письма: «за 2 Квартал 2026 г. План счетов ООО Хреновская Нива» +r5 ООО "Товарное хозяйство" ← организация (у письма — хвост «План счетов …» строки периода) +r7 Единица измерения: рубль (код по ОКЕИ 383) +r9 Субконто | Сальдо на начало периода | Обороты за период | Сальдо на конец периода +r10 | Дебет | Кредит | Дебет | Кредит | Дебет | Кредит +r11 Август - Агро ООО | … суммы … ← сразу контрагенты: outline 0, отступ 0, строки счёта нет +… + Итого развернутое | … ← у файлов ТХО и письма — ПЕРЕД «Итого» + Итого | … +``` + +Вариант со строками субсчетов (ТХО 76, `270b41da`): строки «76.2», «76.5», …, «76.Н.1» на уровне 0 без строки «76», +«Итого развернутое» перед «Итого». Вариант «Субконто» с отступами (ОСВОКТ-24, `8c7e95d9`, лист «60 сч.»): «60.01», под +ним контрагенты с отступом 1, «Итого», затем «Итого развернутое» — контрагенты сейчас `OTHER` без имени. + +Текущее поведение (main `aef2d35`): письмо и `26e4c90d` — `INVALID_FORMAT` на шаге «иерархия строк»; `270b41da` — +`account_code_raw = "76.2"` из первой строки, «Итого развернутое» (r27) — `OTHER` ребёнок «76.Н.1», нарушения +`parent_sum [13, 26]`, `balance_identity [27]` (13 — валюта, трек A). + +## Goals / Non-Goals + +**Goals:** разбор плоской формы без `INVALID_FORMAT`; счёт и организация из шапки; «Итого развернутое» — итог в любом +порядке; контрагенты расчётных счетов видны ядру как `COUNTERPARTY`. + +**Non-Goals:** см. proposal (валюта и `data_type` — трек A; «Учредители»; «Организации» по имени). + +## Decisions + +Решения владельца — текст ожидаемого результата AURORA-1661 (заведён по команде владельца 16.10.2026) и запуск трека +19.10.2026 («трек B можешь запускать»): + +1. Счёт — из шапки «по счету[:] X»; строки таблицы — его аналитика. Фолбэк OQ-134 (код первой строки-счёта) — только + если в шапке счёта нет. +2. «Итого развернутое» — итоговая строка в любом порядке относительно «Итого». +3. Если вид субконто не назван: на расчётных счетах (58, 60, 62, 66, 67, 71, 73, 75, 76, 80) нижний уровень — + контрагенты (`COUNTERPARTY`) с предупреждением. + +Реализация: + +- **Код счёта с двоеточием** — `header._account_code`: хвост после метки `account` очищается от ведущих «:» и пробелов + перед `_ACCOUNT_CODE`. Отдельный синоним не нужен: «по счету:» уже матчит `\bпо\s+счету\b`. +- **Строка вида субконто** — ячейки шапки строго между строкой названия (ячейка с меткой `account`) и строкой периода + (`_find_period`), если название выше периода. Они не кандидаты в организацию. Их текст (через запятую) — виды + субконто: элемент, который `rules.groups` узнаёт **явной** записью `mapping` (не `default`), добавляется в схему + после ACCOUNT, если схема варианта 1 состоит из одной ACCOUNT («Субконто»). Неузнанный элемент («Организации», + «Материалы») в схему не добавляется. +- **Организация** — порядок: хвост метки «План счетов» (`header_markers.chart_of_accounts`, новый ключ) → первая + не-метка шапки без строки вида субконто → хвост названия ведомости (как сейчас). +- **Итоговый блок** — `collect_body_rows` возвращает номером «Итого» **первую** строку итогового блока: вторые итоговые + строки, стоящие непосредственно перед основной «Итого» (`total_row_secondary`), входят в блок. Всё, что использует + `row >= total_row` (каскад, классификация, `inn_rows`), работает без изменений. Сверка «Итого = корень» + (`checks._total`) берёт первую строку TOTAL, текст которой не матчит `total_row_secondary`; если такой нет — первую + TOTAL (как сейчас). +- **Стратегия `header_account`** — `hierarchy.strategy_header_account`: все строки уровня 0. `resolve_levels` получает + `header_account: bool` (в шапке есть код счёта) и пробует стратегию после `account_pattern` и перед `sums`, только + если `header_account` и ни одна строка тела не `is_account`. Код предупреждения `OSV_HIERARCHY_BY_HEADER_ACCOUNT`, + текст «иерархия строк — плоский список под счётом шапки (header_account)». +- **Счёт шапки у корня ветки** — `classify_rows` получает `header_account: str | None`; корневая ветка + `_Branch(header_account, 0)` вместо `_Branch(None, 0)`. +- **«Итого» без корня ACCOUNT** — `checks.total_matches_root`: нет корневой ACCOUNT, но есть строки уровня 0 (кроме + показателей) — сверка «Итого» с их суммой (допуски R-21, как у суммы уровня 0); строк уровня 0 нет — прежнее правило + R-13 (пустая/нулевая «Итого»). +- **Контрагенты по плану счетов** — `osv_groups.yaml: counterparty_accounts` (коды верхнего уровня; запись `76` + покрывает `76`, `76.2`, `76.Н.1`, но не `760` — как `accounts_without_analytic_balance`). В `_classify_row`: позиция + `1` (первая не-счётная) за пределами схемы и счёт ветки из списка → `COUNTERPARTY` без имени группировки; ИНН — по + общим правилам (колонка, строка ИНН, хвост названия). Глубже — `OTHER`, как сейчас. Предупреждение один раз на + документ (`OSV_ROWS`, `osv.py`): «вид субконто в шапке не назван: строки аналитики счёта <код> приняты за контрагентов + по плану счетов — проверьте». + +## Decisions made without discussion + +| № | Маркер | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---|---| +| 1 | R-1 | 19.10.2026 | Строка между названием и периодом — вид субконто, не организация | 6 из 98 xlsx контура (все — «Контрагенты»), в `fixtures` таких строк нет — регрессии golden нет | Организация, напечатанная между названием и периодом, не найдётся: предупреждение «наименование организации не найдено», ядро сверяет по ИНН | +| 2 | R-2 | 19.10.2026 | Известный вид субконто из этой строки — группировка схемы; неизвестный — не добавляется | «Контрагенты» даёт COUNTERPARTY с именем без угадывания по счёту; «Организации» у письма (60) — неизвестно, что это за вид | Неизвестный вид на нерасчётном счёте остаётся OTHER без имени (как сейчас) | +| 3 | R-3 | 19.10.2026 | `header_account` — перед `sums`, только без строк-счетов и при счёте в шапке | Плоский список с равными суммами соседей `sums` вложил бы ложно; условие — определение плоской формы 1С | Файл без оформления и счетов, но с реальной вложенностью по суммам станет плоским → «Итого» не сойдётся → `needs_check` (видно) | +| 4 | R-4 | 19.10.2026 | Корень ветки наследует счёт шапки | Ядро группирует свод по `account_code`; без этого у строк плоского файла счёт `null` | Строки до первой строки-счёта в обычных файлах получат счёт шапки вместо `null` (golden покажет) | +| 5 | R-5 | 19.10.2026 | «Итого» без корня ACCOUNT сверяется с суммой уровня 0 | Плоский список — несколько корней без счёта; прежнее R-13 требовало нулевой «Итого» | — (пустая ОСВ — прежнее правило) | +| 6 | R-6 | 19.10.2026 | Правило расчётных счетов — только позиция 1, только без имени в схеме | 1С кладёт контрагентов первым видом на этих счетах; именованные группировки («Работники организаций», «Учредители») не трогаем | Физлица и подотчётные на 71/73/76 без названия вида → COUNTERPARTY: попадут в «Осталось N» сопоставления (OQ-158) | +| 7 | R-7 | 19.10.2026 | Синтетика `s40`–`s44` | Номера `s33`–`s39` оставлены параллельному треку A, чтобы реестр генератора не конфликтовал | Перенумерация при слиянии | + +## Risks / Trade-offs + +- [OQ-158] Контрагенты по плану счетов на 71/73/76 — в том числе физлица и подотчётные: «Осталось N» в сопоставлении + вырастет. Смягчение: предупреждение `OSV_ROWS` на документе; вопрос владельцу — сузить список (без 71/73). +- [Трек A] До слияния трека A строки без «Выводимые данные» — `data_type = null`; ядро их в свод не берёт. +- [Golden] Изменения затронут снимки, где схема — одна ACCOUNT («Субконто») на расчётном счёте и есть не-счётные строки + (пакет ДКБ, `osv-dkb/**`): OTHER → COUNTERPARTY. Каждое изменение снимка — с просмотром diff. + +## Open Questions + +- «Учредители» на 80 (именованная OTHER, O-29, ОСВ 80 «Тестовой ГК 1» `02c5df51`): считать COUNTERPARTY? Нужен для + гудвилла (§4 п. 2). +- Сузить список расчётных счетов (71, 73 — подотчётные и персонал)? +- «Организации» в строке вида субконто у письма (60): что это за вид в 1С и стоит ли считать его контрагентами по имени. diff --git a/openspec/changes/osv-flat-form/proposal.md b/openspec/changes/osv-flat-form/proposal.md new file mode 100644 index 0000000..5bd627c --- /dev/null +++ b/openspec/changes/osv-flat-form/proposal.md @@ -0,0 +1,55 @@ +## Why + +AURORA-1661 (баг заказчика, найден прогоном выгрузки контура AURORA-1639, 16.10.2026; свод `admin/1639-contour-run`, +находки O-2, O-3, O-4, O-29): ОСВ 1С по одному счёту в **плоской форме** — шапка «Оборотно-сальдовая ведомость по счету: +76» (с двоеточием), колонка «Субконто», строки счёта и группировок нет, сразу список контрагентов, затем «Итого +развернутое» и «Итого» — не разбирается или разбирается неверно: + +- без строки счёта и без оформления — `INVALID_FORMAT` «иерархия строк не определена» (12 файлов контура, в том числе + файл «ОСВ сч. 60 за 2 кв. 2026» из письма заказчика; 6 из 10 файлов Компании 4, tp#49); +- «по счету: X» с двоеточием не распознаётся: счёт берётся из первой строки таблицы («76.2» вместо «76»), вместо + организации — вид субконто («Контрагенты», «Материалы»); +- «Итого развернутое» стоит **перед** «Итого» и становится строкой данных под последним счётом (ложные нарушения + «сумма детей = родитель» и тождества строки); +- если вид субконто не назван (в шапке только «Субконто»), контрагенты 60/62/67/76 молча получают `OTHER`: ядро их не + сопоставляет, в сводной ОСВ нет признака ГСК/ГК/Внешний, ВГО их не видит (O-29, ОСВОКТ-24). + +Валютная часть AURORA-1661 («Валюта USD» / «В валюте», O-7) — в параллельном треке вместе с AURORA-1613 (путь +`data_type`), не здесь. + +Срез: доработка Среза 2a (парсер ОСВ, `09-delivery-plan.md`). Репозитории: tabular-parser (код, эта спека), +arm-ka-docs (`05-domain-rules.md` §1.1, §1.3, §1.4, `07-open-questions.md`), arm-ka-fixtures (синтетика и golden). +Таблицы DBML v5.19 не затрагиваются; контракт результата не меняется (`CONTRACT_VERSION` прежний). + +## What Changes + +- **Шапка (§1.1).** Код счёта после «по счету» — и с двоеточием («по счету: 76.Н.1»); счёт шапки важнее первой + строки таблицы (фолбэк OQ-134 — только без счёта в шапке, как сейчас). Ячейки между строкой названия ведомости и + строкой периода — строка вида субконто, не организация; организация — также из метки «План счетов <организация>». + Известные виды субконто из этой строки («Контрагенты») становятся группировками схемы после ACCOUNT. +- **«Итого развернутое» (§1.4).** Вторая итоговая строка — `TOTAL` и **перед**, и после «Итого»; сверка «Итого = + корень» берёт основную «Итого», а не «Итого развернутое». +- **Плоский список (§1.3).** Новая стратегия иерархии `header_account` — все строки уровня 0 под счётом шапки: + применима, только если в шапке есть код счёта и среди строк таблицы нет ни одной строки-счёта; стоит в каскаде перед + `sums`. Строки без счёта в ветке наследуют счёт шапки (`account_code_raw`). «Итого» без корневой строки ACCOUNT + сверяется с суммой строк уровня 0. +- **Контрагенты по плану счетов (§1.1/§1.3).** На расчётных счетах (58, 60, 62, 66, 67, 71, 73, 75, 76, 80 — данные + `rules/osv_groups.yaml`) первая не-счётная позиция без названия в схеме — `COUNTERPARTY` с предупреждением `OSV_ROWS`. +- Синтетика `s40`–`s44`, golden; правила §1 и открытые вопросы в docs. + +## Capabilities + +### Modified Capabilities + +- `osv-parsing`: «Шапка документа и схема группировок», «Иерархия строк — каскад стратегий», «Классификация строк», + «Контрольные суммы определяют статус». + +## Non-goals + +- Валюта («Валюта <код>», «В валюте»), колонка «Показатели», `data_type` строки при молчащей шапке — трек A (AURORA-1613 + + валютная часть 1661). До его слияния строки плоских файлов без «Выводимые данные» приходят с `data_type = null`, и + ядро не берёт их в свод (`SUMMARY_DATA_TYPES = (BU, SUM)`): файлы распознаются, но в сводную ОСВ не попадают. +- «Учредители» на 80 (именованная группировка `OTHER`, O-29) — вопрос владельцу, не меняется. +- Распознавание вида субконто «Организации» как контрагентов по имени — не делается: неизвестный вид в схему не + добавляется, позиция остаётся без названия и подпадает под правило расчётных счетов. +- Склейка кварталов (O-28), «Итого» с потерянной подписью, буквенные счета первого сегмента (OQ-103) — без изменений. diff --git a/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md b/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md new file mode 100644 index 0000000..be50634 --- /dev/null +++ b/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md @@ -0,0 +1,234 @@ +## MODIFIED Requirements + +### Requirement: Шапка документа и схема группировок +Парсер SHALL извлечь из области выше заголовка таблицы: `organization_name` (хвост метки «План счетов <организация>»; иначе первая непустая текстовая строка, не являющаяся меткой шапки и не лежащая между строкой названия ведомости и строкой периода; иначе хвост названия ведомости; trim), +`account_code_raw` (после «по счету»/«по счёту», в том числе через двоеточие «по счету: 76.Н.1», как в файле: «60», «58.03»; код счёта шапки важнее первой строки-счёта таблицы — она используется, только если в шапке кода нет, OQ-134), `period_text` (после «за» до «г.»/конца), +`data_type` («Выводимые данные: БУ/НУ/сумма»), `currency` («Валюта …: RUB/руб.» → `RUB`), `header_variant` (1 — группировки +строками первой колонки блока заголовка; 2 — перечисление «субконто: …»). `group_schema` SHALL начинаться с ACCOUNT и далее +перечислять группировки в порядке вложенности по словарю `rules/osv_groups.yaml`; ячейка `ИНН` в строке группировки → +`has_inn = true`. Ячейки между строкой названия ведомости (с меткой «по счету») и строкой периода, если название выше периода, — **строка вида субконто** (плоская форма 1С: «Контрагенты», «Организации», «Материалы»): она MUST NOT становиться `organization_name`; если схема варианта 1 состоит из одной ACCOUNT («Субконто»), элементы этой строки (через запятую), которые словарь `rules/osv_groups.yaml` узнаёт явной записью `mapping` (не `default`), SHALL добавляться в схему после ACCOUNT; неузнанные элементы в схему MUST NOT попадать. Элемент вида `<группировка>.ИНН` (суффикс после последней точки — по словарю `service_columns.inn`, +префикс — имя группировки `COUNTERPARTY` этой же шапки; вариант 2 — элемент списка «субконто: …», вариант 1 — строка блока +заголовка) MUST NOT попадать в `group_schema`: группировка-владелец получает `has_inn = true`, а элемент служит +**подсказкой** для строк ИНН (требование «Строки ИНН рядом с контрагентом»): стоит после владельца — ИНН под +наименованием, перед владельцем — над. `period_quarter`/`is_cumulative` SHALL вычисляться только по тексту по +`rules/periods.yaml` (§1.5, `ASSUMPTION(OQ-02)`), `period_year` — четыре цифры года. Отсутствие любого поля шапки, кроме +обязательных по контракту, — `null` и предупреждение; отсутствие `organization_name`/`period_text` — пустая строка и +предупреждение (контракт требует строку). + +#### Scenario: Вариант 1 с тремя строками группировок +- **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls` +- **THEN** `group_schema = [ACCOUNT, DEPARTMENT, COUNTERPARTY(has_inn=true)]`, `header_variant = 1`, + `account_code_raw = "62"`, `period_text = "3 квартал 2024 г."`, `period_quarter = "Q3"`, `is_cumulative = false`, + `data_type = "BU"`, `currency = "RUB"` + +#### Scenario: ОСВ по субсчёту и годовой период +- **WHEN** разбираются `rb/2024-Q3/сч 583_3 кв 2024.xlsx` и `rb/2024-FY/ОСВ_66 за 2024.xls` +- **THEN** у первого `account_code_raw = "58.03"`; у второго `period_quarter = "Q4"`, `is_cumulative = true` + +#### Scenario: Вариант 2 +- **WHEN** разбирается синтетическая ОСВ с шапкой «Период: 1 квартал 2026 г.», «Детализация по субсчетам, субконто: + Контрагенты, Договоры», заголовком «Субконто» +- **THEN** `header_variant = 2`, `group_schema = [ACCOUNT, COUNTERPARTY, CONTRACT]`, `period_quarter = "Q1"` + +#### Scenario: Реквизит ИНН после группировки +- **WHEN** разбирается `osv-dkb/novopetrovskaya/2025-Q3/60_ 9мес.2025г.xls` (шапка «субконто: Контрагенты, + Контрагенты.ИНН») +- **THEN** `group_schema = [ACCOUNT, COUNTERPARTY(has_inn=true)]`, подсказка «ИНН под наименованием» + +#### Scenario: Реквизит ИНН перед группировкой +- **WHEN** в синтетической ОСВ шапка «субконто: Контрагенты.ИНН, Контрагенты» +- **THEN** `group_schema = [ACCOUNT, COUNTERPARTY(has_inn=true)]`, подсказка «ИНН над наименованием» + +#### Scenario: Плоская форма — счёт с двоеточием, вид субконто, организация +- **WHEN** разбирается синтетическая ОСВ `s40` (шапка «Оборотно-сальдовая ведомость по счету: 76.Н.1», строка + «Контрагенты», «за 2025 г.», строка «ООО "Товарное хозяйство"», колонка «Субконто», сразу контрагенты) +- **THEN** `account_code_raw = "76.Н.1"` без предупреждения «код счёта … взят из первой строки-счёта», + `organization_name = "ООО \"Товарное хозяйство\""`, `group_schema = [ACCOUNT(Субконто), COUNTERPARTY(Контрагенты)]` + +#### Scenario: Организация из «План счетов» +- **WHEN** разбирается синтетическая ОСВ `s41` (шапка «…по счету: 60», строка «Организации», «за 2 Квартал 2026 г. + План счетов ООО Хреновская Нива») +- **THEN** `organization_name = "ООО Хреновская Нива"`, `account_code_raw = "60"`, `group_schema = [ACCOUNT(Субконто)]` + («Организации» словарь не узнаёт) + +#### Scenario: Счёт шапки важнее строки таблицы +- **WHEN** разбирается синтетическая ОСВ `s42` (шапка «…по счету: 76», первая строка таблицы «76.2») +- **THEN** `account_code_raw = "76"` + +### Requirement: Иерархия строк — каскад стратегий +Парсер SHALL определять `level` строк данных первой подходящей стратегией из списка: outline level Excel; отступ ячейки +или ведущие пробелы; шаблон кода счёта и жирность; плоский список под счётом шапки (`header_account`); равенство «сумма детей = родитель». Стратегия `header_account` (все строки — уровень 0) применима, только если в шапке есть код счёта и среди строк таблицы нет ни одной строки-счёта (плоская форма 1С). Стратегия подходит, если даёт дерево, +где у каждой строки есть предок с меньшим уровнем (кроме уровня 0). Строка «Итого» стратегиям MUST NOT передаваться: +она всегда имеет уровень 0 и не имеет родителя (R-14). Если строк данных (кроме «Итого») меньше двух (пустая ОСВ или одна +строка) — стратегия `trivial`: дерево из корней (R-13). Выбранная стратегия SHALL быть записана в `warnings[]` документа листа предупреждением +`OSV_HIERARCHY_BY_<СТРАТЕГИЯ>` (`severity = info`); в `metrics.warnings` строк `sheet=<имя> hierarchy=…` нет. `parent_row_number` SHALL указывать на ближайшую +предыдущую строку с меньшим уровнем. + +#### Scenario: Outline level (реальные файлы) +- **WHEN** разбирается `rb/2024-FY/ОСВ_66 за 2024.xls` (и любой другой реальный файл со строками данных) +- **THEN** предупреждение документа `OSV_HIERARCHY_BY_OUTLINE`, строки `66` (0) → `66.01` (1) → контрагент (2); у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» + `parent_row_number` — строка `66.03` + +#### Scenario: Без outline level +- **WHEN** разбирается синтетическая ОСВ без группировок Excel, с отступами ячеек (`s02`) +- **THEN** `OSV_HIERARCHY_BY_INDENT`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по + кодам счетов и жирности (`s03`) + +#### Scenario: Без outline и отступов +- **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`) +- **THEN** `OSV_HIERARCHY_BY_ACCOUNT_PATTERN`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт + +#### Scenario: Пустая ОСВ — тривиальное дерево +- **WHEN** в ОСВ нет строк данных кроме «Итого» +- **THEN** `OSV_HIERARCHY_BY_TRIVIAL`, «Итого» — уровень 0 без родителя + +#### Scenario: Плоский список под счётом шапки +- **WHEN** разбирается синтетическая ОСВ `s40` либо `s41` (без outline, отступов и строк-счетов; сразу контрагенты) +- **THEN** вместо `INVALID_FORMAT` — `OSV_HIERARCHY_BY_HEADER_ACCOUNT`, все строки данных уровня 0 без родителя, + `account_code_raw` каждой строки равен коду счёта шапки + +### 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, кроме **расчётных счетов**: если позиция 1 (первая не-счётная) выходит за схему, а счёт ветки — из списка `counterparty_accounts` `rules/osv_groups.yaml` (начальный состав 58, 60, 62, 66, 67, 71, 73, 75, 76, 80; запись `76` покрывает `76`, `76.2`, `76.Н.1`, но не `760`), строка SHALL получить COUNTERPARTY без имени группировки, а документ — предупреждение `OSV_ROWS` «вид субконто в шапке не назван: строки аналитики счёта <код> приняты за контрагентов по плану счетов — проверьте» (один раз на счёт); счёт ветки строк до первой строки-счёта — код счёта шапки; строка ИНН без пары — OTHER; +строка «Итого» и вторые итоговые строки («Итого развернутое») сразу перед ней или сразу за ней — 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` CURRENCY/QUANTITY, MUST NOT занимать позицию в `group_schema` и MUST NOT +наследовать `counterparty_name`/`counterparty_inn` (`account_code_raw` наследуется от ветки, R-19); их суммы остаются в +шести полях. Остальные строки получают `data_type` шапки. Все строки, включая 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` (лист как ОСВОКТ-24 «60 сч.»: колонка «Субконто», строка «60.01», под ней + контрагенты с отступом) либо `s41` +- **THEN** контрагенты — COUNTERPARTY без `group_name`, `counterparty_name` — текст строки; в `warnings[]` — `OSV_ROWS` + «вид субконто в шапке не назван…»; на нерасчётном счёте (синтетика `s44`, счёт 10) те же строки — OTHER без + предупреждения + +### 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С показывает счёт нетто, +а субсчета и контрагентов — развёрнуто; остаточные риски перечислены ниже); строки CURRENCY/QUANTITY в суммирование не входят и сами +родителями не проверяются; строка TOTAL = корневая строка ACCOUNT (первая строка уровня 0) либо сумма строк уровня 0; основная «Итого» — первая строка TOTAL, текст которой не «Итого развернутое» (`total_row_secondary`), в каком бы порядке они ни стояли. Если корневой ACCOUNT нет, а строки уровня 0 есть (плоский список), «Итого» SHALL сверяться с их суммой (те же допуски R-21); если строк данных нет (пустая ОСВ, 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=…` нет + +#### Scenario: «Итого развернутое» перед «Итого» +- **WHEN** разбирается синтетическая ОСВ `s42` (субсчета 76.2, 76.5, 76.Н.1 на уровне 0, затем «Итого развернутое» и + «Итого», «Итого» — свёрнутое нетто) +- **THEN** обе итоговые строки — TOTAL уровня 0 без родителя, «Итого развернутое» не ребёнок «76.Н.1»; + `total_row_matches_root = true` (сверка по «Итого»), нарушений нет, `status = done` + +#### Scenario: «Итого» плоского списка +- **WHEN** разбирается синтетическая ОСВ `s40` («Итого» = сумма контрагентов, сальдо «Итого» свёрнуто) +- **THEN** `total_row_matches_root = true`, `status = done`; если сумма одного контрагента испорчена — `false`, + `OSV_TOTAL`, `needs_check` diff --git a/openspec/changes/osv-flat-form/tasks.md b/openspec/changes/osv-flat-form/tasks.md new file mode 100644 index 0000000..56fc4b0 --- /dev/null +++ b/openspec/changes/osv-flat-form/tasks.md @@ -0,0 +1,25 @@ +Исполнение — по плану `docs/superpowers/plans/2026-10-19-osv-flat-form.md` (ветка `feat/59-osv-flat-form`, issue #59). + +## 1. Синтетика и снимки (tabular-parser, fixtures) + +- [ ] 1.1 Синтетика `s40`–`s44` в `api/tests/synthetic/generate.py`, файлы в `fixtures/osv/synthetic/`, снимки текущего поведения + +## 2. Шапка (tabular-parser) + +- [ ] 2.1 Код счёта «по счету: X», строка вида субконто, «План счетов <организация>» (`header.py`, `rules.py`, `osv_headers.yaml`) + +## 3. Итоговый блок (tabular-parser) + +- [ ] 3.1 «Итого развернутое» перед «Итого» — TOTAL; сверка по основной «Итого» (`classify.py`, `checks.py`, `osv.py`) + +## 4. Плоский список (tabular-parser) + +- [ ] 4.1 Стратегия `header_account`, счёт шапки у корня ветки, «Итого» без корня ACCOUNT (`hierarchy.py`, `classify.py`, `checks.py`, `osv.py`, `osv_warnings.py`) + +## 5. Контрагенты по плану счетов (tabular-parser) + +- [ ] 5.1 `counterparty_accounts` в `osv_groups.yaml`, COUNTERPARTY на позиции 1 за схемой, предупреждение `OSV_ROWS` + +## 6. Документация и приёмка (docs, tabular-parser) + +- [ ] 6.1 `05-domain-rules.md` §1.1, §1.3, §1.4; `07-open-questions.md`; README парсера; прогон реальных файлов контура; `make check-full`