From 32b8ad76eccdf5cf8567f7a2600b32c02ff17743 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 11:00:06 +0400 Subject: [PATCH 01/16] =?UTF-8?q?docs(openspec):=20change=20osv-flat-form?= =?UTF-8?q?=20=D0=B8=20=D0=BF=D0=BB=D0=B0=D0=BD=20=E2=80=94=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=BE=D1=81=D0=BA=D0=B0=D1=8F=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=20=D0=9E=D0=A1=D0=92=201=D0=A1=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-19-osv-flat-form.md | 216 ++++++++++++++++ openspec/changes/osv-flat-form/.openspec.yaml | 2 + openspec/changes/osv-flat-form/design.md | 103 ++++++++ openspec/changes/osv-flat-form/proposal.md | 55 ++++ .../osv-flat-form/specs/osv-parsing/spec.md | 234 ++++++++++++++++++ openspec/changes/osv-flat-form/tasks.md | 25 ++ 6 files changed, 635 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-19-osv-flat-form.md create mode 100644 openspec/changes/osv-flat-form/.openspec.yaml create mode 100644 openspec/changes/osv-flat-form/design.md create mode 100644 openspec/changes/osv-flat-form/proposal.md create mode 100644 openspec/changes/osv-flat-form/specs/osv-parsing/spec.md create mode 100644 openspec/changes/osv-flat-form/tasks.md 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` -- 2.47.2 From 23a5b7b41994de839df10540fff530ecb960c3a0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 11:30:37 +0400 Subject: [PATCH 02/16] =?UTF-8?q?docs(openspec):=20=D1=80=D1=83=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=D0=B8=20R-8=E2=80=A6R-10=20change=20osv-flat?= =?UTF-8?q?-form=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/osv-flat-form/design.md | 14 +++++++++----- openspec/changes/osv-flat-form/proposal.md | 8 ++++---- .../osv-flat-form/specs/osv-parsing/spec.md | 6 +++--- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/openspec/changes/osv-flat-form/design.md b/openspec/changes/osv-flat-form/design.md index f84541a..5ec4727 100644 --- a/openspec/changes/osv-flat-form/design.md +++ b/openspec/changes/osv-flat-form/design.md @@ -40,7 +40,7 @@ r11 Август - Агро ООО | … суммы … ← с 1. Счёт — из шапки «по счету[:] X»; строки таблицы — его аналитика. Фолбэк OQ-134 (код первой строки-счёта) — только если в шапке счёта нет. 2. «Итого развернутое» — итоговая строка в любом порядке относительно «Итого». -3. Если вид субконто не назван: на расчётных счетах (58, 60, 62, 66, 67, 71, 73, 75, 76, 80) нижний уровень — +3. Если вид субконто не назван: на расчётных счетах (58, 60, 62, 66, 67, 75, 76, 80; 71 и 73 исключены — R-9) нижний уровень — контрагенты (`COUNTERPARTY`) с предупреждением. Реализация: @@ -86,6 +86,9 @@ r11 Август - Агро ООО | … суммы … ← с | 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, чтобы реестр генератора не конфликтовал | Перенумерация при слиянии | +| 8 | R-8 | 19.10.2026 | «Учредители» на счёте 80 не трогаем: именованная группировка остаётся OTHER (R-6 сохраняется) | Принято по рекомендации сессии при одобрении запуска владельцем; именованные группировки правило расчётных счетов не переопределяет | Учредители не станут COUNTERPARTY автоматически — гудвилл (§4 п. 2) потребует ручного сопоставления | +| 9 | R-9 | 19.10.2026 | Счета 71 и 73 исключены из `counterparty_accounts`; итог: 58, 60, 62, 66, 67, 75, 76, 80 | Подотчётные и персонал — физлица, а не контрагенты (OQ-158); принято по рекомендации сессии | Контрагенты на 71/73 без названия вида останутся OTHER и не попадут в сопоставление | +| 10 | R-10 | 19.10.2026 | «Организации» — синоним COUNTERPARTY в `osv_groups.yaml` (`^организаци`) | Вид субконто письма (сч. 60) — организации-контрагенты; принято по рекомендации сессии | Другой смысл вида «Организации» на иных счетах даст COUNTERPARTY; видно в golden/предупреждениях | ## Risks / Trade-offs @@ -97,7 +100,8 @@ r11 Август - Агро ООО | … суммы … ← с ## Open Questions -- «Учредители» на 80 (именованная OTHER, O-29, ОСВ 80 «Тестовой ГК 1» `02c5df51`): считать COUNTERPARTY? Нужен для - гудвилла (§4 п. 2). -- Сузить список расчётных счетов (71, 73 — подотчётные и персонал)? -- «Организации» в строке вида субконто у письма (60): что это за вид в 1С и стоит ли считать его контрагентами по имени. +Закрыты по рекомендации сессии при одобрении запуска 19.10.2026 (R-8…R-10); владелец может пересмотреть: + +- «Учредители» на 80 (O-29, `02c5df51`) — остаётся OTHER; гудвилл §4 п. 2 — вопрос владельцу. +- Счета 71 и 73 (подотчётные, персонал) — исключены из списка расчётных. +- «Организации» — синоним COUNTERPARTY. diff --git a/openspec/changes/osv-flat-form/proposal.md b/openspec/changes/osv-flat-form/proposal.md index 5bd627c..7b25930 100644 --- a/openspec/changes/osv-flat-form/proposal.md +++ b/openspec/changes/osv-flat-form/proposal.md @@ -33,7 +33,7 @@ arm-ka-docs (`05-domain-rules.md` §1.1, §1.3, §1.4, `07-open-questions.md`), применима, только если в шапке есть код счёта и среди строк таблицы нет ни одной строки-счёта; стоит в каскаде перед `sums`. Строки без счёта в ветке наследуют счёт шапки (`account_code_raw`). «Итого» без корневой строки ACCOUNT сверяется с суммой строк уровня 0. -- **Контрагенты по плану счетов (§1.1/§1.3).** На расчётных счетах (58, 60, 62, 66, 67, 71, 73, 75, 76, 80 — данные +- **Контрагенты по плану счетов (§1.1/§1.3).** На расчётных счетах (58, 60, 62, 66, 67, 75, 76, 80 — данные `rules/osv_groups.yaml`) первая не-счётная позиция без названия в схеме — `COUNTERPARTY` с предупреждением `OSV_ROWS`. - Синтетика `s40`–`s44`, golden; правила §1 и открытые вопросы в docs. @@ -49,7 +49,7 @@ arm-ka-docs (`05-domain-rules.md` §1.1, §1.3, §1.4, `07-open-questions.md`), - Валюта («Валюта <код>», «В валюте»), колонка «Показатели», `data_type` строки при молчащей шапке — трек A (AURORA-1613 + валютная часть 1661). До его слияния строки плоских файлов без «Выводимые данные» приходят с `data_type = null`, и ядро не берёт их в свод (`SUMMARY_DATA_TYPES = (BU, SUM)`): файлы распознаются, но в сводную ОСВ не попадают. -- «Учредители» на 80 (именованная группировка `OTHER`, O-29) — вопрос владельцу, не меняется. -- Распознавание вида субконто «Организации» как контрагентов по имени — не делается: неизвестный вид в схему не - добавляется, позиция остаётся без названия и подпадает под правило расчётных счетов. +- «Учредители» на 80 (именованная группировка `OTHER`, O-29) — не меняется (R-8). +- Вид субконто «Организации» — синоним COUNTERPARTY в `osv_groups.yaml` (R-10); прочие неизвестные виды в схему не + добавляются, позиция остаётся без названия и подпадает под правило расчётных счетов. - Склейка кварталов (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 index be50634..9a6616e 100644 --- a/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md +++ b/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md @@ -48,8 +48,8 @@ #### Scenario: Организация из «План счетов» - **WHEN** разбирается синтетическая ОСВ `s41` (шапка «…по счету: 60», строка «Организации», «за 2 Квартал 2026 г. План счетов ООО Хреновская Нива») -- **THEN** `organization_name = "ООО Хреновская Нива"`, `account_code_raw = "60"`, `group_schema = [ACCOUNT(Субконто)]` - («Организации» словарь не узнаёт) +- **THEN** `organization_name = "ООО Хреновская Нива"`, `account_code_raw = "60"`, + `group_schema = [ACCOUNT(Субконто), COUNTERPARTY(Организации)]` («Организации» — синоним COUNTERPARTY в словаре) #### Scenario: Счёт шапки важнее строки таблицы - **WHEN** разбирается синтетическая ОСВ `s42` (шапка «…по счету: 76», первая строка таблицы «76.2») @@ -95,7 +95,7 @@ `^\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; +(число не-счётных предков после последнего ACCOUNT + 1), при выходе за схему — OTHER, кроме **расчётных счетов**: если позиция 1 (первая не-счётная) выходит за схему, а счёт ветки — из списка `counterparty_accounts` `rules/osv_groups.yaml` (начальный состав 58, 60, 62, 66, 67, 75, 76, 80; запись `76` покрывает `76`, `76.2`, `76.Н.1`, но не `760`), строка SHALL получить COUNTERPARTY без имени группировки, а документ — предупреждение `OSV_ROWS` «вид субконто в шапке не назван: строки аналитики счёта <код> приняты за контрагентов по плану счетов — проверьте» (один раз на счёт); счёт ветки строк до первой строки-счёта — код счёта шапки; строка ИНН без пары — OTHER; строка «Итого» и вторые итоговые строки («Итого развернутое») сразу перед ней или сразу за ней — TOTAL. Для COUNTERPARTY `counterparty_name` SHALL быть текстом строки без ИНН в конце и разделителя перед ним (`row_value` — -- 2.47.2 From ecb23475db2b84f1c9adbf87800f7f91de8a73dd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 11:31:04 +0400 Subject: [PATCH 03/16] =?UTF-8?q?docs(plan):=20=D1=83=D1=87=D1=91=D1=82=20?= =?UTF-8?q?R-8=E2=80=A6R-10=20=D0=B2=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B5=20os?= =?UTF-8?q?v-flat-form=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/superpowers/plans/2026-10-19-osv-flat-form.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-10-19-osv-flat-form.md b/docs/superpowers/plans/2026-10-19-osv-flat-form.md index 7a8c218..9d00929 100644 --- a/docs/superpowers/plans/2026-10-19-osv-flat-form.md +++ b/docs/superpowers/plans/2026-10-19-osv-flat-form.md @@ -96,8 +96,9 @@ git -C /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/tabular-parser commi - `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"`. + - `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`. @@ -166,7 +167,7 @@ git -C /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/tabular-parser commi > Перед задачей: если трек 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/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`) @@ -184,7 +185,7 @@ git -C /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/tabular-parser commi - `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». + - `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`: перечислить файлы в отчёте; статусы снимков не должны меняться (если меняются — стоп). @@ -201,7 +202,7 @@ git -C /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/tabular-parser commi - 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 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: @@ -209,7 +210,7 @@ Run: 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"`. +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) — без ошибок. -- 2.47.2 From e625409818513a5bf75c78426acf6df3b94398ae Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 11:40:38 +0400 Subject: [PATCH 04/16] =?UTF-8?q?test:=20=D1=81=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=D0=B0=20s40=E2=80=93s44=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=BE=D1=81=D0=BA=D0=B0=D1=8F=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=20=D0=9E=D0=A1=D0=92=201=D0=A1=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- api/tests/synthetic/generate.py | 120 +++++++++++++++++++- api/tests/synthetic/test_generate.py | 4 +- api/tests/synthetic/test_synthetic_files.py | 16 ++- 3 files changed, 135 insertions(+), 5 deletions(-) diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 2a5f9d0..7893503 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -3,7 +3,8 @@ Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во», -строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47); описание — `osv/synthetic/README.md` +строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты +другим треком); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован. @@ -861,6 +862,116 @@ def _s32() -> list[SheetSpec]: return [sheet] +FlatRow = tuple[str, tuple[str | None, ...], int] + + +def _flat_sheet(texts: dict[int, str], group_row: int, rows: Sequence[FlatRow]) -> SheetSpec: + """Плоская форма 1С (#59): колонка A — текст, B…G — суммы «начало / обороты / конец» × «Дебет | Кредит»; заголовок + «Субконто» + группы (объединения B:C, D:E, F:G) в `group_row`, подзаголовки под ним, данные сразу ниже. `texts` — + строки шапки над таблицей; в `rows` пустая сумма (None) — нет ячейки, отступ — выравнивание ячейки.""" + sheet = SheetSpec(SHEET) + for row, text in texts.items(): + sheet.put(row, 1, text) + sheet.put(group_row, 1, "Субконто") + for index, key in enumerate(("opening", "turnover", "closing")): + col = 2 + index * PAIR_WIDTH + sheet.put(group_row, col, GROUP_TITLES[key]) + sheet.merge(group_row, col, group_row, col + 1) + sheet.put(group_row + 1, col, DEBIT) + sheet.put(group_row + 1, col + 1, CREDIT) + for offset, (text, sums, indent) in enumerate(rows): + row = group_row + 2 + offset + sheet.put(row, 1, text, indent=indent) + for col, value in enumerate(sums, start=2): + if value is not None: + sheet.put(row, col, D(value)) + return sheet + + +def _s40() -> list[SheetSpec]: + """Плоская форма (#59): счёт в заголовке «по счету: 76.Н.1» и вид аналитики «Контрагенты» строкой ниже, данные без + outline и отступов; «Итого развернутое» — сумма строк, «Итого» — свёрнутое сальдо.""" + texts = { + 2: "Оборотно-сальдовая ведомость по счету: 76.Н.1", + 3: "Контрагенты", + 4: "за 2025 г.", + 5: 'ООО "Товарное хозяйство"', + 7: "Единица измерения: рубль (код по ОКЕИ 383)", + } + rows: list[FlatRow] = [ + ("Август - Агро ООО", ("1000.00", None, "500.00", "300.00", "1200.00", None), 0), + ("АВИС ООО", (None, "200.00", "100.00", "400.00", None, "500.00"), 0), + ("Агроаспект ООО", (None, None, "50.00", "50.00", None, None), 0), + ("Итого развернутое", ("1000.00", "200.00", None, None, "1200.00", "500.00"), 0), + ("Итого", ("800.00", None, "650.00", "750.00", "700.00", None), 0), + ] + return [_flat_sheet(texts, 9, rows)] + + +def _s41() -> list[SheetSpec]: + """Плоская форма (#59): вид аналитики «Организации», период «за 2 Квартал 2026 г.» со словами «План счетов …».""" + texts = { + 2: "Оборотно-сальдовая ведомость по счету: 60", + 3: "Организации", + 4: "за 2 Квартал 2026 г. План счетов ООО Хреновская Нива", + } + rows: list[FlatRow] = [ + ("Автодор-Платные Дороги ООО", ("820.21", None, "5000.00", "4991.00", "829.21", None), 0), + ("АГРОПРОФИ ООО", (None, "2763.79", None, None, None, "2763.79"), 0), + ("Итого развернутое", ("820.21", "2763.79", None, None, "829.21", "2763.79"), 0), + ("Итого", (None, "1943.58", "5000.00", "4991.00", None, "1934.58"), 0), + ] + return [_flat_sheet(texts, 5, rows)] + + +def _s42() -> list[SheetSpec]: + """Плоская форма (#59): под счётом 76 идут субсчета «76.2», «76.5», «76.Н», «76.Н.1»; «Итого развернутое» перед + «Итого».""" + texts = { + 2: "Оборотно-сальдовая ведомость по счету: 76", + 3: "за 2025 г.", + 4: 'ООО "Товарное хозяйство"', + 6: "Единица измерения: рубль (код по ОКЕИ 383)", + } + rows: list[FlatRow] = [ + ("76.2", ("1000.00", None, None, "1000.00", None, None), 0), + ("76.5", (None, "300.00", "700.00", "600.00", None, "200.00"), 0), + ("76.Н", (None, None, "5000.00", "5000.00", None, None), 0), + ("76.Н.1", (None, None, "5000.00", "5000.00", None, None), 0), + ("Итого развернутое", ("1000.00", "300.00", None, None, None, "200.00"), 0), + ("Итого", ("700.00", None, "5700.00", "6600.00", None, "200.00"), 0), + ] + return [_flat_sheet(texts, 8, rows)] + + +def _s43_s44(account: str, root: str, kids: tuple[str, str]) -> list[SheetSpec]: + """Счёт с отступами: корень-субсчёт (без отступа) и два элемента аналитики с отступом 1, без вида аналитики в шапке; + «Итого» перед «Итого развернутое».""" + texts = { + 1: 'ООО "Октябрьское"', + 2: f"Оборотно-сальдовая ведомость по счету {account}", + 3: "Период: 9 месяцев 2024 г.", + } + rows: list[FlatRow] = [ + (root, ("34618000.00", None, "4236500.00", "38854500.00", None, None), 0), + (kids[0], ("34618000.00", None, None, "34618000.00", None, None), 1), + (kids[1], (None, None, "4236500.00", "4236500.00", None, None), 1), + ("Итого", ("34618000.00", None, "4236500.00", "38854500.00", None, None), 0), + ("Итого развернутое", ("34618000.00", None, "4236500.00", "38854500.00", None, None), 0), + ] + return [_flat_sheet(texts, 5, rows)] + + +def _s43() -> list[SheetSpec]: + """Плоская форма (#59): счёт 60, контрагенты без вида аналитики в шапке (подстроки с отступом).""" + return _s43_s44("60", "60.01", ("ИП Знаменских А.И.", 'ООО "ЭСТЕТ ОМСК"')) + + +def _s44() -> list[SheetSpec]: + """То же, что s43, но счёт 10: номенклатура вместо контрагентов.""" + return _s43_s44("10", "10.01", ("Мука пшеничная", "Сахар-песок")) + + def _s28() -> list[SheetSpec]: """Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится.""" tree = _inn_layout_tree() @@ -969,6 +1080,11 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp "s34_indicator_column_qty_val_diff.xlsx": (_s34, to_xlsx), "s35_currency_slices.xlsx": (_s35, to_xlsx), "s36_no_data_type_header.xlsx": (_s36, to_xlsx), + "s40_flat_header_account.xlsx": (_s40, to_xlsx), + "s41_flat_chart_of_accounts.xlsx": (_s41, to_xlsx), + "s42_subaccounts_totals_reversed.xlsx": (_s42, to_xlsx), + "s43_subkonto_unnamed_60.xlsx": (_s43, to_xlsx), + "s44_subkonto_unnamed_10.xlsx": (_s44, to_xlsx), } FILE_NAMES = tuple(FILES) @@ -987,7 +1103,7 @@ def generate_all(directory: Path) -> list[Path]: def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="python -m tests.synthetic.generate", - description="Синтетические ОСВ s01…s36 (xlsx, один xlsb и один xls) в указанный каталог.", + description="Синтетические ОСВ s01…s36, s40…s44 (xlsx, один xlsb и один xls) в указанный каталог.", ) parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic") args = parser.parse_args(argv) diff --git a/api/tests/synthetic/test_generate.py b/api/tests/synthetic/test_generate.py index 3368500..c86e98d 100644 --- a/api/tests/synthetic/test_generate.py +++ b/api/tests/synthetic/test_generate.py @@ -31,7 +31,9 @@ def test_generated_set_is_s01_to_s19_with_one_xlsb(tmp_path: Path) -> None: assert names == list(FILE_NAMES) assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names) - assert [name[1:3] for name in names] == [f"{number:02d}" for number in range(1, len(names) + 1)] + # s33…s36 — строки-показатели (AURORA-1613, #58), плоская форма ОСВ 1С (#59) — s40…s44 + numbers = [*range(1, 37), *range(40, 45)] + assert [name[1:3] for name in names] == [f"{number:02d}" for number in numbers] assert [name for name in names if name.endswith(".xlsb")] == ["s13_no_outline_indent.xlsb"] assert sorted(path.name for path in tmp_path.iterdir()) == sorted(names) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index a808005..17bbd70 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -42,6 +42,15 @@ XLS_NO_CODEPAGE_FILE = "s29_xls_no_codepage.xls" VAL_KOL_VO_FILE = "s30_val_kol_vo_materials.xlsx" ROWS_BELOW_TOTAL_FILE = "s31_rows_below_total.xlsx" TOTAL_WITH_SUFFIX_FILE = "s32_total_with_suffix.xlsx" + +# Плоская форма ОСВ 1С (#59): на этапе снимков текущего поведения ожиданий нет — их добавляют задачи 2–5 плана +FLAT_FORM_FILES = ( + "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", +) TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28 EXPECTED_NAMES = [ "s01_variant2_header.xlsx", @@ -73,6 +82,7 @@ EXPECTED_NAMES = [ "s34_indicator_column_qty_val_diff.xlsx", "s35_currency_slices.xlsx", "s36_no_data_type_header.xlsx", + *FLAT_FORM_FILES, ] XLSB_NAME = "s13_no_outline_indent.xlsb" XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)" @@ -229,7 +239,7 @@ def tree(doc: OsvDocument) -> list[tuple[str, int, int | None, tuple[Decimal | N def test_synthetic_set_is_complete(fixtures_dir: Path) -> None: - """В `fixtures/osv/synthetic/` лежат файлы набора s01…s36 (s13 — xlsb; допустима деградация: нет только его).""" + """В `fixtures/osv/synthetic/` лежат файлы набора s01…s36, s40…s44 (s13 — xlsb; допустима деградация: нет только его).""" present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file()) assert present == [name for name in EXPECTED_NAMES if name in present] missing = [name for name in EXPECTED_NAMES if name not in present] @@ -238,11 +248,13 @@ def test_synthetic_set_is_complete(fixtures_dir: Path) -> None: def test_every_synthetic_file_is_listed(synthetic_file: Path) -> None: """Каждый файл каталога описан в таблице ожиданий (новый файл без ожиданий — ошибка набора).""" - assert synthetic_file.name in EXPECTED, f"добавьте ожидания для {synthetic_file.name}" + assert synthetic_file.name in {*EXPECTED, *FLAT_FORM_FILES}, f"добавьте ожидания для {synthetic_file.name}" def test_header_status_and_hierarchy(synthetic_file: Path) -> None: """Статус, число документов, стратегия иерархии и шапка из таблицы `EXPECTED`; результат валиден по контракту.""" + if synthetic_file.name in FLAT_FORM_FILES: + pytest.skip("плоская форма (#59): ожидания по файлу — в сценариях задач 2–5, до них поведение фиксирует golden") expect = EXPECTED[synthetic_file.name] outcome = parse_path(synthetic_file) -- 2.47.2 From 29f264d842a6b7b4f928906a8756fed55b81ffa5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 11:47:53 +0400 Subject: [PATCH 05/16] =?UTF-8?q?feat(osv):=20=D0=BA=D0=BE=D0=B4=20=D1=81?= =?UTF-8?q?=D1=87=D1=91=D1=82=D0=B0=20=C2=AB=D0=BF=D0=BE=20=D1=81=D1=87?= =?UTF-8?q?=D0=B5=D1=82=D1=83:=20X=C2=BB,=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B2=D0=B8=D0=B4=D0=B0=20=D1=81=D1=83=D0=B1=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D0=BE,=20=C2=AB=D0=9F=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=81=D1=87=D0=B5=D1=82=D0=BE=D0=B2=C2=BB=20=D0=B2=20=D1=88?= =?UTF-8?q?=D0=B0=D0=BF=D0=BA=D0=B5=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- api/rules/osv_groups.yaml | 2 + api/rules/osv_headers.yaml | 3 ++ api/src/parser/header.py | 57 +++++++++++++++++++-- api/src/parser/rules.py | 6 ++- api/tests/unit/parser/test_header.py | 76 +++++++++++++++++++++++++++- api/tests/unit/parser/test_rules.py | 8 +++ 6 files changed, 146 insertions(+), 6 deletions(-) diff --git a/api/rules/osv_groups.yaml b/api/rules/osv_groups.yaml index 8691bb6..8adc464 100644 --- a/api/rules/osv_groups.yaml +++ b/api/rules/osv_groups.yaml @@ -4,6 +4,8 @@ mapping: - ['^(счета?|субконто):?$', ACCOUNT] - ['^подразделени', DEPARTMENT] - ['^контрагент', COUNTERPARTY] + # вид субконто «Организации» (плоская форма 1С, счёт 60) — контрагенты-организации (R-10) + - ['^организаци', COUNTERPARTY] - ['^договор', CONTRACT] # Известные группировки OTHER перечислены явно: по ним детектор узнаёт строку-метку в блоке заголовка таблицы # (строка «Основные средства» под «Счет / Подразделение» — часть заголовка, а не первая строка данных) diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml index 375d300..b4956f3 100644 --- a/api/rules/osv_headers.yaml +++ b/api/rules/osv_headers.yaml @@ -95,3 +95,6 @@ header_markers: subkonto: - '\bсубконто\b' - '\bдетализация\b' + # плоская форма 1С: «за 2 Квартал 2026 г. План счетов ООО Хреновская Нива» — организация после метки + chart_of_accounts: + - '\bплан\s+счетов\b' diff --git a/api/src/parser/header.py b/api/src/parser/header.py index ac7e8a9..58ca007 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -128,6 +128,7 @@ def _is_structural_marker(cell: _HeadCell, rules: Rules) -> bool: *markers["data_type"], *markers["org_params"], *markers["subkonto"], + *markers["chart_of_accounts"], ] return _find(patterns, cell.text) is not None @@ -254,14 +255,49 @@ def _title_tail_organization(cells: list[_HeadCell], rules: Rules) -> str | None return None +def _kind_row_cells(cells: list[_HeadCell], rules: Rules) -> list[_HeadCell]: + """Ячейки строго между строкой названия ведомости (метка `account`) и строкой периода, если название выше периода. + + Плоская форма 1С: «по счету: 76» / «Контрагенты» / «за 2025 г.» — средняя строка называет вид субконто, а не + организацию. Название и период в одной строке (или период выше) — таких ячеек нет. + """ + title = next((cell for cell in cells if _find(rules.headers.header_markers["account"], cell.text)), None) + period = _find_period(cells, rules) + if title is None or period is None or period.cell.row <= title.row: + return [] + return [cell for cell in cells if title.row < cell.row < period.cell.row] + + +def _chart_of_accounts_tail(cells: list[_HeadCell], rules: Rules) -> str | None: + """Организация после метки «План счетов» (хвост строки периода плоской формы 1С); пустой хвост — `None`.""" + for cell in cells: + match = _find(rules.headers.header_markers["chart_of_accounts"], cell.text) + if match is None: + continue + tail = _tail(cell.text, match).strip(" ,;:") + if tail: + return tail + return None + + def _organization( cells: list[_HeadCell], rules: Rules, period: _PeriodHit | None, data_type: _HeadCell | None ) -> str | None: - """Первая текстовая ячейка, не являющаяся меткой шапки, строкой периода, меткой валюты или типом данных; иначе - хвост названия ведомости. + """Организация: хвост метки «План счетов»; иначе первая текстовая ячейка, не являющаяся меткой шапки, строкой + периода, строкой вида субконто, меткой валюты или типом данных; иначе хвост названия ведомости. """ + tail = _chart_of_accounts_tail(cells, rules) + if tail is not None: + return tail taken = [ - cell for cell in (None if period is None else period.cell, data_type, *_currency_cells(cells, rules)) if cell + cell + for cell in ( + None if period is None else period.cell, + data_type, + *_currency_cells(cells, rules), + *_kind_row_cells(cells, rules), + ) + if cell ] found = next( (cell for cell in cells if cell not in taken and not _is_structural_marker(cell, rules)), @@ -275,7 +311,7 @@ def _account_code(cells: list[_HeadCell], rules: Rules) -> str | None: match = _find(rules.headers.header_markers["account"], cell.text) if match is None: continue - code = _ACCOUNT_CODE.match(_tail(cell.text, match)) + code = _ACCOUNT_CODE.match(_tail(cell.text, match).lstrip(": ")) if code is not None: return code.group(0).rstrip(_CODE_TRAILING) return None @@ -539,9 +575,22 @@ def _variant1_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> GroupSc groups.append(_group(group_type, label, has_inn=has_inn)) if not groups: groups = [_group("ACCOUNT")] + if len(groups) == 1: + groups.extend(_kind_row_groups(sheet, header, rules)) return GroupSchema(groups, 1, hint) +def _kind_row_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> list[OsvGroup]: + """Виды субконто из строки шапки между названием и периодом: только узнанные явной записью словаря групп.""" + groups: list[OsvGroup] = [] + for cell in _kind_row_cells(_head_cells(sheet, header), rules): + for item in (part.strip() for part in cell.text.split(_LIST_SEPARATOR)): + known = rules.groups.known(item) if item else None + if known is not None: + groups.append(_group(known, item)) + return groups + + def _variant2_groups(sheet: Sheet, header: TableHeader, rules: Rules, items: list[str]) -> GroupSchema: groups = [_group("ACCOUNT", _first_group_name(sheet, header))] hint = _inn_hint(items, rules) diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index 9e7846c..3972b0e 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -49,7 +49,7 @@ CHECKS_FILE = "osv_checks.yaml" GroupType = Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"] SumGroup = Literal["opening", "turnover", "closing"] -HeaderMarker = Literal["account", "period", "data_type", "currency", "org_params", "subkonto"] +HeaderMarker = Literal["account", "period", "data_type", "currency", "org_params", "subkonto", "chart_of_accounts"] RowDataType = Literal["CURRENCY", "QUANTITY"] Quarter = Literal["Q1", "Q2", "Q3", "Q4"] @@ -193,6 +193,10 @@ class OsvGroupRules(_Model): found = first_match(self.mapping, text) return self.default if found is None else found + def known(self, text: str) -> GroupType | None: + """Тип группировки по явной записи `mapping`; не совпавшее ни с одной записью — `None` (не `default`).""" + return first_match(self.mapping, text) + class OsvCheckRules(_Model): accounts_without_analytic_balance: list[str] diff --git a/api/tests/unit/parser/test_header.py b/api/tests/unit/parser/test_header.py index dfe2820..c2776ce 100644 --- a/api/tests/unit/parser/test_header.py +++ b/api/tests/unit/parser/test_header.py @@ -10,7 +10,7 @@ from gridbuilder import M, at, sheet_from_rows from src.parser.detect import TableHeader, find_table_header from src.parser.grid import Sheet -from src.parser.header import DocHeader, build_group_schema, parse_doc_header +from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_schema, parse_doc_header from src.parser.rules import Rules, load_rules OPENING = "Сальдо на начало периода" @@ -569,3 +569,77 @@ def test_period_bounds_unknown_when_period_unrecognised(rules: Rules) -> None: doc = _variant1(rules, title=_title("за неизвестно что")) assert doc.period_start is None and doc.period_end is None + + +# --- Плоская форма 1С: «по счету: X», строка вида субконто, «План счетов» --- + + +def _flat_rows(*head: str, group_label: str = "Субконто") -> list[list[object]]: + """Шапка плоской формы: строки шапки, заголовок «Субконто» с группами сумм, строка данных.""" + groups_row = [M(group_label, 4), M(OPENING, 5), M(TURNOVER, 5), M(CLOSING, 3)] + subhead = at({5: "Дебет", 8: "Кредит", 10: "Дебет", 12: "Кредит", 15: "Дебет", 17: "Кредит"}) + return [*[[text] for text in head], groups_row, subhead, DATA_ROW] + + +def test_account_code_after_colon(rules: Rules) -> None: + rows = _flat_rows("Оборотно-сальдовая ведомость по счету: 76.Н.1", "за 2025 г.", "ООО Ромашка") + + doc = _parse(rows, rules)[2] + + assert doc.account_code_raw == "76.Н.1" + assert ACCOUNT_NOT_FOUND_WARNING not in doc.warnings + + +def test_colon_without_code_falls_back_to_table_row(rules: Rules) -> None: + rows = _flat_rows("Оборотно-сальдовая ведомость по счету:", "за 2025 г.", "ООО Ромашка") + + doc = _parse(rows, rules)[2] + + assert doc.account_code_raw == "" + assert ACCOUNT_NOT_FOUND_WARNING in doc.warnings + + +def test_kind_row_between_title_and_period_is_not_organization(rules: Rules) -> None: + rows = _flat_rows( + "Оборотно-сальдовая ведомость по счету: 76", "Контрагенты", "за 2025 г.", 'ООО "Товарное хозяйство"' + ) + + assert _parse(rows, rules)[2].organization_name == 'ООО "Товарное хозяйство"' + + +def test_organization_from_chart_of_accounts_tail(rules: Rules) -> None: + rows = _flat_rows( + "Оборотно-сальдовая ведомость по счету: 60", + "Организации", + "за 2 Квартал 2026 г. План счетов ООО Хреновская Нива", + ) + + doc = _parse(rows, rules)[2] + + assert doc.organization_name == "ООО Хреновская Нива" + assert doc.period_quarter == "Q2" + assert doc.period_year == 2026 # noqa: PLR2004 + + +def test_title_and_period_in_one_row_keep_old_organization_rule(rules: Rules) -> None: + header = _variant1(rules, title=_title("3 квартал 2024 г.")) + + assert header.organization_name == 'АО "Ромашка"' + + +def _flat_schema(rules: Rules, kind: str) -> list[tuple[str, str | None]]: + rows = _flat_rows("Оборотно-сальдовая ведомость по счету: 76", kind, "за 2025 г.", "ООО Ромашка") + sheet, header, _ = _parse(rows, rules) + return [(g.type, g.name) for g in build_group_schema(sheet, header, rules)[0]] + + +def test_known_kind_row_extends_single_account_schema(rules: Rules) -> None: + assert _flat_schema(rules, "Контрагенты") == [("ACCOUNT", "Субконто"), ("COUNTERPARTY", "Контрагенты")] + + +def test_unknown_kind_row_is_not_added_to_schema(rules: Rules) -> None: + assert _flat_schema(rules, "Материалы") == [("ACCOUNT", "Субконто")] + + +def test_organizations_kind_row_is_counterparty(rules: Rules) -> None: + assert _flat_schema(rules, "Организации") == [("ACCOUNT", "Субконто"), ("COUNTERPARTY", "Организации")] diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index e8f0f0f..ae7ba75 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -359,12 +359,20 @@ def test_credit_synonyms(rules: Rules, text: str) -> None: ("org_params", "Параметры:"), ("subkonto", "Детализация по субсчетам, субконто: Контрагенты, Договоры"), ("subkonto", "Субконто"), + ("chart_of_accounts", "за 2 Квартал 2026 г. План счетов ООО Хреновская Нива"), ], ) def test_header_markers(rules: Rules, kind: str, text: str) -> None: assert any(p.search(text) for p in rules.headers.header_markers[kind]) # type: ignore[index] +def test_known_group_is_none_for_default(rules: Rules) -> None: + assert rules.groups.known("Контрагенты") == "COUNTERPARTY" + assert rules.groups.known("Материалы") is None + assert rules.groups.known("Организации") == "COUNTERPARTY" + assert rules.groups.known("Номенклатура") == "OTHER" + + def test_account_marker_ends_right_before_the_account_code(rules: Rules) -> None: text = "Оборотно-сальдовая ведомость по счёту 58.03 за 3 квартал 2024 г." match = next(m for p in rules.headers.header_markers["account"] if (m := p.search(text))) -- 2.47.2 From 883c6fb628619317a431ccd55505bae56db3ce10 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 11:53:24 +0400 Subject: [PATCH 06/16] =?UTF-8?q?fix(osv):=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B2=D0=B8=D0=B4=D0=B0=20=D1=81=D1=83=D0=B1?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D0=BE=20=D0=BD=D0=B5=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=20=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B8=20=D0=B8=20ACCOUN?= =?UTF-8?q?T=20=D0=B2=20=D1=81=D1=85=D0=B5=D0=BC=D1=83=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- api/src/parser/header.py | 11 ++++++++--- api/tests/unit/parser/test_header.py | 8 ++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/api/src/parser/header.py b/api/src/parser/header.py index 58ca007..4512e3a 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -581,12 +581,17 @@ def _variant1_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> GroupSc def _kind_row_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> list[OsvGroup]: - """Виды субконто из строки шапки между названием и периодом: только узнанные явной записью словаря групп.""" + """Виды субконто из строки шапки между названием и периодом: только узнанные явной записью словаря групп, не + ACCOUNT; метки шапки (в том числе валюта) видом субконто не считаются.""" + cells = _head_cells(sheet, header) + currency = _currency_cells(cells, rules) groups: list[OsvGroup] = [] - for cell in _kind_row_cells(_head_cells(sheet, header), rules): + for cell in _kind_row_cells(cells, rules): + if cell in currency or _is_structural_marker(cell, rules): + continue for item in (part.strip() for part in cell.text.split(_LIST_SEPARATOR)): known = rules.groups.known(item) if item else None - if known is not None: + if known is not None and known != "ACCOUNT": groups.append(_group(known, item)) return groups diff --git a/api/tests/unit/parser/test_header.py b/api/tests/unit/parser/test_header.py index c2776ce..e0079fc 100644 --- a/api/tests/unit/parser/test_header.py +++ b/api/tests/unit/parser/test_header.py @@ -643,3 +643,11 @@ def test_unknown_kind_row_is_not_added_to_schema(rules: Rules) -> None: def test_organizations_kind_row_is_counterparty(rules: Rules) -> None: assert _flat_schema(rules, "Организации") == [("ACCOUNT", "Субконто"), ("COUNTERPARTY", "Организации")] + + +def test_currency_row_between_title_and_period_is_not_added_to_schema(rules: Rules) -> None: + assert _flat_schema(rules, "Валюта БУ, НУ: RUB") == [("ACCOUNT", "Субконто")] + + +def test_subkonto_cell_between_title_and_period_does_not_add_second_account(rules: Rules) -> None: + assert _flat_schema(rules, "Субконто") == [("ACCOUNT", "Субконто")] -- 2.47.2 From 8dd8a5086839d05735d7362bac2e7b8a02080d04 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 11:57:31 +0400 Subject: [PATCH 07/16] =?UTF-8?q?fix(osv):=20=D0=BC=D0=B5=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B2=D0=B0=D0=BB=D1=8E=D1=82=D1=8B=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=B4=D0=B2=D0=BE=D0=B5=D1=82=D0=BE=D1=87=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=B2=D0=B8=D0=B4=D0=BE=D0=BC=20=D1=81?= =?UTF-8?q?=D1=83=D0=B1=D0=BA=D0=BE=D0=BD=D1=82=D0=BE=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- api/src/parser/header.py | 5 +++-- api/tests/unit/parser/test_header.py | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/api/src/parser/header.py b/api/src/parser/header.py index 4512e3a..acbadb7 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -584,10 +584,11 @@ def _kind_row_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> list[Os """Виды субконто из строки шапки между названием и периодом: только узнанные явной записью словаря групп, не ACCOUNT; метки шапки (в том числе валюта) видом субконто не считаются.""" cells = _head_cells(sheet, header) - currency = _currency_cells(cells, rules) + currency = rules.headers.header_markers["currency"] groups: list[OsvGroup] = [] for cell in _kind_row_cells(cells, rules): - if cell in currency or _is_structural_marker(cell, rules): + # Метка валюты — с двоеточием или без него: «Валюта БУ, НУ» вид субконто не называет + if _find(currency, cell.text) is not None or _is_structural_marker(cell, rules): continue for item in (part.strip() for part in cell.text.split(_LIST_SEPARATOR)): known = rules.groups.known(item) if item else None diff --git a/api/tests/unit/parser/test_header.py b/api/tests/unit/parser/test_header.py index e0079fc..61a2fc9 100644 --- a/api/tests/unit/parser/test_header.py +++ b/api/tests/unit/parser/test_header.py @@ -649,5 +649,13 @@ def test_currency_row_between_title_and_period_is_not_added_to_schema(rules: Rul assert _flat_schema(rules, "Валюта БУ, НУ: RUB") == [("ACCOUNT", "Субконто")] +def test_currency_row_without_colon_is_not_added_to_schema(rules: Rules) -> None: + assert _flat_schema(rules, "Валюта БУ, НУ") == [("ACCOUNT", "Субконто")] + + +def test_account_cell_between_title_and_period_does_not_add_second_account(rules: Rules) -> None: + assert _flat_schema(rules, "Счет") == [("ACCOUNT", "Субконто")] + + def test_subkonto_cell_between_title_and_period_does_not_add_second_account(rules: Rules) -> None: assert _flat_schema(rules, "Субконто") == [("ACCOUNT", "Субконто")] -- 2.47.2 From 774141588eaf4c2556e16dc6d9bac5de5034fc81 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 12:08:58 +0400 Subject: [PATCH 08/16] =?UTF-8?q?fix(osv):=20=C2=AB=D0=98=D1=82=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D1=80=D0=B0=D0=B7=D0=B2=D0=B5=D1=80=D0=BD=D1=83?= =?UTF-8?q?=D1=82=D0=BE=D0=B5=C2=BB=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20?= =?UTF-8?q?=C2=AB=D0=98=D1=82=D0=BE=D0=B3=D0=BE=C2=BB=20=E2=80=94=20=D0=B8?= =?UTF-8?q?=D1=82=D0=BE=D0=B3=D0=BE=D0=B2=D0=B0=D1=8F=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0,=20=D1=81=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE=D0=B9?= =?UTF-8?q?=20=C2=AB=D0=98=D1=82=D0=BE=D0=B3=D0=BE=C2=BB=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- api/src/parser/checks.py | 35 ++++++++++++++------- api/src/parser/classify.py | 14 +++++++++ api/src/parser/osv.py | 2 +- api/tests/synthetic/generate.py | 2 +- api/tests/synthetic/test_synthetic_files.py | 14 +++++++++ api/tests/unit/parser/test_checks.py | 18 ++++++++++- api/tests/unit/parser/test_classify.py | 30 ++++++++++++++++++ 7 files changed, 101 insertions(+), 14 deletions(-) diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 3d85650..4b9f399 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -32,7 +32,7 @@ from decimal import Decimal from src.contract.result import OsvChecks from src.parser.classify import SUM_FIELDS, ClassifiedRow from src.parser.osv_warnings import OSV_BALANCE_IDENTITY, OSV_PARENT_SUM, OSV_TOTAL, OsvNote -from src.parser.rules import OsvCheckRules +from src.parser.rules import OsvCheckRules, Pattern __all__ = [ "TOLERANCE", @@ -145,9 +145,16 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No ] -def _total(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None: - """Основная «Итого» листа — первая строка TOTAL без признака показателя (следующие за ней в сверку не входят).""" - return next((item for item in rows if item.group_type == "TOTAL" and not item.indicator), None) +def _total(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern] = ()) -> ClassifiedRow | None: + """Основная «Итого» листа — первая строка TOTAL без признака показателя, чей текст не матчит вторые итоговые. + + Строки-показатели (НУ, Кол., Вал. под «Итого») отсекаются первыми (#58). Вторая итоговая строка («Итого + развернутое», `secondary`) в сверку не входит, даже если стоит перед «Итого» (#59); если других TOTAL нет — + служит единственной итоговой. + """ + totals = [item for item in rows if item.group_type == "TOTAL" and not item.indicator] + main = next((item for item in totals if not any(p.search(item.body.text) for p in secondary)), None) + return main or next(iter(totals), None) def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None: @@ -174,13 +181,13 @@ def _sum_of(rows: Iterable[ClassifiedRow]) -> tuple[Decimal, ...]: return tuple(totals) -def total_matches_root(rows: Sequence[ClassifiedRow]) -> bool: +def total_matches_root(rows: Sequence[ClassifiedRow], secondary_totals: Sequence[Pattern] = ()) -> bool: """«Итого» = первая корневая строка ACCOUNT или сумма строк уровня 0 по шести колонкам (±0.01). Нет «Итого» — `False` (R-20); нет корня — `True`, только если все суммы «Итого» пусты или нулевые (R-13). Сумма уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов, «Итого» равно их сумме. """ - total = _total(rows) + total = _total(rows, secondary_totals) if total is None: return False root = _root(rows) @@ -202,8 +209,8 @@ def _rows_text(rows: Iterable[int]) -> str: return shown -def _total_warning(rows: Sequence[ClassifiedRow]) -> str: - total = _total(rows) +def _total_warning(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern] = ()) -> str: + total = _total(rows, secondary) root = _root(rows) if total is None: return TOTAL_NOT_FOUND_WARNING @@ -216,14 +223,20 @@ def _total_warning(rows: Sequence[ClassifiedRow]) -> str: return message -def run_checks(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> tuple[OsvChecks, list[OsvNote]]: +def run_checks( + rows: Sequence[ClassifiedRow], + exempt: OsvCheckRules | None = None, + *, + secondary_totals: Sequence[Pattern] = (), +) -> tuple[OsvChecks, list[OsvNote]]: """Три проверки §1.4 и заметки с кодами `OSV_*` и номерами строк (пустой список — всё сошлось). `exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью. + `secondary_totals` — вторые итоговые строки («Итого развернутое»): «Итого» для сверки — не они (#59). """ identity = balance_identity(rows, exempt) parents = parent_sums(rows, exempt) - matches = total_matches_root(rows) + matches = total_matches_root(rows, secondary_totals) warnings: list[OsvNote] = [] if identity: text = f"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±{TOLERANCE}): строки {_rows_text(identity)}" @@ -232,7 +245,7 @@ def run_checks(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = Non text = f"§1.4: суммы родителя ≠ сумме детей (±{TOLERANCE}): строки {_rows_text(parents)}" warnings.append(OsvNote(OSV_PARENT_SUM, text)) if not matches: - warnings.append(OsvNote(OSV_TOTAL, _total_warning(rows))) + warnings.append(OsvNote(OSV_TOTAL, _total_warning(rows, secondary_totals))) checks = OsvChecks( total_row_matches_root=matches, balance_identity_violations=identity, diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index eddb1a6..ae122d9 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -196,6 +196,19 @@ def _trailing_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], tot return last +def _leading_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], total: int, rules: Rules) -> int: + """Номер самой верхней из вторых итоговых строк сразу перед основной «Итого» (она сама, если их нет). + + Плоская форма 1С (#59): «Итого развернутое» стоит перед «Итого» — вместе они итоговый блок. + """ + first = total + for row in reversed(rows[: rows.index(total)]): + if not _is_secondary_total(sheet.text_at(row, header.text_col), rules): + break + first = row + return first + + def _split_collapsed(sums: dict[str, Decimal | None], columns: ColumnMap) -> None: """Свёрнутое сальдо (одна колонка, значение в `*_debit`): отрицательное — в `*_credit` по модулю (R-1).""" for group in columns.collapsed: @@ -313,6 +326,7 @@ def collect_body_rows( if tail: warnings.append(_tail_warning(tail)) rows = rows[: last + 1] + total_row = _leading_totals(sheet, header, rows, total_row, rules) label_cols = _label_cols(sheet, header, rows) body: list[BodyRow] = [] for index, row in enumerate(rows, start=1): diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index 3c0809c..9ed7de9 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -219,7 +219,7 @@ class OsvParser: glue = _check_inn_owners(glue, classified) doc = _with_table_account(doc, classified) deadline.check(f"лист «{sheet.name}»: контрольные суммы") - checks, check_notes = run_checks(classified, rules.checks) + checks, check_notes = run_checks(classified, rules.checks, secondary_totals=rules.headers.total_row_secondary) notes = [ hierarchy_note(levels.strategy), *notes_of(OSV_HEADER, doc.warnings), diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 7893503..b441dd9 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -938,7 +938,7 @@ def _s42() -> list[SheetSpec]: ("76.5", (None, "300.00", "700.00", "600.00", None, "200.00"), 0), ("76.Н", (None, None, "5000.00", "5000.00", None, None), 0), ("76.Н.1", (None, None, "5000.00", "5000.00", None, None), 0), - ("Итого развернутое", ("1000.00", "300.00", None, None, None, "200.00"), 0), + ("Итого развернутое", ("1000.00", "300.00", "5700.00", "6600.00", None, "200.00"), 0), ("Итого", ("700.00", None, "5700.00", "6600.00", None, "200.00"), 0), ] return [_flat_sheet(texts, 8, rows)] diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index 17bbd70..5a68d0c 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -704,6 +704,20 @@ def test_s31_rows_below_total_are_outside_the_document(fixtures_dir: Path) -> No assert any("ниже таблицы" in warning for warning in warnings_of(outcome)), warnings_of(outcome) +def test_s42_secondary_total_before_main_total(fixtures_dir: Path) -> None: + """#59: «Итого развернутое» перед «Итого» — обе TOTAL, сверка по основной «Итого», статус done.""" + outcome = parse_named(fixtures_dir, "s42_subaccounts_totals_reversed.xlsx") + doc = one_document(outcome) + + assert outcome.status is ResultStatus.DONE + assert doc.status == "done" + assert [row.group_type for row in doc.rows[-2:]] == ["TOTAL", "TOTAL"] + assert doc.checks is not None + assert doc.checks.total_row_matches_root is True + assert doc.checks.parent_sum_violations == [] + assert doc.checks.balance_identity_violations == [] + + def test_s32_total_with_suffix_is_total(fixtures_dir: Path) -> None: """I-1: нет точного «Итого» — итогом служит строка, начинающаяся с «Итого» («Итого по счету 60»).""" doc = one_document(parse_named(fixtures_dir, TOTAL_WITH_SUFFIX_FILE)) diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index f90f4bd..2571c78 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -10,7 +10,7 @@ from src.contract.common import DataType from src.parser.checks import TOLERANCE, balance_identity, parent_sums, run_checks, total_matches_root from src.parser.classify import ClassifiedRow, RowGroupType from src.parser.hierarchy import BodyRow -from src.parser.rules import OsvCheckRules +from src.parser.rules import OsvCheckRules, Pattern Sums = tuple[str | None, str | None, str | None, str | None, str | None, str | None] EMPTY: Sums = (None, None, None, None, None, None) @@ -241,6 +241,22 @@ def test_rows_without_children_are_not_parents() -> None: assert parent_sums(rows) == [] +def test_total_check_uses_main_total_when_secondary_comes_first() -> None: + """Плоская форма (#59): «Итого развернутое» перед «Итого» — сверка по основной «Итого».""" + secondary = [Pattern.compile(r"^итого\s+развернут")] + rows = [ + account(7, ROOT), + row(8, ("1", None, None, None, None, "1"), group_type="TOTAL", text="Итого развернутое"), + total(9, ROOT), + ] + + assert total_matches_root(rows, secondary) is True + assert total_matches_root(rows) is False + checks, notes = run_checks(rows, secondary_totals=secondary) + assert checks.total_row_matches_root is True + assert not any(note.code == "OSV_TOTAL" for note in notes) + + def test_total_matches_first_root_account() -> None: rows = tree() diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index e120156..a1d780c 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -1112,3 +1112,33 @@ def test_row_without_label_takes_document_data_type(rules: Rules) -> None: "60": "NU", "ООО Поставщик": "NU", } + + +def test_secondary_total_before_main_total_is_in_total_block(rules: Rules) -> None: + """Плоская форма (#59): «Итого развернутое» стоит перед «Итого» — обе строки итоговый блок, `total_row` — первая.""" + sheet = osv( + r(0, "60.01", S100), + r(0, "60.02", S100), + r(None, "Итого развернутое", (1, None, 2, 3, 4, None)), + r(None, "Итого", (200, None, 100, 60, 240, None)), + ) + + parsed = parse(sheet, rules) + + secondary, main = parsed.one("Итого развернутое"), parsed.one("Итого") + assert parsed.total_row == secondary.body.row + assert [row.group_type for row in (secondary, main)] == ["TOTAL", "TOTAL"] + assert [row.level for row in (secondary, main)] == [0, 0] + assert [row.parent for row in (secondary, main)] == [None, None] + assert parsed.one("60.02").group_type != "TOTAL" + + +def test_only_secondary_total_is_the_total(rules: Rules) -> None: + """Review Focus 3: без обычной «Итого» «Итого развернутое» — единственная итоговая строка.""" + sheet = osv(r(0, "60", S100), r(None, "Итого развернутое", S100)) + + parsed = parse(sheet, rules) + + secondary = parsed.one("Итого развернутое") + assert parsed.total_row == secondary.body.row + assert secondary.group_type == "TOTAL" -- 2.47.2 From 71c4c93db2d5c1ce7bc6ed979081e73b044bcd8e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 12:17:15 +0400 Subject: [PATCH 09/16] =?UTF-8?q?feat(osv):=20=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=B3=D0=B8=D1=8F=20header=5Faccount=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D0=BB=D0=BE=D1=81=D0=BA=D0=B8=D0=B9=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BE=D0=BA=20=D0=BF=D0=BE=D0=B4=20=D1=81=D1=87?= =?UTF-8?q?=D1=91=D1=82=D0=BE=D0=BC=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B8=20(#5?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- api/src/parser/checks.py | 7 +- api/src/parser/classify.py | 8 +- api/src/parser/hierarchy.py | 24 ++++- api/src/parser/indicators.py | 8 +- api/src/parser/osv.py | 3 +- api/src/parser/osv_warnings.py | 1 + api/tests/synthetic/generate.py | 4 +- api/tests/unit/parser/test_checks.py | 23 +++++ api/tests/unit/parser/test_hierarchy.py | 30 ++++++ api/tests/unit/parser/test_osv_flat_form.py | 103 ++++++++++++++++++++ 10 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 api/tests/unit/parser/test_osv_flat_form.py diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 4b9f399..a9b5342 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -184,7 +184,9 @@ def _sum_of(rows: Iterable[ClassifiedRow]) -> tuple[Decimal, ...]: def total_matches_root(rows: Sequence[ClassifiedRow], secondary_totals: Sequence[Pattern] = ()) -> bool: """«Итого» = первая корневая строка ACCOUNT или сумма строк уровня 0 по шести колонкам (±0.01). - Нет «Итого» — `False` (R-20); нет корня — `True`, только если все суммы «Итого» пусты или нулевые (R-13). Сумма + Нет «Итого» — `False` (R-20); нет корня ACCOUNT, но есть строки уровня 0 — «Итого» сверяется с их суммой (плоский + список под счётом шапки, #59, R-5); нет ни того ни другого — `True`, только если все суммы «Итого» пусты или + нулевые (R-13). Сумма уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов, «Итого» равно их сумме. """ total = _total(rows, secondary_totals) @@ -192,6 +194,9 @@ def total_matches_root(rows: Sequence[ClassifiedRow], secondary_totals: Sequence return False root = _root(rows) if root is None: + roots = _level_zero(rows) + if roots: + return _sums_match(_values(total), _sum_of(roots)) return all(abs(value) <= TOLERANCE for value in _values(total)) if not _differs(_values(total), _values(root)): return True diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index ae122d9..3da3c06 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -445,6 +445,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta *, inns: Mapping[int, str] | None = None, unpaired: AbstractSet[int] = frozenset(), + header_account: str | None = None, ) -> list[ClassifiedRow]: """Классификация строк по дереву `levels` и схеме группировок. @@ -458,12 +459,15 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta - вид данных строки — метка (`label_data_type`), иначе `doc_data_type` (умолчание БУ подставляет `osv.py`); - прочая строка — `schema[позиция]`, позиция = число не-счётных предков после последнего счёта + 1; за схемой — OTHER. COUNTERPARTY получает `counterparty_name` (текст строки) и `counterparty_inn` (колонка ИНН); - - строка ИНН без пары (`unpaired`) — OTHER, позицию в схеме не занимает; ИНН склеенных строк — `inns`. + - строка ИНН без пары (`unpaired`) — OTHER, позицию в схеме не занимает; ИНН склеенных строк — `inns`; + - корневая ветка наследует счёт шапки `header_account` (R-4, #59): строки до первой строки-счёта и плоский список + получают его код. """ context = _Context( schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes), inns or {}, unpaired ) extra_keys = _extra_keys(sheet, header, rows) + root_branch = _Branch(header_account, 0) branches: dict[int, _Branch] = {} result: list[ClassifiedRow] = [] for item in rows: @@ -475,7 +479,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta level, parent = 0, None else: parent = levels.parent[item.row] - inherited = _ROOT_BRANCH if parent is None else branches.get(parent, _ROOT_BRANCH) + inherited = root_branch if parent is None else branches.get(parent, root_branch) kind, branch = _classify_row(item, inherited, context) level = levels.by_row[item.row] branches[item.row] = branch diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index af1836a..295029e 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -186,6 +186,17 @@ def _matches(left: tuple[Decimal, ...], right: tuple[Decimal, ...]) -> bool: return all(abs(a - b) <= SUM_TOLERANCE for a, b in zip(left, right, strict=True)) +def strategy_header_account(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None: + """Плоский список под счётом шапки (#59): все строки — уровень 0. + + Применима, только если ни одна строка тела не является счётом (`is_account`): счёт назван в шапке, а строки таблицы + — его аналитика без оформления. Условие «в шапке есть код счёта» проверяет `resolve_levels`. + """ + if any(item.is_account for item in _paced(rows, deadline, "иерархия (header_account)")): + return None + return {item.row: ROOT_LEVEL for item in rows} + + def strategy_sums(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None: """Последнее средство: строка — родитель следующих поддеревьев, пока их сумма не совпадёт с её суммами. @@ -269,9 +280,14 @@ def parents(rows: Sequence[BodyRow], by_row: Mapping[int, int]) -> dict[int, int return result -def resolve_levels(rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline) -> Levels: +def resolve_levels( + rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline, *, header_account: bool = False +) -> Levels: """Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`. + `header_account` — в шапке есть код счёта: тогда после `account_pattern` и перед `sums` пробуется `header_account` + (плоский список под счётом шапки, #59). + «Итого» (`total_row`) и строки после неё (вторые итоговые) в стратегии не передаются и получают уровень 0: это контрольная сумма документа, а не ребёнок последнего счёта. Меньше двух строк, кроме «Итого», — тривиальное дерево корней `TRIVIAL_STRATEGY`. @@ -283,7 +299,11 @@ def resolve_levels(rows: Sequence[BodyRow], total_row: int | None, deadline: Dea if len(body) < MIN_ROWS_FOR_CASCADE: by_row = {item.row: ROOT_LEVEL for item in rows} return Levels(by_row=by_row, parent=parents(rows, by_row), strategy=TRIVIAL_STRATEGY) - for name, strategy in STRATEGIES: + cascade = list(STRATEGIES) + if header_account: + before_sums = next((index for index, (name, _) in enumerate(cascade) if name == "sums"), len(cascade)) + cascade.insert(before_sums, ("header_account", strategy_header_account)) + for name, strategy in cascade: deadline.check(f"иерархия ({name})") found = strategy(body, deadline) if found is None: diff --git a/api/src/parser/indicators.py b/api/src/parser/indicators.py index 13fc7ac..7da5ee3 100644 --- a/api/src/parser/indicators.py +++ b/api/src/parser/indicators.py @@ -60,13 +60,17 @@ def is_indicator_total(sheet: Sheet, row: int, header: TableHeader, rules: Rules return bool(label) and first_match(rules.data_types.indicator_column, label) != "BU" -def levels_with_indicators(rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline) -> Levels: +def levels_with_indicators( + rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline, *, header_account: bool = False +) -> Levels: """Каскад иерархии без строк-показателей; показатель — ребёнок предыдущей строки данных, на уровень глубже (§1.3): так строки НУ/Кол./«В валюте» на одном outline-уровне со своей строкой не ломают дерево строк БУ. Итоговые строки-показатели и показатель без строки данных выше — уровень 0 без родителя. """ - levels = resolve_levels([item for item in rows if not item.indicator], total_row, deadline) + levels = resolve_levels( + [item for item in rows if not item.indicator], total_row, deadline, header_account=header_account + ) by_row: dict[int, int] = {} parent: dict[int, int | None] = {} previous: int | None = None diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index 9ed7de9..e4818dc 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -184,7 +184,7 @@ class OsvParser: step = "строки таблицы" body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline) step = "иерархия строк" - levels = levels_with_indicators(body, total_row, deadline) + levels = levels_with_indicators(body, total_row, deadline, header_account=bool(doc.account_code_raw)) step = "строки ИНН" glue = glue_inn_rows( body, @@ -213,6 +213,7 @@ class OsvParser: rules, inns=glue.inns, unpaired=glue.unpaired, + header_account=doc.account_code_raw or None, ) except InvalidFormatError as exc: raise _invalid_format(sheet, step, exc) from exc diff --git a/api/src/parser/osv_warnings.py b/api/src/parser/osv_warnings.py index 22ad46a..b0d07ad 100644 --- a/api/src/parser/osv_warnings.py +++ b/api/src/parser/osv_warnings.py @@ -44,6 +44,7 @@ HIERARCHY_TEXTS = { "outline": "по группировке листа (outline)", "indent": "по отступам (indent)", "account_pattern": "по кодам счетов (account_pattern)", + "header_account": "плоский список под счётом шапки (header_account)", "sums": "по суммам (sums)", "trivial": "плоский список (trivial)", } diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index b441dd9..2771051 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -902,7 +902,7 @@ def _s40() -> list[SheetSpec]: ("Август - Агро ООО", ("1000.00", None, "500.00", "300.00", "1200.00", None), 0), ("АВИС ООО", (None, "200.00", "100.00", "400.00", None, "500.00"), 0), ("Агроаспект ООО", (None, None, "50.00", "50.00", None, None), 0), - ("Итого развернутое", ("1000.00", "200.00", None, None, "1200.00", "500.00"), 0), + ("Итого развернутое", ("1000.00", "200.00", "650.00", "750.00", "1200.00", "500.00"), 0), ("Итого", ("800.00", None, "650.00", "750.00", "700.00", None), 0), ] return [_flat_sheet(texts, 9, rows)] @@ -918,7 +918,7 @@ def _s41() -> list[SheetSpec]: rows: list[FlatRow] = [ ("Автодор-Платные Дороги ООО", ("820.21", None, "5000.00", "4991.00", "829.21", None), 0), ("АГРОПРОФИ ООО", (None, "2763.79", None, None, None, "2763.79"), 0), - ("Итого развернутое", ("820.21", "2763.79", None, None, "829.21", "2763.79"), 0), + ("Итого развернутое", ("820.21", "2763.79", "5000.00", "4991.00", "829.21", "2763.79"), 0), ("Итого", (None, "1943.58", "5000.00", "4991.00", None, "1934.58"), 0), ] return [_flat_sheet(texts, 5, rows)] diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index 2571c78..e5d1d72 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -355,6 +355,29 @@ def test_second_total_row_does_not_replace_the_first_one() -> None: assert total_matches_root(rows) is True +def test_single_total_matching_secondary_is_used_as_the_total() -> None: + """T3.1: единственная TOTAL, чей текст матчит вторые итоговые, — всё равно итоговая строка.""" + secondary = [Pattern.compile(r"^итого\s+развернут")] + only = row(8, ROOT, group_type="TOTAL", text="Итого развернутое") + + assert total_matches_root([account(7, ROOT), only], secondary) is True + + +def test_total_without_account_root_matches_level_zero_sum() -> None: + flat = [ + row(7, ("10", None, "5", "3", "12", None), group_type="OTHER"), + row(8, (None, "4", "1", "2", None, "3"), group_type="COUNTERPARTY"), + row(9, ("6", None, "2", "1", "7", None), group_type="COUNTERPARTY"), + ] + # сумма корней: дебет/кредит сальдо 16/4 -> 12 нетто, на конец 19/3 -> 16 нетто; обороты 8/6 по колонкам + good = total(10, ("12", None, "8", "6", "16", None)) + bad = total(10, ("12", None, "9", "6", "16", None)) + + assert total_matches_root([*flat, good]) is True + assert total_matches_root([*flat, bad]) is False + assert total_matches_root([total(10, EMPTY)]) is True + + def test_empty_osv_without_root_matches_when_total_is_empty_or_zero() -> None: assert total_matches_root([total(8, EMPTY)]) is True assert total_matches_root([total(8, ("0", None, "0.00", None, None, "0"))]) is True diff --git a/api/tests/unit/parser/test_hierarchy.py b/api/tests/unit/parser/test_hierarchy.py index 7a15156..c963974 100644 --- a/api/tests/unit/parser/test_hierarchy.py +++ b/api/tests/unit/parser/test_hierarchy.py @@ -23,6 +23,7 @@ from src.parser.hierarchy import ( parents, resolve_levels, strategy_account_pattern, + strategy_header_account, strategy_indent, strategy_outline, strategy_sums, @@ -456,3 +457,32 @@ def test_linear_strategies_check_deadline_every_n_rows(strategy: Strategy, name: with pytest.raises(ParseTimeoutError, match=rf"иерархия \({name}\)"): strategy(rows, deadline) assert clock.calls == CLOCK_READS_CREATE_AND_ONE_CHECK + + +# --- Плоский список под счётом шапки (#59, стратегия header_account) --- + + +def test_header_account_strategy_flat_rows() -> None: + rows = numbered(("Август - Агро ООО", {}), ("АВИС ООО", {}), ("Агроаспект ООО", {})) + + levels = resolve_levels(rows, None, never_expires(), header_account=True) + + assert levels.strategy == "header_account" + assert levels_list(rows, levels.by_row) == [0, 0, 0] + assert set(levels.parent.values()) == {None} + + +def test_header_account_not_used_without_header_code() -> None: + rows = numbered(("Август - Агро ООО", {}), ("АВИС ООО", {}), ("Агроаспект ООО", {})) + + with pytest.raises(InvalidFormatError, match="иерархия строк не определена"): + resolve_levels(rows, None, never_expires(), header_account=False) + + +def test_header_account_not_used_with_account_rows() -> None: + rows = numbered(("60.01", {}), ("ООО А", {})) + + levels = resolve_levels(rows, None, never_expires(), header_account=True) + + assert levels.strategy == "account_pattern" + assert strategy_header_account(rows) is None diff --git a/api/tests/unit/parser/test_osv_flat_form.py b/api/tests/unit/parser/test_osv_flat_form.py new file mode 100644 index 0000000..4bc1f77 --- /dev/null +++ b/api/tests/unit/parser/test_osv_flat_form.py @@ -0,0 +1,103 @@ +"""Плоская форма ОСВ 1С (#59, change osv-flat-form): стратегия `header_account` и счёт шапки у строк.""" + +import pytest +from gridbuilder import L, M, sheet_from_rows + +from src.contract.result import OsvDocument +from src.parser.deadline import Deadline +from src.parser.osv import OsvParser +from src.parser.rules import Rules, load_rules + +TIMEOUT_SECONDS = 60 +HEADER_ACCOUNT_CODE = "OSV_HIERARCHY_BY_HEADER_ACCOUNT" + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +def _table_head() -> list[list[object]]: + return [ + ["Субконто", M("Сальдо на начало периода", 2), M("Обороты за период", 2), M("Сальдо на конец периода", 2)], + [None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + ] + + +def _parse(rules: Rules, rows: list[list[object]]) -> tuple[OsvDocument, list[str]]: + result = OsvParser(rules).parse_sheet(sheet_from_rows(rows), Deadline(TIMEOUT_SECONDS)) + return result.document, [note.code for note in result.notes] + + +def test_flat_list_s40(rules: Rules) -> None: + """Образец s40: счёт «по счету: 76.Н.1», вид «Контрагенты», организация ниже периода, данные без оформления.""" + rows: list[list[object]] = [ + [], + ["Оборотно-сальдовая ведомость по счету: 76.Н.1"], + ["Контрагенты"], + ["за 2025 г."], + ['ООО "Товарное хозяйство"'], + [], + ["Единица измерения: рубль (код по ОКЕИ 383)"], + [], + *_table_head(), + ["Август - Агро ООО", 1000, None, 500, 300, 1200, None], + ["АВИС ООО", None, 200, 100, 400, None, 500], + ["Агроаспект ООО", None, None, 50, 50, None, None], + ["Итого развернутое", 1000, 200, 650, 750, 1200, 500], + ["Итого", 800, None, 650, 750, 700, None], + ] + + document, codes = _parse(rules, rows) + + assert document.status == "done" + assert HEADER_ACCOUNT_CODE in codes + data = [item for item in document.rows if item.group_type != "TOTAL"] + assert [item.row_value for item in data] == ["Август - Агро ООО", "АВИС ООО", "Агроаспект ООО"] + assert all(item.level == 0 and item.parent_row_number is None for item in data) + assert {item.account_code_raw for item in data} == {"76.Н.1"} + assert document.header.organization_name == 'ООО "Товарное хозяйство"' + + +def test_flat_list_s41(rules: Rules) -> None: + """Образец s41 (R-10): схема [ACCOUNT, COUNTERPARTY («Организации»)], счёт шапки «60».""" + rows: list[list[object]] = [ + [], + ["Оборотно-сальдовая ведомость по счету: 60"], + ["Организации"], + ["за 2 Квартал 2026 г. План счетов ООО Хреновская Нива"], + [], + *_table_head(), + ["Автодор-Платные Дороги ООО", 820, None, 5000, 4991, 829, None], + ["АГРОПРОФИ ООО", None, 2764, None, None, None, 2764], + ["Итого развернутое", 820, 2764, 5000, 4991, 829, 2764], + ["Итого", None, 1944, 5000, 4991, None, 1935], + ] + + document, _ = _parse(rules, rows) + + assert document.status == "done" + data = [item for item in document.rows if item.group_type != "TOTAL"] + assert [item.account_code_raw for item in data] == ["60", "60"] + assert [item.group_type for item in data] == ["COUNTERPARTY", "COUNTERPARTY"] + + +def test_outline_wins_over_header_account_and_rows_inherit_header_code(rules: Rules) -> None: + """Review Focus 4: счёт в шапке, строк-счетов нет, outline есть — `outline`, но строки наследуют счёт шапки.""" + rows: list[list[object]] = [ + ["ООО Зерновая"], + ["Оборотно-сальдовая ведомость по счету 60"], + ["Период: 9 месяцев 2025 г."], + [], + *_table_head(), + [L(0), "ООО А", 100, None, 50, 30, 120, None], + [L(1), "Договор 1", 100, None, 50, 30, 120, None], + [L(0), "Итого", 100, None, 50, 30, 120, None], + ] + + document, codes = _parse(rules, rows) + + assert "OSV_HIERARCHY_BY_OUTLINE" in codes + assert HEADER_ACCOUNT_CODE not in codes + data = [item for item in document.rows if item.group_type != "TOTAL"] + assert [item.account_code_raw for item in data] == ["60", "60"] -- 2.47.2 From cdbe23b9b60accc1d1cd7c063c5cf463806e537c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 12:27:00 +0400 Subject: [PATCH 10/16] =?UTF-8?q?feat(osv):=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D1=8B=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D1=87=D1=91=D1=82=D0=BD=D1=8B=D1=85=20=D1=81=D1=87=D0=B5=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B1=D0=B5=D0=B7=20=D0=BD=D0=B0=D0=B7=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=B8=D0=B4=D0=B0=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=BF=D0=BB=D0=B0=D0=BD=D1=83=20=D1=81=D1=87?= =?UTF-8?q?=D0=B5=D1=82=D0=BE=D0=B2=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit counterparty_accounts в osv_groups.yaml (R-9: без 71 и 73), is_counterparty_account, OSV_ROWS по счёту; формулировка проверки «Итого» без корня ACCOUNT (T4.1); ожидания s40–s44 в синтетике (T1.1/T1.2), тест испорченной «Итого» плоского списка (T4.2). Co-Authored-By: Claude Sonnet 5.5 --- api/rules/osv_groups.yaml | 5 ++ api/src/parser/checks.py | 3 + api/src/parser/classify.py | 25 ++++++- api/src/parser/osv.py | 11 +++ api/src/parser/rules.py | 34 +++++++-- api/tests/synthetic/test_synthetic_files.py | 27 ++++++- api/tests/unit/parser/test_classify.py | 71 ++++++++++++++++++ api/tests/unit/parser/test_osv_flat_form.py | 81 ++++++++++++++++++++- api/tests/unit/parser/test_rules.py | 41 +++++++++++ 9 files changed, 285 insertions(+), 13 deletions(-) diff --git a/api/rules/osv_groups.yaml b/api/rules/osv_groups.yaml index 8adc464..8f63bd2 100644 --- a/api/rules/osv_groups.yaml +++ b/api/rules/osv_groups.yaml @@ -15,5 +15,10 @@ mapping: - ['^стать(и|я)\b', OTHER] - ['^валют', OTHER] +# Расчётные счета, у которых аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван +# (ОСВ 1С по счёту 60 без строки «Контрагенты»; AURORA-1661, OQ-158). Счёт сопоставляется по коду и субсчетам +# («76» -> 76.2, 76.Н.1; не 760). 71 и 73 исключены (R-9): подотчётные лица и персонал — не контрагенты. +counterparty_accounts: ['58', '60', '62', '66', '67', '75', '76', '80'] + # Всё прочее (не перечисленное выше); такая группировка в отдельной строке блока заголовка не будет узнана как метка default: OTHER diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index a9b5342..11e0bf9 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -220,6 +220,9 @@ def _total_warning(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern] = if total is None: return TOTAL_NOT_FOUND_WARNING if root is None: + level_zero = _level_zero(rows) + if level_zero: + return f"§1.4: строка «Итого» (строка {total.body.row}) ≠ сумме {len(level_zero)} строк уровня 0" return f"§1.4: строка «Итого» (строка {total.body.row}) не пуста, а корневого счёта нет" message = f"§1.4: строка «Итого» (строка {total.body.row}) ≠ корневому счёту (строка {root.body.row})" level_zero = _level_zero(rows) diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 3da3c06..9ef2c94 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -83,6 +83,8 @@ class ClassifiedRow: data_type: DataType | None extra: dict[str, str | None] | None indicator: bool = False # строка-показатель (§1.3): вне проверок сумм, `data_type` — вид по метке + # COUNTERPARTY получен по плану счетов (вид субконто в шапке не назван, #59, R-9), а не по схеме группировок + counterparty_by_account: bool = False # --- Строки таблицы --- @@ -383,6 +385,7 @@ class _Kind: counterparty_name: str | None = None counterparty_inn: str | None = None indicator: bool = False + counterparty_by_account: bool = False @dataclass(frozen=True) @@ -423,6 +426,16 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple position = inherited.depth + 1 group_type, name = _schema_group(context.schema, position) branch = _Branch(code, position) + # Схема из одного ACCOUNT, первый уровень под расчётным счётом без названия вида — контрагент по плану счетов (R-9) + by_account = ( + group_type == "OTHER" + and name is None + and position == 1 + and position >= len(context.schema) + and context.rules.groups.is_counterparty_account(code) + ) + if by_account: + group_type = "COUNTERPARTY" if group_type != "COUNTERPARTY": return _Kind(group_type, name, code, own), branch inn_col = context.header.inn_col @@ -430,7 +443,16 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple # ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100) counterparty, in_name = split_inn_suffix(item.text, context.rules) inn = column or context.inns.get(item.row) or in_name - return _Kind(group_type, name, code, own, counterparty_name=counterparty, counterparty_inn=inn), branch + kind = _Kind( + group_type, + name, + code, + own, + counterparty_name=counterparty, + counterparty_inn=inn, + counterparty_by_account=by_account, + ) + return kind, branch def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно @@ -496,6 +518,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta data_type=kind.data_type, extra=_extra(sheet, item.row, extra_keys), indicator=kind.indicator, + counterparty_by_account=kind.counterparty_by_account, ) ) return result diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index e4818dc..f68c5af 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -112,6 +112,16 @@ def _with_table_account(doc: DocHeader, rows: Sequence[ClassifiedRow]) -> DocHea return replace(doc, account_code_raw=code, warnings=tuple(warnings)) +def _by_account_warnings(rows: Sequence[ClassifiedRow]) -> list[str]: + """По одному предупреждению на счёт, чьи строки признаны контрагентами по плану счетов (R-9), в порядке листа.""" + codes = dict.fromkeys(item.account_code_raw or "" for item in rows if item.counterparty_by_account) + return [ + f"вид субконто в шапке не назван: строки аналитики счёта {code} приняты за контрагентов по плану счетов — " + "проверьте" + for code in codes + ] + + def _is_ok(checks: OsvChecks) -> bool: return ( bool(checks.total_row_matches_root) @@ -226,6 +236,7 @@ class OsvParser: *notes_of(OSV_HEADER, doc.warnings), *([OsvNote(OSV_HEADER, DEFAULT_DATA_TYPE_WARNING)] if defaulted else []), *notes_of(OSV_ROWS, body_warnings), + *notes_of(OSV_ROWS, _by_account_warnings(classified)), *notes_of(OSV_INN_ROWS, glue.warnings), ] if not any(item.group_type != "TOTAL" for item in classified): diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index 3972b0e..cbeacef 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -184,9 +184,34 @@ class OsvHeaderRules(_Model): return self +def _check_account_codes(field_name: str, codes: Iterable[str]) -> None: + bad = [code for code in codes if not _ACCOUNT_CODE.fullmatch(code)] + if bad: + raise ValueError(f"{field_name}: ожидаются коды счетов вида «91» или «76.09», {bad}") + + +def _is_under_account(account_code: str | None, roots: Iterable[str]) -> bool: + """Код счёта из файла («91», «91.01») совпадает с одним из `roots` либо является его субсчётом (не «910»).""" + if not account_code: + return False + code = account_code.strip() + return any(code == root or code.startswith(f"{root}.") for root in roots) + + class OsvGroupRules(_Model): mapping: list[tuple[Pattern, GroupType]] default: GroupType = "OTHER" + # Расчётные счета: аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван (#59, R-9) + counterparty_accounts: list[str] = [] + + @model_validator(mode="after") + def _check_counterparty_accounts(self) -> "OsvGroupRules": + _check_account_codes("counterparty_accounts", self.counterparty_accounts) + return self + + def is_counterparty_account(self, account_code: str | None) -> bool: + """Счёт из `counterparty_accounts` или его субсчёт («76» -> 76, 76.2, 76.Н.1; не 760).""" + return _is_under_account(account_code, self.counterparty_accounts) def classify(self, text: str) -> GroupType: """Тип группировки по названию из шапки; не совпавшее ни с одним паттерном — `default`.""" @@ -203,17 +228,12 @@ class OsvCheckRules(_Model): @model_validator(mode="after") def _check_codes(self) -> "OsvCheckRules": - bad = [code for code in self.accounts_without_analytic_balance if not _ACCOUNT_CODE.fullmatch(code)] - if bad: - raise ValueError(f"accounts_without_analytic_balance: ожидаются коды счетов вида «91» или «76.09», {bad}") + _check_account_codes("accounts_without_analytic_balance", self.accounts_without_analytic_balance) return self def has_no_analytic_balance(self, account_code: str | None) -> bool: """Код счёта из файла («91», «91.01») относится к счёту из списка: совпадает с ним либо его субсчёт.""" - if not account_code: - return False - code = account_code.strip() - return any(code == root or code.startswith(f"{root}.") for root in self.accounts_without_analytic_balance) + return _is_under_account(account_code, self.accounts_without_analytic_balance) class PeriodRule(_Model): diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index 5a68d0c..e375732 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -43,7 +43,7 @@ VAL_KOL_VO_FILE = "s30_val_kol_vo_materials.xlsx" ROWS_BELOW_TOTAL_FILE = "s31_rows_below_total.xlsx" TOTAL_WITH_SUFFIX_FILE = "s32_total_with_suffix.xlsx" -# Плоская форма ОСВ 1С (#59): на этапе снимков текущего поведения ожиданий нет — их добавляют задачи 2–5 плана +# Плоская форма ОСВ 1С (#59) FLAT_FORM_FILES = ( "s40_flat_header_account.xlsx", "s41_flat_chart_of_accounts.xlsx", @@ -162,6 +162,27 @@ EXPECTED: dict[str, Expect] = { "s34_indicator_column_qty_val_diff.xlsx": Expect("outline", 2025, "Q3", False), "s35_currency_slices.xlsx": Expect("outline", 2025, "Q3", False), "s36_no_data_type_header.xlsx": Expect("outline", 2025, "Q3", False, data_type=None), + # плоская форма 1С (#59): в шапке нет признака данных (БУ/НУ), `data_type = None` (умолчание — трек A, AURORA-1613) + # счёт в шапке «по счету: 76.Н.1», данные без оформления; период «за 2025 г.» + "s40_flat_header_account.xlsx": Expect( + "header_account", 2025, "Q4", True, data_type=None, organization='ООО "Товарное хозяйство"' + ), + # вид «Организации», «за 2 Квартал 2026 г. План счетов …» + "s41_flat_chart_of_accounts.xlsx": Expect( + "header_account", 2026, "Q2", False, data_type=None, organization="ООО Хреновская Нива" + ), + # субсчета 76.2…76.Н.1 по шаблону кода; «Итого развернутое» перед «Итого» + "s42_subaccounts_totals_reversed.xlsx": Expect( + "account_pattern", 2025, "Q4", True, data_type=None, organization='ООО "Товарное хозяйство"' + ), + # счёт 60 без вида аналитики в шапке, отступы + "s43_subkonto_unnamed_60.xlsx": Expect( + "indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"' + ), + # то же на счёте 10: материалы остаются OTHER + "s44_subkonto_unnamed_10.xlsx": Expect( + "indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"' + ), } @@ -248,13 +269,11 @@ def test_synthetic_set_is_complete(fixtures_dir: Path) -> None: def test_every_synthetic_file_is_listed(synthetic_file: Path) -> None: """Каждый файл каталога описан в таблице ожиданий (новый файл без ожиданий — ошибка набора).""" - assert synthetic_file.name in {*EXPECTED, *FLAT_FORM_FILES}, f"добавьте ожидания для {synthetic_file.name}" + assert synthetic_file.name in EXPECTED, f"добавьте ожидания для {synthetic_file.name}" def test_header_status_and_hierarchy(synthetic_file: Path) -> None: """Статус, число документов, стратегия иерархии и шапка из таблицы `EXPECTED`; результат валиден по контракту.""" - if synthetic_file.name in FLAT_FORM_FILES: - pytest.skip("плоская форма (#59): ожидания по файлу — в сценариях задач 2–5, до них поведение фиксирует golden") expect = EXPECTED[synthetic_file.name] outcome = parse_path(synthetic_file) diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index a1d780c..2eeeee1 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -1142,3 +1142,74 @@ def test_only_secondary_total_is_the_total(rules: Rules) -> None: secondary = parsed.one("Итого развернутое") assert parsed.total_row == secondary.body.row assert secondary.group_type == "TOTAL" + + +# --- Контрагенты расчётных счетов без названия вида (#59, R-9) --- + + +def _subkonto_sheet(*rows: list[object], label: str | None = None) -> Sheet: + """Схема из одной ACCOUNT («Субконто»); `label` — вторая группировка в блоке заголовка.""" + block: list[list[object]] = [["Субконто", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)]] + block.append([label, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"]) + return sheet_from_rows([["ООО Ромашка"], [TITLE], [], *block, *rows]) + + +def test_unnamed_first_level_on_settlement_account_is_counterparty(rules: Rules) -> None: + sheet = _subkonto_sheet( + [L(0), "60.01", *S100], + [L(1), "ИП Знаменских А.И.", *S100], + [L(0), "Итого", *S100], + ) + + parsed = parse(sheet, rules) + + row = parsed.one("ИП Знаменских А.И.") + assert row.group_type == "COUNTERPARTY" + assert row.group_name is None + assert row.counterparty_name == "ИП Знаменских А.И." + assert row.counterparty_by_account is True + + +def test_unnamed_second_level_stays_other(rules: Rules) -> None: + sheet = _subkonto_sheet( + [L(0), "60.01", *S100], + [L(1), "ИП Знаменских А.И.", *S100], + [L(2), "Договор № 1", *S100], + [L(0), "Итого", *S100], + ) + + parsed = parse(sheet, rules) + + row = parsed.one("Договор № 1") + assert row.group_type == "OTHER" + assert row.counterparty_by_account is False + + +def test_named_other_group_is_not_reclassified(rules: Rules) -> None: + sheet = _subkonto_sheet( + [L(0), "76.01", *S100], + [L(1), "Станок", *S100], + [L(0), "Итого", *S100], + label="Основные средства", + ) + + parsed = parse(sheet, rules) + + row = parsed.one("Станок") + assert row.group_type == "OTHER" + assert row.group_name == "Основные средства" + assert row.counterparty_by_account is False + + +def test_non_settlement_account_stays_other(rules: Rules) -> None: + sheet = _subkonto_sheet( + [L(0), "10.01", *S100], + [L(1), "Мука пшеничная", *S100], + [L(0), "Итого", *S100], + ) + + parsed = parse(sheet, rules) + + row = parsed.one("Мука пшеничная") + assert row.group_type == "OTHER" + assert row.counterparty_by_account is False diff --git a/api/tests/unit/parser/test_osv_flat_form.py b/api/tests/unit/parser/test_osv_flat_form.py index 4bc1f77..f6e049b 100644 --- a/api/tests/unit/parser/test_osv_flat_form.py +++ b/api/tests/unit/parser/test_osv_flat_form.py @@ -1,9 +1,12 @@ """Плоская форма ОСВ 1С (#59, change osv-flat-form): стратегия `header_account` и счёт шапки у строк.""" +from pathlib import Path + import pytest from gridbuilder import L, M, sheet_from_rows -from src.contract.result import OsvDocument +from src.contract.result import OsvDocument, OsvRow +from src.parser import get_parser from src.parser.deadline import Deadline from src.parser.osv import OsvParser from src.parser.rules import Rules, load_rules @@ -101,3 +104,79 @@ def test_outline_wins_over_header_account_and_rows_inherit_header_code(rules: Ru assert HEADER_ACCOUNT_CODE not in codes data = [item for item in document.rows if item.group_type != "TOTAL"] assert [item.account_code_raw for item in data] == ["60", "60"] + + +# --- Контрагенты расчётных счетов без названия вида (Task 5, R-9, R-10) --- + +BY_ACCOUNT_WARNING = ( + "вид субконто в шапке не назван: строки аналитики счёта 60.01 приняты за контрагентов по плану счетов — проверьте" +) + + +def _parse_synthetic(synthetic_dir: Path, name: str) -> tuple[OsvDocument, list[str], str]: + outcome = get_parser().parse((synthetic_dir / name).read_bytes(), "XLSX") + assert outcome.error is None, outcome.error + (document,) = [doc for doc in outcome.documents if isinstance(doc, OsvDocument)] + return document, [note.message for note in document.warnings], document.status + + +def _data_rows(document: OsvDocument) -> list[OsvRow]: + return [item for item in document.rows if item.group_type != "TOTAL"] + + +@pytest.mark.fixtures +def test_s43_counterparties_with_warning(synthetic_dir: Path) -> None: + document, messages, status = _parse_synthetic(synthetic_dir, "s43_subkonto_unnamed_60.xlsx") + + kids = {item.row_value: item for item in _data_rows(document) if item.row_value != "60.01"} + assert set(kids) == {"ИП Знаменских А.И.", 'ООО "ЭСТЕТ ОМСК"'} + assert {item.group_type for item in kids.values()} == {"COUNTERPARTY"} + notes = [note for note in document.warnings if note.code == "OSV_ROWS"] + assert [note.message for note in notes] == [BY_ACCOUNT_WARNING] + assert status == "done" + assert messages.count(BY_ACCOUNT_WARNING) == 1 + + +@pytest.mark.fixtures +def test_s44_materials_stay_other(synthetic_dir: Path) -> None: + document, messages, _ = _parse_synthetic(synthetic_dir, "s44_subkonto_unnamed_10.xlsx") + + kids = [item for item in _data_rows(document) if item.row_value in {"Мука пшеничная", "Сахар-песок"}] + assert [item.row_value for item in kids] == ["Мука пшеничная", "Сахар-песок"] + assert {item.group_type for item in kids} == {"OTHER"} + assert not any("вид субконто в шапке не назван" in message for message in messages) + + +@pytest.mark.fixtures +def test_s41_flat_counterparties(synthetic_dir: Path) -> None: + document, messages, _ = _parse_synthetic(synthetic_dir, "s41_flat_chart_of_accounts.xlsx") + + data = _data_rows(document) + assert {item.group_type for item in data} == {"COUNTERPARTY"} + assert {item.account_code_raw for item in data} == {"60"} + assert not any("вид субконто в шапке не назван" in message for message in messages) + + +def test_flat_list_with_broken_total_needs_check(rules: Rules) -> None: + """T4.2: плоский список под счётом шапки с испорченной суммой «Итого» — needs_check, не done.""" + rows: list[list[object]] = [ + [], + ["Оборотно-сальдовая ведомость по счету: 76.Н.1"], + ["Контрагенты"], + ["за 2025 г."], + [], + *_table_head(), + ["Август - Агро ООО", 1000, None, 500, 300, 1200, None], + ["АВИС ООО", None, 200, 100, 400, None, 500], + ["Итого", 800, None, 650, 700, 1200, 500], + ] + + document, _ = _parse(rules, rows) + + assert document.status == "needs_check" + assert document.checks is not None + assert document.checks.total_row_matches_root is False + # T4.1: без корня ACCOUNT текст говорит о сумме строк уровня 0, а не о «корневом счёте нет» + messages = [note.message for note in document.warnings] + assert any("≠ сумме 2 строк уровня 0" in message for message in messages), messages + assert not any("корневого счёта нет" in message for message in messages) diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index ae7ba75..e4ffb65 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -706,3 +706,44 @@ def test_v_valyute_row_is_currency(rules: Rules) -> 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"]) + + +# --- Расчётные счета с контрагентами без названия вида (osv_groups.yaml, #59, R-9) --- + + +@pytest.mark.parametrize( + ("code", "expected"), + [ + ("76", True), + ("76.2", True), + ("76.Н.1", True), + ("60.01", True), + ("60", True), + (" 62.01 ", True), + ("760", False), + ("600", False), + ("10.01", False), + ("71", False), + ("73.01", False), + ("", False), + (None, False), + ], +) +def test_counterparty_accounts_match_code_and_subaccounts(rules: Rules, code: str | None, expected: bool) -> None: + assert rules.groups.is_counterparty_account(code) is expected + + +def test_counterparty_accounts_list_is_data(rules: Rules) -> None: + assert rules.groups.counterparty_accounts == ["58", "60", "62", "66", "67", "75", "76", "80"] + + +@pytest.mark.parametrize("bad", ["", "abc", "60.", "6 0"]) +def test_counterparty_account_code_must_be_digits_with_dots(tmp_path: Path, bad: str) -> None: + target = _copy_rules(tmp_path) + path = target / "osv_groups.yaml" + data = yaml.safe_load(path.read_text(encoding="utf-8")) + data["counterparty_accounts"] = [bad] + path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8") + + with pytest.raises(RuntimeError, match=r"osv_groups\.yaml.*counterparty_accounts"): + load_rules(target) -- 2.47.2 From 69564ffcbde8eecebe540614a689c0dd14cf6d6a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 12:40:01 +0400 Subject: [PATCH 11/16] =?UTF-8?q?docs:=20README,=20CLAUDE.md=20=D0=B8=20ch?= =?UTF-8?q?ange=20osv-flat-form=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 2 +- README.md | 8 +++++++- api/rules/osv_headers.yaml | 3 ++- api/src/parser/classify.py | 6 ++++-- openspec/changes/osv-flat-form/design.md | 16 ++++++++++++---- openspec/changes/osv-flat-form/tasks.md | 12 ++++++------ 6 files changed, 32 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6759afb..d7b81b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote - `grid.py` + `readers/` — модель листа и чтение xlsx/xls/xlsb; единственное место, где допустимы индексы и буквы колонок (`column_letter` — только диагностика). `read_book(data, file_type) -> (листы, предупреждения)`, формат — по сигнатуре. - `rules.py` — загрузчик словарей `api/rules/*.yaml`; `detect.py` — вид листа и заголовок таблицы; `header.py` — шапка - и `group_schema`; `hierarchy.py` — каскад `outline → indent → account_pattern → sums` (+ `trivial`); `inn_rows.py` — + и `group_schema`; `hierarchy.py` — каскад `outline → indent → account_pattern → header_account → sums` (+ `trivial`); `inn_rows.py` — строки ИНН рядом с контрагентом (OQ-163); `indicators.py` — строки-показатели (колонка «Показатели», «В валюте»); `numbers.py` — `Decimal` и ИНН (контрольная сумма); `classify.py` — строки и группировки; `checks.py` — контрольные суммы §1.4; `osv.py` — оркестратор `OsvParser`; `deadline.py` — кооперативный таймаут; `errors.py` — коды ошибок; `cli.py` — разбор файла. - `get_parser(settings)` создаёт `OsvParser` один раз при старте (кэша нет); `NullParser` — только для тестов воркера. diff --git a/README.md b/README.md index 25c550f..d08787c 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,12 @@ openspec/ требования и дельта текущего ср ParseOutcome`. Структура ОСВ ищется **по содержимому**: ни букв колонок, ни номеров строк, ни имён листов в коде нет (это проверяет AST-линтер, см. ниже), синонимы лежат в `api/rules/*.yaml`. +**Плоская форма 1С по одному счёту (#59, AURORA-1661):** «по счету: X» в шапке задаёт счёт всем строкам, строка вида +субконто между названием и периодом не считается организацией, «Итого развернутое» — итоговая строка в любом порядке +относительно «Итого», плоский список без строк-счетов разбирается стратегией `header_account`, а аналитика расчётных +счетов (`counterparty_accounts` в `osv_groups.yaml`) без названия вида получает `COUNTERPARTY` с предупреждением. +Правила — `../docs/context/05-domain-rules.md` §1.1, §1.3, §1.4. + ### Модули (`api/src/parser/`) Конвейер идёт сверху вниз; все модули, кроме `grid.py` и `readers/`, оперируют индексами из содержимого и не знают @@ -117,7 +123,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо | `rules.py` | загрузчик словарей `rules/*.yaml` в frozen-модели (pydantic), `normalize_text`, `Pattern`; ошибка словаря — `RuntimeError` с файлом и ключом при старте | | `detect.py` | вид листа (`classify_sheet`: ОСВ / форма ББ-ОФР / прочее), блок заголовка таблицы (`find_table_header`: 1–4 строки, шесть колонок сумм, колонка текста, ИНН, дополнительные колонки, правая граница, повтор заголовка внутри данных) | | `header.py` | шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок `group_schema` (варианты шапки 1 и 2) | -| `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `sums`; `trivial` — меньше двух строк данных; выбранная стратегия пишется в `warnings[]` документа листа как `OSV_HIERARCHY_BY_<СТРАТЕГИЯ>` (`info`) | +| `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `header_account` → `sums`; `trivial` — меньше двух строк данных; `header_account` — плоский список под счётом шапки (код счёта в шапке, строк-счетов в теле нет; плоская форма 1С, #59); выбранная стратегия пишется в `warnings[]` документа листа как `OSV_HIERARCHY_BY_<СТРАТЕГИЯ>` (`info`) | | `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет | | `inn_rows.py` | строки ИНН рядом с контрагентом (восемь раскладок, контрольная сумма без подсказки шапки, противоречие подсказке, OQ-163); выполняется над деревом до классификации | | `indicators.py` | строки-показатели (§1.3): метка вида суммы из колонки «Показатели» или текста строки (`read_label`), привязка показателя к строке данных над ним (`levels_with_indicators`) | diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml index b4956f3..bafa8ac 100644 --- a/api/rules/osv_headers.yaml +++ b/api/rules/osv_headers.yaml @@ -51,7 +51,8 @@ total_row: total_row_exact: - '^итого$' -# Вторая итоговая строка после «Итого» («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная, первая из них +# Вторая итоговая строка («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная. Стоит сразу после основной +# «Итого» или ПЕРЕД ней (плоская форма 1С, #59); сверяется основная total_row_secondary: - '^итого\s+развернут' diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 9ef2c94..789c897 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -310,7 +310,8 @@ def collect_body_rows( """Строки данных листа (включая «Итого»), номер строки «Итого» (`None` — не найдена) и предупреждения. Строки после «Итого» — подвал (подписи, числа правее таблицы): ни в данные, ни в `extra` они не попадают (T4.5); - исключение — вторые итоговые строки («Итого развернутое») сразу за «Итого»: они остаются в таблице как TOTAL. + исключение — вторые итоговые строки («Итого развернутое»): они остаются в таблице как TOTAL и стоят как сразу + за «Итого», так и перед ней (плоская форма 1С); возвращаемый номер — первая строка итогового блока. """ warnings: list[str] = [] if header.columns.collapsed: @@ -471,7 +472,8 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta ) -> list[ClassifiedRow]: """Классификация строк по дереву `levels` и схеме группировок. - - «Итого» (`total_row`) и вторые итоговые строки после неё («Итого развернутое») — TOTAL, уровень 0 без родителя; + - «Итого» (`total_row`) и вторые итоговые строки («Итого развернутое»; стоят после неё или перед ней) — TOTAL, + уровень 0 без родителя; в `rows` остаются (# ASSUMPTION(OQ-01): хранить ли «Итого», решает ядро); - счёт (`ACCOUNT_RE`) — ACCOUNT, `account_code_raw` — код как в файле; дети наследуют код ветки; - строка-показатель (`BodyRow.indicator`: метка колонки «Показатели» не «БУ», «Вал.», «Кол.», код валюты, §1.3) — diff --git a/openspec/changes/osv-flat-form/design.md b/openspec/changes/osv-flat-form/design.md index 5ec4727..b4970bf 100644 --- a/openspec/changes/osv-flat-form/design.md +++ b/openspec/changes/osv-flat-form/design.md @@ -89,6 +89,11 @@ r11 Август - Агро ООО | … суммы … ← с | 8 | R-8 | 19.10.2026 | «Учредители» на счёте 80 не трогаем: именованная группировка остаётся OTHER (R-6 сохраняется) | Принято по рекомендации сессии при одобрении запуска владельцем; именованные группировки правило расчётных счетов не переопределяет | Учредители не станут COUNTERPARTY автоматически — гудвилл (§4 п. 2) потребует ручного сопоставления | | 9 | R-9 | 19.10.2026 | Счета 71 и 73 исключены из `counterparty_accounts`; итог: 58, 60, 62, 66, 67, 75, 76, 80 | Подотчётные и персонал — физлица, а не контрагенты (OQ-158); принято по рекомендации сессии | Контрагенты на 71/73 без названия вида останутся OTHER и не попадут в сопоставление | | 10 | R-10 | 19.10.2026 | «Организации» — синоним COUNTERPARTY в `osv_groups.yaml` (`^организаци`) | Вид субконто письма (сч. 60) — организации-контрагенты; принято по рекомендации сессии | Другой смысл вида «Организации» на иных счетах даст COUNTERPARTY; видно в golden/предупреждениях | +| 11 | R-11 | 19.10.2026 | Данные синтетики s40, s41, s42 исправлены: у «Итого развернутое» добавлены обороты, равные суммам строк (s40: 650/750, s41: 5000/4991, s42: 5700/6600) | В плане у этой строки обороты пусты, поэтому тождество строки (начало + обороты = конец) не сходилось, и файлы не могли стать `done` независимо от кода; бриф плана ошибочен | s40–s42 расходятся с исходным текстом плана. **Приёмка Task 6 показала:** в реальных файлах контура у «Итого развернутое» обороты тоже пусты (`45e06f7d`, `638ecb3d`, `7e49f8b5`, `f2e0e04e`, `270b41da`, `738bfcf5`, письмо), тождество на этой строке остаётся нарушенным, и файл уходит в `needs_check`: правка данных синтетики скрыла дефект кода, а не данных; решается отдельно (см. отчёт task-6) | +| 12 | R-12 | 19.10.2026 | Строка вида субконто не даёт группировку из ячеек-меток шапки («Субконто», «Счет»), меток валюты («Валюта БУ, НУ») и ячеек, на которые срабатывает паттерн `currency` | Ячейка между названием и периодом может быть меткой шапки, а не видом субконто; иначе в схему попали бы ложные группировки (T2.2, ревью задачи 2) | Метка, совпавшая с настоящим видом субконто, не станет группировкой; вид «Организации» уже учтён (R-10) | +| 13 | R-13 | 19.10.2026 | Предупреждение `OSV_ROWS` «вид субконто в шапке не назван…» — одно на каждый различный `account_code_raw` строк, принятых по плану счетов | План требовал «один раз на документ»; у листа с несколькими субсчетами (62.01, 62.02) заметка с точным кодом полезнее одной общей | Больше заметок на листах с несколькими субсчетами (лист `67 сч.` — четыре); на статус не влияют (`info`) | +| 14 | R-14 | 19.10.2026 | `Expect.data_type` в `test_synthetic_files.py` стал `str \| None`: у s40–s44 ожидается `None` | В шапке нет БУ/НУ; умолчание BU — зона трека A (`data_type`) | При слиянии трека A ожидания s40–s44 придётся поправить на `BU` | +| 15 | R-15 | 19.10.2026 | Предупреждение `OSV_TOTAL` при несходимости «Итого» без корня ACCOUNT, но со строками уровня 0, говорит «≠ сумме N строк уровня 0» (T4.1) | Прежний текст «корневого счёта нет» вводил в заблуждение для плоского списка | Текст в `metrics.warnings` изменился; снимков с прежним текстом нет | ## Risks / Trade-offs @@ -100,8 +105,11 @@ r11 Август - Агро ООО | … суммы … ← с ## Open Questions -Закрыты по рекомендации сессии при одобрении запуска 19.10.2026 (R-8…R-10); владелец может пересмотреть: +Решения R-8…R-10 приняты по рекомендации сессии при одобрении запуска 19.10.2026; вопросы **открыты** (docs: `07-open-questions.md`, +OQ-169…OQ-172), в коде действует допущение: -- «Учредители» на 80 (O-29, `02c5df51`) — остаётся OTHER; гудвилл §4 п. 2 — вопрос владельцу. -- Счета 71 и 73 (подотчётные, персонал) — исключены из списка расчётных. -- «Организации» — синоним COUNTERPARTY. +- OQ-169 — «Учредители» на 80 (O-29, `02c5df51`) остаются OTHER (R-8); гудвилл §4 п. 2 — вопрос владельцу. +- OQ-170 — счета 71 и 73 (подотчётные, персонал) исключены из списка расчётных (R-9). +- OQ-171 — «Организации» — синоним COUNTERPARTY (R-10). +- OQ-172 — безымянная аналитика первого уровня на счёте 63 (51 строка в golden) не входит в список расчётных счетов: + нужен ли 63. diff --git a/openspec/changes/osv-flat-form/tasks.md b/openspec/changes/osv-flat-form/tasks.md index 56fc4b0..e63f592 100644 --- a/openspec/changes/osv-flat-form/tasks.md +++ b/openspec/changes/osv-flat-form/tasks.md @@ -2,24 +2,24 @@ ## 1. Синтетика и снимки (tabular-parser, fixtures) -- [ ] 1.1 Синтетика `s40`–`s44` в `api/tests/synthetic/generate.py`, файлы в `fixtures/osv/synthetic/`, снимки текущего поведения +- [x] 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`) +- [x] 2.1 Код счёта «по счету: X», строка вида субконто, «План счетов <организация>» (`header.py`, `rules.py`, `osv_headers.yaml`) ## 3. Итоговый блок (tabular-parser) -- [ ] 3.1 «Итого развернутое» перед «Итого» — TOTAL; сверка по основной «Итого» (`classify.py`, `checks.py`, `osv.py`) +- [x] 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`) +- [x] 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` +- [x] 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` +- [x] 6.1 `05-domain-rules.md` §1.1, §1.3, §1.4; `07-open-questions.md` (OQ-169…OQ-172); README и CLAUDE.md парсера; прогон реальных файлов контура (отчёт task-6; отклонения — «Итого развернутое» без оборотов); `make check`, `make check-full` -- 2.47.2 From 835083ca4dff480d1d670a7c9e549cc7a0855dd0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 12:47:31 +0400 Subject: [PATCH 12/16] =?UTF-8?q?fix(osv):=20=D0=B2=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B8=D1=82=D0=BE=D0=B3=D0=BE=D0=B2=D1=8B=D0=B5?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20(=C2=AB=D0=98=D1=82?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D1=80=D0=B0=D0=B7=D0=B2=D0=B5=D1=80=D0=BD?= =?UTF-8?q?=D1=83=D1=82=D0=BE=D0=B5=C2=BB)=20=D0=B2=D0=BD=D0=B5=20=D1=82?= =?UTF-8?q?=D0=BE=D0=B6=D0=B4=D0=B5=D1=81=D1=82=D0=B2=D0=B0=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D0=B8,=20R-11=20=D0=BE=D1=82=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=91=D0=BD=20R-16=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- api/src/parser/checks.py | 21 +++++++++--- api/tests/synthetic/generate.py | 6 ++-- api/tests/unit/parser/test_checks.py | 33 +++++++++++++++++++ api/tests/unit/parser/test_osv_flat_form.py | 4 +-- openspec/changes/osv-flat-form/design.md | 3 +- .../osv-flat-form/specs/osv-parsing/spec.md | 8 +++++ 7 files changed, 65 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index d08787c..be87fd5 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо **«Итого».** Основная итоговая строка — первая TOTAL, не являющаяся показателем; она сверяется с первым корнем ACCOUNT либо, если корней уровня 0 несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03), с суммой строк уровня 0 (OQ-106, OQ-133; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя выше). Вторая итоговая -строка сразу за «Итого» («Итого развернутое», `total_row_secondary`) — тоже `TOTAL`, в сверку не входит. Строка сумм +строка сразу за «Итого» («Итого развернутое», `total_row_secondary`) — тоже `TOTAL`, в сверку «Итого» и в тождество строки не входит (в 1С у неё только сальдо, без оборотов). Строка сумм без подписи в конце таблицы (1С без «Итого») принимается за итоговую с предупреждением. **ИНН контрагента** (OQ-100, OQ-130): `counterparty_inn` берётся из колонки ИНН (в том числе склеенного «ИНН/КПП»), затем из diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 11e0bf9..31260be 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -75,14 +75,24 @@ def _no_analytic_balance(item: ClassifiedRow, exempt: OsvCheckRules | None) -> b return exempt is not None and exempt.has_no_analytic_balance(item.account_code_raw) -def balance_identity(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> list[int]: +def _is_secondary_total(item: ClassifiedRow, secondary: Sequence[Pattern]) -> bool: + return item.group_type == "TOTAL" and any(p.search(item.body.text) for p in secondary) + + +def balance_identity( + rows: Sequence[ClassifiedRow], + exempt: OsvCheckRules | None = None, + secondary_totals: Sequence[Pattern] = (), +) -> list[int]: """Номера строк, где начало + обороты ≠ конец (±0.01, пусто = 0); проверяются все строки, включая «Итого». - Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются. + Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются. Вторые итоговые строки + (`secondary_totals`, «Итого развернутое») не проверяются: в 1С они несут только развёрнутые сальдо, без оборотов + (#59, R-16); основная «Итого» проверяется. """ violations: list[int] = [] for item in rows: - if _no_analytic_balance(item, exempt): + if _no_analytic_balance(item, exempt) or _is_secondary_total(item, secondary_totals): continue sums = _values(item) expected = _net(sums[_OPENING]) + _net(sums[_TURNOVER]) @@ -240,9 +250,10 @@ def run_checks( """Три проверки §1.4 и заметки с кодами `OSV_*` и номерами строк (пустой список — всё сошлось). `exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью. - `secondary_totals` — вторые итоговые строки («Итого развернутое»): «Итого» для сверки — не они (#59). + `secondary_totals` — вторые итоговые строки («Итого развернутое»): «Итого» для сверки — не они, и тождество + строки на них не проверяется (#59, R-16). """ - identity = balance_identity(rows, exempt) + identity = balance_identity(rows, exempt, secondary_totals) parents = parent_sums(rows, exempt) matches = total_matches_root(rows, secondary_totals) warnings: list[OsvNote] = [] diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 2771051..7893503 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -902,7 +902,7 @@ def _s40() -> list[SheetSpec]: ("Август - Агро ООО", ("1000.00", None, "500.00", "300.00", "1200.00", None), 0), ("АВИС ООО", (None, "200.00", "100.00", "400.00", None, "500.00"), 0), ("Агроаспект ООО", (None, None, "50.00", "50.00", None, None), 0), - ("Итого развернутое", ("1000.00", "200.00", "650.00", "750.00", "1200.00", "500.00"), 0), + ("Итого развернутое", ("1000.00", "200.00", None, None, "1200.00", "500.00"), 0), ("Итого", ("800.00", None, "650.00", "750.00", "700.00", None), 0), ] return [_flat_sheet(texts, 9, rows)] @@ -918,7 +918,7 @@ def _s41() -> list[SheetSpec]: rows: list[FlatRow] = [ ("Автодор-Платные Дороги ООО", ("820.21", None, "5000.00", "4991.00", "829.21", None), 0), ("АГРОПРОФИ ООО", (None, "2763.79", None, None, None, "2763.79"), 0), - ("Итого развернутое", ("820.21", "2763.79", "5000.00", "4991.00", "829.21", "2763.79"), 0), + ("Итого развернутое", ("820.21", "2763.79", None, None, "829.21", "2763.79"), 0), ("Итого", (None, "1943.58", "5000.00", "4991.00", None, "1934.58"), 0), ] return [_flat_sheet(texts, 5, rows)] @@ -938,7 +938,7 @@ def _s42() -> list[SheetSpec]: ("76.5", (None, "300.00", "700.00", "600.00", None, "200.00"), 0), ("76.Н", (None, None, "5000.00", "5000.00", None, None), 0), ("76.Н.1", (None, None, "5000.00", "5000.00", None, None), 0), - ("Итого развернутое", ("1000.00", "300.00", "5700.00", "6600.00", None, "200.00"), 0), + ("Итого развернутое", ("1000.00", "300.00", None, None, None, "200.00"), 0), ("Итого", ("700.00", None, "5700.00", "6600.00", None, "200.00"), 0), ] return [_flat_sheet(texts, 8, rows)] diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index e5d1d72..9464495 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -257,6 +257,39 @@ def test_total_check_uses_main_total_when_secondary_comes_first() -> None: assert not any(note.code == "OSV_TOTAL" for note in notes) +SECONDARY = [Pattern.compile(r"^итого\s+развернут")] +# «Итого развернутое» в 1С несёт только развёрнутые сальдо, без оборотов: тождество строки на ней не выполняется +SECONDARY_ROW: Sums = ("100", "10", None, None, "120", "5") + + +def _secondary(number: int, sums: Sums) -> ClassifiedRow: + return row(number, sums, group_type="TOTAL", text="Итого развернутое") + + +def test_balance_identity_skips_secondary_total_without_turnovers() -> None: + rows = [*tree(), _secondary(11, SECONDARY_ROW)] + + assert balance_identity(rows, secondary_totals=SECONDARY) == [] + checks, notes = run_checks(rows, secondary_totals=SECONDARY) + assert checks.balance_identity_violations == [] + assert not any(note.code == "OSV_BALANCE_IDENTITY" for note in notes) + + +def test_balance_identity_still_checks_main_total_with_secondary_present() -> None: + broken: Sums = ("100", None, "50", "30", "121", None) + rows = [account(7, ROOT), total(8, broken), _secondary(9, SECONDARY_ROW)] + + assert balance_identity(rows, secondary_totals=SECONDARY) == [8] + + +def test_balance_identity_without_secondary_patterns_checks_every_total() -> None: + rows = [*tree(), _secondary(11, SECONDARY_ROW)] + + assert balance_identity(rows) == [11] + checks, _ = run_checks(rows) + assert checks.balance_identity_violations == [11] + + def test_total_matches_first_root_account() -> None: rows = tree() diff --git a/api/tests/unit/parser/test_osv_flat_form.py b/api/tests/unit/parser/test_osv_flat_form.py index f6e049b..aac74c5 100644 --- a/api/tests/unit/parser/test_osv_flat_form.py +++ b/api/tests/unit/parser/test_osv_flat_form.py @@ -47,7 +47,7 @@ def test_flat_list_s40(rules: Rules) -> None: ["Август - Агро ООО", 1000, None, 500, 300, 1200, None], ["АВИС ООО", None, 200, 100, 400, None, 500], ["Агроаспект ООО", None, None, 50, 50, None, None], - ["Итого развернутое", 1000, 200, 650, 750, 1200, 500], + ["Итого развернутое", 1000, 200, None, None, 1200, 500], ["Итого", 800, None, 650, 750, 700, None], ] @@ -73,7 +73,7 @@ def test_flat_list_s41(rules: Rules) -> None: *_table_head(), ["Автодор-Платные Дороги ООО", 820, None, 5000, 4991, 829, None], ["АГРОПРОФИ ООО", None, 2764, None, None, None, 2764], - ["Итого развернутое", 820, 2764, 5000, 4991, 829, 2764], + ["Итого развернутое", 820, 2764, None, None, 829, 2764], ["Итого", None, 1944, 5000, 4991, None, 1935], ] diff --git a/openspec/changes/osv-flat-form/design.md b/openspec/changes/osv-flat-form/design.md index b4970bf..1f2f188 100644 --- a/openspec/changes/osv-flat-form/design.md +++ b/openspec/changes/osv-flat-form/design.md @@ -89,11 +89,12 @@ r11 Август - Агро ООО | … суммы … ← с | 8 | R-8 | 19.10.2026 | «Учредители» на счёте 80 не трогаем: именованная группировка остаётся OTHER (R-6 сохраняется) | Принято по рекомендации сессии при одобрении запуска владельцем; именованные группировки правило расчётных счетов не переопределяет | Учредители не станут COUNTERPARTY автоматически — гудвилл (§4 п. 2) потребует ручного сопоставления | | 9 | R-9 | 19.10.2026 | Счета 71 и 73 исключены из `counterparty_accounts`; итог: 58, 60, 62, 66, 67, 75, 76, 80 | Подотчётные и персонал — физлица, а не контрагенты (OQ-158); принято по рекомендации сессии | Контрагенты на 71/73 без названия вида останутся OTHER и не попадут в сопоставление | | 10 | R-10 | 19.10.2026 | «Организации» — синоним COUNTERPARTY в `osv_groups.yaml` (`^организаци`) | Вид субконто письма (сч. 60) — организации-контрагенты; принято по рекомендации сессии | Другой смысл вида «Организации» на иных счетах даст COUNTERPARTY; видно в golden/предупреждениях | -| 11 | R-11 | 19.10.2026 | Данные синтетики s40, s41, s42 исправлены: у «Итого развернутое» добавлены обороты, равные суммам строк (s40: 650/750, s41: 5000/4991, s42: 5700/6600) | В плане у этой строки обороты пусты, поэтому тождество строки (начало + обороты = конец) не сходилось, и файлы не могли стать `done` независимо от кода; бриф плана ошибочен | s40–s42 расходятся с исходным текстом плана. **Приёмка Task 6 показала:** в реальных файлах контура у «Итого развернутое» обороты тоже пусты (`45e06f7d`, `638ecb3d`, `7e49f8b5`, `f2e0e04e`, `270b41da`, `738bfcf5`, письмо), тождество на этой строке остаётся нарушенным, и файл уходит в `needs_check`: правка данных синтетики скрыла дефект кода, а не данных; решается отдельно (см. отчёт task-6) | +| 11 | R-11 (отменён R-16) | 19.10.2026 | Данные синтетики s40, s41, s42 исправлены: у «Итого развернутое» добавлены обороты, равные суммам строк (s40: 650/750, s41: 5000/4991, s42: 5700/6600) | В плане у этой строки обороты пусты, поэтому тождество строки (начало + обороты = конец) не сходилось, и файлы не могли стать `done` независимо от кода; бриф плана ошибочен | s40–s42 расходятся с исходным текстом плана. **Приёмка Task 6 показала:** в реальных файлах контура у «Итого развернутое» обороты тоже пусты (`45e06f7d`, `638ecb3d`, `7e49f8b5`, `f2e0e04e`, `270b41da`, `738bfcf5`, письмо), тождество на этой строке остаётся нарушенным, и файл уходит в `needs_check`: правка данных синтетики скрыла дефект кода, а не данных; решается отдельно (см. отчёт task-6). **Отменён рулингом R-16:** данные s40–s42 возвращены к исходному тексту плана | | 12 | R-12 | 19.10.2026 | Строка вида субконто не даёт группировку из ячеек-меток шапки («Субконто», «Счет»), меток валюты («Валюта БУ, НУ») и ячеек, на которые срабатывает паттерн `currency` | Ячейка между названием и периодом может быть меткой шапки, а не видом субконто; иначе в схему попали бы ложные группировки (T2.2, ревью задачи 2) | Метка, совпавшая с настоящим видом субконто, не станет группировкой; вид «Организации» уже учтён (R-10) | | 13 | R-13 | 19.10.2026 | Предупреждение `OSV_ROWS` «вид субконто в шапке не назван…» — одно на каждый различный `account_code_raw` строк, принятых по плану счетов | План требовал «один раз на документ»; у листа с несколькими субсчетами (62.01, 62.02) заметка с точным кодом полезнее одной общей | Больше заметок на листах с несколькими субсчетами (лист `67 сч.` — четыре); на статус не влияют (`info`) | | 14 | R-14 | 19.10.2026 | `Expect.data_type` в `test_synthetic_files.py` стал `str \| None`: у s40–s44 ожидается `None` | В шапке нет БУ/НУ; умолчание BU — зона трека A (`data_type`) | При слиянии трека A ожидания s40–s44 придётся поправить на `BU` | | 15 | R-15 | 19.10.2026 | Предупреждение `OSV_TOTAL` при несходимости «Итого» без корня ACCOUNT, но со строками уровня 0, говорит «≠ сумме N строк уровня 0» (T4.1) | Прежний текст «корневого счёта нет» вводил в заблуждение для плоского списка | Текст в `metrics.warnings` изменился; снимков с прежним текстом нет | +| 16 | R-16 | 19.10.2026 | Вторые итоговые строки (`total_row_secondary`, «Итого развернутое») исключены из проверки тождества строки; сверка по основной «Итого» сохранена; R-11 отменён | В 1С «Итого развернутое» содержит только развёрнутые сальдо, без оборотов (подтверждено 7 реальными файлами контура: `638ecb3d`, `45e06f7d`, `f2e0e04e`, `7e49f8b5`, `270b41da`, `738bfcf5`, письмо); решение владельца: «Итого развернутое» — итоговая строка в любом порядке | Тождество на вторых итогах не проверяется: ошибка в сальдо этой строки не будет поймана; основная «Итого» по-прежнему проверяется тождеством и сверкой с корнем/суммой уровня 0 | ## Risks / Trade-offs diff --git a/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md b/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md index 9a6616e..641f97f 100644 --- a/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md +++ b/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md @@ -161,6 +161,9 @@ balance_identity_violations, parent_sum_violations}`. Результат про `OSV_XLSB_NO_OUTLINE`); строк вида `sheet=<имя> <текст>` в `metrics.warnings` больше нет. Парсер MUST NOT подгонять структуру под сходимость. +Вторые итоговые строки (`total_row_secondary`, «Итого развернутое») SHALL NOT участвовать в проверке тождества строки: +в 1С они несут только развёрнутые сальдо, без оборотов (R-16); основная «Итого» проверяется тождеством как прежде. + Исключение — **счета без сальдо по аналитике** (решение владельца 07.10.2026, OQ-132): у 91 и 99 строки аналитики несут только обороты, сальдо закрыто на субсчёт закрытия (91.09). Список счетов — данные `rules/osv_checks.yaml` (`accounts_without_analytic_balance`, начальный состав `91`, `99`; 90 не входит). Строка относится к такому счёту, если @@ -228,6 +231,11 @@ balance_identity_violations, parent_sum_violations}`. Результат про - **THEN** обе итоговые строки — TOTAL уровня 0 без родителя, «Итого развернутое» не ребёнок «76.Н.1»; `total_row_matches_root = true` (сверка по «Итого»), нарушений нет, `status = done` +#### Scenario: «Итого развернутое» без оборотов +- **WHEN** у «Итого развернутое» заполнены только сальдо (обороты пусты), а у основной «Итого» тождество строки выполняется +- **THEN** `balance_identity_violations` не содержит «Итого развернутое», `status = done`; при нарушении тождества у основной + «Итого» её `row_number` в списке остаётся + #### Scenario: «Итого» плоского списка - **WHEN** разбирается синтетическая ОСВ `s40` («Итого» = сумма контрагентов, сальдо «Итого» свёрнуто) - **THEN** `total_row_matches_root = true`, `status = done`; если сумма одного контрагента испорчена — `false`, -- 2.47.2 From a6cd375306c959815cc02d7b1d9e5dd61056fcee Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 13:14:32 +0400 Subject: [PATCH 13/16] =?UTF-8?q?fix(osv):=20=C2=AB=D0=9E=D1=80=D0=B3?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=C2=BB=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B2=D0=B8=D0=B4=20=D1=81=D1=83=D0=B1=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D0=BE,=20=D0=B2=D0=B0=D0=BB=D1=8E=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D1=82,=20=D1=82=D0=BE=D0=B6=D0=B4=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D0=BE=20=D0=B2=D1=82=D0=BE=D1=80=D1=8B=D1=85=20=D0=B8?= =?UTF-8?q?=D1=82=D0=BE=D0=B3=D0=BE=D0=B2=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-1 (R-17): синоним COUNTERPARTY только «Организации» (^организации\b). I-2 (R-18): правило расчётных счетов не применяется к строкам, известным словарю как не-контрагенты (валюта, «В валюте»). I-4 (R-19): тождество у второй итоговой строки пропускается только без оборотов и при наличии другой «Итого». M-1, M-5, M-6, M-7 (вымышленное ФИО ИП в синтетике), M-9. Co-Authored-By: Claude Sonnet 5.5 --- api/rules/osv_groups.yaml | 8 +++--- api/src/parser/checks.py | 25 +++++++++++------ api/src/parser/classify.py | 7 +++-- api/src/parser/osv.py | 2 +- api/src/parser/rules.py | 2 +- api/tests/synthetic/generate.py | 2 +- api/tests/synthetic/test_generate.py | 2 +- api/tests/unit/parser/test_checks.py | 28 +++++++++++++++++-- api/tests/unit/parser/test_classify.py | 30 ++++++++++++++++----- api/tests/unit/parser/test_header.py | 5 ++++ api/tests/unit/parser/test_hierarchy.py | 12 +++++++++ api/tests/unit/parser/test_osv_flat_form.py | 6 ++--- api/tests/unit/parser/test_rules.py | 14 +++++++++- 13 files changed, 114 insertions(+), 29 deletions(-) diff --git a/api/rules/osv_groups.yaml b/api/rules/osv_groups.yaml index 8f63bd2..9509235 100644 --- a/api/rules/osv_groups.yaml +++ b/api/rules/osv_groups.yaml @@ -4,8 +4,9 @@ mapping: - ['^(счета?|субконто):?$', ACCOUNT] - ['^подразделени', DEPARTMENT] - ['^контрагент', COUNTERPARTY] - # вид субконто «Организации» (плоская форма 1С, счёт 60) — контрагенты-организации (R-10) - - ['^организаци', COUNTERPARTY] + # вид субконто «Организации» (плоская форма 1С, счёт 60) — контрагенты-организации (R-10, R-17); единственное число + # «Организация» — собственная организация учёта (измерение шапки), контрагентом не является + - ['^организации\b', COUNTERPARTY] - ['^договор', CONTRACT] # Известные группировки OTHER перечислены явно: по ним детектор узнаёт строку-метку в блоке заголовка таблицы # (строка «Основные средства» под «Счет / Подразделение» — часть заголовка, а не первая строка данных) @@ -13,7 +14,8 @@ mapping: - ['^нематериальн\w*\s+актив', OTHER] - ['^номенклатур', OTHER] - ['^стать(и|я)\b', OTHER] - - ['^валют', OTHER] + # строки валюты («Валюта USD», «В валюте») — не контрагенты и по плану счетов (R-18) + - ['^(в\s+)?валют', OTHER] # Расчётные счета, у которых аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван # (ОСВ 1С по счёту 60 без строки «Контрагенты»; AURORA-1661, OQ-158). Счёт сопоставляется по коду и субсчетам diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 31260be..493a149 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -79,6 +79,11 @@ def _is_secondary_total(item: ClassifiedRow, secondary: Sequence[Pattern]) -> bo return item.group_type == "TOTAL" and any(p.search(item.body.text) for p in secondary) +def _has_turnovers(item: ClassifiedRow) -> bool: + """В строке есть хотя бы одно значение оборотов за период (дебет или кредит; пусто = нет, 0 — значение).""" + return any(value is not None for value in item.body.sums[_TURNOVER]) + + def balance_identity( rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None, @@ -86,13 +91,17 @@ def balance_identity( ) -> list[int]: """Номера строк, где начало + обороты ≠ конец (±0.01, пусто = 0); проверяются все строки, включая «Итого». - Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются. Вторые итоговые строки - (`secondary_totals`, «Итого развернутое») не проверяются: в 1С они несут только развёрнутые сальдо, без оборотов - (#59, R-16); основная «Итого» проверяется. + Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются. Вторая итоговая строка + (`secondary_totals`, «Итого развернутое») пропускается, только если у неё нет оборотов (обе колонки пусты) и она не + единственная TOTAL листа: «Итого развернутое» перед «Итого» несёт одни развёрнутые сальдо (#59, R-19). С оборотами + или единственная — проверяется как обычная строка. """ violations: list[int] = [] + totals = sum(1 for item in rows if item.group_type == "TOTAL") for item in rows: - if _no_analytic_balance(item, exempt) or _is_secondary_total(item, secondary_totals): + if _no_analytic_balance(item, exempt): + continue + if _is_secondary_total(item, secondary_totals) and totals > 1 and not _has_turnovers(item): continue sums = _values(item) expected = _net(sums[_OPENING]) + _net(sums[_TURNOVER]) @@ -196,8 +205,8 @@ def total_matches_root(rows: Sequence[ClassifiedRow], secondary_totals: Sequence Нет «Итого» — `False` (R-20); нет корня ACCOUNT, но есть строки уровня 0 — «Итого» сверяется с их суммой (плоский список под счётом шапки, #59, R-5); нет ни того ни другого — `True`, только если все суммы «Итого» пусты или - нулевые (R-13). Сумма - уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов, «Итого» равно их сумме. + нулевые (R-13). Сумма уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов, + «Итого» равно их сумме. """ total = _total(rows, secondary_totals) if total is None: @@ -250,8 +259,8 @@ def run_checks( """Три проверки §1.4 и заметки с кодами `OSV_*` и номерами строк (пустой список — всё сошлось). `exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью. - `secondary_totals` — вторые итоговые строки («Итого развернутое»): «Итого» для сверки — не они, и тождество - строки на них не проверяется (#59, R-16). + `secondary_totals` — вторые итоговые строки («Итого развернутое»): «Итого» для сверки — не они; тождество на них + пропускается только без оборотов, когда есть другая «Итого» (#59, R-19). """ identity = balance_identity(rows, exempt, secondary_totals) parents = parent_sums(rows, exempt) diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 789c897..4055ab5 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -83,7 +83,7 @@ class ClassifiedRow: data_type: DataType | None extra: dict[str, str | None] | None indicator: bool = False # строка-показатель (§1.3): вне проверок сумм, `data_type` — вид по метке - # COUNTERPARTY получен по плану счетов (вид субконто в шапке не назван, #59, R-9), а не по схеме группировок + # COUNTERPARTY получен по плану счетов (вид субконто в шапке не назван, #59, R-6), а не по схеме группировок counterparty_by_account: bool = False @@ -427,12 +427,15 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple position = inherited.depth + 1 group_type, name = _schema_group(context.schema, position) branch = _Branch(code, position) - # Схема из одного ACCOUNT, первый уровень под расчётным счётом без названия вида — контрагент по плану счетов (R-9) + # Схема из одного ACCOUNT, первый уровень под расчётным счётом без названия вида — контрагент по плану счетов + # (решение владельца №3, R-6); строки, которые словарь групп явно знает как не-контрагентов (валюта), — нет (R-18) + known = context.rules.groups.known(item.text) by_account = ( group_type == "OTHER" and name is None and position == 1 and position >= len(context.schema) + and (known is None or known == "COUNTERPARTY") and context.rules.groups.is_counterparty_account(code) ) if by_account: diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index f68c5af..5bceb0e 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -113,7 +113,7 @@ def _with_table_account(doc: DocHeader, rows: Sequence[ClassifiedRow]) -> DocHea def _by_account_warnings(rows: Sequence[ClassifiedRow]) -> list[str]: - """По одному предупреждению на счёт, чьи строки признаны контрагентами по плану счетов (R-9), в порядке листа.""" + """По одному предупреждению на счёт, чьи строки признаны контрагентами по плану счетов (R-6), в порядке листа.""" codes = dict.fromkeys(item.account_code_raw or "" for item in rows if item.counterparty_by_account) return [ f"вид субконто в шапке не назван: строки аналитики счёта {code} приняты за контрагентов по плану счетов — " diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index cbeacef..037cd30 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -201,7 +201,7 @@ def _is_under_account(account_code: str | None, roots: Iterable[str]) -> bool: class OsvGroupRules(_Model): mapping: list[tuple[Pattern, GroupType]] default: GroupType = "OTHER" - # Расчётные счета: аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван (#59, R-9) + # Расчётные счета: аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван (#59, R-6) counterparty_accounts: list[str] = [] @model_validator(mode="after") diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 7893503..ab638d8 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -964,7 +964,7 @@ def _s43_s44(account: str, root: str, kids: tuple[str, str]) -> list[SheetSpec]: def _s43() -> list[SheetSpec]: """Плоская форма (#59): счёт 60, контрагенты без вида аналитики в шапке (подстроки с отступом).""" - return _s43_s44("60", "60.01", ("ИП Знаменских А.И.", 'ООО "ЭСТЕТ ОМСК"')) + return _s43_s44("60", "60.01", ("ИП Иванов А.А.", 'ООО "ЭСТЕТ ОМСК"')) def _s44() -> list[SheetSpec]: diff --git a/api/tests/synthetic/test_generate.py b/api/tests/synthetic/test_generate.py index c86e98d..eddc7d5 100644 --- a/api/tests/synthetic/test_generate.py +++ b/api/tests/synthetic/test_generate.py @@ -26,7 +26,7 @@ def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None: assert left.read_bytes() == right.read_bytes(), left.name -def test_generated_set_is_s01_to_s19_with_one_xlsb(tmp_path: Path) -> None: +def test_generated_set_is_s01_to_s32_and_s40_to_s44_with_one_xlsb(tmp_path: Path) -> None: names = [path.name for path in generate_all(tmp_path)] assert names == list(FILE_NAMES) diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index 9464495..54d1ef3 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -258,7 +258,7 @@ def test_total_check_uses_main_total_when_secondary_comes_first() -> None: SECONDARY = [Pattern.compile(r"^итого\s+развернут")] -# «Итого развернутое» в 1С несёт только развёрнутые сальдо, без оборотов: тождество строки на ней не выполняется +# «Итого развернутое» перед «Итого» несёт только развёрнутые сальдо, без оборотов: тождество на ней не выполняется SECONDARY_ROW: Sums = ("100", "10", None, None, "120", "5") @@ -266,7 +266,7 @@ def _secondary(number: int, sums: Sums) -> ClassifiedRow: return row(number, sums, group_type="TOTAL", text="Итого развернутое") -def test_balance_identity_skips_secondary_total_without_turnovers() -> None: +def test_balance_identity_skips_secondary_total_without_turnovers_when_not_the_only_total() -> None: rows = [*tree(), _secondary(11, SECONDARY_ROW)] assert balance_identity(rows, secondary_totals=SECONDARY) == [] @@ -275,6 +275,30 @@ def test_balance_identity_skips_secondary_total_without_turnovers() -> None: assert not any(note.code == "OSV_BALANCE_IDENTITY" for note in notes) +def test_balance_identity_checks_secondary_total_with_turnovers() -> None: + """R-19: «Итого развернутое» после «Итого» несёт обороты — тождество проверяется, сломанное даёт нарушение.""" + broken: Sums = ("100", None, "50", "30", "121", None) + rows = [*tree(), _secondary(11, broken)] + + assert balance_identity(rows, secondary_totals=SECONDARY) == [11] + checks, notes = run_checks(rows, secondary_totals=SECONDARY) + assert checks.balance_identity_violations == [11] + assert any(note.code == "OSV_BALANCE_IDENTITY" for note in notes) + + +def test_balance_identity_accepts_secondary_total_with_turnovers_that_balance() -> None: + rows = [*tree(), _secondary(11, ROOT)] + + assert balance_identity(rows, secondary_totals=SECONDARY) == [] + + +def test_balance_identity_checks_only_total_even_if_secondary_without_turnovers() -> None: + """Единственная итоговая строка проверяется, даже если она вторая и без оборотов.""" + rows = [account(7, ROOT), _secondary(8, SECONDARY_ROW)] + + assert balance_identity(rows, secondary_totals=SECONDARY) == [8] + + def test_balance_identity_still_checks_main_total_with_secondary_present() -> None: broken: Sums = ("100", None, "50", "30", "121", None) rows = [account(7, ROOT), total(8, broken), _secondary(9, SECONDARY_ROW)] diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index 2eeeee1..3a5dabd 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -1134,7 +1134,7 @@ def test_secondary_total_before_main_total_is_in_total_block(rules: Rules) -> No def test_only_secondary_total_is_the_total(rules: Rules) -> None: - """Review Focus 3: без обычной «Итого» «Итого развернутое» — единственная итоговая строка.""" + """Без обычной «Итого» «Итого развернутое» — единственная итоговая строка.""" sheet = osv(r(0, "60", S100), r(None, "Итого развернутое", S100)) parsed = parse(sheet, rules) @@ -1144,7 +1144,7 @@ def test_only_secondary_total_is_the_total(rules: Rules) -> None: assert secondary.group_type == "TOTAL" -# --- Контрагенты расчётных счетов без названия вида (#59, R-9) --- +# --- Контрагенты расчётных счетов без названия вида (#59, R-6) --- def _subkonto_sheet(*rows: list[object], label: str | None = None) -> Sheet: @@ -1157,23 +1157,23 @@ def _subkonto_sheet(*rows: list[object], label: str | None = None) -> Sheet: def test_unnamed_first_level_on_settlement_account_is_counterparty(rules: Rules) -> None: sheet = _subkonto_sheet( [L(0), "60.01", *S100], - [L(1), "ИП Знаменских А.И.", *S100], + [L(1), "ИП Иванов А.А.", *S100], [L(0), "Итого", *S100], ) parsed = parse(sheet, rules) - row = parsed.one("ИП Знаменских А.И.") + row = parsed.one("ИП Иванов А.А.") assert row.group_type == "COUNTERPARTY" assert row.group_name is None - assert row.counterparty_name == "ИП Знаменских А.И." + assert row.counterparty_name == "ИП Иванов А.А." assert row.counterparty_by_account is True def test_unnamed_second_level_stays_other(rules: Rules) -> None: sheet = _subkonto_sheet( [L(0), "60.01", *S100], - [L(1), "ИП Знаменских А.И.", *S100], + [L(1), "ИП Иванов А.А.", *S100], [L(2), "Договор № 1", *S100], [L(0), "Итого", *S100], ) @@ -1213,3 +1213,21 @@ def test_non_settlement_account_stays_other(rules: Rules) -> None: row = parsed.one("Мука пшеничная") assert row.group_type == "OTHER" assert row.counterparty_by_account is False + + +@pytest.mark.parametrize("text", ["Валюта", "Валюта USD", "Валюта CNY", "Валюта руб", "В валюте"]) +def test_currency_row_under_settlement_account_is_not_counterparty(rules: Rules, text: str) -> None: + """R-18: строка валюты под 76.55 — не контрагент по плану счетов, а контрагент рядом остаётся контрагентом.""" + sheet = _subkonto_sheet( + [L(0), "76.55", *S100], + [L(1), "ООО Ромашка", *S100], + [L(1), text, *S100], + [L(0), "Итого", *S100], + ) + + parsed = parse(sheet, rules) + + row = parsed.one(text) + assert row.group_type == "OTHER" + assert row.counterparty_by_account is False + assert parsed.one("ООО Ромашка").group_type == "COUNTERPARTY" diff --git a/api/tests/unit/parser/test_header.py b/api/tests/unit/parser/test_header.py index 61a2fc9..ac0a226 100644 --- a/api/tests/unit/parser/test_header.py +++ b/api/tests/unit/parser/test_header.py @@ -645,6 +645,11 @@ def test_organizations_kind_row_is_counterparty(rules: Rules) -> None: assert _flat_schema(rules, "Организации") == [("ACCOUNT", "Субконто"), ("COUNTERPARTY", "Организации")] +def test_single_organization_kind_row_is_not_counterparty(rules: Rules) -> None: + """R-17: «Организация» — собственная организация учёта (варианты 1/2 шапки), схема остаётся [ACCOUNT].""" + assert _flat_schema(rules, "Организация") == [("ACCOUNT", "Субконто")] + + def test_currency_row_between_title_and_period_is_not_added_to_schema(rules: Rules) -> None: assert _flat_schema(rules, "Валюта БУ, НУ: RUB") == [("ACCOUNT", "Субконто")] diff --git a/api/tests/unit/parser/test_hierarchy.py b/api/tests/unit/parser/test_hierarchy.py index c963974..e4ce1e9 100644 --- a/api/tests/unit/parser/test_hierarchy.py +++ b/api/tests/unit/parser/test_hierarchy.py @@ -486,3 +486,15 @@ def test_header_account_not_used_with_account_rows() -> None: assert levels.strategy == "account_pattern" assert strategy_header_account(rows) is None + + +def test_header_account_goes_before_sums_for_adjacent_rows_with_equal_sums() -> None: + """Две соседние строки с равными суммами при счёте шапки — плоский список, а не вложенность по суммам.""" + same = money("100.00", None, "50.00", None, "150.00", None) + rows = numbered(("ООО Ромашка", {"sums": same}), ("ООО Лютик", {"sums": same})) + + levels = resolve_levels(rows, None, never_expires(), header_account=True) + + assert levels.strategy == "header_account" + assert levels_list(rows, levels.by_row) == [0, 0] + assert set(levels.parent.values()) == {None} diff --git a/api/tests/unit/parser/test_osv_flat_form.py b/api/tests/unit/parser/test_osv_flat_form.py index aac74c5..ed45612 100644 --- a/api/tests/unit/parser/test_osv_flat_form.py +++ b/api/tests/unit/parser/test_osv_flat_form.py @@ -86,7 +86,7 @@ def test_flat_list_s41(rules: Rules) -> None: def test_outline_wins_over_header_account_and_rows_inherit_header_code(rules: Rules) -> None: - """Review Focus 4: счёт в шапке, строк-счетов нет, outline есть — `outline`, но строки наследуют счёт шапки.""" + """Счёт в шапке, строк-счетов нет, outline есть — `outline`, но строки наследуют счёт шапки.""" rows: list[list[object]] = [ ["ООО Зерновая"], ["Оборотно-сальдовая ведомость по счету 60"], @@ -106,7 +106,7 @@ def test_outline_wins_over_header_account_and_rows_inherit_header_code(rules: Ru assert [item.account_code_raw for item in data] == ["60", "60"] -# --- Контрагенты расчётных счетов без названия вида (Task 5, R-9, R-10) --- +# --- Контрагенты расчётных счетов без названия вида (Task 5, R-6, R-10, R-17) --- BY_ACCOUNT_WARNING = ( "вид субконто в шапке не назван: строки аналитики счёта 60.01 приняты за контрагентов по плану счетов — проверьте" @@ -129,7 +129,7 @@ def test_s43_counterparties_with_warning(synthetic_dir: Path) -> None: document, messages, status = _parse_synthetic(synthetic_dir, "s43_subkonto_unnamed_60.xlsx") kids = {item.row_value: item for item in _data_rows(document) if item.row_value != "60.01"} - assert set(kids) == {"ИП Знаменских А.И.", 'ООО "ЭСТЕТ ОМСК"'} + assert set(kids) == {"ИП Иванов А.А.", 'ООО "ЭСТЕТ ОМСК"'} assert {item.group_type for item in kids.values()} == {"COUNTERPARTY"} notes = [note for note in document.warnings if note.code == "OSV_ROWS"] assert [note.message for note in notes] == [BY_ACCOUNT_WARNING] diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index e4ffb65..a17a5f6 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -373,6 +373,18 @@ def test_known_group_is_none_for_default(rules: Rules) -> None: assert rules.groups.known("Номенклатура") == "OTHER" +def test_single_organization_is_own_organization_not_counterparty(rules: Rules) -> None: + """R-17: «Организация» (единственное число) — собственная организация учёта, не вид субконто «Организации».""" + assert rules.groups.known("Организация") is None + assert rules.groups.classify("Организация") == "OTHER" + + +@pytest.mark.parametrize("text", ["Валюта", "Валюта USD", "В валюте", "в валюте CNY"]) +def test_currency_rows_are_known_other_groups(rules: Rules, text: str) -> None: + """R-18: строки валюты словарь групп знает как OTHER — по плану счетов они контрагентами не становятся.""" + assert rules.groups.known(text) == "OTHER" + + def test_account_marker_ends_right_before_the_account_code(rules: Rules) -> None: text = "Оборотно-сальдовая ведомость по счёту 58.03 за 3 квартал 2024 г." match = next(m for p in rules.headers.header_markers["account"] if (m := p.search(text))) @@ -708,7 +720,7 @@ def test_indicator_service_column(rules: Rules, title: str) -> None: assert any(pattern.search(title) for pattern in rules.headers.service_columns["indicator"]) -# --- Расчётные счета с контрагентами без названия вида (osv_groups.yaml, #59, R-9) --- +# --- Расчётные счета с контрагентами без названия вида (osv_groups.yaml, #59, R-6) --- @pytest.mark.parametrize( -- 2.47.2 From 7cd71677da3be383a52df0ec2c58a017abd32121 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 13:14:32 +0400 Subject: [PATCH 14/16] =?UTF-8?q?docs(osv-flat-form):=20=D1=80=D1=83=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=D0=B8=20R-17=E2=80=A6R-19,=20=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D1=83=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?design,=20README,=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B8=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- README.md | 6 ++- .../plans/2026-10-19-osv-flat-form.md | 6 +-- openspec/changes/osv-flat-form/design.md | 41 +++++++++++-------- openspec/changes/osv-flat-form/proposal.md | 3 +- .../osv-flat-form/specs/osv-parsing/spec.md | 13 ++++-- 5 files changed, 43 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index be87fd5..10a5369 100644 --- a/README.md +++ b/README.md @@ -182,10 +182,12 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо показывает счёт нетто, а субсчета и контрагентов — развёрнуто (решение R-21, design №31). Остаточный риск: перестановка сторон при совпавшем нетто родителя этой проверкой не ловится — её ловят тождество строки и «Итого = корень». -**«Итого».** Основная итоговая строка — первая TOTAL, не являющаяся показателем; она сверяется с первым корнем ACCOUNT либо, если корней +**«Итого».** Основная итоговая строка — первая TOTAL листа, не являющаяся показателем и не «Итого развернутое»; она сверяется с первым корнем ACCOUNT либо, если корней уровня 0 несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03), с суммой строк уровня 0 (OQ-106, OQ-133; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя выше). Вторая итоговая -строка сразу за «Итого» («Итого развернутое», `total_row_secondary`) — тоже `TOTAL`, в сверку «Итого» и в тождество строки не входит (в 1С у неё только сальдо, без оборотов). Строка сумм +строка в итоговом блоке, и перед «Итого», и после неё («Итого развернутое», `total_row_secondary`), — тоже `TOTAL`, в сверку +«Итого» не входит. Тождество строки на ней пропускается, только если обороты пусты и на листе есть другая «Итого» (перед +«Итого» в 1С только развёрнутые сальдо); с оборотами или единственная итоговая — проверяется как обычная. Строка сумм без подписи в конце таблицы (1С без «Итого») принимается за итоговую с предупреждением. **ИНН контрагента** (OQ-100, OQ-130): `counterparty_inn` берётся из колонки ИНН (в том числе склеенного «ИНН/КПП»), затем из diff --git a/docs/superpowers/plans/2026-10-19-osv-flat-form.md b/docs/superpowers/plans/2026-10-19-osv-flat-form.md index 9d00929..eb871e8 100644 --- a/docs/superpowers/plans/2026-10-19-osv-flat-form.md +++ b/docs/superpowers/plans/2026-10-19-osv-flat-form.md @@ -51,7 +51,7 @@ - `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 | — | —. +- `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)`). @@ -179,11 +179,11 @@ git -C /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/tabular-parser commi - [ ] **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_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_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.** diff --git a/openspec/changes/osv-flat-form/design.md b/openspec/changes/osv-flat-form/design.md index 1f2f188..4125b34 100644 --- a/openspec/changes/osv-flat-form/design.md +++ b/openspec/changes/osv-flat-form/design.md @@ -30,7 +30,8 @@ r11 Август - Агро ООО | … суммы … ← с **Goals:** разбор плоской формы без `INVALID_FORMAT`; счёт и организация из шапки; «Итого развернутое» — итог в любом порядке; контрагенты расчётных счетов видны ядру как `COUNTERPARTY`. -**Non-Goals:** см. proposal (валюта и `data_type` — трек A; «Учредители»; «Организации» по имени). +**Non-Goals:** см. proposal (валюта и `data_type` — трек A; «Учредители»; прочие неизвестные виды субконто; «Организации» +узнаются только как синоним COUNTERPARTY по множественному числу, R-10, R-17). ## Decisions @@ -50,8 +51,8 @@ r11 Август - Агро ООО | … суммы … ← с - **Строка вида субконто** — ячейки шапки строго между строкой названия (ячейка с меткой `account`) и строкой периода (`_find_period`), если название выше периода. Они не кандидаты в организацию. Их текст (через запятую) — виды субконто: элемент, который `rules.groups` узнаёт **явной** записью `mapping` (не `default`), добавляется в схему - после ACCOUNT, если схема варианта 1 состоит из одной ACCOUNT («Субконто»). Неузнанный элемент («Организации», - «Материалы») в схему не добавляется. + после ACCOUNT, если схема варианта 1 состоит из одной ACCOUNT («Субконто»). Неузнанный элемент («Материалы», + единственное «Организация» — R-17) в схему не добавляется; «Организации» узнаётся как COUNTERPARTY (R-10, R-17). - **Организация** — порядок: хвост метки «План счетов» (`header_markers.chart_of_accounts`, новый ключ) → первая не-метка шапки без строки вида субконто → хвост названия ведомости (как сейчас). - **Итоговый блок** — `collect_body_rows` возвращает номером «Итого» **первую** строку итогового блока: вторые итоговые @@ -71,38 +72,43 @@ r11 Август - Агро ООО | … суммы … ← с - **Контрагенты по плану счетов** — `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`): «вид субконто в шапке не назван: строки аналитики счёта <код> приняты за контрагентов - по плану счетов — проверьте». + общим правилам (колонка, строка ИНН, хвост названия). Глубже — `OTHER`, как сейчас. Строка, которую словарь групп + явно знает как не-COUNTERPARTY (валюта), остаётся `OTHER` (R-18). Предупреждение по одному на счёт (R-13; `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 без имени (как сейчас) | +| 2 | R-2 | 19.10.2026 | Известный вид субконто из этой строки — группировка схемы; неизвестный — не добавляется | «Контрагенты» даёт COUNTERPARTY с именем без угадывания по счёту; «Материалы» — не контрагенты (вид «Организации» затем узнан R-10, R-17) | Неизвестный вид на нерасчётном счёте остаётся 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) | +| 6 | R-6 | 19.10.2026 | Правило расчётных счетов — только позиция 1, только без имени в схеме | 1С кладёт контрагентов первым видом на этих счетах; именованные группировки («Работники организаций», «Учредители») не трогаем | Физлица на 76 без названия вида → COUNTERPARTY: попадут в «Осталось N» сопоставления (OQ-158); 71/73 исключены R-9 | | 7 | R-7 | 19.10.2026 | Синтетика `s40`–`s44` | Номера `s33`–`s39` оставлены параллельному треку A, чтобы реестр генератора не конфликтовал | Перенумерация при слиянии | | 8 | R-8 | 19.10.2026 | «Учредители» на счёте 80 не трогаем: именованная группировка остаётся OTHER (R-6 сохраняется) | Принято по рекомендации сессии при одобрении запуска владельцем; именованные группировки правило расчётных счетов не переопределяет | Учредители не станут COUNTERPARTY автоматически — гудвилл (§4 п. 2) потребует ручного сопоставления | | 9 | R-9 | 19.10.2026 | Счета 71 и 73 исключены из `counterparty_accounts`; итог: 58, 60, 62, 66, 67, 75, 76, 80 | Подотчётные и персонал — физлица, а не контрагенты (OQ-158); принято по рекомендации сессии | Контрагенты на 71/73 без названия вида останутся OTHER и не попадут в сопоставление | -| 10 | R-10 | 19.10.2026 | «Организации» — синоним COUNTERPARTY в `osv_groups.yaml` (`^организаци`) | Вид субконто письма (сч. 60) — организации-контрагенты; принято по рекомендации сессии | Другой смысл вида «Организации» на иных счетах даст COUNTERPARTY; видно в golden/предупреждениях | +| 10 | R-10 (сужен R-17) | 19.10.2026 | «Организации» — синоним COUNTERPARTY в `osv_groups.yaml` (`^организаци`; после R-17 — `^организации\b`) | Вид субконто письма (сч. 60) — организации-контрагенты; принято по рекомендации сессии | Другой смысл вида «Организации» на иных счетах даст COUNTERPARTY; видно в golden/предупреждениях | | 11 | R-11 (отменён R-16) | 19.10.2026 | Данные синтетики s40, s41, s42 исправлены: у «Итого развернутое» добавлены обороты, равные суммам строк (s40: 650/750, s41: 5000/4991, s42: 5700/6600) | В плане у этой строки обороты пусты, поэтому тождество строки (начало + обороты = конец) не сходилось, и файлы не могли стать `done` независимо от кода; бриф плана ошибочен | s40–s42 расходятся с исходным текстом плана. **Приёмка Task 6 показала:** в реальных файлах контура у «Итого развернутое» обороты тоже пусты (`45e06f7d`, `638ecb3d`, `7e49f8b5`, `f2e0e04e`, `270b41da`, `738bfcf5`, письмо), тождество на этой строке остаётся нарушенным, и файл уходит в `needs_check`: правка данных синтетики скрыла дефект кода, а не данных; решается отдельно (см. отчёт task-6). **Отменён рулингом R-16:** данные s40–s42 возвращены к исходному тексту плана | | 12 | R-12 | 19.10.2026 | Строка вида субконто не даёт группировку из ячеек-меток шапки («Субконто», «Счет»), меток валюты («Валюта БУ, НУ») и ячеек, на которые срабатывает паттерн `currency` | Ячейка между названием и периодом может быть меткой шапки, а не видом субконто; иначе в схему попали бы ложные группировки (T2.2, ревью задачи 2) | Метка, совпавшая с настоящим видом субконто, не станет группировкой; вид «Организации» уже учтён (R-10) | | 13 | R-13 | 19.10.2026 | Предупреждение `OSV_ROWS` «вид субконто в шапке не назван…» — одно на каждый различный `account_code_raw` строк, принятых по плану счетов | План требовал «один раз на документ»; у листа с несколькими субсчетами (62.01, 62.02) заметка с точным кодом полезнее одной общей | Больше заметок на листах с несколькими субсчетами (лист `67 сч.` — четыре); на статус не влияют (`info`) | | 14 | R-14 | 19.10.2026 | `Expect.data_type` в `test_synthetic_files.py` стал `str \| None`: у s40–s44 ожидается `None` | В шапке нет БУ/НУ; умолчание BU — зона трека A (`data_type`) | При слиянии трека A ожидания s40–s44 придётся поправить на `BU` | | 15 | R-15 | 19.10.2026 | Предупреждение `OSV_TOTAL` при несходимости «Итого» без корня ACCOUNT, но со строками уровня 0, говорит «≠ сумме N строк уровня 0» (T4.1) | Прежний текст «корневого счёта нет» вводил в заблуждение для плоского списка | Текст в `metrics.warnings` изменился; снимков с прежним текстом нет | -| 16 | R-16 | 19.10.2026 | Вторые итоговые строки (`total_row_secondary`, «Итого развернутое») исключены из проверки тождества строки; сверка по основной «Итого» сохранена; R-11 отменён | В 1С «Итого развернутое» содержит только развёрнутые сальдо, без оборотов (подтверждено 7 реальными файлами контура: `638ecb3d`, `45e06f7d`, `f2e0e04e`, `7e49f8b5`, `270b41da`, `738bfcf5`, письмо); решение владельца: «Итого развернутое» — итоговая строка в любом порядке | Тождество на вторых итогах не проверяется: ошибка в сальдо этой строки не будет поймана; основная «Итого» по-прежнему проверяется тождеством и сверкой с корнем/суммой уровня 0 | +| 16 | R-16 (уточнён R-19) | 19.10.2026 | Вторые итоговые строки (`total_row_secondary`, «Итого развернутое») исключены из проверки тождества строки; сверка по основной «Итого» сохранена; R-11 отменён | «Итого развернутое» перед «Итого» содержит только развёрнутые сальдо, без оборотов (подтверждено 7 реальными файлами контура: `638ecb3d`, `45e06f7d`, `f2e0e04e`, `7e49f8b5`, `270b41da`, `738bfcf5`, письмо); решение владельца: «Итого развернутое» — итоговая строка в любом порядке. Исключение оказалось шире обоснования: после «Итого» строка идёт с оборотами и сходящимся тождеством — см. R-19 | Тождество на вторых итогах без оборотов не проверяется: ошибка в сальдо этой строки не будет поймана; основная «Итого» по-прежнему проверяется тождеством и сверкой с корнем/суммой уровня 0 | +| 17 | R-17 (заменяет R-10) | 19.10.2026 | Синоним вида субконто сужен до множественного числа: `^организации\b` → COUNTERPARTY; единственное «Организация» остаётся OTHER и в схему не добавляется | «Организация» в шапке вариантов 1/2 — собственная организация учёта (измерение отчитывающейся организации); на контуре `dae56120` (сч. 83) и `02c5df51` (сч. 80) схема `[Счет, Организация]` из-за широкого паттерна стала `[ACCOUNT, COUNTERPARTY]` (финальное ревью I-1) | Прочие формы написания («Организации-контрагенты» и т. п.) не узнаются; вид во множественном числе с иным смыслом на нерасчётном счёте даст COUNTERPARTY (OQ-171) | +| 18 | R-18 | 19.10.2026 | Правило расчётных счетов (`counterparty_accounts`) не применяется к строке, которую словарь групп явно знает как не-COUNTERPARTY; паттерн валюты расширен до `^(в\s+)?валют` | Строки «Валюта», «Валюта CNY/EUR/USD/руб», «В валюте» под 76.55 становились контрагентами (`270b41da`, `738bfcf5`; финальное ревью I-2) | Контрагент с названием на «Валют…» («Валютные решения ООО») остаётся OTHER без имени и не попадёт в сопоставление | +| 19 | R-19 | 19.10.2026 | Тождество у второй итоговой строки пропускается, только если обе колонки оборотов пусты и на листе есть другая TOTAL; иначе строка проверяется как обычная | На контуре «Итого развернутое» перед «Итого» — без оборотов, после «Итого» — с оборотами и сходящимся тождеством; R-16 пропускал обе (финальное ревью I-4) | Вторая итоговая без оборотов и с ошибочным сальдо не ловится; единственная итоговая без оборотов проверяется, и пустое тождество может дать нарушение | ## Risks / Trade-offs -- [OQ-158] Контрагенты по плану счетов на 71/73/76 — в том числе физлица и подотчётные: «Осталось N» в сопоставлении - вырастет. Смягчение: предупреждение `OSV_ROWS` на документе; вопрос владельцу — сузить список (без 71/73). +- [OQ-158] Контрагенты по плану счетов на 76 и прочих расчётных счетах могут включать физлиц: «Осталось N» в + сопоставлении вырастет. Смягчение: предупреждение `OSV_ROWS` на документе; список уже сужен (без 71/73, R-9). - [Трек A] До слияния трека A строки без «Выводимые данные» — `data_type = null`; ядро их в свод не берёт. -- [Golden] Изменения затронут снимки, где схема — одна ACCOUNT («Субконто») на расчётном счёте и есть не-счётные строки - (пакет ДКБ, `osv-dkb/**`): OTHER → COUNTERPARTY. Каждое изменение снимка — с просмотром diff. +- [Golden] Правило расчётных счетов срабатывает только при схеме из одной ACCOUNT без названия вида; у реальных файлов + и пакета ДКБ вид назван в шапке, поэтому снимки `osv/**` и `osv-dkb/**` не изменились (OTHER → COUNTERPARTY не + случилось). Эффект проверен на файлах контура. ## Open Questions @@ -111,6 +117,7 @@ OQ-169…OQ-172), в коде действует допущение: - OQ-169 — «Учредители» на 80 (O-29, `02c5df51`) остаются OTHER (R-8); гудвилл §4 п. 2 — вопрос владельцу. - OQ-170 — счета 71 и 73 (подотчётные, персонал) исключены из списка расчётных (R-9). -- OQ-171 — «Организации» — синоним COUNTERPARTY (R-10). -- OQ-172 — безымянная аналитика первого уровня на счёте 63 (51 строка в golden) не входит в список расчётных счетов: - нужен ли 63. +- OQ-171 — «Организации» (множественное число) — синоним COUNTERPARTY (R-10, R-17); «Организация» — OTHER. +- OQ-172 — безымянная аналитика первого уровня на счёте 63 не входит в список расчётных счетов: нужен ли 63. На файлах + контура таких строк нет; 51 строка — в fixtures, golden `osv-dkb/nm/2025-Q3/НМ_ОСВ_63_Контрагенты_…json`, и имя файла + («63_Контрагенты») — довод за включение счёта 63. diff --git a/openspec/changes/osv-flat-form/proposal.md b/openspec/changes/osv-flat-form/proposal.md index 7b25930..274b563 100644 --- a/openspec/changes/osv-flat-form/proposal.md +++ b/openspec/changes/osv-flat-form/proposal.md @@ -50,6 +50,7 @@ arm-ka-docs (`05-domain-rules.md` §1.1, §1.3, §1.4, `07-open-questions.md`), + валютная часть 1661). До его слияния строки плоских файлов без «Выводимые данные» приходят с `data_type = null`, и ядро не берёт их в свод (`SUMMARY_DATA_TYPES = (BU, SUM)`): файлы распознаются, но в сводную ОСВ не попадают. - «Учредители» на 80 (именованная группировка `OTHER`, O-29) — не меняется (R-8). -- Вид субконто «Организации» — синоним COUNTERPARTY в `osv_groups.yaml` (R-10); прочие неизвестные виды в схему не +- Вид субконто «Организации» (множественное число) — синоним COUNTERPARTY в `osv_groups.yaml` (R-10, R-17), единственное + «Организация» — собственная организация учёта, не вид субконто; прочие неизвестные виды в схему не добавляются, позиция остаётся без названия и подпадает под правило расчётных счетов. - Склейка кварталов (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 index 641f97f..a94cf4e 100644 --- a/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md +++ b/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md @@ -49,7 +49,7 @@ - **WHEN** разбирается синтетическая ОСВ `s41` (шапка «…по счету: 60», строка «Организации», «за 2 Квартал 2026 г. План счетов ООО Хреновская Нива») - **THEN** `organization_name = "ООО Хреновская Нива"`, `account_code_raw = "60"`, - `group_schema = [ACCOUNT(Субконто), COUNTERPARTY(Организации)]` («Организации» — синоним COUNTERPARTY в словаре) + `group_schema = [ACCOUNT(Субконто), COUNTERPARTY(Организации)]` (множественное «Организации» — синоним COUNTERPARTY в словаре; единственное «Организация» — собственная организация учёта, R-17) #### Scenario: Счёт шапки важнее строки таблицы - **WHEN** разбирается синтетическая ОСВ `s42` (шапка «…по счету: 76», первая строка таблицы «76.2») @@ -161,8 +161,11 @@ balance_identity_violations, parent_sum_violations}`. Результат про `OSV_XLSB_NO_OUTLINE`); строк вида `sheet=<имя> <текст>` в `metrics.warnings` больше нет. Парсер MUST NOT подгонять структуру под сходимость. -Вторые итоговые строки (`total_row_secondary`, «Итого развернутое») SHALL NOT участвовать в проверке тождества строки: -в 1С они несут только развёрнутые сальдо, без оборотов (R-16); основная «Итого» проверяется тождеством как прежде. +Вторая итоговая строка (`total_row_secondary`, «Итого развернутое») SHALL NOT участвовать в проверке тождества строки, +только если у неё обе колонки оборотов за период пусты и на листе есть другая строка TOTAL: перед «Итого» она несёт +одни развёрнутые сальдо (R-16, уточнён R-19). Вторая итоговая строка с оборотами (в 1С она бывает и после «Итого», с +оборотами) и единственная итоговая строка листа проверяются тождеством как обычные; основная «Итого» проверяется как +прежде. Исключение — **счета без сальдо по аналитике** (решение владельца 07.10.2026, OQ-132): у 91 и 99 строки аналитики несут только обороты, сальдо закрыто на субсчёт закрытия (91.09). Список счетов — данные `rules/osv_checks.yaml` @@ -236,6 +239,10 @@ balance_identity_violations, parent_sum_violations}`. Результат про - **THEN** `balance_identity_violations` не содержит «Итого развернутое», `status = done`; при нарушении тождества у основной «Итого» её `row_number` в списке остаётся +#### Scenario: «Итого развернутое» с оборотами +- **WHEN** «Итого развернутое» стоит после «Итого», у неё заполнены обороты, и сальдо на конец не сходится с началом и оборотами +- **THEN** её `row_number` попадает в `balance_identity_violations`; при сходящемся тождестве нарушения нет + #### Scenario: «Итого» плоского списка - **WHEN** разбирается синтетическая ОСВ `s40` («Итого» = сумма контрагентов, сальдо «Итого» свёрнуто) - **THEN** `total_row_matches_root = true`, `status = done`; если сумма одного контрагента испорчена — `false`, -- 2.47.2 From 831180847289ff76fb76c72389dc5ceb7c674829 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 13:30:34 +0400 Subject: [PATCH 15/16] =?UTF-8?q?fix(osv):=20=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=20=D1=82=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D0=BE=D0=BC=20A=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=20rebase=20=E2=80=94=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B0,=20golde?= =?UTF-8?q?n-=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA,=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=20=D1=81=D1=82=D1=8B=D0=BA=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=B8?= =?UTF-8?q?=20=D0=B2=D1=82=D0=BE=D1=80=D0=BE=D0=B9=20=C2=AB=D0=98=D1=82?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=C2=BB=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 2 +- api/tests/synthetic/test_generate.py | 2 +- api/tests/synthetic/test_synthetic_files.py | 4 +- api/tests/unit/parser/test_osv_flat_form.py | 48 +++++++++++++++++++ openspec/changes/osv-flat-form/design.md | 3 +- openspec/changes/osv-flat-form/proposal.md | 4 +- .../osv-flat-form/specs/osv-parsing/spec.md | 27 +++++++---- 7 files changed, 75 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d7b81b8..d9a2abc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают. - Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле (`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой. -- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 131 файл: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 36 синтетических) — регрессия, а не +- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 136 файлов: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 41 синтетический) — регрессия, а не оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность проверяют property-тест и ручная сверка «Итого». - Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не diff --git a/api/tests/synthetic/test_generate.py b/api/tests/synthetic/test_generate.py index eddc7d5..6c78d51 100644 --- a/api/tests/synthetic/test_generate.py +++ b/api/tests/synthetic/test_generate.py @@ -26,7 +26,7 @@ def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None: assert left.read_bytes() == right.read_bytes(), left.name -def test_generated_set_is_s01_to_s32_and_s40_to_s44_with_one_xlsb(tmp_path: Path) -> None: +def test_generated_set_is_s01_to_s36_and_s40_to_s44_with_one_xlsb(tmp_path: Path) -> None: names = [path.name for path in generate_all(tmp_path)] assert names == list(FILE_NAMES) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index e375732..0ede05d 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -162,7 +162,7 @@ EXPECTED: dict[str, Expect] = { "s34_indicator_column_qty_val_diff.xlsx": Expect("outline", 2025, "Q3", False), "s35_currency_slices.xlsx": Expect("outline", 2025, "Q3", False), "s36_no_data_type_header.xlsx": Expect("outline", 2025, "Q3", False, data_type=None), - # плоская форма 1С (#59): в шапке нет признака данных (БУ/НУ), `data_type = None` (умолчание — трек A, AURORA-1613) + # плоская форма 1С (#59): в шапке нет признака данных (БУ/НУ), `header.data_type = None`; строки — BU (#58) # счёт в шапке «по счету: 76.Н.1», данные без оформления; период «за 2025 г.» "s40_flat_header_account.xlsx": Expect( "header_account", 2025, "Q4", True, data_type=None, organization='ООО "Товарное хозяйство"' @@ -260,7 +260,7 @@ def tree(doc: OsvDocument) -> list[tuple[str, int, int | None, tuple[Decimal | N def test_synthetic_set_is_complete(fixtures_dir: Path) -> None: - """В `fixtures/osv/synthetic/` лежат файлы набора s01…s36, s40…s44 (s13 — xlsb; допустима деградация: нет только его).""" + """В `fixtures/osv/synthetic/` лежат файлы набора s01…s36, s40…s44 (s13 — xlsb; без него допустимо).""" present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file()) assert present == [name for name in EXPECTED_NAMES if name in present] missing = [name for name in EXPECTED_NAMES if name not in present] diff --git a/api/tests/unit/parser/test_osv_flat_form.py b/api/tests/unit/parser/test_osv_flat_form.py index ed45612..47c4710 100644 --- a/api/tests/unit/parser/test_osv_flat_form.py +++ b/api/tests/unit/parser/test_osv_flat_form.py @@ -13,6 +13,7 @@ from src.parser.rules import Rules, load_rules TIMEOUT_SECONDS = 60 HEADER_ACCOUNT_CODE = "OSV_HIERARCHY_BY_HEADER_ACCOUNT" +NU_ROWS = 3 # две строки НУ под контрагентами и итоговая НУ после «Итого» @pytest.fixture(scope="module") @@ -180,3 +181,50 @@ def test_flat_list_with_broken_total_needs_check(rules: Rules) -> None: messages = [note.message for note in document.warnings] assert any("≠ сумме 2 строк уровня 0" in message for message in messages), messages assert not any("корневого счёта нет" in message for message in messages) + + +def test_flat_list_with_indicator_column_and_second_total(rules: Rules) -> None: + """Стык треков A и B (#58, #59): колонка «Показатели», «Итого развернутое» перед «Итого» и строка НУ после неё. + + Показатели не становятся контрагентами (R-6, R-18), не попадают в тождество строки (R-19) и не сбивают сверку + «Итого» (она идёт по основной «Итого» с меткой БУ). + """ + rows: list[list[object]] = [ + [], + ["Оборотно-сальдовая ведомость по счету: 76"], + ["Контрагенты"], + ["за 2025 г."], + ['ООО "Тест"'], + [], + [ + "Субконто", + "Показатели", + M("Сальдо на начало периода", 2), + M("Обороты за период", 2), + M("Сальдо на конец периода", 2), + ], + [None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + ["Август - Агро ООО", "БУ", 1000, None, 500, 300, 1200, None], + [None, "НУ", 900, None, 400, 300, 1000, None], + ["АВИС ООО", "БУ", None, 200, 100, 400, None, 500], + [None, "НУ", None, 150, 100, 350, None, 400], + ["Итого развернутое", "БУ", 1000, 200, 600, 700, 1200, 500], + ["Итого", "БУ", 1000, 200, 600, 700, 1200, 500], + [None, "НУ", 900, 150, 500, 650, 1000, 400], + ] + + document, codes = _parse(rules, rows) + + assert document.status == "done", [note.message for note in document.warnings] + assert document.checks is not None + assert document.checks.total_row_matches_root is True + indicators = [item for item in document.rows if item.data_type == "NU"] + assert len(indicators) == NU_ROWS + assert {item.counterparty_name for item in indicators} == {None} + assert {item.group_type for item in indicators if item.group_type != "TOTAL"} == {"OTHER"} + counterparties = [item.row_value for item in document.rows if item.group_type == "COUNTERPARTY"] + assert counterparties == ["Август - Агро ООО", "АВИС ООО"] + assert [item.group_type for item in document.rows if item.group_type == "TOTAL" and item.data_type == "BU"] == [ + "TOTAL", + "TOTAL", + ] diff --git a/openspec/changes/osv-flat-form/design.md b/openspec/changes/osv-flat-form/design.md index 4125b34..6b027dc 100644 --- a/openspec/changes/osv-flat-form/design.md +++ b/openspec/changes/osv-flat-form/design.md @@ -105,7 +105,8 @@ r11 Август - Агро ООО | … суммы … ← с - [OQ-158] Контрагенты по плану счетов на 76 и прочих расчётных счетах могут включать физлиц: «Осталось N» в сопоставлении вырастет. Смягчение: предупреждение `OSV_ROWS` на документе; список уже сужен (без 71/73, R-9). -- [Трек A] До слияния трека A строки без «Выводимые данные» — `data_type = null`; ядро их в свод не берёт. +- [Трек A] Слит в main (#58): строки без «Выводимые данные» — `data_type = BU` с предупреждением, `header.data_type` + остаётся `null`; golden s40–s44 обновлены при rebase (O-3 ledger). - [Golden] Правило расчётных счетов срабатывает только при схеме из одной ACCOUNT без названия вида; у реальных файлов и пакета ДКБ вид назван в шапке, поэтому снимки `osv/**` и `osv-dkb/**` не изменились (OTHER → COUNTERPARTY не случилось). Эффект проверен на файлах контура. diff --git a/openspec/changes/osv-flat-form/proposal.md b/openspec/changes/osv-flat-form/proposal.md index 274b563..eaf1872 100644 --- a/openspec/changes/osv-flat-form/proposal.md +++ b/openspec/changes/osv-flat-form/proposal.md @@ -47,8 +47,8 @@ arm-ka-docs (`05-domain-rules.md` §1.1, §1.3, §1.4, `07-open-questions.md`), ## Non-goals - Валюта («Валюта <код>», «В валюте»), колонка «Показатели», `data_type` строки при молчащей шапке — трек A (AURORA-1613 - + валютная часть 1661). До его слияния строки плоских файлов без «Выводимые данные» приходят с `data_type = null`, и - ядро не берёт их в свод (`SUMMARY_DATA_TYPES = (BU, SUM)`): файлы распознаются, но в сводную ОСВ не попадают. + + валютная часть 1661), слит в main (#58). После его слияния строки плоских файлов без «Выводимые данные» получают + `data_type = BU` с предупреждением `OSV_HEADER`, `header.data_type` остаётся `null`; в свод они попадают. - «Учредители» на 80 (именованная группировка `OTHER`, O-29) — не меняется (R-8). - Вид субконто «Организации» (множественное число) — синоним COUNTERPARTY в `osv_groups.yaml` (R-10, R-17), единственное «Организация» — собственная организация учёта, не вид субконто; прочие неизвестные виды в схему не diff --git a/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md b/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md index a94cf4e..7afcd4b 100644 --- a/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md +++ b/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md @@ -106,10 +106,12 @@ 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` +Контрольная сумма ИНН из колонки и из названия 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: Подразделение, контрагент с ИНН, физлицо @@ -138,6 +140,15 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST - **THEN** `group_type = OTHER`, `data_type = CURRENCY` и `QUANTITY`, `counterparty_name`/`counterparty_inn` равны `null`, `account_code_raw` совпадает с родителем, строки исключены из проверки «сумма детей = родитель», статус `done` +#### Scenario: Шапка без вида данных +- **WHEN** разбирается синтетическая ОСВ `s36` (шапка без «Выводимые данные», колонки «Показатели» нет) +- **THEN** `header.data_type = null`, у всех строк данных и «Итого» `data_type = BU`, в `warnings` одно `OSV_HEADER` «вид + данных в шапке не указан — строки без метки приняты как БУ», статус `done` + +#### Scenario: «Валюта <код>» — обычная строка +- **WHEN** в синтетической ОСВ `s35` под контрагентом строка «Валюта USD» с рублёвыми суммами +- **THEN** `group_type = OTHER`, `data_type` шапки, строка участвует в сверке «сумма детей = родитель» + #### Scenario: Контрагенты расчётного счёта без названия вида - **WHEN** разбирается синтетическая ОСВ `s43` (лист как ОСВОКТ-24 «60 сч.»: колонка «Субконто», строка «60.01», под ней контрагенты с отступом) либо `s41` @@ -150,8 +161,8 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST 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` и в +а субсчета и контрагентов — развёрнуто; остаточные риски перечислены ниже); строки-показатели (требование «Строки-показатели и колонка +«Показатели»») в суммирование не входят и сами родителями не проверяются; строка 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, применённые к листу); любое @@ -167,9 +178,9 @@ balance_identity_violations, parent_sum_violations}`. Результат про оборотами) и единственная итоговая строка листа проверяются тождеством как обычные; основная «Итого» проверяется как прежде. -Исключение — **счета без сальдо по аналитике** (решение владельца 07.10.2026, OQ-132): у 91 и 99 строки аналитики несут +Исключение — **счета без сальдо по аналитике** (решение владельца 07.10.2026, OQ-132): у 90, 91 и 99 строки аналитики несут только обороты, сальдо закрыто на субсчёт закрытия (91.09). Список счетов — данные `rules/osv_checks.yaml` -(`accounts_without_analytic_balance`, начальный состав `91`, `99`; 90 не входит). Строка относится к такому счёту, если +(`accounts_without_analytic_balance`, состав `90`, `91`, `99`; счёт 90 добавлен решением владельца 19.10.2026). Строка относится к такому счёту, если код ближайшего счёта её ветки (`account_code_raw`) совпадает с записью списка либо является её субсчётом (`91`, `91.01`, но не `910`). Для таких строк тождество строки SHALL NOT проверяться; сверка «сумма детей = родитель» сохраняется: обороты — всегда по колонкам, сальдо — только у родителя с дочерними субсчетами (корень → субсчета), а у -- 2.47.2 From d6d1c7171c9e3824d3ead1c6b058de4b2e623294 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 13:46:34 +0400 Subject: [PATCH 16/16] =?UTF-8?q?fix(osv):=20=D0=BF=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D0=B8=20=D0=B8=D1=82=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=20=E2=80=94=20=D1=88=D0=B0=D0=B3=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=9D=D0=A3=20=D0=BF=D0=BE=D0=B4=20=C2=AB=D0=98=D1=82?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D1=80=D0=B0=D0=B7=D0=B2=D0=B5=D1=80=D0=BD?= =?UTF-8?q?=D1=83=D1=82=D0=BE=D0=B5=C2=BB,=20=D0=BF=D1=80=D0=BE=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D0=BA=20=D1=82=D0=BE=D0=B6=D0=B4=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D0=B0=20(R-21,=20#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- api/src/parser/checks.py | 12 ++- api/src/parser/classify.py | 9 +- api/tests/unit/parser/test_osv_flat_form.py | 88 ++++++++++++++++++- openspec/changes/osv-flat-form/design.md | 1 + .../osv-flat-form/specs/osv-parsing/spec.md | 8 +- 5 files changed, 110 insertions(+), 8 deletions(-) diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 493a149..88d77a3 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -94,14 +94,22 @@ def balance_identity( Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются. Вторая итоговая строка (`secondary_totals`, «Итого развернутое») пропускается, только если у неё нет оборотов (обе колонки пусты) и она не единственная TOTAL листа: «Итого развернутое» перед «Итого» несёт одни развёрнутые сальдо (#59, R-19). С оборотами - или единственная — проверяется как обычная строка. + или единственная — проверяется как обычная строка. Итоговые строки-показатели («НУ»), идущие блоком сразу за + пропущенной второй итоговой строкой, пропускаются на тех же условиях (R-21); в счётчик TOTAL показатели не входят. """ violations: list[int] = [] - totals = sum(1 for item in rows if item.group_type == "TOTAL") + totals = sum(1 for item in rows if item.group_type == "TOTAL" and not item.indicator) + in_skipped_block = False for item in rows: + if item.group_type == "TOTAL" and item.indicator: + if in_skipped_block and not _has_turnovers(item): + continue + else: + in_skipped_block = False if _no_analytic_balance(item, exempt): continue if _is_secondary_total(item, secondary_totals) and totals > 1 and not _has_turnovers(item): + in_skipped_block = True continue sums = _values(item) expected = _net(sums[_OPENING]) + _net(sums[_TURNOVER]) diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 4055ab5..aa35132 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -201,13 +201,16 @@ def _trailing_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], tot def _leading_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], total: int, rules: Rules) -> int: """Номер самой верхней из вторых итоговых строк сразу перед основной «Итого» (она сама, если их нет). - Плоская форма 1С (#59): «Итого развернутое» стоит перед «Итого» — вместе они итоговый блок. + Плоская форма 1С (#59): «Итого развернутое» стоит перед «Итого» — вместе они итоговый блок. Строки-показатели + («НУ» под «Итого развернутое») шагу не мешают, но в блок входят, только если выше них найдена вторая итоговая + строка (R-21): «НУ» под последней строкой данных в блок не втягивается. """ first = total for row in reversed(rows[: rows.index(total)]): - if not _is_secondary_total(sheet.text_at(row, header.text_col), rules): + if _is_secondary_total(sheet.text_at(row, header.text_col), rules): + first = row + elif not is_indicator_total(sheet, row, header, rules): break - first = row return first diff --git a/api/tests/unit/parser/test_osv_flat_form.py b/api/tests/unit/parser/test_osv_flat_form.py index 47c4710..22e7d69 100644 --- a/api/tests/unit/parser/test_osv_flat_form.py +++ b/api/tests/unit/parser/test_osv_flat_form.py @@ -186,8 +186,8 @@ def test_flat_list_with_broken_total_needs_check(rules: Rules) -> None: def test_flat_list_with_indicator_column_and_second_total(rules: Rules) -> None: """Стык треков A и B (#58, #59): колонка «Показатели», «Итого развернутое» перед «Итого» и строка НУ после неё. - Показатели не становятся контрагентами (R-6, R-18), не попадают в тождество строки (R-19) и не сбивают сверку - «Итого» (она идёт по основной «Итого» с меткой БУ). + Показатели не становятся контрагентами (R-6, R-18) и не сбивают сверку «Итого» (она идёт по основной «Итого» с + меткой БУ); тождество строки у них проверяется, как у всех (спека трека A) — строки НУ его соблюдают. """ rows: list[list[object]] = [ [], @@ -228,3 +228,87 @@ def test_flat_list_with_indicator_column_and_second_total(rules: Rules) -> None: "TOTAL", "TOTAL", ] + + +def _indicator_flat_sheet(tail: list[list[object]]) -> list[list[object]]: + """Плоская форма 1С с колонкой «Показатели»: два контрагента с БУ/НУ, дальше — итоговый блок `tail`.""" + return [ + [], + ["Оборотно-сальдовая ведомость по счету: 76"], + ["Контрагенты"], + ["за 2025 г."], + ['ООО "Тест"'], + [], + [ + "Субконто", + "Показатели", + M("Сальдо на начало периода", 2), + M("Обороты за период", 2), + M("Сальдо на конец периода", 2), + ], + [None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + ["Август - Агро ООО", "БУ", 1000, None, 500, 300, 1200, None], + [None, "НУ", 900, None, 400, 300, 1000, None], + ["АВИС ООО", "БУ", None, 200, 100, 400, None, 500], + [None, "НУ", None, 150, 100, 350, None, 400], + *tail, + ] + + +def test_indicator_under_second_total_before_total_joins_total_block(rules: Rules) -> None: + """I-6, R-21: НУ без объединения под «Итого развернутое» перед «Итого» — итоговый блок, не фиктивный контрагент.""" + rows = _indicator_flat_sheet( + [ + ["Итого развернутое", "БУ", 1000, 200, None, None, 1200, 500], + [None, "НУ", 900, 150, None, None, 1000, 400], + ["Итого", "БУ", 1000, 200, 600, 700, 1200, 500], + [None, "НУ", 900, 150, 500, 650, 1000, 400], + ] + ) + + document, _ = _parse(rules, rows) + + assert document.status == "done", [note.message for note in document.warnings] + assert [item.row_value for item in document.rows if item.group_type == "COUNTERPARTY"] == [ + "Август - Агро ООО", + "АВИС ООО", + ] + secondary = next(item for item in document.rows if item.row_value == "Итого развернутое") + assert secondary.group_type == "TOTAL" + assert document.checks is not None + assert document.checks.balance_identity_violations == [] + + +def test_indicator_under_last_data_row_is_not_pulled_into_total_block(rules: Rules) -> None: + """R-21, негативный: НУ под последней строкой данных перед «Итого» остаётся показателем этой строки.""" + rows = _indicator_flat_sheet( + [ + ["Итого", "БУ", 1000, 200, 600, 700, 1200, 500], + [None, "НУ", 900, 150, 500, 650, 1000, 400], + ] + ) + + document, _ = _parse(rules, rows) + + nu_under_data = [item for item in document.rows if item.data_type == "NU" and item.group_type != "TOTAL"] + assert len(nu_under_data) == NU_ROWS - 1 + assert document.status == "done", [note.message for note in document.warnings] + + +@pytest.mark.parametrize("secondary_first", [True, False]) +def test_indicators_of_second_total_without_turnovers_skip_identity(rules: Rules, secondary_first: bool) -> None: + """I-7, R-21: «Итого развернутое» без оборотов со своим НУ не даёт нарушений тождества (до и после «Итого»).""" + secondary: list[list[object]] = [ + ["Итого развернутое", "БУ", 1000, 200, None, None, 1200, 500], + [None, "НУ", 900, 150, None, None, 1000, 400], + ] + main: list[list[object]] = [ + ["Итого", "БУ", 1000, 200, 600, 700, 1200, 500], + [None, "НУ", 900, 150, 500, 650, 1000, 400], + ] + + document, _ = _parse(rules, _indicator_flat_sheet([*secondary, *main] if secondary_first else [*main, *secondary])) + + assert document.status == "done", [note.message for note in document.warnings] + assert document.checks is not None + assert document.checks.balance_identity_violations == [] diff --git a/openspec/changes/osv-flat-form/design.md b/openspec/changes/osv-flat-form/design.md index 6b027dc..bcafaa3 100644 --- a/openspec/changes/osv-flat-form/design.md +++ b/openspec/changes/osv-flat-form/design.md @@ -100,6 +100,7 @@ r11 Август - Агро ООО | … суммы … ← с | 17 | R-17 (заменяет R-10) | 19.10.2026 | Синоним вида субконто сужен до множественного числа: `^организации\b` → COUNTERPARTY; единственное «Организация» остаётся OTHER и в схему не добавляется | «Организация» в шапке вариантов 1/2 — собственная организация учёта (измерение отчитывающейся организации); на контуре `dae56120` (сч. 83) и `02c5df51` (сч. 80) схема `[Счет, Организация]` из-за широкого паттерна стала `[ACCOUNT, COUNTERPARTY]` (финальное ревью I-1) | Прочие формы написания («Организации-контрагенты» и т. п.) не узнаются; вид во множественном числе с иным смыслом на нерасчётном счёте даст COUNTERPARTY (OQ-171) | | 18 | R-18 | 19.10.2026 | Правило расчётных счетов (`counterparty_accounts`) не применяется к строке, которую словарь групп явно знает как не-COUNTERPARTY; паттерн валюты расширен до `^(в\s+)?валют` | Строки «Валюта», «Валюта CNY/EUR/USD/руб», «В валюте» под 76.55 становились контрагентами (`270b41da`, `738bfcf5`; финальное ревью I-2) | Контрагент с названием на «Валют…» («Валютные решения ООО») остаётся OTHER без имени и не попадёт в сопоставление | | 19 | R-19 | 19.10.2026 | Тождество у второй итоговой строки пропускается, только если обе колонки оборотов пусты и на листе есть другая TOTAL; иначе строка проверяется как обычная | На контуре «Итого развернутое» перед «Итого» — без оборотов, после «Итого» — с оборотами и сходящимся тождеством; R-16 пропускал обе (финальное ревью I-4) | Вторая итоговая без оборотов и с ошибочным сальдо не ловится; единственная итоговая без оборотов проверяется, и пустое тождество может дать нарушение | +| 20 | R-21 | 19.10.2026 | Шаг вверх от «Итого» проходит строки-показатели, но включает их в итоговый блок, только если выше найдена вторая итоговая строка; показатели второй итоговой строки без оборотов наследуют пропуск тождества (R-19); счётчик TOTAL в тождестве не считает показатели | 1С штатно даёт «Показатели БУ, НУ» + «Итого развернутое»: без шага «Итого развернутое» оставалась в теле и становилась фиктивным контрагентом, а НУ давала нарушение тождества (ревью интеграции I-6, I-7, M-13) | Ошибка сальдо показателя второй итоговой строки не ловится | ## Risks / Trade-offs diff --git a/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md b/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md index 7afcd4b..93a7556 100644 --- a/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md +++ b/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md @@ -96,7 +96,7 @@ пробел или его отсутствие, 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, 75, 76, 80; запись `76` покрывает `76`, `76.2`, `76.Н.1`, но не `760`), строка SHALL получить COUNTERPARTY без имени группировки, а документ — предупреждение `OSV_ROWS` «вид субконто в шапке не назван: строки аналитики счёта <код> приняты за контрагентов по плану счетов — проверьте» (один раз на счёт); счёт ветки строк до первой строки-счёта — код счёта шапки; строка ИНН без пары — OTHER; -строка «Итого» и вторые итоговые строки («Итого развернутое») сразу перед ней или сразу за ней — TOTAL. +строка «Итого» и вторые итоговые строки («Итого развернутое») в итоговом блоке сразу перед ней или сразу за ней, включая строки-показатели итоговых строк, — TOTAL; строки-показатели входят в блок перед «Итого», только если выше них стоит вторая итоговая строка. Для COUNTERPARTY `counterparty_name` SHALL быть текстом строки без ИНН в конце и разделителя перед ним (`row_value` — исходный текст; OQ-100), а `counterparty_inn` — первым найденным из: @@ -254,6 +254,12 @@ balance_identity_violations, parent_sum_violations}`. Результат про - **WHEN** «Итого развернутое» стоит после «Итого», у неё заполнены обороты, и сальдо на конец не сходится с началом и оборотами - **THEN** её `row_number` попадает в `balance_identity_violations`; при сходящемся тождестве нарушения нет +#### Scenario: Показатели второй итоговой строки без оборотов +- **WHEN** колонка «Показатели», «Итого развернутое» без оборотов (до или после «Итого») и под ней строка «НУ» без оборотов, + на листе есть основная «Итого» +- **THEN** строка «НУ» блока пропускает тождество строки так же, как «Итого развернутое»; `balance_identity_violations` + пуст, `status = done`; строка «НУ» под последней строкой данных в итоговый блок не входит + #### Scenario: «Итого» плоского списка - **WHEN** разбирается синтетическая ОСВ `s40` («Итого» = сумма контрагентов, сальдо «Итого» свёрнуто) - **THEN** `total_row_matches_root = true`, `status = done`; если сумма одного контрагента испорчена — `false`, -- 2.47.2