From f5b73d21461f9cd04ce6e11e0621c1b91c94d46c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 19:52:43 +0400 Subject: [PATCH 01/10] =?UTF-8?q?spec:=20=D0=9E=D0=A1=D0=92=20=D0=B4=D1=80?= =?UTF-8?q?=D1=83=D0=B3=D0=B8=D1=85=20=D1=83=D1=87=D1=91=D1=82=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=B7=D0=BD=D0=B0=D0=BA=D0=B8=20=D0=BB=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B0,=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B,=20?= =?UTF-8?q?=C2=AB=D0=92=D0=A1=D0=95=D0=93=D0=9E=C2=BB=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AURORA-1717, AURORA-1719, AURORA-1732. Co-Authored-By: Claude Opus 5.5 --- .../changes/osv-other-systems/.openspec.yaml | 2 + openspec/changes/osv-other-systems/design.md | 129 ++++++++++++++++++ .../changes/osv-other-systems/proposal.md | 42 ++++++ .../specs/osv-parsing/spec.md | 79 +++++++++++ 4 files changed, 252 insertions(+) create mode 100644 openspec/changes/osv-other-systems/.openspec.yaml create mode 100644 openspec/changes/osv-other-systems/design.md create mode 100644 openspec/changes/osv-other-systems/proposal.md create mode 100644 openspec/changes/osv-other-systems/specs/osv-parsing/spec.md diff --git a/openspec/changes/osv-other-systems/.openspec.yaml b/openspec/changes/osv-other-systems/.openspec.yaml new file mode 100644 index 0000000..1bb6456 --- /dev/null +++ b/openspec/changes/osv-other-systems/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-28 diff --git a/openspec/changes/osv-other-systems/design.md b/openspec/changes/osv-other-systems/design.md new file mode 100644 index 0000000..54b688e --- /dev/null +++ b/openspec/changes/osv-other-systems/design.md @@ -0,0 +1,129 @@ +## Context + +Классификация листа (`detect.classify_sheet`): ОСВ, если в первых `SHEET_MARKER_ROWS` (30) строках есть ячейка, +совпадающая с `sheet_markers`; иначе вид листа определяет пакет `arm_statements` (форма / продолжение / прочее). +`sheet_markers` сейчас один: `оборотно\s*сальдовая\s+ведомость`. Заголовок таблицы (`_find_group_row`) — первая строка, +где есть группы «обороты» и «сальдо на конец». Сторона ищется в начале текста ячейки (`^(дебет|дт|д т)\b`) — в +отдельной ячейке под группой либо в остатке ячейки группы после совпадения (`_group_cells`: «Сальдо на начало Дт»). + +Файлы — банк первичных документов `awp-ca/doc-bank/` (индекс `index/index.csv`, колонки `parse_status`, `parse_error`, +`parse_bug`, `sha256`; в git не кладутся). Списки затронутых файлов с ГК, ЮЛ и sha256 — вложения `список_файлов.csv` +к AURORA-1717 и AURORA-1719. Образцы во вложениях задач: + +| Задача | Файл | Что показывает | +|---|---|---| +| 1717, 1719 | «ОСВ по внб счету 009 за 9 мес 2025.xlsx» (Промлит) | «Оборотная ведомость», «Остаток на начало/конец» | +| 1717, 1719 | «ОСВ 66 67 01-06 2025.xlsx» (Виола) | «Отчет по Главной книге», SAP «Сальдо вх Дт / Сальдо исх Кт» | +| 1717 | «67 9 мес. 25.xlsx» (Айсберри) | Navision «Фин. Обороты Листок», «НачалоДт / КонецКт» | +| 1719 | «ОСВсч62_3кв2025.xls» (НМЖК) | SAP «Сальдо НачальноеДТВВ / КонечноеКТВВ» | +| 1732 | «ОСВ по внб сч 009 за 3 кв 2025.xlsx» (КТЗ) | итог «ВСЕГО» | + +Эксперимент автора задач на копии словарей (20.10.2026, синонимы 1717 + 1719 без «ДТВВ» и без запасного признака): из +129 файлов 1717 — 43 «Готово», 43 «Требует проверки», 43 упираются в группы и иерархию; из 159 файлов 1719 — 53 +«Готово», 58 «Требует проверки», 48 — другое (AURORA-1720, 1721, 1723). Остальные ОСВ банка статусы не ухудшили. + +## Goals / Non-Goals + +**Goals:** +- Листы с перечисленными названиями и листы без названия с полным заголовком таблицы ОСВ распознаются как ОСВ. +- Перечисленные написания групп и сторон находятся; заголовок таблицы этих файлов не падает с `INVALID_FORMAT` + «нет групп сумм». +- «ВСЕГО» — итоговая строка листа. +- Ни один документ банка первички и ни одна фикстура не ухудшает результат (см. «Регрессия»). + +**Non-Goals:** +- Довести все затронутые файлы до «Готово»: дальше их держат иерархия (AURORA-1721, 1730), одинаковые подписи начала и + конца (AURORA-1720), обороты без подзаголовков в варианте НОВО-МАР и SAP со стороной перед группой (AURORA-1722), + ведомости ОС/ТМЦ (AURORA-1723, 1731). Эти файлы могут остаться `needs_check` или `INVALID_FORMAT` по другой причине. +- Отчёт 1С «Обороты счета» (1 файл, ГК Сейма): внутри «Оборот Дт/Кт» колонки корреспондирующих счетов, это отдельная + форма. Синоним `обороты\s+счета` не добавляется. +- Правило «расшифровка без кодов ББ не может быть Готово» (вторая половина AURORA-1718) — пакет `arm_statements`, + ждёт AURORA-1677. + +**Anti-goals (провал, даже если тесты зелёные):** +- Лист формы ББ/ОФР, её продолжения или расшифровки становится ОСВ по запасному признаку или по новому названию + ведомости, хотя ведомостью не является. Ведомость, которую сегодня ложно забирает конвейер форм (10 файлов «Оборотный + баланс», AURORA-1718), становится ОСВ по названию — это цель, а не ухудшение (признак по названию проверяется раньше + форм, как сейчас). +- `if` под файл, организацию, систему-источник или имя листа вместо синонима в YAML. + +## Decisions + +### D1. Запасной признак листа — последним шагом (решение владельца 28.10.2026) + +Порядок классификации: признак ОСВ по названию (`sheet_markers`) → форма / продолжение / прочее (пакет). Только если +пакет вернул «прочее», лист проверяется запасным признаком: в первых `SHEET_MARKER_ROWS` строках есть строка групп, где +найдены **все три** группы (начало, обороты, конец), и у каждой группы найдены **обе** стороны — Дебет и Кредит +(в ячейках под группой или в самой ячейке группы). Свёрнутое сальдо (одна колонка в группе) запасной признак не даёт. +Нашёлся — лист `OSV`, дальше обычный конвейер ОСВ; нет — `SHEET_OTHER`, как сейчас. + +Почему так: расшифровки ББ с колонками «Остаток на начало / … / Остаток на конец» забирает классификатор форм раньше, +до запасного признака они не доходят; поиск заголовка выполняется только на листах «прочее», а не на каждом листе книги +с формами. Требование трёх групп и обеих сторон строже обычного заголовка ОСВ («начало» там необязательно), поэтому +ложное срабатывание требует почти полной таблицы ОСВ. + +### D2. Новые записи словарей + +Ориентир — рекомендации из AURORA-1717/1719/1732; окончательные паттерны подбирает исполнитель по образцам, каждая +запись — с синтетикой, где она срабатывает (R-22). Паттерны пишутся по нормализованному тексту (нижний регистр, «ё» → +«е», дефисы и переносы → пробел). + +| Ключ | Написания | +|---|---| +| `sheet_markers` | «Оборотная ведомость …», «ОСВ по сч. …» (с начала ячейки), «Оборотный баланс», «Фин. Обороты Листок», «Отчет по Главной книге» | +| `groups.opening` | «Остаток на начало (периода)», «Начальное сальдо» (уже есть `\bнач(альн\w*)?\.?\s*сальдо`), «Сальдо начальное…», «Сальдо вх …», «НачалоДт / НачалоКт» | +| `groups.closing` | симметрично: «Остаток на конец», «Конечное сальдо», «Сальдо конечное…», «Сальдо исх …», «КонецДт / КонецКт» | +| `debit` / `credit` | «по дебету» / «по кредиту» (в остатке ячейки группы «Оборот по дебету»); сторона, приклеенная к названию без границы слова («…ДТВВ», «…КТВВ», «НачалоДт») | +| `total_row_exact` | «ВСЕГО» | + +Если сторону без границы слова нельзя выразить записью словаря при текущем `_side` (поиск с начала остатка ячейки), +допустима правка поиска стороны в `detect.py`, общая для всех написаний, без упоминания конкретной системы. + +### D3. Регрессия по банку первички + +До изменений и после — прогон парсера (CLI, `ENV_FILE=.env.test`) по всем файлам банка с `route = парсер` (xls, xlsx, +xlsb) на main `a8c9605` и на ветке. Сравнение по каждому документу: вид (ОСВ / форма / прочее), статус, код ошибки. +Ухудшение — `done` → `needs_check`/ошибка, `needs_check` → ошибка, любой переход форма/расшифровка → ОСВ через запасной +признак, переход форма/расшифровка → ОСВ по названию ведомости или «прочее» → ОСВ, если лист не ведомость (каждый такой +переход по названию просматривается вручную: ожидаемы «Оборотный баланс» и другие ведомости из списков 1717/1719). +Каждое ухудшение разбирается до PR: либо исправляется, либо выносится владельцу с образцом. + +Банк — `awp-ca/doc-bank/` (в worktree — симлинк `.wt/osv-dict/doc-bank`), только чтение. Базовый прогон на main +`a8c9605` снимается до первой правки кода. Скрипт прогона и сравнения и его результаты — `awp-ca/admin/osv-other-systems-regress/` +(не репозиторий; в PR — только итоговая таблица). + +Приёмка: сценарии спеки на синтетике, зелёный `make check-full`, нет ухудшений D3. Число файлов банка, перешедших в +«Готово» / «Требует проверки», — отчёт, а не порог. Реальные безымянные листы, которые конвейер форм относит к форме или +продолжению (до запасного признака они не доходят), остаются как есть и перечисляются в PR как остаток. Итоговая +таблица переходов статусов (было → стало, число файлов) — в описание PR и комментарий к AURORA-1717/1719. Плюс обычные +`make check-full` (golden, property, покрытие словарей) и `tests/fixtures/test_osv_dkb.py`. + +### D4. Правило в 05-domain-rules.md + +§1.1: признак листа — название ведомости по словарю `sheet_markers` (примеры написаний) или, если лист не опознан как +форма ББ/ОФР или её продолжение, полный заголовок таблицы ОСВ в первых 30 строках (D1). §1.2: варианты написания групп +и сторон дополняются написаниями из D2. PR в arm-ka-docs из ветки `feat/81-osv-other-systems` worktree +`.wt/osv-dict/docs`, ссылка на tabular-parser#81. + +## Risks / Trade-offs + +- [`^осв\b` и «оборотн… ведомост» в тексте формы] → проверка ОСВ идёт первой; регрессия D3 ловит листы форм, ставшие + ОСВ; паттерн «ОСВ» — только с начала ячейки. +- [Расшифровка, не узнанная пакетом, проходит запасной признак] → D1 требует три группы и обе стороны; регрессия D3. +- [Новые синонимы групп матчат преамбулу шапки («Остаток на начало: …») как строку групп] → в такой строке нет + «оборотов», `_find_group_row` её пропускает (R-12); синтетика с преамбулой. +- [Сторона без границы слова («дт» внутри слова)] → искать её только в остатке ячейки группы после совпадения группы, не + в произвольных ячейках. + +## Decisions made without discussion + +| Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| 2026-10-28 | R-1. Отчёт 1С «Обороты счета» вне объёма, синоним не добавляется | автор AURORA-1717 проверил: с синонимом файл даёт «Требует проверки» с договорами в контрагентах — нужна отдельная форма | 1 файл остаётся `INVALID_DOCUMENT`; отдельная задача | +| 2026-10-28 | R-2. Запасной признак ищет строку групп только в первых `SHEET_MARKER_ROWS` (30) строках | у безымянных листов таблица начинается сразу; ограничение снижает время и ложные срабатывания на длинных листах «прочее» | безымянный лист с преамбулой длиннее 30 строк не распознаётся; поднимется регрессией D3 | +| 2026-10-28 | R-3. Запасной признак не принимает свёрнутое сальдо (одна колонка в группе) | строгость против ложных ОСВ (D1) | ОСВ без названия со свёрнутым сальдо не распознаётся | +| 2026-10-28 | R-5. Новые `sheet_markers` якорятся к началу ячейки (`^…`); якорь снимается для конкретного названия, только если по спискам 1717 файлы с ним иначе не находятся | «Оборотная ведомость», «Оборотный баланс» могут встретиться в примечаниях к ББ; признак по названию проверяется раньше форм | часть ведомостей с текстом перед названием не распознаётся; видно в регрессии D3 | +| 2026-10-28 | R-6. Запасной признак переиспользует поиск строки групп и колонок обычного заголовка (`_find_group_row`, `_columns_of_group`) в строгом режиме, а не отдельную функцию | запасной признак и конвейер не должны расходиться в том, что считать заголовком | — | +| 2026-10-28 | R-7. Порядок PR: `fixtures` (golden синтетики, отдельный коммит `make golden-update`) → tabular-parser → docs | golden-тесты tabular-parser читают `../fixtures` | — | +| 2026-10-28 | R-8. Приёмка — синтетика + `make check-full` + нет ухудшений D3; число исправленных файлов банка — отчёт, не порог | эксперимент автора задач — ориентир, а не обязательство; остаток держат 1720–1723 | — | +| 2026-10-28 | R-4. AURORA-1722 (SAP со стороной перед группой, НОВО-МАР) не входит | в задачах указан порядок: после 1717/1719; причина варианта НОВО-МАР не разобрана | эти файлы остаются с ошибкой до 1722 | diff --git a/openspec/changes/osv-other-systems/proposal.md b/openspec/changes/osv-other-systems/proposal.md new file mode 100644 index 0000000..55e4901 --- /dev/null +++ b/openspec/changes/osv-other-systems/proposal.md @@ -0,0 +1,42 @@ +## Why + +Прогон банка первичных документов (AURORA-1706, архивы «ДКБ отчетность» 20.10.2026, 1681 табличная ОСВ) показал, что +парсер знает только ведомости 1С с названием «Оборотно-сальдовая ведомость» и написания групп сумм 1С. ОСВ других +учётных систем (SAP, Navision, выгрузки КТЗ) и отчёты 1С с другим названием отклоняются целиком: + +- AURORA-1717 — 119 файлов `INVALID_DOCUMENT` «в книге нет листов ОСВ или форм»: «Оборотная ведомость», «ОСВ по сч.», + «Оборотный баланс», «Фин. Обороты Листок» (Navision), «Отчет по Главной книге», листы без названия; ещё 10 файлов + «Оборотный баланс» получают ложное «Готово» как расшифровка формы (часть AURORA-1718); +- AURORA-1719 — 159 файлов `INVALID_FORMAT` «не найден заголовок таблицы ОСВ»: «Остаток на начало/конец», «Начальное / + Конечное сальдо», «Сальдо вх/исх Дт» (SAP), «НачалоДт/КонецКт» (Navision), «Оборот по дебету/кредиту» (группа и + сторона в одной ячейке), «Сальдо НачальноеДТВВ» (сторона приклеена к названию); +- AURORA-1732 — итог листа «ВСЕГО» (ГК КТЗ, сч. 009) не распознаётся как «Итого» («ИТОГ» и «ИТОГ по …» уже добавлены + AURORA-1714). + +Gitea: RSHB/arm-tabular-parser#81. + +## What Changes + +- Признаки листа ОСВ (`sheet_markers`): синонимы названий ведомости других систем и отчётов 1С. +- Запасной признак листа ОСВ: лист, который не опознан ни как ОСВ по названию, ни как форма ББ/ОФР или её продолжение, + считается ОСВ, если в его первых строках найден полный заголовок таблицы ОСВ (три группы сумм, у каждой — Дебет и + Кредит). Проверка выполняется последней, только для листов, которые иначе ушли бы в «прочее» (`SHEET_OTHER`). +- Синонимы групп сумм (`groups.opening`, `groups.closing`) и сторон (`debit`, `credit`), включая сторону в одной + ячейке с группой («Оборот по дебету», «НачалоДт») и приклеенную к названию группы («НачальноеДТВВ»). +- Итоговая строка «ВСЕГО» (`total_row_exact`). +- Синтетика на каждое новое написание (правило покрытия словарей R-22). +- `05-domain-rules.md` §1.1–§1.2 — правило поиска листа и написания групп (отдельный PR в arm-ka-docs, та же ветка + `feat/81-osv-other-systems`). + +## Capabilities + +### Modified Capabilities +- `osv-parsing`: признак листа ОСВ — название ведомости или (запасной путь) полный заголовок таблицы; написания групп и + сторон других учётных систем. + +## Impact + +- `api/rules/osv_headers.yaml`, `api/src/parser/detect.py` (классификация листа, поиск стороны), синтетика + `tests/synthetic/generate.py`, golden-снимки синтетики (отдельный коммит в `fixtures`). +- Контракт результата не меняется. arm-core не меняется. +- Пакет `arm_statements` не меняется (не ждёт AURORA-1677). diff --git a/openspec/changes/osv-other-systems/specs/osv-parsing/spec.md b/openspec/changes/osv-other-systems/specs/osv-parsing/spec.md new file mode 100644 index 0000000..644a002 --- /dev/null +++ b/openspec/changes/osv-other-systems/specs/osv-parsing/spec.md @@ -0,0 +1,79 @@ +## MODIFIED Requirements + +### Requirement: Листы ОСВ находятся по содержимому +Парсер SHALL считать листом ОСВ любой лист, где ячейка в первых 30 строках листа (`SHEET_MARKER_ROWS`: шапка ведомости +всегда над таблицей) матчит признак ОСВ из `rules/osv_headers.yaml` (без учёта регистра, «ё/е», переносов строк и +дефисов). Признаки ОСВ — названия ведомости разных учётных систем («Оборотно-сальдовая ведомость», «Оборотная +ведомость», «ОСВ по сч.», «Оборотный баланс», «Фин. Обороты», «Отчет по Главной книге»). Лист с признаками формы ББ/ОФР +или её продолжения уходит в конвейер форм (спека `statement-parsing`). Лист, не опознанный ни как ОСВ по признаку, ни +как форма или её продолжение, SHALL считаться листом ОСВ по запасному признаку, если в первых `SHEET_MARKER_ROWS` +строках есть строка групп сумм со всеми тремя группами («начало», «обороты», «конец») и у каждой группы найдены обе +стороны — «Дебет» и «Кредит»; свёрнутое сальдо (одна колонка в группе) запасного признака не даёт. Запасной признак +MUST проверяться только после классификации форм и MUST NOT отнимать у конвейера форм лист формы, её продолжения или +расшифровки. Прочие листы SHALL пропускаться с предупреждением файла `SHEET_OTHER` (`severity = info`, +`locator = sheet:<имя>`) в `metrics.warnings`. Книга без единого листа ОСВ и без форм → `status = error`, +`error.code = INVALID_DOCUMENT` («в книге нет листов ОСВ или форм»). Несколько листов ОСВ → несколько `documents` с +`locator = sheet:<имя>`. Имя листа MUST NOT использоваться для поиска. + +#### Scenario: Один лист — одна ОСВ (все реальные фикстуры) +- **WHEN** разбирается любой файл из `fixtures/osv/**` +- **THEN** `documents` содержит ровно один `osvDocument` с `locator = "sheet:Лист_1"`, статус `done` + +#### Scenario: Два листа ОСВ и посторонний лист +- **WHEN** разбирается синтетический файл с листами «ОСВ 60», «Справка», «ОСВ 62» +- **THEN** `documents` содержит два документа в порядке листов, в `metrics.warnings` есть `SHEET_OTHER` с `locator = "sheet:Справка"` + +#### Scenario: Нет ОСВ +- **WHEN** разбирается книга с одним листом произвольной таблицы +- **THEN** `status = error`, `error.code = INVALID_DOCUMENT`, сообщение называет число просмотренных листов + +#### Scenario: Название ведомости другой учётной системы +- **WHEN** разбирается синтетическая ОСВ, у которой в шапке вместо «Оборотно-сальдовая ведомость» стоит одно из + названий «Оборотная ведомость по счету 009», «ОСВ по сч. 41 за 3 кв.», «Оборотный баланс», «Фин. Обороты Листок», + «Отчет по Главной книге» +- **THEN** лист разобран как ОСВ (`osvDocument`), `SHEET_OTHER` для него нет + +#### Scenario: Лист без названия ведомости — по заголовку таблицы +- **WHEN** разбирается синтетический лист без названия ведомости, таблица начинается в первых строках с групп + «НачалоДт | НачалоКт | ОборотДт | ОборотКт | КонецДт | КонецКт» (Navision) +- **THEN** лист разобран как ОСВ по запасному признаку + +#### Scenario: Расшифровка формы не становится ОСВ +- **WHEN** разбирается книга с формой ББ и листом расшифровки с колонками «Остаток на начало | Поступило | Выбыло | + Остаток на конец» +- **THEN** вид листа расшифровки тот же, что без запасного признака (форма или продолжение конвейера форм либо + `SHEET_OTHER`), документа ОСВ из него нет + +#### Scenario: Неполная таблица не даёт запасного признака +- **WHEN** лист без названия ведомости содержит группы «обороты» и «конец», но без группы «начало» или со свёрнутым + сальдо (одна колонка в группе) +- **THEN** лист пропускается с `SHEET_OTHER` + +## ADDED Requirements + +### Requirement: Написания групп и сторон других учётных систем +Словари `groups`, `debit`, `credit` SHALL знать написания групп и сторон выгрузок SAP, Navision и других систем: «Остаток +на начало/конец (периода)», «Начальное/Конечное сальдо», «Сальдо начальное/конечное», «Сальдо вх/исх», «НачалоДт/Кт», +«КонецДт/Кт». Сторона SHALL находиться и в отдельной ячейке под группой, и в самой ячейке группы после её названия — в +том числе в форме «по дебету»/«по кредиту» («Оборот по дебету») и приклеенной к названию без пробела («Сальдо +НачальноеДТВВ», «НачалоДт»). Сторона без границы слова SHALL искаться только в остатке ячейки группы после совпадения +группы. Итоговая строка «ВСЕГО» SHALL распознаваться как итог листа наравне с «Итого» и «ИТОГ». + +#### Scenario: Остаток и оборот по дебету +- **WHEN** разбирается синтетическая ОСВ с группами «Остаток на начало периода | Оборот по дебету | Оборот по кредиту | + Остаток на конец периода» и подзаголовками «Дебет | Кредит» под остатками +- **THEN** найдены все шесть колонок сумм; обороты — из ячеек «Оборот по дебету» и «Оборот по кредиту» + +#### Scenario: Сторона приклеена к названию +- **WHEN** разбирается синтетическая ОСВ с заголовком «Сальдо НачальноеДТВВ | Сальдо НачальноеКТВВ | Оборот ДТ | Оборот + КТ | Сальдо КонечноеДТВВ | Сальдо КонечноеКТВВ» +- **THEN** найдены все шесть колонок сумм с верными сторонами + +#### Scenario: SAP «Сальдо вх/исх» +- **WHEN** разбирается синтетическая ОСВ с группами «Сальдо вх Дт | Сальдо вх Кт | Оборот Дт | Оборот Кт | Сальдо исх Дт + | Сальдо исх Кт» +- **THEN** найдены все шесть колонок сумм + +#### Scenario: Итог «ВСЕГО» +- **WHEN** разбирается синтетическая ОСВ, итоговая строка которой подписана «ВСЕГО» +- **THEN** строка — `TOTAL`, предупреждения «строка «Итого» не найдена» нет From bb9eb2965955fb4956d7326ec81c8a5021554909 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 20:07:03 +0400 Subject: [PATCH 02/10] =?UTF-8?q?plan:=20=D0=9E=D0=A1=D0=92=20=D0=B4=D1=80?= =?UTF-8?q?=D1=83=D0=B3=D0=B8=D1=85=20=D1=83=D1=87=D1=91=D1=82=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=20(#81)?= 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-28-osv-other-systems.md | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-28-osv-other-systems.md diff --git a/docs/superpowers/plans/2026-10-28-osv-other-systems.md b/docs/superpowers/plans/2026-10-28-osv-other-systems.md new file mode 100644 index 0000000..64048db --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-osv-other-systems.md @@ -0,0 +1,262 @@ +# ОСВ других учётных систем — план реализации + +> **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:** листы ОСВ SAP, Navision, выгрузок КТЗ и отчётов 1С с другим названием распознаются как ОСВ: признаки листа по +названию, запасной признак по полному заголовку таблицы, написания групп и сторон, итог «ВСЕГО». + +**Architecture:** новые написания — только записи `api/rules/osv_headers.yaml` + синтетика на каждую (R-22). Код меняется +в одном месте: запасной признак `has_osv_table` в `detect.py`, который `plan_sheets` вызывает для листа, попавшего бы в +«прочее». Регрессия — прогон банка первички до и после, сравнение по документам. + +**Tech Stack:** Python 3.11, pytest, ruff, mypy; openpyxl (генератор синтетики); OpenSpec. + +**Spec:** `tabular-parser/openspec/changes/osv-other-systems/` (proposal.md, design.md, specs/osv-parsing/spec.md). +Правило — `docs/context/05-domain-rules.md` §1.1–§1.2 (меняется в Task 5). Issue RSHB/arm-tabular-parser#81. Jira +AURORA-1717, AURORA-1719, AURORA-1732. Ветка `feat/81-osv-other-systems` в трёх worktree: `.wt/osv-dict/tabular-parser`, +`.wt/osv-dict/fixtures`, `.wt/osv-dict/docs` (корень `W = /Users/andrey.epifancev/projects/awp-ca/.wt/osv-dict`). + +## Global Constraints + +- Никаких `if` под файл, организацию, систему-источник или имя листа: новое написание = запись YAML + синтетика (CLAUDE.md + tabular-parser, «Правила расширения»). +- Каждая новая запись словаря срабатывает хотя бы на одной синтетике (`tests/fixtures/test_rules_coverage.py`, R-22). +- Паттерны пишутся по нормализованному тексту: нижний регистр, «ё» → «е», дефисы/тире/переносы/nbsp → пробел, пробелы + схлопнуты (шапка `osv_headers.yaml`). +- Новые `sheet_markers` — с якорем `^` (R-5); якорь снимается для названия, только если файлы списка 1717 без этого не + находятся (записать в design.md, раздел решений). +- Запасной признак: только лист, который `plan_sheets` иначе отнёс бы к «прочее»; строка групп в первых + `SHEET_MARKER_ROWS` (30) строках; все три группы; у каждой — и Дебет, и Кредит; свёрнутое сальдо не годится (D1, R-2, + R-3, R-6). +- Пакет `api/arm_statements/` не трогать. Контракт не меняется. +- Код — только Edit/Write, не sed/скрипты через Bash (правило владельца 18.10.2026). cwd оболочки держать в + `W/tabular-parser`; fixtures и docs править по абсолютным путям, git — `git -C <абсолютный путь>`. +- Файлы банка первички (`/Users/andrey.epifancev/projects/awp-ca/doc-bank/`) — только чтение, в git не кладутся. +- Golden правится только `make golden-update` с просмотром diff, отдельным коммитом в fixtures. +- Комментарии, сообщения коммитов — по-русски; коммиты заканчиваются строкой + `Co-Authored-By: Claude Opus 5.5 `. + +## Review Focus + +1. Расшифровка ББ, которую пакет не узнал как форму («Остаток на начало | Поступило | Выбыло | Остаток на конец»), + не становится ОСВ: в ней нет оборотов → `has_osv_table` ложь (тест в Task 3). +2. Преамбула шапки «Остаток на начало: … | Остаток на конец: …» над таблицей не принимается за строку групп: в ней нет + «оборотов» (тест в Task 2 на синтетике s49 — преамбула выше таблицы). +3. Сторона без границы слова («ДТВВ») не ловится в ячейках, где нет группы: «Дт» внутри текста контрагента + («ООО Дтвв-Сервис») не даёт стороны (тест в Task 2). +4. Лист «прочее» после листа формы, который сейчас идёт как слабое продолжение (`is_weak_continuation`), остаётся + продолжением — запасной признак проверяется только в ветке «прочее» (тест в Task 3). +5. Книга с формами и без единого листа ОСВ: на листах форм `has_osv_table` не вызывается (тест в Task 3 — счётчик + вызовов на книге s14). + +--- + +### Task 1: Окружение и базовый прогон банка первички + +**Files:** +- Create: `/Users/andrey.epifancev/projects/awp-ca/admin/osv-other-systems-regress/regress.py` +- Create: `/Users/andrey.epifancev/projects/awp-ca/admin/osv-other-systems-regress/headers.md` +- Create (вывод): `.../osv-other-systems-regress/base.csv` + +**Interfaces:** +- Produces: `regress.py run ` и `regress.py diff `; `headers.md` — точные тексты шапок и + заголовков реальных образцов для синтетики Task 2–3. + +- [ ] **Step 1: venv.** `cd W/tabular-parser && make venv` → `api/.venv/bin/python` есть; `make check` зелёный на + исходной ветке (golden и покрытие читают `../fixtures` — это worktree fixtures на той же базе `f1f7f83`). + +- [ ] **Step 2: `regress.py run`.** Обходит `doc-bank/index/index.csv`, строки с `route == "парсер"`; разбирает файл + в процессе тем же путём, что `src.parser.cli` (`get_parser(Settings())`, тип файла — по расширению), запуск из + `W/tabular-parser/api` с `ENV_FILE=.env.test` и `PYTHONPATH=.`. Пишет CSV, по строке на документ (на файл без + документов — одна строка): `sha256, name, locator, kind (osv|BALANCE|INCOME|…|none), status, error_code, + sheet_other_count`. Исключение парсера — строка с `error_code = EXCEPTION:<класс>`, прогон не прерывается. + +- [ ] **Step 3: `regress.py diff`.** Сопоставляет по `(sha256, locator)`; печатает таблицу переходов `(kind, status) → + (kind, status)` с числом файлов и список ухудшений по D3: `done → needs_check|error`, `needs_check → error`, любой + `kind` формы → `osv`, `none/other → osv`. Переходы в `osv` помечает «проверить вручную» с именем файла. + +- [ ] **Step 4: базовый прогон.** `regress.py run base.csv` на текущем HEAD ветки (код = main `a8c9605`). Ожидаемо по + индексу: ~1681 файл; среди ошибок — `INVALID_DOCUMENT` у файлов из списка 1717. + +- [ ] **Step 5: образцы заголовков.** Из `doc-bank` найти по `index.csv` (`parse_bug` содержит `AURORA-1717`/`1719`) + по одному файлу на каждое написание из таблиц AURORA-1717 и 1719 (+ «ВСЕГО» из 1732) и выписать в `headers.md` + дословно: строку названия, строку(и) групп и подзаголовков (ячейка за ячейкой), подпись итога. Особо: как у Navision + подписан оборот («ОборотДт»? «Оборот Дт»?), как у SAP НМЖК — «Оборот» со стороной. + +Коммита нет (каталог `admin/` не репозиторий). + +--- + +### Task 2: Словари и синтетика написаний (1717 названия, 1719 группы и стороны, 1732 «ВСЕГО») + +**Files:** +- Modify: `api/rules/osv_headers.yaml` (`sheet_markers`, `groups.opening`, `groups.closing`, `groups.turnover` — если + нужно для «ОборотДт», `debit`, `credit`, `total_row_exact`) +- Modify: `api/tests/synthetic/generate.py` (`OsvSpec`, `_header_v1`, `_header_rows`, `build_osv`, новые `_s48…_s50`, + `FILE_NAMES`, docstring модуля) +- Modify: `api/tests/synthetic/test_synthetic_files.py` (`EXPECTED`, список файлов, новые тесты) +- Modify: `api/tests/unit/parser/test_detect.py` +- Create (генерация): `W/fixtures/osv/synthetic/s48_*.xlsx`, `s49_*.xlsx`, `s50_*.xlsx`; Modify: + `W/fixtures/osv/synthetic/README.md` + +**Interfaces:** +- Consumes: `headers.md` из Task 1. +- Produces: поля `OsvSpec` (используются в Task 3): + - `title: str | None = None` — строка названия вместо «Оборотно-сальдовая ведомость по счету {account} за {period}» + (`None` — как сейчас); + - `untitled: bool = False` — без строк организации, названия, параметров: заголовок таблицы с первой строки; + - `split_groups: dict[str, tuple[str, str]] = {}` — группы, у которых вместо слитого названия + «Дебет/Кредит» две + ячейки в строке групп со своими текстами (сторона в ячейке группы); если разделены все три группы, строки + подзаголовков нет; + - `total_text: str = TOTAL_TEXT`. + +- [ ] **Step 1: падающие тесты синтетики.** Тексты брать из `headers.md`; ниже — ориентир: + - `s48_other_system_titles.xlsx` — пять листов `build_osv` с `title`: «Оборотная ведомость по счету 60 за 1 квартал + 2025 г.», «ОСВ по сч. 60 за 1 квартал 2025 г.», «Оборотный баланс …», «Фин. Обороты Листок», «Отчет по Главной + книге …»; у последнего листа `total_text="ВСЕГО"`. Тест `test_s48_other_system_titles_are_osv`: 5 документов + `osvDocument` с локаторами листов, `SHEET_OTHER` нет; у листа «ВСЕГО» строка итога `TOTAL`, предупреждения + «строка «Итого» не найдена» нет. + - `s49_balance_and_turnover_by_side.xlsx` — `group_titles` «Остаток на начало периода» / «Остаток на конец периода», + `split_groups={"turnover": ("Оборот по дебету", "Оборот по кредиту")}`; над таблицей преамбула «Остаток на + начало: 0 | Остаток на конец: 0» (Review Focus 2). Тест: шесть колонок сумм, суммы `base_tree()` верны, статус + `done`. + - `s50_sap_in_out_glued_side.xlsx` — два листа: SAP «Сальдо вх Дт | Сальдо вх Кт | Оборот Дт | Оборот Кт | Сальдо + исх Дт | Сальдо исх Кт» (все три `split_groups`), SAP НМЖК «Сальдо НачальноеДТВВ | Сальдо НачальноеКТВВ | … | + Сальдо КонечноеКТВВ»; у контрагента имя «ООО Дтвв-Сервис» (Review Focus 3). Тест: оба листа — шесть колонок с + верными сторонами, суммы как у дерева, «ООО Дтвв-Сервис» — строка контрагента. + - `test_detect.py::test_side_without_word_boundary_only_in_group_cell` — сетка `sheet_from_rows`: заголовок «Сальдо + НачальноеДТВВ | …», ниже строка с текстом «Дтвв» в колонке сумм → `find_table_header` берёт колонки только из + строки групп. + Добавить имена в `EXPECTED` (s48 — `documents=5`), в список файлов и `FILE_NAMES`. + +- [ ] **Step 2: генерация и прогон — тесты падают.** `cd api && .venv/bin/python -m tests.synthetic.generate + W/fixtures/osv/synthetic` → `.venv/bin/pytest tests/synthetic tests/unit/parser/test_detect.py -k "s48 or s49 or s50 + or word_boundary"` — FAIL (`INVALID_DOCUMENT`/`INVALID_FORMAT`). + +- [ ] **Step 3: записи словаря.** Ориентир (уточнить по `headers.md`; ловушки: `\w*` жадно съест приклеенную сторону — + «сальдо\s+начальн\w*» оставит пустой остаток вместо «дтвв»; `\bоборот(ы)?\b` не матчит «оборотдт»): + - `sheet_markers`: `^оборотн\w*\s+ведомост`, `^осв\b`, `^оборотн\w*\s+баланс`, `^фин\.?\s*обороты`, + `^отчет\s+по\s+главной\s+книге`; + - `groups.opening`: `остат\w*\s+на\s+начало`, `сальдо\s+начальное`, `сальдо\s+вх\b`, `^начало(?=\s*(дт|кт))`; + - `groups.closing`: `остат\w*\s+на\s+конец`, `сальдо\s+конечное`, `сальдо\s+исх\b`, `^конец(?=\s*(дт|кт))`; + - `groups.turnover` (если `headers.md` подтверждает «ОборотДт» без пробела): `^оборот(?=\s*(дт|кт))`; + - `debit`: `^по\s+дебету\b`, `^дт\s*вв\b`; `credit`: `^по\s+кредиту\b`, `^кт\s*вв\b`; + - `total_row_exact`: `^всего$` (комментарий: ГК КТЗ, сч. 009, AURORA-1732). + Каждой записи — комментарий с источником (AURORA-…, система). Если сторону без границы слова нельзя выразить + записью (остаток ячейки не начинается со стороны), правка `_side`/`_group_cells` общая, без упоминания системы + (design D2), и тест на неё. + +- [ ] **Step 4: тесты зелёные.** Тот же `pytest` → PASS. `tests/fixtures/test_golden.py` требует снимок на каждый + синтетический файл: `make golden-update`, в diff — только новые снимки s48–s50 (изменился старый — стоп, разобрать). + Затем `make check` → PASS (покрытие словарей видит новые записи на s48–s50). + +- [ ] **Step 5: коммиты.** tabular-parser: `rules/osv_headers.yaml`, `tests/synthetic/*`, `tests/unit/parser/test_detect.py` + — «ОСВ: названия ведомостей, группы и стороны других систем, «ВСЕГО» (#81)». fixtures (`git -C W/fixtures`): + `osv/synthetic/s48…s50`, README — «Синтетика s48–s50 под tp#81»; снимки `golden/expected_osv_parse/s48…s50` — + отдельным коммитом «Golden синтетики s48–s50 под tp#81». + +--- + +### Task 3: Запасной признак листа ОСВ по заголовку таблицы + +**Files:** +- Modify: `api/src/parser/detect.py` (новая `has_osv_table`, `classify_sheet`, `__all__`) +- Modify: `api/src/parser/statements/adapter.py:56-83` (`plan_sheets`) +- Modify: `api/src/parser/book.py:146` +- Modify: `api/tests/synthetic/generate.py` (`_s51`), `api/tests/synthetic/test_synthetic_files.py` +- Test: `api/tests/unit/parser/test_detect.py`, `api/tests/unit/parser/statements/test_adapter.py` (тесты `plan_sheets`), `api/tests/unit/parser/test_book_parser.py` + +**Interfaces:** +- Consumes: поля `OsvSpec` из Task 2 (`untitled`, `split_groups`). +- Produces: + - `detect.has_osv_table(sheet: Sheet, rules: Rules) -> bool` — в первых `SHEET_MARKER_ROWS` строках есть строка, где + `_group_cells` даёт все три группы, и `_columns_of_group` для каждой возвращает `debit` и `credit` (не `collapsed`); + `InvalidFormatError` из `_columns_of_group` → `False`. Без дедлайна (≤ 30 строк). + - `plan_sheets(sheets, is_osv, rules, deadline, osv_by_table: Callable[[Sheet], bool] | None = None) -> SheetPlan` — + в ветке `else` (лист пошёл бы в `other`): если `osv_by_table` задан и истинен — лист в `osv`, `last_type = None`; + иначе `other`, как сейчас. + - `classify_sheet`: если пакет дал «прочее» и `has_osv_table` — `SheetKind.OSV` (согласованность с `plan_sheets`). + - `book.py`: `plan_sheets(..., osv_by_table=lambda sheet: has_osv_table(sheet, self._rules))`. + +- [ ] **Step 1: падающие тесты.** + - `test_detect.py`: + - `test_has_osv_table_navision_split_sides` — `sheet_from_rows`: первая строка «ФинСчет | НачалоДт | НачалоКт | + ОборотДт | ОборотКт | КонецДт | КонецКт» (тексты — из `headers.md`), строки данных → `True`; + - `test_has_osv_table_requires_opening` — то же без «НачалоДт/Кт» → `False`; + - `test_has_osv_table_rejects_collapsed` — «Сальдо на начало | Обороты (Дебет/Кредит) | Сальдо на конец» с одной + колонкой у начала → `False`; + - `test_has_osv_table_rejects_disclosure` — «Наименование | Остаток на начало | Поступило | Выбыло | Остаток на + конец» → `False` (Review Focus 1); + - `test_has_osv_table_only_first_rows` — полный заголовок в строке 31 → `False`; + - `test_classify_sheet_falls_back_to_table` — Navision-сетка → `SheetKind.OSV`. + - тест `plan_sheets`: + - `test_plan_sheets_table_fallback_only_for_other` — лист формы, затем Navision-лист, затем «Справка» → `osv` + содержит только Navision-лист, «Справка» в `other`; `osv_by_table` вызван только для двух последних листов + (счётчик вызовов, Review Focus 5); + - `test_plan_sheets_weak_continuation_not_taken` — лист формы, затем лист из 1–2 кодов (слабое продолжение, + образец — существующие тесты `is_weak_continuation`) → остаётся `STATEMENT_CONTINUATION`, `osv_by_table` не + вызван (Review Focus 4). + - синтетика `s51_untitled_navision.xlsx` (`build_osv` с `untitled=True`, `split_groups` для трёх групп по + `headers.md`) и `test_s51_untitled_sheet_is_osv_by_table`: один `osvDocument`, `SHEET_OTHER` нет, суммы дерева + верны; `EXPECTED` — статус по факту разбора шапки без периода (ожидаемо `needs_check` из-за ненайденного + периода/организации; если иначе — зафиксировать фактический и объяснить в комментарии). + - `test_book_parser.py::test_statement_book_sheets_unchanged_by_fallback` — книга s14 (формы + ОСВ): виды листов те же, + что без запасного признака. + +- [ ] **Step 2: тесты падают.** `make venv` уже есть; `cd api && .venv/bin/python -m tests.synthetic.generate + W/fixtures/osv/synthetic && .venv/bin/pytest tests/unit/parser tests/synthetic -k "osv_table or plan_sheets or + s51 or fallback"` → FAIL (`ImportError: has_osv_table`, `INVALID_DOCUMENT`). + +- [ ] **Step 3: реализация** по Interfaces. `has_osv_table` переиспользует `_group_cells` и `_columns_of_group` (R-6), + отдельного разбора сторон нет. + +- [ ] **Step 4: тесты зелёные.** Тот же `pytest` → PASS; `make golden-update` — в diff только новый снимок s51; + `make check` → PASS. + +- [ ] **Step 5: коммиты.** tabular-parser: «ОСВ: запасной признак листа по заголовку таблицы (#81, AURORA-1717)». + fixtures: `osv/synthetic/s51…`, README — «Синтетика s51 под tp#81»; снимок s51 — отдельным коммитом. + +--- + +### Task 4: Golden, регрессия банка, check-full + +**Files:** +- Modify (генерация): `W/fixtures/golden/expected_osv_parse/*.json` +- Create (вывод): `/Users/andrey.epifancev/projects/awp-ca/admin/osv-other-systems-regress/new.csv`, `report.md` + +- [ ] **Step 1: golden.** `make golden-check` → код 0; `git -C W/fixtures diff --stat f1f7f83 -- golden/` — только + добавленные снимки s48–s51, ни одного изменённого (изменённый — стоп, разобрать с объяснением в design.md). + +- [ ] **Step 2: прогон ветки.** `regress.py run new.csv`; `regress.py diff base.csv new.csv > report.md`. + +- [ ] **Step 3: разбор.** Каждое ухудшение и каждый переход в `osv` из формы/«прочее» — открыть файл, решить: ведомость + (ожидаемо: «Оборотный баланс» 1718 и файлы списков 1717/1719) или ложное срабатывание. Ложное — правка записи словаря + или якоря, повтор Task 2 Step 4 и этого шага. Не устраняется — стоп, вопрос владельцу с файлом и тем, что видно. + В `report.md` — таблица переходов, список ручных проверок с вердиктом, файлы списков 1717/1719, оставшиеся с + ошибкой, с причиной (1720–1723, «Обороты счета», безымянный лист, ушедший в форму). + +- [ ] **Step 4: `make check-full`** (поднимает compose для интеграционных тестов) → PASS. Также + `.venv/bin/pytest tests/fixtures/test_osv_dkb.py` → PASS. + +--- + +### Task 5: Правило 05-domain-rules, документация сервиса + +**Files:** +- Modify: `W/docs/context/05-domain-rules.md` §1.1 (поиск листа), §1.2 (написания групп и сторон); строка «Итого» — + где перечислены итоговые подписи (§1.3 или §1.4) +- Modify: `W/tabular-parser/README.md` (раздел «Парсер ОСВ»: признаки листа, запасной признак), `W/tabular-parser/CLAUDE.md` + (перечень синтетики s48–s51 в абзаце golden), docstring `generate.py` (s48–s51) +- Modify: `W/tabular-parser/openspec/changes/osv-other-systems/design.md` — решения, принятые по ходу (R-9…), и + сводка регрессии из `report.md` + +- [ ] **Step 1: §1.1** по design D4: признак — название ведомости по `sheet_markers` (перечислить написания) или, + если лист не опознан как форма или продолжение, полный заголовок таблицы ОСВ в первых 30 строках: три группы, у каждой + Дебет и Кредит, без свёрнутого сальдо. §1.2 — написания групп и сторон из Task 2 (сторона в ячейке группы, «по + дебету», приклеенная «ДТВВ»). «ВСЕГО» — рядом с «ИТОГ». + +- [ ] **Step 2: проверки.** `openspec validate osv-other-systems --strict` → valid; `make check` → PASS (docstring и + README не ломают линтеры). + +- [ ] **Step 3: коммиты.** docs (`git -C W/docs`): «05 §1.1–§1.2: ОСВ других учётных систем (tp#81)»; tabular-parser: + «Документация: признаки листа ОСВ, синтетика s48–s51 (#81)». From 79aff91b886c08b651c547f085ee8cd74220ed01 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 20:14:42 +0400 Subject: [PATCH 03/10] =?UTF-8?q?plan:=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BD=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=20=D1=81=D0=B8=D0=BD=D1=82=D0=B5=D1=82=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20=E2=80=94=20=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=B8=D0=B2=D1=8B?= =?UTF-8?q?=20=D0=B8=20=D0=BD=D0=B5=D0=B3=D0=B0=D1=82=D0=B8=D0=B2=D1=8B=20?= =?UTF-8?q?(#81)?= 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-28-osv-other-systems.md | 149 +++++++++++------- 1 file changed, 89 insertions(+), 60 deletions(-) diff --git a/docs/superpowers/plans/2026-10-28-osv-other-systems.md b/docs/superpowers/plans/2026-10-28-osv-other-systems.md index 64048db..b711cb4 100644 --- a/docs/superpowers/plans/2026-10-28-osv-other-systems.md +++ b/docs/superpowers/plans/2026-10-28-osv-other-systems.md @@ -51,12 +51,15 @@ AURORA-1717, AURORA-1719, AURORA-1732. Ветка `feat/81-osv-other-systems` в --- -### Task 1: Окружение и базовый прогон банка первички +### Task 1: Базовая линия: банк первички, образцы, контрольный набор синтетики **Files:** - Create: `/Users/andrey.epifancev/projects/awp-ca/admin/osv-other-systems-regress/regress.py` - Create: `/Users/andrey.epifancev/projects/awp-ca/admin/osv-other-systems-regress/headers.md` -- Create (вывод): `.../osv-other-systems-regress/base.csv` +- Create (вывод): `.../osv-other-systems-regress/base.csv`, `control-base.md` +- Modify: `api/tests/synthetic/generate.py` (`OsvSpec`, `_header_v1`, `_header_rows`, `build_osv`, `table_sheet`, + `_s48…_s57`, `FILE_NAMES`, docstring модуля), `api/tests/synthetic/test_synthetic_files.py` +- Create (генерация): `W/fixtures/osv/synthetic/s48…s57`; Modify: `W/fixtures/osv/synthetic/README.md` **Interfaces:** - Produces: `regress.py run ` и `regress.py diff `; `headers.md` — точные тексты шапок и @@ -83,55 +86,82 @@ AURORA-1717, AURORA-1719, AURORA-1732. Ветка `feat/81-osv-other-systems` в дословно: строку названия, строку(и) групп и подзаголовков (ячейка за ячейкой), подпись итога. Особо: как у Navision подписан оборот («ОборотДт»? «Оборот Дт»?), как у SAP НМЖК — «Оборот» со стороной. -Коммита нет (каталог `admin/` не репозиторий). +- [ ] **Step 6: генератор.** В `api/tests/synthetic/generate.py` поля `OsvSpec` (см. Interfaces ниже) и их поддержка в + `_header_v1`/`_header_rows`/`build_osv`; существующие s01–s47 побайтно не меняются (`test_generate.py` и повторная + генерация в `W/fixtures/osv/synthetic` — `git -C W/fixtures status` чистый по старым файлам). ---- +- [ ] **Step 7: контрольный набор синтетики** — файлы и тесты по таблице ниже, тексты подписей — из `headers.md`, где + есть реальный образец. Набор пишется до правок словаря и кода и должен уметь опровергнуть успех: позитивы проверяют + не только «лист — ОСВ», но и точные суммы по сторонам (перепутанные Дебет/Кредит ломают тождество строки и суммы); + негативы проверяют, что вид листа и результат не изменились. -### Task 2: Словари и синтетика написаний (1717 названия, 1719 группы и стороны, 1732 «ВСЕГО») + Позитивы (на базе падают, после Task 2–3 проходят): -**Files:** -- Modify: `api/rules/osv_headers.yaml` (`sheet_markers`, `groups.opening`, `groups.closing`, `groups.turnover` — если - нужно для «ОборотДт», `debit`, `credit`, `total_row_exact`) -- Modify: `api/tests/synthetic/generate.py` (`OsvSpec`, `_header_v1`, `_header_rows`, `build_osv`, новые `_s48…_s50`, - `FILE_NAMES`, docstring модуля) -- Modify: `api/tests/synthetic/test_synthetic_files.py` (`EXPECTED`, список файлов, новые тесты) -- Modify: `api/tests/unit/parser/test_detect.py` -- Create (генерация): `W/fixtures/osv/synthetic/s48_*.xlsx`, `s49_*.xlsx`, `s50_*.xlsx`; Modify: - `W/fixtures/osv/synthetic/README.md` + | Файл | Что в нём | Тест и проверки | + |---|---|---| + | `s48_other_system_titles.xlsx` | листы `build_osv` с `title`: «Оборотная ведомость по счету 60 за 1 квартал 2025 г.», «ОСВ по сч. 60 …», «Оборотный баланс …», «Фин. Обороты Листок» (+ строка «Период: …»), «Отчет по Главной книге …»; ещё два листа — название во второй колонке и «ОБОРОТНАЯ\nВЕДОМОСТЬ по счету 60» (верхний регистр, перенос); у одного листа `total_text="ВСЕГО"`, у другого `"Всего "` | `test_s48_other_system_titles_are_osv`: 7 `osvDocument` по листам, `SHEET_OTHER` нет; у листов «ВСЕГО» итог — `TOTAL`, предупреждения «строка «Итого» не найдена» нет; суммы `base_tree()` | + | `s49_balance_and_turnover_by_side.xlsx` | «Остаток на начало периода» / «Остаток на конец периода» с «Дебет/Кредит» под ними, `split_groups={"turnover": ("Оборот по дебету", "Оборот по кредиту")}`; над таблицей преамбула «Остаток на начало: 0 \| Остаток на конец: 0» (Review Focus 2) | шесть колонок, суммы дерева, статус `done` | + | `s50_sap_in_out_glued_side.xlsx` | лист SAP «Сальдо вх Дт \| Сальдо вх Кт \| Оборот Дт \| Оборот Кт \| Сальдо исх Дт \| Сальдо исх Кт»; лист НМЖК «Сальдо НачальноеДТВВ \| … \| Сальдо КонечноеКТВВ»; контрагент «ООО Дтвв-Сервис» (Review Focus 3) | оба листа — шесть колонок с верными сторонами, суммы дерева, «ООО Дтвв-Сервис» — строка контрагента, `done` | + | `s51_untitled_navision.xlsx` | `untitled=True`, три `split_groups` «НачалоДт/НачалоКт», «Оборот…», «КонецДт/КонецКт» (по `headers.md`), первая колонка «ФинСчет» | один `osvDocument`, `SHEET_OTHER` нет, суммы дерева; статус — фактический (без шапки ожидаемо `needs_check` по периоду/организации), зафиксировать в `EXPECTED` с комментарием | + | `s52_mixed_book.xlsx` | `statement_sheet()` (форма ББ) → лист как в s51 → `reference_sheet()` | документ формы тот же, что у книги без Navision-листа; Navision — `osvDocument`; «Справка» — `SHEET_OTHER` | + | `s53_balance_by_side.xls`, `s54_untitled_navision.xlsb` | s49 через `to_xls`, s51 через `to_xlsb` | те же проверки, что у s49 / s51 (читатели xls и xlsb) | -**Interfaces:** -- Consumes: `headers.md` из Task 1. -- Produces: поля `OsvSpec` (используются в Task 3): + Негативы (проходят и на базе, и после — иначе успех опровергнут): + + | Файл | Что в нём | Тест и проверки | + |---|---|---| + | `s55_not_osv_titles.xlsx` | обычная ОСВ 1С (чтобы книга не была ошибкой) + листы с таблицей без групп ОСВ: «Справка: оборотная ведомость прилагается», «Оборотный капитал и его структура», отчёт 1С «Обороты счета 60» с колонками корр. счетов внутри «Оборот Дт/Кт» (R-1) | 1 `osvDocument`; три листа — `SHEET_OTHER` | + | `s56_almost_osv_tables.xlsx` | обычная ОСВ 1С + безымянные листы: (а) «Наименование \| Остаток на начало \| Поступило \| Выбыло \| Остаток на конец»; (б) «Оборот Дт \| Оборот Кт \| Сальдо на конец Дт \| Кт» без начала; (в) свёрнутое начало «Сальдо на начало» одной колонкой + обороты и конец с Дт/Кт; (г) полный заголовок Navision с 31-й строки | 1 `osvDocument`; (а)–(г) — `SHEET_OTHER` (D1, R-2, R-3, Review Focus 1) | + | `s57_form_mentions_osv.xlsx` | форма ББ (`statement_sheet()`) с ячейкой примечания «см. оборотную ведомость по сч. 60» и строкой «Остаток на начало периода» в тексте пояснения; следом лист слабого продолжения формы (1–2 кода, как в существующих тестах `is_weak_continuation`) | документы формы те же, что на базе (вид, `locator`, число строк); `osvDocument` нет; продолжение присоединено (Review Focus 4) | + + `EXPECTED`, список файлов, `FILE_NAMES` — для всех. Негативам — отдельный тест-маркер в имени (`test_neg_…`), чтобы + их прогон читался отдельно. + +- [ ] **Step 8: прогон набора на базе.** Сгенерировать в `W/fixtures/osv/synthetic`, `make golden-update` (снимки + негативов — регрессия базового поведения; позитивам снимки появятся после Task 2–3), затем + `.venv/bin/pytest tests/synthetic -k "s48 or s49 or s50 or s51 or s52 or s53 or s54 or s55 or s56 or s57" -rA`. + Ожидаемо: все `test_neg_…` — PASS, все позитивы — FAIL. Иначе стоп: позитив, проходящий на базе, ничего не + доказывает (переписать), негатив, падающий на базе, неверно описывает текущее поведение (разобрать). Итог — таблица + «тест → результат на базе» в `admin/osv-other-systems-regress/control-base.md`. + +- [ ] **Step 9: коммиты.** tabular-parser: генератор и тесты контрольного набора — «Контрольный набор синтетики ОСВ + других систем (#81)» (позитивы падают — допустимо в ветке до Task 3: пометить их `pytest.mark.xfail(strict=True, + reason="tp#81: до Task 2–3")`, снимать `xfail` по мере реализации). fixtures: файлы s48–s57 и README — «Синтетика + s48–s57 под tp#81»; снимки негативов — отдельным коммитом. + +**Interfaces (генератор, Step 6):** +- Поля `OsvSpec`: - `title: str | None = None` — строка названия вместо «Оборотно-сальдовая ведомость по счету {account} за {period}» (`None` — как сейчас); + - `title_col: int = 1` — колонка строки названия; - `untitled: bool = False` — без строк организации, названия, параметров: заголовок таблицы с первой строки; + - `top_padding: int = 0` — пустых строк над заголовком таблицы при `untitled` (для s56 (г): 30); - `split_groups: dict[str, tuple[str, str]] = {}` — группы, у которых вместо слитого названия + «Дебет/Кредит» две ячейки в строке групп со своими текстами (сторона в ячейке группы); если разделены все три группы, строки подзаголовков нет; - `total_text: str = TOTAL_TEXT`. +- Листы-негативы без дерева ОСВ — функцией по образцу `reference_sheet`/`_flat_sheet`: `table_sheet(name: str, + rows: Sequence[Sequence[str | D | None]], *, start_row: int = 1) -> SheetSpec`. -- [ ] **Step 1: падающие тесты синтетики.** Тексты брать из `headers.md`; ниже — ориентир: - - `s48_other_system_titles.xlsx` — пять листов `build_osv` с `title`: «Оборотная ведомость по счету 60 за 1 квартал - 2025 г.», «ОСВ по сч. 60 за 1 квартал 2025 г.», «Оборотный баланс …», «Фин. Обороты Листок», «Отчет по Главной - книге …»; у последнего листа `total_text="ВСЕГО"`. Тест `test_s48_other_system_titles_are_osv`: 5 документов - `osvDocument` с локаторами листов, `SHEET_OTHER` нет; у листа «ВСЕГО» строка итога `TOTAL`, предупреждения - «строка «Итого» не найдена» нет. - - `s49_balance_and_turnover_by_side.xlsx` — `group_titles` «Остаток на начало периода» / «Остаток на конец периода», - `split_groups={"turnover": ("Оборот по дебету", "Оборот по кредиту")}`; над таблицей преамбула «Остаток на - начало: 0 | Остаток на конец: 0» (Review Focus 2). Тест: шесть колонок сумм, суммы `base_tree()` верны, статус - `done`. - - `s50_sap_in_out_glued_side.xlsx` — два листа: SAP «Сальдо вх Дт | Сальдо вх Кт | Оборот Дт | Оборот Кт | Сальдо - исх Дт | Сальдо исх Кт» (все три `split_groups`), SAP НМЖК «Сальдо НачальноеДТВВ | Сальдо НачальноеКТВВ | … | - Сальдо КонечноеКТВВ»; у контрагента имя «ООО Дтвв-Сервис» (Review Focus 3). Тест: оба листа — шесть колонок с - верными сторонами, суммы как у дерева, «ООО Дтвв-Сервис» — строка контрагента. - - `test_detect.py::test_side_without_word_boundary_only_in_group_cell` — сетка `sheet_from_rows`: заголовок «Сальдо - НачальноеДТВВ | …», ниже строка с текстом «Дтвв» в колонке сумм → `find_table_header` берёт колонки только из - строки групп. - Добавить имена в `EXPECTED` (s48 — `documents=5`), в список файлов и `FILE_NAMES`. +--- -- [ ] **Step 2: генерация и прогон — тесты падают.** `cd api && .venv/bin/python -m tests.synthetic.generate - W/fixtures/osv/synthetic` → `.venv/bin/pytest tests/synthetic tests/unit/parser/test_detect.py -k "s48 or s49 or s50 - or word_boundary"` — FAIL (`INVALID_DOCUMENT`/`INVALID_FORMAT`). +### Task 2: Словари (1717 названия, 1719 группы и стороны, 1732 «ВСЕГО») + +**Files:** +- Modify: `api/rules/osv_headers.yaml` (`sheet_markers`, `groups.opening`, `groups.closing`, `groups.turnover` — если + нужно для «ОборотДт», `debit`, `credit`, `total_row_exact`) +- Modify: `api/tests/synthetic/test_synthetic_files.py` (снять `xfail` у s48–s50, s53) +- Modify: `api/tests/unit/parser/test_detect.py` + +**Interfaces:** +- Consumes: контрольный набор и `headers.md` из Task 1. + +- [ ] **Step 1: падающий unit-тест.** `test_detect.py::test_side_without_word_boundary_only_in_group_cell` — сетка + `sheet_from_rows`: заголовок «Сальдо НачальноеДТВВ | …», ниже строка с текстом «Дтвв» в колонке сумм → + `find_table_header` берёт колонки только из строки групп. Снять `xfail` у тестов s48, s49, s50, s53. + +- [ ] **Step 2: тесты падают.** `.venv/bin/pytest tests/synthetic tests/unit/parser/test_detect.py -k "s48 or s49 or + s50 or s53 or word_boundary"` — FAIL (`INVALID_DOCUMENT`/`INVALID_FORMAT`). - [ ] **Step 3: записи словаря.** Ориентир (уточнить по `headers.md`; ловушки: `\w*` жадно съест приклеенную сторону — «сальдо\s+начальн\w*» оставит пустой остаток вместо «дтвв»; `\bоборот(ы)?\b` не матчит «оборотдт»): @@ -146,14 +176,14 @@ AURORA-1717, AURORA-1719, AURORA-1732. Ветка `feat/81-osv-other-systems` в записью (остаток ячейки не начинается со стороны), правка `_side`/`_group_cells` общая, без упоминания системы (design D2), и тест на неё. -- [ ] **Step 4: тесты зелёные.** Тот же `pytest` → PASS. `tests/fixtures/test_golden.py` требует снимок на каждый - синтетический файл: `make golden-update`, в diff — только новые снимки s48–s50 (изменился старый — стоп, разобрать). - Затем `make check` → PASS (покрытие словарей видит новые записи на s48–s50). +- [ ] **Step 4: тесты зелёные.** Тот же `pytest` → PASS; все `test_neg_…` контрольного набора → PASS (негатив упал — + запись словаря слишком широкая: сузить, не ослаблять тест). `make golden-update`: новые снимки s48–s50, s53; снимки + негативов s55–s57 и всех старых файлов не изменились (изменились — стоп, разобрать). `make check` → PASS (покрытие + словарей видит новые записи). -- [ ] **Step 5: коммиты.** tabular-parser: `rules/osv_headers.yaml`, `tests/synthetic/*`, `tests/unit/parser/test_detect.py` - — «ОСВ: названия ведомостей, группы и стороны других систем, «ВСЕГО» (#81)». fixtures (`git -C W/fixtures`): - `osv/synthetic/s48…s50`, README — «Синтетика s48–s50 под tp#81»; снимки `golden/expected_osv_parse/s48…s50` — - отдельным коммитом «Golden синтетики s48–s50 под tp#81». +- [ ] **Step 5: коммиты.** tabular-parser: `rules/osv_headers.yaml`, `tests/synthetic/test_synthetic_files.py`, + `tests/unit/parser/test_detect.py` — «ОСВ: названия ведомостей, группы и стороны других систем, «ВСЕГО» (#81)». + fixtures (`git -C W/fixtures`): снимки s48–s50, s53 — «Golden синтетики s48–s50, s53 под tp#81». --- @@ -163,11 +193,11 @@ AURORA-1717, AURORA-1719, AURORA-1732. Ветка `feat/81-osv-other-systems` в - Modify: `api/src/parser/detect.py` (новая `has_osv_table`, `classify_sheet`, `__all__`) - Modify: `api/src/parser/statements/adapter.py:56-83` (`plan_sheets`) - Modify: `api/src/parser/book.py:146` -- Modify: `api/tests/synthetic/generate.py` (`_s51`), `api/tests/synthetic/test_synthetic_files.py` +- Modify: `api/tests/synthetic/test_synthetic_files.py` (снять `xfail` у s51, s52, s54) - Test: `api/tests/unit/parser/test_detect.py`, `api/tests/unit/parser/statements/test_adapter.py` (тесты `plan_sheets`), `api/tests/unit/parser/test_book_parser.py` **Interfaces:** -- Consumes: поля `OsvSpec` из Task 2 (`untitled`, `split_groups`). +- Consumes: контрольный набор из Task 1 (s51, s52, s54 — позитивы; s55–s57 — негативы). - Produces: - `detect.has_osv_table(sheet: Sheet, rules: Rules) -> bool` — в первых `SHEET_MARKER_ROWS` строках есть строка, где `_group_cells` даёт все три группы, и `_columns_of_group` для каждой возвращает `debit` и `credit` (не `collapsed`); @@ -196,25 +226,21 @@ AURORA-1717, AURORA-1719, AURORA-1732. Ветка `feat/81-osv-other-systems` в - `test_plan_sheets_weak_continuation_not_taken` — лист формы, затем лист из 1–2 кодов (слабое продолжение, образец — существующие тесты `is_weak_continuation`) → остаётся `STATEMENT_CONTINUATION`, `osv_by_table` не вызван (Review Focus 4). - - синтетика `s51_untitled_navision.xlsx` (`build_osv` с `untitled=True`, `split_groups` для трёх групп по - `headers.md`) и `test_s51_untitled_sheet_is_osv_by_table`: один `osvDocument`, `SHEET_OTHER` нет, суммы дерева - верны; `EXPECTED` — статус по факту разбора шапки без периода (ожидаемо `needs_check` из-за ненайденного - периода/организации; если иначе — зафиксировать фактический и объяснить в комментарии). + - снять `xfail` у тестов s51, s52, s54 контрольного набора. - `test_book_parser.py::test_statement_book_sheets_unchanged_by_fallback` — книга s14 (формы + ОСВ): виды листов те же, что без запасного признака. -- [ ] **Step 2: тесты падают.** `make venv` уже есть; `cd api && .venv/bin/python -m tests.synthetic.generate - W/fixtures/osv/synthetic && .venv/bin/pytest tests/unit/parser tests/synthetic -k "osv_table or plan_sheets or - s51 or fallback"` → FAIL (`ImportError: has_osv_table`, `INVALID_DOCUMENT`). +- [ ] **Step 2: тесты падают.** `cd api && .venv/bin/pytest tests/unit/parser tests/synthetic -k "osv_table or + plan_sheets or s51 or s52 or s54 or fallback"` → FAIL (`ImportError: has_osv_table`, `INVALID_DOCUMENT`). - [ ] **Step 3: реализация** по Interfaces. `has_osv_table` переиспользует `_group_cells` и `_columns_of_group` (R-6), отдельного разбора сторон нет. -- [ ] **Step 4: тесты зелёные.** Тот же `pytest` → PASS; `make golden-update` — в diff только новый снимок s51; - `make check` → PASS. +- [ ] **Step 4: тесты зелёные.** Тот же `pytest` → PASS; все `test_neg_…` → PASS; `make golden-update` — в diff + только новые снимки s51, s52, s54, снимки негативов s55–s57 не изменились; `make check` → PASS. - [ ] **Step 5: коммиты.** tabular-parser: «ОСВ: запасной признак листа по заголовку таблицы (#81, AURORA-1717)». - fixtures: `osv/synthetic/s51…`, README — «Синтетика s51 под tp#81»; снимок s51 — отдельным коммитом. + fixtures: снимки s51, s52, s54 — отдельным коммитом. --- @@ -225,7 +251,10 @@ AURORA-1717, AURORA-1719, AURORA-1732. Ветка `feat/81-osv-other-systems` в - Create (вывод): `/Users/andrey.epifancev/projects/awp-ca/admin/osv-other-systems-regress/new.csv`, `report.md` - [ ] **Step 1: golden.** `make golden-check` → код 0; `git -C W/fixtures diff --stat f1f7f83 -- golden/` — только - добавленные снимки s48–s51, ни одного изменённого (изменённый — стоп, разобрать с объяснением в design.md). + добавленные снимки s48–s57, ни одного изменённого (изменённый — стоп, разобрать с объяснением в design.md). + Контрольный набор целиком: `.venv/bin/pytest tests/synthetic -k "s48 or s49 or s50 or s51 or s52 or s53 or s54 or + s55 or s56 or s57" -rA` → все PASS, `xfail` не осталось; таблица «тест → база → ветка» дописывается в + `control-base.md`. - [ ] **Step 2: прогон ветки.** `regress.py run new.csv`; `regress.py diff base.csv new.csv > report.md`. @@ -246,7 +275,7 @@ AURORA-1717, AURORA-1719, AURORA-1732. Ветка `feat/81-osv-other-systems` в - Modify: `W/docs/context/05-domain-rules.md` §1.1 (поиск листа), §1.2 (написания групп и сторон); строка «Итого» — где перечислены итоговые подписи (§1.3 или §1.4) - Modify: `W/tabular-parser/README.md` (раздел «Парсер ОСВ»: признаки листа, запасной признак), `W/tabular-parser/CLAUDE.md` - (перечень синтетики s48–s51 в абзаце golden), docstring `generate.py` (s48–s51) + (перечень синтетики s48–s57 в абзаце golden), docstring `generate.py` (s48–s57) - Modify: `W/tabular-parser/openspec/changes/osv-other-systems/design.md` — решения, принятые по ходу (R-9…), и сводка регрессии из `report.md` @@ -259,4 +288,4 @@ AURORA-1717, AURORA-1719, AURORA-1732. Ветка `feat/81-osv-other-systems` в README не ломают линтеры). - [ ] **Step 3: коммиты.** docs (`git -C W/docs`): «05 §1.1–§1.2: ОСВ других учётных систем (tp#81)»; tabular-parser: - «Документация: признаки листа ОСВ, синтетика s48–s51 (#81)». + «Документация: признаки листа ОСВ, синтетика s48–s57 (#81)». From 29071bdc7b204c2620ab7ecc24c134bc1799876a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 20:14:59 +0400 Subject: [PATCH 04/10] =?UTF-8?q?plan:=20=D1=81=D0=BD=D0=B8=D0=BC=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=BD=D0=B0=D0=B1=D0=BE=D1=80=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=88=D0=B0=D0=B3=D0=B0=D0=BC=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/superpowers/plans/2026-10-28-osv-other-systems.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-10-28-osv-other-systems.md b/docs/superpowers/plans/2026-10-28-osv-other-systems.md index b711cb4..a692c61 100644 --- a/docs/superpowers/plans/2026-10-28-osv-other-systems.md +++ b/docs/superpowers/plans/2026-10-28-osv-other-systems.md @@ -118,7 +118,8 @@ AURORA-1717, AURORA-1719, AURORA-1732. Ветка `feat/81-osv-other-systems` в их прогон читался отдельно. - [ ] **Step 8: прогон набора на базе.** Сгенерировать в `W/fixtures/osv/synthetic`, `make golden-update` (снимки - негативов — регрессия базового поведения; позитивам снимки появятся после Task 2–3), затем + всех s48–s57; у негативов это регрессия базового поведения, снимки позитивов фиксируют ошибку базы и обновятся в + Task 2–3), затем `.venv/bin/pytest tests/synthetic -k "s48 or s49 or s50 or s51 or s52 or s53 or s54 or s55 or s56 or s57" -rA`. Ожидаемо: все `test_neg_…` — PASS, все позитивы — FAIL. Иначе стоп: позитив, проходящий на базе, ничего не доказывает (переписать), негатив, падающий на базе, неверно описывает текущее поведение (разобрать). Итог — таблица @@ -177,7 +178,7 @@ AURORA-1717, AURORA-1719, AURORA-1732. Ветка `feat/81-osv-other-systems` в (design D2), и тест на неё. - [ ] **Step 4: тесты зелёные.** Тот же `pytest` → PASS; все `test_neg_…` контрольного набора → PASS (негатив упал — - запись словаря слишком широкая: сузить, не ослаблять тест). `make golden-update`: новые снимки s48–s50, s53; снимки + запись словаря слишком широкая: сузить, не ослаблять тест). `make golden-update`: меняются снимки s48–s50, s53; снимки негативов s55–s57 и всех старых файлов не изменились (изменились — стоп, разобрать). `make check` → PASS (покрытие словарей видит новые записи). @@ -237,7 +238,7 @@ AURORA-1717, AURORA-1719, AURORA-1732. Ветка `feat/81-osv-other-systems` в отдельного разбора сторон нет. - [ ] **Step 4: тесты зелёные.** Тот же `pytest` → PASS; все `test_neg_…` → PASS; `make golden-update` — в diff - только новые снимки s51, s52, s54, снимки негативов s55–s57 не изменились; `make check` → PASS. + меняются только снимки s51, s52, s54, снимки негативов s55–s57 и старых файлов не изменились; `make check` → PASS. - [ ] **Step 5: коммиты.** tabular-parser: «ОСВ: запасной признак листа по заголовку таблицы (#81, AURORA-1717)». fixtures: снимки s51, s52, s54 — отдельным коммитом. From 4d8f2429905e7408b3e60b31ff61466f42176ddd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 20:32:29 +0400 Subject: [PATCH 05/10] =?UTF-8?q?=D0=9A=D0=BE=D0=BD=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BD=D0=B0=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=20=D1=81=D0=B8=D0=BD=D1=82=D0=B5=D1=82=D0=B8=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=9E=D0=A1=D0=92=20=D0=B4=D1=80=D1=83=D0=B3=D0=B8=D1=85=20?= =?UTF-8?q?=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Генератор: название ведомости, безымянный лист, группы со стороной в ячейке, подпись итога. Позитивы s48–s54 (xfail до словарей и запасного признака), негативы s55–s57. Co-Authored-By: Claude Opus 5.5 --- api/tests/synthetic/generate.py | 267 +++++++++++++++++++- api/tests/synthetic/test_generate.py | 12 +- api/tests/synthetic/test_synthetic_files.py | 166 +++++++++++- 3 files changed, 433 insertions(+), 12 deletions(-) diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index ae25f81..acf2361 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -6,6 +6,8 @@ строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты другим треком), s45 — счёт шапки «Счет Поставщика» | «60*» в соседних ячейках, s46 — выгрузка «по контрагентам» с блоками субсчетов (#71), s47 — иерархия по отступам: подуровень счёта, строка закрытия, вложенность по суммам (#78); +s48…s57 — ОСВ других учётных систем (#81): позитивы s48…s54 (названия, группы и стороны, Navision без названия, xls и +xlsb), негативы s55…s57 (почти-ОСВ, которые обязаны остаться прочими листами или формой); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован. @@ -214,6 +216,15 @@ class OsvSpec: group_titles: dict[str, str] | None = None # названия групп сумм вместо `GROUP_TITLES` inn_header: str = "ИНН" # подпись колонки ИНН («ИНН/КПП» — склеенная) labels_below: bool = False # вариант 1: «Счет» и «Контрагенты» — отдельными строками под подзаголовками сумм + # Название ведомости вместо «Оборотно-сальдовая ведомость по счету … за …» (вариант 1) и его колонка (#81) + title: str | None = None + title_col: int = 1 + untitled: bool = False # нет организации, названия и параметров: заголовок таблицы с `1 + top_padding` (#81) + top_padding: int = 0 + # Группы, подписанные двумя ячейками со стороной («НачалоДт» | «НачалоКт») вместо названия над «Дебет/Кредит»; + # разделены все группы — строки подзаголовков нет (#81) + split_groups: dict[str, tuple[str, str]] = field(default_factory=dict) + total_text: str | None = None # подпись итоговой строки; None — «Итого» GROUP_TITLES = { @@ -247,27 +258,40 @@ def _header_rows(sheet: SheetSpec, spec: OsvSpec, top: int, label: str, sub_labe """ titles = spec.group_titles or GROUP_TITLES below = spec.labels_below and bool(label) + groups = _groups(spec) + one_row = all(key in spec.split_groups for key, _, _ in groups) # заголовок в одну строку, без «Дебет/Кредит» label_row = top + 2 if below else top # строка меток группировок (первая) и колонок ИНН/КПП sheet.put(label_row, 1, label) - sheet.put(label_row + 1, 1, sub_label) - service_row = label_row + 1 if below else top + 1 + if not one_row: + sheet.put(label_row + 1, 1, sub_label) + service_row = label_row + 1 if below else (top if one_row else top + 1) if spec.inn_col is not None: sheet.put(service_row, spec.inn_col, spec.inn_header) if spec.kpp_col is not None: sheet.put(service_row, spec.kpp_col, "КПП") - for key, col, width in _groups(spec): + for key, col, width in groups: + if key in spec.split_groups: + debit, credit = spec.split_groups[key] + sheet.put(top, col, debit) + sheet.put(top, col + 1, credit) + continue sheet.put(top, col, titles[key]) sheet.merge(top, col, top, col + width - 1) if width == PAIR_WIDTH: sheet.put(top + 1, col, DEBIT) sheet.put(top + 1, col + 1, CREDIT) - return top + 4 if below else top + 2 + if below: + return top + 4 + return top + 1 if one_row else top + 2 def _header_v1(sheet: SheetSpec, spec: OsvSpec) -> int: + if spec.untitled: + return _header_rows(sheet, spec, 1 + spec.top_padding, "Счет", "Контрагенты") if spec.org_name is not None: sheet.put(1, 1, spec.org_name) - sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {spec.account} за {spec.period}") + title = spec.title or f"Оборотно-сальдовая ведомость по счету {spec.account} за {spec.period}" + sheet.put(2, spec.title_col, title) sheet.put(4, 1, spec.data_type_line) sheet.put(7, 1, "Параметры:") sheet.put(7, 3, spec.currency_line) @@ -375,7 +399,7 @@ def build_osv(spec: OsvSpec, root: Node, name: str = SHEET) -> SheetSpec: _row_cells(sheet, spec, next_row, (node, depth), (node.twin or node).six(), blank=node.blank) next_row += 1 total = SheetSpec("") - _row_cells(total, spec, 1, (Node(TOTAL_TEXT), 0), root.six()) + _row_cells(total, spec, 1, (Node(spec.total_text or TOTAL_TEXT), 0), root.six()) for (_, col), cell in total.cells.items(): sheet.cells[(next_row, col)] = CellSpec(cell.value, bold=spec.bold_accounts) return sheet @@ -418,6 +442,16 @@ def statement_sheet(name: str = "Бухгалтерский баланс") -> Sh return sheet +def table_sheet(name: str, rows: Sequence[Sequence[str | int | D | None]], *, start_row: int = 1) -> SheetSpec: + """Лист «как есть»: строки ячеек с `start_row`, `None` — пустая ячейка (листы-негативы #81).""" + sheet = SheetSpec(name) + for row, values in enumerate(rows, start=start_row): + for col, value in enumerate(values, start=1): + if value is not None: + sheet.put(row, col, value) + return sheet + + def form_sheet(name: str, *lines: str) -> SheetSpec: """Лист формы без таблицы: в шапке только `lines` (так проверяется каждый признак формы по отдельности).""" sheet = SheetSpec(name) @@ -1119,6 +1153,215 @@ def _inn_layout(layout: InnLayout, *, sums: bool) -> Callable[[], list[SheetSpec return build +# --- ОСВ других учётных систем (#81): контрольный набор. s48–s54 — позитивы, s55–s57 — негативы --- + +PERIOD_TAIL = "по счету 60 за 1 квартал 2025 г." +NAVISION_GROUPS = { + "opening": ("НачалоДт", "НачалоКт"), + "turnover": ("ОборотДт", "ОборотКт"), + "closing": ("КонецДт", "КонецКт"), +} + + +def _titled(name: str, title: str, *, title_col: int = 1, total_text: str | None = None) -> SheetSpec: + spec = OsvSpec(inn_col=5, first_sum_col=7, title=title, title_col=title_col, total_text=total_text) + return build_osv(spec, base_tree(), name) + + +def _s48() -> list[SheetSpec]: + """Названия ведомостей других систем и отчётов 1С (AURORA-1717) и итог «ВСЕГО» (AURORA-1732): по листу на название; + название — в разных колонках, верхним регистром с переносом строки.""" + return [ + _titled("ОВ", f"Оборотная ведомость {PERIOD_TAIL}", title_col=3), + _titled("ОСВ сокр", "ОСВ по сч. 60 за 1 квартал 2025 г."), + _titled("Оборотный баланс", f"Оборотный баланс {PERIOD_TAIL}", title_col=7), + _titled("Навижн", "Фин. Обороты Листок за 1 квартал 2025 г.", title_col=4), + _titled("Главная книга", f"Отчет по Главной книге {PERIOD_TAIL}", title_col=3, total_text="ВСЕГО"), + _titled("Верхний регистр", f"ОБОРОТНАЯ\nВЕДОМОСТЬ {PERIOD_TAIL}"), + _titled("Вторая колонка", f"Оборотная ведомость по счетам {PERIOD_TAIL}", title_col=2, total_text="Всего "), + ] + + +def _s49_sheets(hierarchy: Hierarchy = "outline") -> list[SheetSpec]: + """«Остаток на начало/конец» над «Дебет/Кредит» и «Оборот по дебету/кредиту» в ячейке группы (КТЗ); преамбула + «Остаток на начало: …» над таблицей; второй лист — «Остаток по дебету на начало» (сторона внутри названия).""" + first = build_osv( + OsvSpec( + inn_col=5, + first_sum_col=7, + hierarchy=hierarchy, + title=f"Оборотная ведомость {PERIOD_TAIL}", + group_titles={ + **GROUP_TITLES, + "opening": "Остаток на начало периода", + "closing": "Остаток на конец периода", + }, + split_groups={"turnover": ("Оборот по дебету", "Оборот по кредиту")}, + ), + base_tree(), + "Остатки", + ) + first.put(5, 1, "Остаток на начало: 0") + first.put(5, 3, "Остаток на конец: 0") + second = build_osv( + OsvSpec( + inn_col=None, + first_sum_col=3, + hierarchy=hierarchy, + title=f"Оборотная ведомость {PERIOD_TAIL}", + split_groups={ + "opening": ("Остаток по дебету на начало", "Остаток по кредиту на начало"), + "turnover": ("Оборот по дебету", "Оборот по кредиту"), + "closing": ("Остаток по дебету на конец", "Остаток по кредиту на конец"), + }, + ), + receivables_tree(), + "Остатки по сторонам", + ) + return [first, second] + + +def _s49() -> list[SheetSpec]: + return _s49_sheets() + + +def _s50() -> list[SheetSpec]: + """SAP: «Сальдо вх/исх Дт/Кт» (Виола, обороты 1С-вида — «Дт оборот» это AURORA-1722) и сторона, приклеенная к + названию группы «Сальдо НачальноеДТВВ» / «ОборотыДтВВ» (НМЖК); контрагент «ООО Дтвв-Сервис» — не сторона.""" + sap = OsvSpec( + inn_col=None, + first_sum_col=3, + title=f"Отчет по Главной книге {PERIOD_TAIL}", + split_groups={ + "opening": ("Сальдо вх Дт", "Сальдо вх Кт"), + "turnover": ("Оборот Дт", "Оборот Кт"), + "closing": ("Сальдо исх Дт", "Сальдо исх Кт"), + }, + ) + glued = OsvSpec( + inn_col=None, + first_sum_col=3, + split_groups={ + "opening": ("Сальдо НачальноеДТВВ", "Сальдо НачальноеКТВВ"), + "turnover": ("ОборотыДтВВ", "ОборотыКтВВ"), + "closing": ("Сальдо КонечноеДТВВ", "Сальдо КонечноеКТВВ"), + }, + ) + tree = base_tree() + tree.kids[0].kids[0].text = "ООО Дтвв-Сервис" + return [build_osv(sap, base_tree(), "SAP"), build_osv(glued, tree, "НМЖК")] + + +def _navision_sheet(name: str = "ОВ", hierarchy: Hierarchy = "outline", top_padding: int = 0) -> SheetSpec: + spec = OsvSpec( + untitled=True, + top_padding=top_padding, + inn_col=None, + first_sum_col=2, + hierarchy=hierarchy, + split_groups=NAVISION_GROUPS, + ) + return build_osv(spec, base_tree(), name) + + +def _s51() -> list[SheetSpec]: + """Navision без названия ведомости: таблица с первой строки, «НачалоДт | … | КонецКт» (Айсберри); запасной + признак.""" + return [_navision_sheet()] + + +def _s52() -> list[SheetSpec]: + """Форма ББ, лист Navision без названия, посторонний лист: форма остаётся формой, «Справка» — прочим.""" + return [statement_sheet(), _navision_sheet(), reference_sheet()] + + +def _s53() -> list[SheetSpec]: + """s49 в xls (BIFF5 без объединений и outline): отступ пробелами.""" + return _s49_sheets("spaces") + + +def _s54() -> list[SheetSpec]: + """s51 в xlsb (без объединений и outline): отступ пробелами.""" + return [_navision_sheet(hierarchy="spaces")] + + +def _s55() -> list[SheetSpec]: + """Негатив: названия, похожие на ведомость, но не ведомость (не с начала ячейки, «Оборотный капитал»), и отчёт 1С + «Обороты счета» с колонками корр. счетов между «Оборот Дт» и «Оборот Кт» (R-1) — все три листа прочие.""" + return [ + build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree(), "ОСВ 60"), + table_sheet( + "Справка", + [ + ["Справка: оборотная ведомость прилагается"], + [], + ["Показатель", "Сумма"], + ["Выручка", D("100000.00")], + ], + ), + table_sheet( + "Капитал", + [ + ["Оборотный капитал и его структура"], + [], + ["Показатель", "На начало", "На конец"], + ["Запасы", D("500.00"), D("700.00")], + ], + ), + table_sheet( + "Обороты счета", + [ + ["Обороты счета 60 за 9 месяцев 2025 г."], + [], + ["Счет", "Начальное сальдо Дт", "Начальное сальдо Кт", "Оборот Дт", "51", "68", "Оборот Кт"] + + ["20", "Конечное сальдо Дт", "Конечное сальдо Кт"], + ["60", D("100.00"), None, D("50.00"), D("30.00"), D("20.00"), D("40.00"), D("40.00"), D("110.00")], + ["Итого", D("100.00"), None, D("50.00"), D("30.00"), D("20.00"), D("40.00"), D("40.00"), D("110.00")], + ], + ), + ] + + +def _s56() -> list[SheetSpec]: + """Негатив: безымянные таблицы, почти ОСВ — расшифровка «Остаток на начало | Поступило | Выбыло | Остаток на + конец», нет группы «начало», свёрнутое начало, полный заголовок ниже 30-й строки — все прочие (D1, R-2, R-3).""" + return [ + build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree(), "ОСВ 60"), + table_sheet( + "Расшифровка", + [ + ["Наименование", "Остаток на начало", "Поступило", "Выбыло", "Остаток на конец"], + ["Материалы", D("100.00"), D("50.00"), D("30.00"), D("120.00")], + ], + ), + table_sheet( + "Без начала", + [ + ["Счет", "Оборот Дт", "Оборот Кт", "Сальдо на конец Дт", "Сальдо на конец Кт"], + ["60", D("10.00"), D("5.00"), D("5.00"), None], + ], + ), + table_sheet( + "Свёрнутое начало", + [ + ["Счет", "Сальдо на начало", "Оборот Дт", "Оборот Кт", "Сальдо на конец Дт", "Сальдо на конец Кт"], + ["60", D("100.00"), D("10.00"), D("5.00"), D("105.00"), None], + ], + ), + _navision_sheet("Ниже 30", top_padding=30), + ] + + +def _s57() -> list[SheetSpec]: + """Негатив: форма ББ с примечанием «см. оборотную ведомость …» и текстом «Остаток на начало периода …», за ней + лист слабого продолжения (одна строка с кодом) — форма и продолжение остаются формой.""" + form = statement_sheet() + form.put(9, 1, "Примечание: см. оборотную ведомость по сч. 60") + form.put(10, 1, "Остаток на начало периода — по данным учёта") + continuation = table_sheet("Баланс (2)", [["Капитал и резервы", "1300", D("500000.00")]]) + return [form, continuation] + + def _normalized_zip(data: bytes) -> bytes: """Тот же архив с постоянными метками времени: записей zip и `dcterms:modified` (openpyxl ставит текущее время при каждом `save`, заданное в свойствах значение перезаписывает).""" @@ -1219,6 +1462,16 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp "s45_account_label_value_cells.xlsx": (_s45, to_xlsx), "s46_counterparty_code_blocks.xlsx": (_s46, to_xlsx), "s47_indent_hierarchy_code_sums.xlsx": (_s47, to_xlsx), + "s48_other_system_titles.xlsx": (_s48, to_xlsx), + "s49_balance_and_turnover_by_side.xlsx": (_s49, to_xlsx), + "s50_sap_in_out_glued_side.xlsx": (_s50, to_xlsx), + "s51_untitled_navision.xlsx": (_s51, to_xlsx), + "s52_mixed_book.xlsx": (_s52, to_xlsx), + "s53_balance_by_side.xls": (_s53, to_xls), + "s54_untitled_navision.xlsb": (_s54, to_xlsb), + "s55_not_osv_titles.xlsx": (_s55, to_xlsx), + "s56_almost_osv_tables.xlsx": (_s56, to_xlsx), + "s57_form_mentions_osv.xlsx": (_s57, to_xlsx), } FILE_NAMES = tuple(FILES) @@ -1237,7 +1490,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, s40…s44 (xlsx, один xlsb и один xls) в указанный каталог.", + description="Синтетические ОСВ s01…s36, s40…s57 (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 684c908..e032e34 100644 --- a/api/tests/synthetic/test_generate.py +++ b/api/tests/synthetic/test_generate.py @@ -26,15 +26,19 @@ 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_s36_and_s40_to_s47_with_one_xlsb(tmp_path: Path) -> None: +def test_generated_set_is_s01_to_s36_and_s40_to_s57_with_two_xlsb(tmp_path: Path) -> None: names = [path.name for path in generate_all(tmp_path)] assert names == list(FILE_NAMES) assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names) - # s33…s36 — строки-показатели (AURORA-1613, #58), плоская форма ОСВ 1С (#59) — s40…s44 - numbers = [*range(1, 37), *range(40, 48)] + # s33…s36 — строки-показатели (AURORA-1613, #58), плоская форма ОСВ 1С (#59) — s40…s44, ОСВ других систем (#81) — + # s48…s57 + numbers = [*range(1, 37), *range(40, 58)] 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 [name for name in names if name.endswith(".xlsb")] == [ + "s13_no_outline_indent.xlsb", + "s54_untitled_navision.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 1e21f8e..b6b3d02 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -54,6 +54,25 @@ FLAT_FORM_FILES = ( "s46_counterparty_code_blocks.xlsx", "s47_indent_hierarchy_code_sums.xlsx", ) + +# ОСВ других учётных систем (#81): контрольный набор — позитивы s48–s54 (на базе падают), негативы s55–s57 (вид листов +# и результат не меняются) +S48 = "s48_other_system_titles.xlsx" +S49 = "s49_balance_and_turnover_by_side.xlsx" +S50 = "s50_sap_in_out_glued_side.xlsx" +S51 = "s51_untitled_navision.xlsx" +S52 = "s52_mixed_book.xlsx" +S53 = "s53_balance_by_side.xls" +S54 = "s54_untitled_navision.xlsb" +S55 = "s55_not_osv_titles.xlsx" +S56 = "s56_almost_osv_tables.xlsx" +S57 = "s57_form_mentions_osv.xlsx" +OTHER_SYSTEMS_FILES = (S48, S49, S50, S51, S52, S53, S54, S55, S56, S57) +PENDING_DICTIONARY = "tp#81 Task 2: словари названий, групп и сторон" +PENDING_FALLBACK = "tp#81 Task 3: запасной признак листа по заголовку таблицы" +PENDING_81 = {S48: PENDING_DICTIONARY, S49: PENDING_DICTIONARY, S50: PENDING_DICTIONARY, S53: PENDING_DICTIONARY} +PENDING_81 |= {S51: PENDING_FALLBACK, S52: PENDING_FALLBACK, S54: PENDING_FALLBACK} +S48_SHEETS = ("ОВ", "ОСВ сокр", "Оборотный баланс", "Навижн", "Главная книга", "Верхний регистр", "Вторая колонка") TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28 EXPECTED_NAMES = [ "s01_variant2_header.xlsx", @@ -86,6 +105,7 @@ EXPECTED_NAMES = [ "s35_currency_slices.xlsx", "s36_no_data_type_header.xlsx", *FLAT_FORM_FILES, + *OTHER_SYSTEMS_FILES, ] XLSB_NAME = "s13_no_outline_indent.xlsb" XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)" @@ -194,6 +214,17 @@ EXPECTED: dict[str, Expect] = { "s47_indent_hierarchy_code_sums.xlsx": Expect( "indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"', documents=3 ), + # #81: названия других систем, остатки, SAP; безымянный лист — без шапки (организация, период, вид данных пусты) + S48: Expect("outline", 2025, "Q1", True, documents=len(S48_SHEETS)), + S49: Expect("outline", 2025, "Q1", True, documents=2), + S50: Expect("outline", 2025, "Q1", True, documents=2), + S51: Expect("outline", None, None, None, data_type=None, organization=""), + S52: Expect("outline", None, None, None, status=ResultStatus.NEEDS_CHECK, data_type=None, organization=""), + S53: Expect("indent", 2025, "Q1", True, documents=2), + S54: Expect("indent", None, None, None, data_type=None, organization=""), + S55: Expect("outline", 2025, "Q1", True), + S56: Expect("outline", 2025, "Q1", True), + S57: Expect("outline", None, None, None, status=ResultStatus.NEEDS_CHECK, documents=0), } @@ -283,8 +314,10 @@ def test_every_synthetic_file_is_listed(synthetic_file: Path) -> None: assert synthetic_file.name in EXPECTED, f"добавьте ожидания для {synthetic_file.name}" -def test_header_status_and_hierarchy(synthetic_file: Path) -> None: +def test_header_status_and_hierarchy(synthetic_file: Path, request: pytest.FixtureRequest) -> None: """Статус, число документов, стратегия иерархии и шапка из таблицы `EXPECTED`; результат валиден по контракту.""" + if synthetic_file.name in PENDING_81: + request.applymarker(pytest.mark.xfail(strict=True, reason=PENDING_81[synthetic_file.name])) expect = EXPECTED[synthetic_file.name] outcome = parse_path(synthetic_file) @@ -810,3 +843,134 @@ def test_s36_no_data_type_in_header_rows_are_bu(fixtures_dir: Path) -> None: assert {row.data_type for row in doc.rows} == {"BU"} assert len([text for text in warnings_of(outcome) if "приняты как БУ" in text]) == 1 assert outcome.status is ResultStatus.DONE + + +# --- ОСВ других учётных систем (#81): контрольный набор --- + +ALFA_SUMS = (None, D("250000.50"), D("180000.25"), D("420000.00"), None, D("490000.25")) +BUYER_SUMS = (D("500000.00"), None, D("320000.00"), D("410000.00"), D("410000.00"), None) +TOTAL_NOT_FOUND = "строка «Итого» не найдена" + + +def sheet_other(outcome: ParseOutcome) -> list[str]: + """Листы, пропущенные как прочие (`SHEET_OTHER`), по порядку.""" + warnings = outcome.metrics.warnings if outcome.metrics is not None else None + return [w.locator or "" for w in warnings or [] if w.code == "SHEET_OTHER"] + + +def assert_done_with_sums(doc: OsvDocument, text: str, expected: tuple[Decimal | None, ...]) -> None: + """Документ сошёлся, и у строки `text` шесть сумм по сторонам: перепутанные Дебет/Кредит здесь не пройдут.""" + assert sums(by_text(doc)[text]) == expected + assert doc.status == "done", doc.checks + assert doc.rows[-1].group_type == "TOTAL" + + +@pytest.mark.xfail(strict=True, reason=PENDING_DICTIONARY) +def test_s48_other_system_titles_are_osv(fixtures_dir: Path) -> None: + """Каждое название ведомости — свой лист ОСВ; «ВСЕГО» и «Всего » — итоговая строка (AURORA-1717, 1732).""" + outcome = parse_named(fixtures_dir, S48) + + docs = documents(outcome) + assert [doc.locator for doc in docs] == [f"sheet:{name}" for name in S48_SHEETS] + assert sheet_other(outcome) == [] + for doc in docs: + assert_done_with_sums(doc, ALFA, ALFA_SUMS) + totals = {doc.locator: doc.rows[-1].row_value.strip() for doc in docs} + assert totals["sheet:Главная книга"] == "ВСЕГО" + assert totals["sheet:Вторая колонка"] == "Всего" + assert not [text for text in warnings_of(outcome) if TOTAL_NOT_FOUND in text] + + +@pytest.mark.xfail(strict=True, reason=PENDING_DICTIONARY) +def test_s49_balance_and_turnover_by_side(fixtures_dir: Path) -> None: + """«Остаток на начало/конец», «Оборот по дебету/кредиту», «Остаток по дебету на начало»; преамбула «Остаток на + начало: 0» над таблицей строкой групп не считается.""" + outcome = parse_named(fixtures_dir, S49) + + first, second = documents(outcome) + assert (first.locator, second.locator) == ("sheet:Остатки", "sheet:Остатки по сторонам") + assert_done_with_sums(first, ALFA, ALFA_SUMS) + assert_done_with_sums(second, "ООО Покупатель-1", BUYER_SUMS) + + +@pytest.mark.xfail(strict=True, reason=PENDING_DICTIONARY) +def test_s50_sap_in_out_and_glued_side(fixtures_dir: Path) -> None: + """SAP «Сальдо вх/исх Дт/Кт» и сторона, приклеенная к группе («ДТВВ»); «ООО Дтвв-Сервис» — контрагент.""" + outcome = parse_named(fixtures_dir, S50) + + sap, glued = documents(outcome) + assert (sap.locator, glued.locator) == ("sheet:SAP", "sheet:НМЖК") + assert_done_with_sums(sap, ALFA, ALFA_SUMS) + assert_done_with_sums(glued, "ООО Дтвв-Сервис", ALFA_SUMS) + assert by_text(glued)["ООО Дтвв-Сервис"].group_type == "COUNTERPARTY" + + +@pytest.mark.xfail(strict=True, reason=PENDING_FALLBACK) +def test_s51_untitled_sheet_is_osv_by_table(fixtures_dir: Path) -> None: + """Navision без названия ведомости: ОСВ по полному заголовку таблицы (запасной признак).""" + outcome = parse_named(fixtures_dir, S51) + + doc = one_document(outcome) + assert doc.locator == "sheet:ОВ" + assert sheet_other(outcome) == [] + assert_done_with_sums(doc, ALFA, ALFA_SUMS) + + +@pytest.mark.xfail(strict=True, reason=PENDING_FALLBACK) +def test_s52_form_stays_form_navision_is_osv_reference_is_other(fixtures_dir: Path) -> None: + """Форма ББ — свой документ (лист Navision не присоединяется к ней слабым продолжением, R-12), Navision — ОСВ, + «Справка» — прочий лист.""" + outcome = parse_named(fixtures_dir, S52) + + assert [(d.document_type, d.locator) for d in outcome.documents] == [ + ("OSV", "sheet:ОВ"), + ("BALANCE", "sheet:Бухгалтерский баланс"), + ] + assert sheet_other(outcome) == ["sheet:Справка"] + assert_done_with_sums(one_document(outcome), ALFA, ALFA_SUMS) + + +@pytest.mark.xfail(strict=True, reason=PENDING_DICTIONARY) +def test_s53_xls_balance_by_side(fixtures_dir: Path) -> None: + """s49 в xls (без объединений): те же колонки и суммы.""" + outcome = parse_named(fixtures_dir, S53) + + first, second = documents(outcome) + assert_done_with_sums(first, ALFA, ALFA_SUMS) + assert_done_with_sums(second, "ООО Покупатель-1", BUYER_SUMS) + + +@pytest.mark.xfail(strict=True, reason=PENDING_FALLBACK) +def test_s54_xlsb_untitled_navision(fixtures_dir: Path) -> None: + """s51 в xlsb: ОСВ по запасному признаку, те же суммы.""" + outcome = parse_named(fixtures_dir, S54) + + assert sheet_other(outcome) == [] + assert_done_with_sums(one_document(outcome), ALFA, ALFA_SUMS) + + +def test_neg_s55_titles_like_osv_are_other(fixtures_dir: Path) -> None: + """Название не с начала ячейки, «Оборотный капитал», 1С «Обороты счета» с корр. счетами между «Оборот Дт» и + «Оборот Кт» (R-1, R-11) — прочие листы; ОСВ одна.""" + outcome = parse_named(fixtures_dir, S55) + + assert [doc.locator for doc in documents(outcome)] == ["sheet:ОСВ 60"] + assert sheet_other(outcome) == ["sheet:Справка", "sheet:Капитал", "sheet:Обороты счета"] + + +def test_neg_s56_almost_osv_tables_are_other(fixtures_dir: Path) -> None: + """Расшифровка с остатками, таблица без «начала», свёрнутое начало, заголовок ниже 30-й строки — прочие листы.""" + outcome = parse_named(fixtures_dir, S56) + + assert [doc.locator for doc in documents(outcome)] == ["sheet:ОСВ 60"] + assert sheet_other(outcome) == ["sheet:Расшифровка", "sheet:Без начала", "sheet:Свёрнутое начало", "sheet:Ниже 30"] + + +def test_neg_s57_form_with_osv_words_stays_form(fixtures_dir: Path) -> None: + """Форма ББ с примечанием «см. оборотную ведомость» и слабым продолжением — форма из двух листов, ОСВ нет.""" + outcome = parse_named(fixtures_dir, S57) + + assert [(d.document_type, d.locator) for d in outcome.documents] == [ + ("BALANCE", "sheet:Бухгалтерский баланс,Баланс (2)") + ] + assert sheet_other(outcome) == [] From f5bb178899685e8e01daff2265321476732f6844 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 20:41:09 +0400 Subject: [PATCH 06/10] =?UTF-8?q?=D0=9E=D0=A1=D0=92:=20=D0=BD=D0=B0=D0=B7?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B5=D0=B9,=20=D0=B3=D1=80=D1=83=D0=BF?= =?UTF-8?q?=D0=BF=D1=8B=20=D0=B8=20=D1=81=D1=82=D0=BE=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=B4=D1=80=D1=83=D0=B3=D0=B8=D1=85=20=D1=81=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=BC,=20=C2=AB=D0=92=D0=A1=D0=95=D0=93?= =?UTF-8?q?=D0=9E=C2=BB=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AURORA-1717 (названия), AURORA-1719 (остатки, «по дебету», «Сальдо вх/исх», «ДТВВ»), AURORA-1732 («ВСЕГО»). Co-Authored-By: Claude Opus 5.5 --- api/rules/osv_headers.yaml | 30 ++++++++++++++++++--- api/tests/synthetic/test_synthetic_files.py | 8 +----- api/tests/unit/parser/test_detect.py | 19 +++++++++++++ api/tests/unit/parser/test_rules.py | 8 +++++- 4 files changed, 54 insertions(+), 11 deletions(-) diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml index 17c89c4..01a158f 100644 --- a/api/rules/osv_headers.yaml +++ b/api/rules/osv_headers.yaml @@ -7,29 +7,51 @@ # ведомость», «д т», `\w` вместо диапазона; # - новый синоним = новая строка в нужном списке, без правки кода. -# Лист содержит ОСВ: любая ячейка (первые строки листа) матчит один из паттернов +# Лист содержит ОСВ: любая ячейка (первые строки листа) матчит один из паттернов. Названия других учётных систем и +# отчётов 1С (AURORA-1717) — только с начала ячейки: «… см. оборотную ведомость» в примечании формы листом ОСВ не делает sheet_markers: - 'оборотно\s*сальдовая\s+ведомость' + - '^оборотн\w*\s+ведомост' # «Оборотная ведомость по счетам …» (выгрузки КТЗ) + - '^осв\b' # «ОСВ по сч. 41 за 3 кв.» (КТЗ) + - '^оборотн\w*\s+баланс' # «Оборотный баланс» (КТЗ) + - '^фин\.?\s*обороты' # «Фин. Обороты Листок» (Navision, Айсберри) + - '^отчет\s+по\s+главной\s+книге' # SAP, ООО «Виола» # Три группы сумм в заголовке таблицы. «Оборотно сальдовая» (название ведомости) не должно матчить turnover: -# отсюда границы слова `\bоборот(ы)?\b`. +# отсюда границы слова `\bоборот(ы)?\b`. Сторона может стоять в ячейке группы после названия — в том числе вплотную +# («НачалоДт», «ОборотыДтВВ»): паттерн группы тогда заканчивается перед стороной (опережающая проверка), остаток ячейки +# разбирают `debit`/`credit` (AURORA-1719) groups: opening: - 'сальдо\s+на\s+начало' - '\bнач(альн\w*)?\.?\s*сальдо' - 'входящ\w*\s+сальдо' + - 'остат\w*\s+на\s+начало' # «Остаток на начало (периода)» (КТЗ) + - 'остат\w*(?=\s+по\s+(дебету|кредиту)\s+на\s+начало)' # «Остаток по дебету на начало» (КТЗ, ЧАЗ) + - 'сальдо\s+начальное' # «Сальдо НачальноеДТВВ» (SAP, НМЖК) + - 'сальдо\s+вх\b' # «Сальдо вх Дт» (SAP, Виола) turnover: - '\bоборот(ы)?\b' + - '^оборот(ы)?(?=\s*(дт|кт))' # «ОборотДт» (Navision), «ОборотыДтВВ» (SAP, НМЖК) closing: - 'сальдо\s+на\s+конец' - '\bкон(ечн\w*)?\.?\s*сальдо' - 'исходящ\w*\s+сальдо' + - 'остат\w*\s+на\s+конец' # «Остаток на конец (периода)» (КТЗ) + - 'остат\w*(?=\s+по\s+(дебету|кредиту)\s+на\s+конец)' # «Остаток по кредиту на конец» (КТЗ, ЧАЗ) + - 'сальдо\s+конечное' # «Сальдо КонечноеКТВВ» (SAP, НМЖК) + - 'сальдо\s+исх\b' # «Сальдо исх Кт» (SAP, Виола) -# Подзаголовки сумм под группой («Дебет»/«Кредит», «Дт»/«Кт», «Д-т»/«К-т»): ячейка начинается с синонима +# Подзаголовки сумм под группой («Дебет»/«Кредит», «Дт»/«Кт», «Д-т»/«К-т») или сторона в остатке ячейки группы: +# ячейка (остаток) начинается с синонима debit: - '^(дебет|дт|д т)\b' + - '^по\s+дебету\b' # «Оборот по дебету», «Остаток по дебету на начало» (КТЗ) + - '^дт\s*вв\b' # «Сальдо НачальноеДТВВ» (SAP, НМЖК) credit: - '^(кредит|кт|к т)\b' + - '^по\s+кредиту\b' # «Оборот по кредиту» (КТЗ) + - '^кт\s*вв\b' # «Сальдо НачальноеКТВВ» (SAP, НМЖК) # Служебные колонки таблицы по тексту заголовка. Код знает колонки ИНН и «Показатели»; прочие колонки таблицы (КПП, код, # количество, валюта и т. п.) попадают в `rows[].extra` под текстом своего заголовка и словаря не требуют. @@ -52,6 +74,8 @@ total_row_exact: - '^итого$' # выгрузка 1С «по контрагентам»: итог листа — «ИТОГ» (без «О»); «ИТОГ по 60.111» — итог субсчёта (subaccount_sums) - '^итог$' + # «ВСЕГО» — итог листа ведомостей ГК КТЗ по счёту 009 (AURORA-1732) + - '^всего$' # Итог субсчёта в выгрузке 1С «по контрагентам»: «ИТОГ по 60.111» — суммы строки-счёта с тем же кодом (группа account); # отдельной строкой в результат не попадает (05-domain-rules.md §1.3, AURORA-1714) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index b6b3d02..f1641f2 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -68,10 +68,8 @@ S55 = "s55_not_osv_titles.xlsx" S56 = "s56_almost_osv_tables.xlsx" S57 = "s57_form_mentions_osv.xlsx" OTHER_SYSTEMS_FILES = (S48, S49, S50, S51, S52, S53, S54, S55, S56, S57) -PENDING_DICTIONARY = "tp#81 Task 2: словари названий, групп и сторон" PENDING_FALLBACK = "tp#81 Task 3: запасной признак листа по заголовку таблицы" -PENDING_81 = {S48: PENDING_DICTIONARY, S49: PENDING_DICTIONARY, S50: PENDING_DICTIONARY, S53: PENDING_DICTIONARY} -PENDING_81 |= {S51: PENDING_FALLBACK, S52: PENDING_FALLBACK, S54: PENDING_FALLBACK} +PENDING_81 = {S51: PENDING_FALLBACK, S52: PENDING_FALLBACK, S54: PENDING_FALLBACK} S48_SHEETS = ("ОВ", "ОСВ сокр", "Оборотный баланс", "Навижн", "Главная книга", "Верхний регистр", "Вторая колонка") TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28 EXPECTED_NAMES = [ @@ -865,7 +863,6 @@ def assert_done_with_sums(doc: OsvDocument, text: str, expected: tuple[Decimal | assert doc.rows[-1].group_type == "TOTAL" -@pytest.mark.xfail(strict=True, reason=PENDING_DICTIONARY) def test_s48_other_system_titles_are_osv(fixtures_dir: Path) -> None: """Каждое название ведомости — свой лист ОСВ; «ВСЕГО» и «Всего » — итоговая строка (AURORA-1717, 1732).""" outcome = parse_named(fixtures_dir, S48) @@ -881,7 +878,6 @@ def test_s48_other_system_titles_are_osv(fixtures_dir: Path) -> None: assert not [text for text in warnings_of(outcome) if TOTAL_NOT_FOUND in text] -@pytest.mark.xfail(strict=True, reason=PENDING_DICTIONARY) def test_s49_balance_and_turnover_by_side(fixtures_dir: Path) -> None: """«Остаток на начало/конец», «Оборот по дебету/кредиту», «Остаток по дебету на начало»; преамбула «Остаток на начало: 0» над таблицей строкой групп не считается.""" @@ -893,7 +889,6 @@ def test_s49_balance_and_turnover_by_side(fixtures_dir: Path) -> None: assert_done_with_sums(second, "ООО Покупатель-1", BUYER_SUMS) -@pytest.mark.xfail(strict=True, reason=PENDING_DICTIONARY) def test_s50_sap_in_out_and_glued_side(fixtures_dir: Path) -> None: """SAP «Сальдо вх/исх Дт/Кт» и сторона, приклеенная к группе («ДТВВ»); «ООО Дтвв-Сервис» — контрагент.""" outcome = parse_named(fixtures_dir, S50) @@ -930,7 +925,6 @@ def test_s52_form_stays_form_navision_is_osv_reference_is_other(fixtures_dir: Pa assert_done_with_sums(one_document(outcome), ALFA, ALFA_SUMS) -@pytest.mark.xfail(strict=True, reason=PENDING_DICTIONARY) def test_s53_xls_balance_by_side(fixtures_dir: Path) -> None: """s49 в xls (без объединений): те же колонки и суммы.""" outcome = parse_named(fixtures_dir, S53) diff --git a/api/tests/unit/parser/test_detect.py b/api/tests/unit/parser/test_detect.py index 87ec004..a62706c 100644 --- a/api/tests/unit/parser/test_detect.py +++ b/api/tests/unit/parser/test_detect.py @@ -282,6 +282,25 @@ def test_one_row_header_with_sides_in_group_cells(rules: Rules) -> None: assert [first_data_row(sheet, header)] == [3] +def test_side_without_word_boundary_only_in_group_cell(rules: Rules) -> None: + """#81, SAP НМЖК: сторона приклеена к названию группы («Сальдо НачальноеДТВВ», «ОборотыДтВВ»); «Дтвв» в тексте + контрагента ниже — не сторона и колонок не сдвигает.""" + sheet = sheet_from_rows( + [ + [TITLE], + ["Счет", "Сальдо НачальноеДТВВ", "Сальдо НачальноеКТВВ", "ОборотыДтВВ", "ОборотыКтВВ"] + + ["Сальдо КонечноеДТВВ", "Сальдо КонечноеКТВВ"], + [L(0), *at({1: "60", 2: 10, 7: 10})], + [L(1), *at({1: "ООО Дтвв-Сервис", 2: 10, 7: 10})], + ] + ) + + header = find_table_header(sheet, rules) + + assert header.columns == ColumnMap(2, 3, 4, 5, 6, 7, frozenset()) + assert (header.first_row, header.last_row) == (2, 2) + + def test_unmerged_group_titles_take_subheaders_up_to_the_next_group(rules: Rules) -> None: sheet = sheet_from_rows( [ diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index c85fc3a..81cba57 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -330,10 +330,16 @@ def test_sheet_title_is_not_a_turnover_header(rules: Rules) -> None: def test_initial_balance_synonym_is_not_in_the_default_dictionary(rules: Rules) -> None: # «Нач. остаток» добавляет сценарий «новый синоним без релиза кода» (R-8): в штатном словаре его быть не должно - for text in ("Нач. остаток", "Начальный остаток", "Остаток на начало периода"): + for text in ("Нач. остаток", "Начальный остаток"): assert not any(p.search(text) for p in rules.headers.groups["opening"]), text +@pytest.mark.parametrize("text", ["остаток на начало периода", "остаток по дебету на начало", "сальдо вх дт"]) +def test_other_systems_opening_synonyms(rules: Rules, text: str) -> None: + """#81, AURORA-1719: написания группы «начало» выгрузок КТЗ и SAP (текст — нормализованный).""" + assert any(p.search(text) for p in rules.headers.groups["opening"]), text + + @pytest.mark.parametrize("text", ["Дебет", "Дт", "Д-т", "дебет, руб."]) def test_debit_synonyms(rules: Rules, text: str) -> None: assert any(p.search(text) for p in rules.headers.debit) From b5f0b9d77372f479b58eda74adb4287feac4bd3b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 20:48:46 +0400 Subject: [PATCH 07/10] =?UTF-8?q?=D0=9E=D0=A1=D0=92:=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D0=B0=D1=81=D0=BD=D0=BE=D0=B9=20=D0=BF=D1=80=D0=B8=D0=B7=D0=BD?= =?UTF-8?q?=D0=B0=D0=BA=20=D0=BB=D0=B8=D1=81=D1=82=D0=B0=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D0=BA=D1=83=20=D1=82?= =?UTF-8?q?=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=8B=20(#81,=20AURORA-1717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Лист без названия ведомости, не опознанный как форма, — ОСВ, если в первых 30 строках полный заголовок: три группы, у каждой Дт и Кт рядом. Проверяется раньше слабого продолжения формы (R-12). Co-Authored-By: Claude Opus 5.5 --- api/rules/osv_headers.yaml | 2 + api/src/parser/book.py | 10 ++- api/src/parser/detect.py | 39 ++++++++++- api/src/parser/statements/adapter.py | 15 ++++- api/tests/synthetic/test_synthetic_files.py | 9 +-- .../unit/parser/statements/test_adapter.py | 55 ++++++++++++++++ api/tests/unit/parser/test_detect.py | 64 +++++++++++++++++++ .../specs/osv-parsing/spec.md | 22 +++++-- 8 files changed, 196 insertions(+), 20 deletions(-) diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml index 01a158f..a0ac2a7 100644 --- a/api/rules/osv_headers.yaml +++ b/api/rules/osv_headers.yaml @@ -30,6 +30,7 @@ groups: - 'остат\w*(?=\s+по\s+(дебету|кредиту)\s+на\s+начало)' # «Остаток по дебету на начало» (КТЗ, ЧАЗ) - 'сальдо\s+начальное' # «Сальдо НачальноеДТВВ» (SAP, НМЖК) - 'сальдо\s+вх\b' # «Сальдо вх Дт» (SAP, Виола) + - '^начало(?=\s*(дт|кт)\b)' # «НачалоДт» (Navision без названия ведомости, Айсберри) turnover: - '\bоборот(ы)?\b' - '^оборот(ы)?(?=\s*(дт|кт))' # «ОборотДт» (Navision), «ОборотыДтВВ» (SAP, НМЖК) @@ -41,6 +42,7 @@ groups: - 'остат\w*(?=\s+по\s+(дебету|кредиту)\s+на\s+конец)' # «Остаток по кредиту на конец» (КТЗ, ЧАЗ) - 'сальдо\s+конечное' # «Сальдо КонечноеКТВВ» (SAP, НМЖК) - 'сальдо\s+исх\b' # «Сальдо исх Кт» (SAP, Виола) + - '^конец(?=\s*(дт|кт)\b)' # «КонецКт» (Navision без названия ведомости, Айсберри) # Подзаголовки сумм под группой («Дебет»/«Кредит», «Дт»/«Кт», «Д-т»/«К-т») или сторона в остатке ячейки группы: # ячейка (остаток) начинается с синонима diff --git a/api/src/parser/book.py b/api/src/parser/book.py index becfe08..14f3be3 100644 --- a/api/src/parser/book.py +++ b/api/src/parser/book.py @@ -35,7 +35,7 @@ from src.contract import ( ) from src.parser import ParseOutcome from src.parser.deadline import Deadline -from src.parser.detect import is_osv_sheet +from src.parser.detect import has_osv_table, is_osv_sheet from src.parser.errors import InvalidDocumentError, ParserError, UnsupportedFormatError from src.parser.osv import OsvParser, data_rows from src.parser.osv_warnings import OSV_XLSB_NO_OUTLINE, OsvNote @@ -143,7 +143,13 @@ class BookParser: if read.no_codepage: warnings.append(_file_warning(make_warning(WarningCode.XLS_NO_CODEPAGE, NO_CODEPAGE_WARNING, rules))) sheets = read.sheets - plan = plan_sheets(sheets, lambda sheet: is_osv_sheet(sheet, self._rules), rules, deadline) + plan = plan_sheets( + sheets, + lambda sheet: is_osv_sheet(sheet, self._rules), + rules, + deadline, + osv_by_table=lambda sheet: has_osv_table(sheet, self._rules), + ) osv_names = {sheet.name for sheet in plan.osv} other_names = {sheet.name for sheet in plan.other} reader_notes = _reader_notes(read) diff --git a/api/src/parser/detect.py b/api/src/parser/detect.py index fade3e9..1570cff 100644 --- a/api/src/parser/detect.py +++ b/api/src/parser/detect.py @@ -30,6 +30,7 @@ __all__ = [ "classify_sheet", "find_table_header", "first_data_row", + "has_osv_table", "inn_from_cell", "is_header_repeat", "is_osv_sheet", @@ -166,13 +167,35 @@ def is_osv_sheet(sheet: Sheet, rules: Rules) -> bool: def classify_sheet(sheet: Sheet, rules: Rules, statement_rules: StatementRules | None = None) -> SheetKind: """ОСВ — если в первых строках есть признак ведомости; иначе форма ББ/ОФР, её продолжение или прочее — по словарям - пакета `arm_statements` (`statements.forms`; `None` — словари пакета по умолчанию).""" + пакета `arm_statements` (`statements.forms`; `None` — словари пакета по умолчанию); «прочее» с полным заголовком + таблицы ОСВ — ОСВ (`has_osv_table`).""" if is_osv_sheet(sheet, rules): return SheetKind.OSV # цикл импорта: forms → cells → number_format → numbers → detect (INN_LENGTHS, inn_from_cell) from src.parser.statements.forms import classify_statement_sheet # noqa: PLC0415 - return _STATEMENT_KINDS[classify_statement_sheet(sheet, statement_rules or default_rules())] + kind = _STATEMENT_KINDS[classify_statement_sheet(sheet, statement_rules or default_rules())] + if kind is SheetKind.OTHER and has_osv_table(sheet, rules): + return SheetKind.OSV + return kind + + +def has_osv_table(sheet: Sheet, rules: Rules) -> bool: + """Запасной признак листа ОСВ без названия ведомости (#81, design D1): в первых `SHEET_MARKER_ROWS` строках есть + строка со всеми тремя группами сумм, и у каждой группы — колонки «Дебет» и «Кредит» (свёрнутое сальдо не годится, + R-3), между которыми в строках заголовка нет других подписей (R-11: у отчёта 1С «Обороты счета» между «Оборот Дт» и + «Оборот Кт» — колонки корр. счетов, это не ОСВ). Колонки ищутся тем же разбором, что у обычного заголовка (R-6).""" + for row in range(1, min(sheet.n_rows, SHEET_MARKER_ROWS) + 1): + cells = _group_cells(sheet, row, rules) + if {cell.group for cell in cells} != set(GROUP_ORDER): + continue + try: + groups = [_columns_of_group(sheet, row, cells, group, rules) for group in GROUP_ORDER] + except InvalidFormatError: + continue + if all(_sides_side_by_side(sheet, row, columns) for columns in groups): + return True + return False # --- Заголовок таблицы --- @@ -311,6 +334,18 @@ def _check_sides(sheet: Sheet, group: SumGroup, debit: _SumCell | None, credit: return _GroupColumns(debit, credit, None) +def _sides_side_by_side(sheet: Sheet, group_row: int, columns: _GroupColumns) -> bool: + """У группы найдены «Дебет» и «Кредит», и между их колонками в строках заголовка (от строки групп до нижней строки + подзаголовков) нет других непустых ячеек.""" + if columns.debit is None or columns.credit is None: + return False + left, right = sorted((columns.debit, columns.credit), key=lambda cell: cell.col) + bottom = max(left.bottom, right.bottom) + return not any( + left.right < col < right.col for row in range(group_row, bottom + 1) for col, _ in _texts(sheet, row) + ) + + def _is_label_row(sheet: Sheet, row: int, sums: range, rules: Rules) -> bool: """Строка-метка блока заголовка: левее сумм — текст группировки, «ИНН» или «Субконто», в колонках сумм — пусто. diff --git a/api/src/parser/statements/adapter.py b/api/src/parser/statements/adapter.py index 3998273..d819054 100644 --- a/api/src/parser/statements/adapter.py +++ b/api/src/parser/statements/adapter.py @@ -54,10 +54,16 @@ class SheetPlan: def plan_sheets( - sheets: Sequence[Sheet], is_osv: Callable[[Sheet], bool], rules: StatementRules, deadline: Deadline + sheets: Sequence[Sheet], + is_osv: Callable[[Sheet], bool], + rules: StatementRules, + deadline: Deadline, + osv_by_table: Callable[[Sheet], bool] | None = None, ) -> SheetPlan: - """Листы по порядку книги: ОСВ; форма; продолжение (в том числе слабое — лист из 1–2 кодов сразу после листа - формы или продолжения); прочее. Лист ОСВ или «прочее» прерывает «сразу после».""" + """Листы по порядку книги: ОСВ; форма; продолжение; ОСВ по заголовку таблицы (`osv_by_table`, запасной признак + #81: только для листа, который не форма и не продолжение по признакам пакета, — раньше слабого продолжения, R-12); + слабое продолжение — лист из 1–2 кодов сразу после листа формы или продолжения; прочее. Лист ОСВ или «прочее» + прерывает «сразу после».""" osv: list[Sheet] = [] statements: list[tuple[Sheet, SheetKind]] = [] other: list[Sheet] = [] @@ -75,6 +81,9 @@ def plan_sheets( last_type = blocks[-1].type_hint if blocks else None elif kind == "continuation": statements.append((sheet, SheetKind.STATEMENT_CONTINUATION)) + elif osv_by_table is not None and osv_by_table(sheet): + osv.append(sheet) + last_type = None elif last_type is not None and is_weak_continuation(sheet, rules, last_type): statements.append((sheet, SheetKind.STATEMENT_CONTINUATION)) else: diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index f1641f2..7afe5ed 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -68,8 +68,6 @@ S55 = "s55_not_osv_titles.xlsx" S56 = "s56_almost_osv_tables.xlsx" S57 = "s57_form_mentions_osv.xlsx" OTHER_SYSTEMS_FILES = (S48, S49, S50, S51, S52, S53, S54, S55, S56, S57) -PENDING_FALLBACK = "tp#81 Task 3: запасной признак листа по заголовку таблицы" -PENDING_81 = {S51: PENDING_FALLBACK, S52: PENDING_FALLBACK, S54: PENDING_FALLBACK} S48_SHEETS = ("ОВ", "ОСВ сокр", "Оборотный баланс", "Навижн", "Главная книга", "Верхний регистр", "Вторая колонка") TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28 EXPECTED_NAMES = [ @@ -312,10 +310,8 @@ def test_every_synthetic_file_is_listed(synthetic_file: Path) -> None: assert synthetic_file.name in EXPECTED, f"добавьте ожидания для {synthetic_file.name}" -def test_header_status_and_hierarchy(synthetic_file: Path, request: pytest.FixtureRequest) -> None: +def test_header_status_and_hierarchy(synthetic_file: Path) -> None: """Статус, число документов, стратегия иерархии и шапка из таблицы `EXPECTED`; результат валиден по контракту.""" - if synthetic_file.name in PENDING_81: - request.applymarker(pytest.mark.xfail(strict=True, reason=PENDING_81[synthetic_file.name])) expect = EXPECTED[synthetic_file.name] outcome = parse_path(synthetic_file) @@ -900,7 +896,6 @@ def test_s50_sap_in_out_and_glued_side(fixtures_dir: Path) -> None: assert by_text(glued)["ООО Дтвв-Сервис"].group_type == "COUNTERPARTY" -@pytest.mark.xfail(strict=True, reason=PENDING_FALLBACK) def test_s51_untitled_sheet_is_osv_by_table(fixtures_dir: Path) -> None: """Navision без названия ведомости: ОСВ по полному заголовку таблицы (запасной признак).""" outcome = parse_named(fixtures_dir, S51) @@ -911,7 +906,6 @@ def test_s51_untitled_sheet_is_osv_by_table(fixtures_dir: Path) -> None: assert_done_with_sums(doc, ALFA, ALFA_SUMS) -@pytest.mark.xfail(strict=True, reason=PENDING_FALLBACK) def test_s52_form_stays_form_navision_is_osv_reference_is_other(fixtures_dir: Path) -> None: """Форма ББ — свой документ (лист Navision не присоединяется к ней слабым продолжением, R-12), Navision — ОСВ, «Справка» — прочий лист.""" @@ -934,7 +928,6 @@ def test_s53_xls_balance_by_side(fixtures_dir: Path) -> None: assert_done_with_sums(second, "ООО Покупатель-1", BUYER_SUMS) -@pytest.mark.xfail(strict=True, reason=PENDING_FALLBACK) def test_s54_xlsb_untitled_navision(fixtures_dir: Path) -> None: """s51 в xlsb: ОСВ по запасному признаку, те же суммы.""" outcome = parse_named(fixtures_dir, S54) diff --git a/api/tests/unit/parser/statements/test_adapter.py b/api/tests/unit/parser/statements/test_adapter.py index 0f5b82b..ddf29d5 100644 --- a/api/tests/unit/parser/statements/test_adapter.py +++ b/api/tests/unit/parser/statements/test_adapter.py @@ -146,6 +146,61 @@ def test_plan_sheets_weak_continuation_only_after_form(rules: StatementRules) -> assert [s.name for s in plan.other] == ["Примечания", "2400"] +class _CountingTable: + """`osv_by_table` для `plan_sheets`: лист с именем из `osv` — ОСВ по таблице; запоминает, для каких листов звали.""" + + def __init__(self, *osv: str) -> None: + self.osv = set(osv) + self.asked: list[str] = [] + + def __call__(self, sheet: Sheet) -> bool: + self.asked.append(sheet.name) + return sheet.name in self.osv + + +def _no_osv(sheet: Sheet) -> bool: + return False + + +def test_plan_sheets_table_fallback_only_for_other(rules: StatementRules) -> None: + """#81, D1: запасной признак не спрашивается у листа формы; лист с таблицей ОСВ — ОСВ, «Справка» — прочее.""" + form = _form("Бухгалтерский баланс", BALANCE_CODES, "ББ") + table = sheet_from_rows([["НачалоДт"]], name="ОВ") + reference = sheet_from_rows([["Справка"], ["Аренда", 100]], name="Справка") + by_table = _CountingTable("ОВ") + + plan = plan_sheets([form, table, reference], _no_osv, rules, NO_DEADLINE, osv_by_table=by_table) + + assert plan.osv == (table,) + assert [(s.name, k) for s, k in plan.statements] == [("ББ", STATEMENT)] + assert plan.other == (reference,) + assert by_table.asked == ["ОВ", "Справка"] + + +def test_plan_sheets_table_fallback_before_weak_continuation(rules: StatementRules) -> None: + """R-12: лист с таблицей ОСВ после формы — ОСВ, а не слабое продолжение; без таблицы — продолжение, как было + (Review Focus 4).""" + ofr = _form("Отчет о финансовых результатах", INCOME_CODES, "ОФР") + weak = sheet_from_rows([["Чистая прибыль", "2400", 10, 8]], name="2400") + + plan = plan_sheets([ofr, weak], _no_osv, rules, NO_DEADLINE, osv_by_table=_CountingTable()) + assert [(s.name, k) for s, k in plan.statements] == [("ОФР", STATEMENT), ("2400", CONTINUATION)] + assert plan.osv == () + + plan = plan_sheets([ofr, weak], _no_osv, rules, NO_DEADLINE, osv_by_table=_CountingTable("2400")) + assert [(s.name, k) for s, k in plan.statements] == [("ОФР", STATEMENT)] + assert plan.osv == (weak,) + + +def test_plan_sheets_without_table_fallback_is_unchanged(rules: StatementRules) -> None: + """Без `osv_by_table` — прежнее поведение: «прочее» остаётся прочим.""" + reference = sheet_from_rows([["Справка"], ["Аренда", 100]], name="Справка") + + plan = plan_sheets([reference], _no_osv, rules, NO_DEADLINE) + + assert (plan.osv, plan.statements, plan.other) == ((), (), (reference,)) + + def test_is_osv_sheet_by_marker() -> None: rules = load_rules() osv = sheet_from_rows([["Оборотно-сальдовая ведомость по счету 60"]]) diff --git a/api/tests/unit/parser/test_detect.py b/api/tests/unit/parser/test_detect.py index a62706c..89f547c 100644 --- a/api/tests/unit/parser/test_detect.py +++ b/api/tests/unit/parser/test_detect.py @@ -17,6 +17,7 @@ from src.parser.detect import ( classify_sheet, find_table_header, first_data_row, + has_osv_table, inn_from_cell, is_header_repeat, ) @@ -110,6 +111,69 @@ def test_osv_marker_far_below_the_head_is_not_searched(rules: Rules) -> None: assert classify_sheet(sheet, rules) is SheetKind.OTHER +# --- Запасной признак листа ОСВ по заголовку таблицы (#81, design D1, R-2, R-3, R-11) --- + +NAVISION_HEADER = ["ФинСчет", "ИсточникНазвание", "НачалоДт", "НачалоКт", "ОборотДт", "ОборотКт", "КонецДт", "КонецКт"] +NAVISION_ROW = at({1: "60", 3: 10, 5: 5, 7: 15}) + + +def test_has_osv_table_navision_split_sides(rules: Rules) -> None: + """Navision без названия: все три группы, у каждой Дт и Кт в ячейках группы — ОСВ.""" + assert has_osv_table(sheet_from_rows([NAVISION_HEADER, NAVISION_ROW]), rules) is True + + +def test_has_osv_table_with_subheaders(rules: Rules) -> None: + """Обычный заголовок: группы над «Дебет | Кредит».""" + sheet = sheet_from_rows( + [ + ["Счет", OPENING, None, TURNOVER, None, CLOSING], + [None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + at({1: "60", 2: 10, 7: 10}), + ] + ) + + assert has_osv_table(sheet, rules) is True + + +def test_has_osv_table_requires_opening(rules: Rules) -> None: + header = ["Счет", "ОборотДт", "ОборотКт", "КонецДт", "КонецКт"] + + assert has_osv_table(sheet_from_rows([header, at({1: "60", 2: 5})]), rules) is False + + +def test_has_osv_table_rejects_collapsed(rules: Rules) -> None: + header = ["Счет", "Сальдо на начало", "Оборот Дт", "Оборот Кт", "Сальдо на конец Дт", "Сальдо на конец Кт"] + + assert has_osv_table(sheet_from_rows([header, at({1: "60", 2: 100, 3: 10})]), rules) is False + + +def test_has_osv_table_rejects_disclosure(rules: Rules) -> None: + """Расшифровка с остатками: «Поступило/Выбыло» — не обороты (Review Focus 1).""" + header = ["Наименование", "Остаток на начало", "Поступило", "Выбыло", "Остаток на конец"] + + assert has_osv_table(sheet_from_rows([header, ["Материалы", 100, 50, 30, 120]]), rules) is False + + +def test_has_osv_table_rejects_columns_between_sides(rules: Rules) -> None: + """1С «Обороты счета»: между «Оборот Дт» и «Оборот Кт» — колонки корр. счетов (R-1, R-11).""" + header = ["Счет", "Начальное сальдо Дт", "Начальное сальдо Кт", "Оборот Дт", "51", "68", "Оборот Кт"] + header += ["Конечное сальдо Дт", "Конечное сальдо Кт"] + + assert has_osv_table(sheet_from_rows([header, at({1: "60", 2: 100, 4: 50})]), rules) is False + + +@pytest.mark.parametrize(("blank_rows", "expected"), [(29, True), (30, False)]) +def test_has_osv_table_only_first_rows(rules: Rules, blank_rows: int, expected: bool) -> None: + """Строка групп ищется в первых `SHEET_MARKER_ROWS` (30) строках (R-2): 30-я — да, 31-я — нет.""" + sheet = sheet_from_rows([*([[]] * blank_rows), NAVISION_HEADER, NAVISION_ROW]) + + assert has_osv_table(sheet, rules) is expected + + +def test_classify_sheet_falls_back_to_table(rules: Rules) -> None: + assert classify_sheet(sheet_from_rows([NAVISION_HEADER, NAVISION_ROW]), rules) is SheetKind.OSV + + # --- find_table_header: варианты заголовка --- diff --git a/openspec/changes/osv-other-systems/specs/osv-parsing/spec.md b/openspec/changes/osv-other-systems/specs/osv-parsing/spec.md index 644a002..6f59b85 100644 --- a/openspec/changes/osv-other-systems/specs/osv-parsing/spec.md +++ b/openspec/changes/osv-other-systems/specs/osv-parsing/spec.md @@ -6,11 +6,13 @@ дефисов). Признаки ОСВ — названия ведомости разных учётных систем («Оборотно-сальдовая ведомость», «Оборотная ведомость», «ОСВ по сч.», «Оборотный баланс», «Фин. Обороты», «Отчет по Главной книге»). Лист с признаками формы ББ/ОФР или её продолжения уходит в конвейер форм (спека `statement-parsing`). Лист, не опознанный ни как ОСВ по признаку, ни -как форма или её продолжение, SHALL считаться листом ОСВ по запасному признаку, если в первых `SHEET_MARKER_ROWS` -строках есть строка групп сумм со всеми тремя группами («начало», «обороты», «конец») и у каждой группы найдены обе -стороны — «Дебет» и «Кредит»; свёрнутое сальдо (одна колонка в группе) запасного признака не даёт. Запасной признак -MUST проверяться только после классификации форм и MUST NOT отнимать у конвейера форм лист формы, её продолжения или -расшифровки. Прочие листы SHALL пропускаться с предупреждением файла `SHEET_OTHER` (`severity = info`, +как форма или её продолжение по признакам форм, SHALL считаться листом ОСВ по запасному признаку, если в первых +`SHEET_MARKER_ROWS` строках есть строка групп сумм со всеми тремя группами («начало», «обороты», «конец»), у каждой +группы найдены обе стороны — «Дебет» и «Кредит», и между колонками «Дебет» и «Кредит» группы в строках заголовка нет +других подписей; свёрнутое сальдо (одна колонка в группе) запасного признака не даёт. Запасной признак MUST +проверяться только после классификации форм и MUST NOT отнимать у конвейера форм лист формы, её продолжения или +расшифровки; слабое продолжение (лист из 1–2 кодов сразу после формы) признаком формы не считается — лист с полной +таблицей ОСВ после формы — ОСВ. Прочие листы SHALL пропускаться с предупреждением файла `SHEET_OTHER` (`severity = info`, `locator = sheet:<имя>`) в `metrics.warnings`. Книга без единого листа ОСВ и без форм → `status = error`, `error.code = INVALID_DOCUMENT` («в книге нет листов ОСВ или форм»). Несколько листов ОСВ → несколько `documents` с `locator = sheet:<имя>`. Имя листа MUST NOT использоваться для поиска. @@ -44,6 +46,16 @@ MUST проверяться только после классификации - **THEN** вид листа расшифровки тот же, что без запасного признака (форма или продолжение конвейера форм либо `SHEET_OTHER`), документа ОСВ из него нет +#### Scenario: Лист ОСВ без названия после формы — не слабое продолжение +- **WHEN** разбирается книга: форма ББ, за ней лист Navision без названия ведомости с полным заголовком таблицы, за ним + посторонний лист +- **THEN** форма — свой документ, лист Navision — `osvDocument`, посторонний лист — `SHEET_OTHER` + +#### Scenario: Отчёт «Обороты счета» не ОСВ +- **WHEN** лист без названия ведомости содержит все три группы со сторонами, но между «Оборот Дт» и «Оборот Кт» стоят + колонки корреспондирующих счетов +- **THEN** лист пропускается с `SHEET_OTHER` + #### Scenario: Неполная таблица не даёт запасного признака - **WHEN** лист без названия ведомости содержит группы «обороты» и «конец», но без группы «начало» или со свёрнутым сальдо (одна колонка в группе) From dc3d7c8ce9ba2ad3dfb7a036d928153b6a8fc2a1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:07:08 +0400 Subject: [PATCH 08/10] =?UTF-8?q?=D0=9E=D0=A1=D0=92:=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=B7=D0=BD=D0=B0=D0=BA=20=C2=AB=D0=9E=D0=A1=D0=92=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=81=D1=87.=C2=BB=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=20=C2=AB=D0=9E=D0=A1=D0=92=E2=80=A6=C2=BB=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=87=D0=B5=D0=BD=D1=8C=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20=D1=88=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=BE=D0=BD=D0=B0=20=D0=B1=D0=B0=D0=BD=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=9E=D0=A1=D0=92=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Регрессия банка первички: «ОСВ за квартал по счетам …» на листе «Входные данные_ИП» роняла книги расшифровок. Негатив s55 дополнен листом-перечнем. Co-Authored-By: Claude Opus 5.5 --- api/rules/osv_headers.yaml | 3 ++- api/tests/synthetic/generate.py | 14 ++++++++++++-- api/tests/synthetic/test_synthetic_files.py | 7 ++++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml index a0ac2a7..27648d3 100644 --- a/api/rules/osv_headers.yaml +++ b/api/rules/osv_headers.yaml @@ -12,7 +12,8 @@ sheet_markers: - 'оборотно\s*сальдовая\s+ведомость' - '^оборотн\w*\s+ведомост' # «Оборотная ведомость по счетам …» (выгрузки КТЗ) - - '^осв\b' # «ОСВ по сч. 41 за 3 кв.» (КТЗ) + # «ОСВ по сч. 41 за 3 кв.», «ОСВ по ДЗ по сч. 62.01» (КТЗ); не «ОСВ за квартал по счетам …» в перечне документов + - '^осв\s+по\s+(\w+\s+по\s+)?сч\b' - '^оборотн\w*\s+баланс' # «Оборотный баланс» (КТЗ) - '^фин\.?\s*обороты' # «Фин. Обороты Листок» (Navision, Айсберри) - '^отчет\s+по\s+главной\s+книге' # SAP, ООО «Виола» diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index acf2361..327c886 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -1286,8 +1286,9 @@ def _s54() -> list[SheetSpec]: def _s55() -> list[SheetSpec]: - """Негатив: названия, похожие на ведомость, но не ведомость (не с начала ячейки, «Оборотный капитал»), и отчёт 1С - «Обороты счета» с колонками корр. счетов между «Оборот Дт» и «Оборот Кт» (R-1) — все три листа прочие.""" + """Негатив: названия, похожие на ведомость, но не ведомость (не с начала ячейки, «ОСВ за квартал по счетам …» в + перечне документов шаблона банка, «Оборотный капитал»), и отчёт 1С «Обороты счета» с колонками корр. счетов между + «Оборот Дт» и «Оборот Кт» (R-1) — все листы прочие.""" return [ build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree(), "ОСВ 60"), table_sheet( @@ -1299,6 +1300,15 @@ def _s55() -> list[SheetSpec]: ["Выручка", D("100000.00")], ], ), + table_sheet( + "Входные данные", + [ + ["Перечень документов к заявке"], + ["№", "Документ", "Срок"], + [1, "Расшифровка дебиторской задолженности", "ежеквартально"], + [2, "ОСВ за квартал по счетам бухгалтерского учета 02, 58, 60, 62, 66, 67, 76", "ежеквартально"], + ], + ), table_sheet( "Капитал", [ diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index 7afe5ed..2dd4ed3 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -941,7 +941,12 @@ def test_neg_s55_titles_like_osv_are_other(fixtures_dir: Path) -> None: «Оборот Кт» (R-1, R-11) — прочие листы; ОСВ одна.""" outcome = parse_named(fixtures_dir, S55) - assert [doc.locator for doc in documents(outcome)] == ["sheet:ОСВ 60"] + # «Входные данные» (перечень документов шаблона банка, R-13) пакет форм относит к расшифровке — как реальный лист + # «Входные данные_ИП» на базе; главное — не ОСВ и книга не падает + assert [(d.document_type, d.locator) for d in outcome.documents] == [ + ("OSV", "sheet:ОСВ 60"), + ("DISCLOSURE", "sheet:Входные данные"), + ] assert sheet_other(outcome) == ["sheet:Справка", "sheet:Капитал", "sheet:Обороты счета"] From f7cb32e3d4a9ac37c497c99f6cb90f6ecfeca519 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:09:27 +0400 Subject: [PATCH 09/10] =?UTF-8?q?=D0=94=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F:=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=B7=D0=BD=D0=B0=D0=BA=D0=B8=20=D0=BB=D0=B8=D1=81=D1=82=D0=B0?= =?UTF-8?q?=20=D0=9E=D0=A1=D0=92,=20=D1=81=D0=B8=D0=BD=D1=82=D0=B5=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20s48=E2=80=93s57,=20=D0=B8=D1=82=D0=BE?= =?UTF-8?q?=D0=B3=20=D1=80=D0=B5=D0=B3=D1=80=D0=B5=D1=81=D1=81=D0=B8=D0=B8?= =?UTF-8?q?=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 ++++- README.md | 2 +- openspec/changes/osv-other-systems/design.md | 22 ++++++++++++++++++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d61ac68..2818b06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,10 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают. - Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле (`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой. -- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 136 файлов: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 41 синтетический) — регрессия, а не +- Признак листа ОСВ шире названия ведомости — запасной признак по заголовку таблицы (`detect.has_osv_table`, #81). + Его ограничения (три группы, Дт и Кт рядом, первые 30 строк, без свёрнутого сальдо) держат негативы синтетики + s55–s57: ослабили ограничение — падает свой негатив. Новое ослабление — только с новым негативом. +- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 149 файлов: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 54 синтетических) — регрессия, а не оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность проверяют property-тест и ручная сверка «Итого». - Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не diff --git a/README.md b/README.md index 66d6a47..6e1882c 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо | `grid.py` | модель листа: `Cell`, `Sheet`, `MergedRange`, `column_letter` (буква колонки — только для диагностики) | | `readers/` | чтение книги в `Sheet`: `read_book(data, file_type) -> (листы, предупреждения)`; формат определяется по сигнатуре байтов (xlsx — openpyxl; части архива, отличающиеся от ссылок `[Content_Types].xml`/`*.rels` только регистром (`xl/SharedStrings.xml`), до openpyxl переименовываются по ссылкам, как их находит Excel — AURORA-1707; xls — xlrd с `formatting_info`, xlsb — pyxlsb, без outline level); несовпадение с `file_type` — предупреждение; до библиотеки чтения — лимиты чтения (`_limits.py`, см. «Лимиты») | | `rules.py` | загрузчик словарей `rules/*.yaml` в frozen-модели (pydantic), `normalize_text`, `Pattern`; ошибка словаря — `RuntimeError` с файлом и ключом при старте | -| `detect.py` | вид листа (`classify_sheet`: ОСВ / форма ББ-ОФР / прочее), блок заголовка таблицы (`find_table_header`: 1–4 строки, шесть колонок сумм, колонка текста, ИНН, дополнительные колонки, правая граница, повтор заголовка внутри данных) | +| `detect.py` | вид листа (`classify_sheet`: ОСВ / форма ББ-ОФР / прочее; ОСВ — по названию ведомости `sheet_markers` или, если лист не форма, по полному заголовку таблицы `has_osv_table`: три группы, у каждой «Дебет» и «Кредит» рядом, в первых 30 строках, #81), блок заголовка таблицы (`find_table_header`: 1–4 строки, шесть колонок сумм, колонка текста, ИНН, дополнительные колонки, правая граница, повтор заголовка внутри данных) | | `header.py` | шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок `group_schema` (варианты шапки 1 и 2) | | `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` в домене нет | diff --git a/openspec/changes/osv-other-systems/design.md b/openspec/changes/osv-other-systems/design.md index 54b688e..5129fd9 100644 --- a/openspec/changes/osv-other-systems/design.md +++ b/openspec/changes/osv-other-systems/design.md @@ -52,9 +52,10 @@ ### D1. Запасной признак листа — последним шагом (решение владельца 28.10.2026) Порядок классификации: признак ОСВ по названию (`sheet_markers`) → форма / продолжение / прочее (пакет). Только если -пакет вернул «прочее», лист проверяется запасным признаком: в первых `SHEET_MARKER_ROWS` строках есть строка групп, где +пакет вернул «прочее», лист проверяется запасным признаком — раньше слабого продолжения формы (R-12): в первых `SHEET_MARKER_ROWS` строках есть строка групп, где найдены **все три** группы (начало, обороты, конец), и у каждой группы найдены **обе** стороны — Дебет и Кредит -(в ячейках под группой или в самой ячейке группы). Свёрнутое сальдо (одна колонка в группе) запасной признак не даёт. +(в ячейках под группой или в самой ячейке группы), и между ними в строках заголовка нет других подписей (R-11). +Свёрнутое сальдо (одна колонка в группе) запасной признак не даёт. Нашёлся — лист `OSV`, дальше обычный конвейер ОСВ; нет — `SHEET_OTHER`, как сейчас. Почему так: расшифровки ББ с колонками «Остаток на начало / … / Остаток на конец» забирает классификатор форм раньше, @@ -105,6 +106,17 @@ xlsb) на main `a8c9605` и на ветке. Сравнение по каждо и сторон дополняются написаниями из D2. PR в arm-ka-docs из ветки `feat/81-osv-other-systems` worktree `.wt/osv-dict/docs`, ссылка на tabular-parser#81. +## Итог регрессии (28.10.2026) + +Банк первички, 2152 файла (`admin/osv-other-systems-regress/report.md` workspace): 60 файлов из ошибки → ОСВ «Готово», +70 → «Требует проверки», 40 узнаны как ОСВ, но стоят на 1720–1723; ни один лист формы или прочий лист книги с формами +не стал ОСВ; ухудшения — только R-14. Файлы списков 1717/1719 (175): 60 «Готово», 60 «Требует проверки», 50 ошибка +формата, 4 ложных «Готово» безымянных ведомостей 009, которые забирает конвейер форм (D1), 1 — неполный заголовок. + +Контрольный набор синтетики s48–s57: позитивы на базе падали, негативы проходили; на ветке проходят все. Мутации +запасного признака (без R-11, без лимита 30 строк, со свёрнутым сальдо, с двумя группами) и снятие якоря `^` у названий +ловит каждая свой негатив (s55, s56, s57). + ## Risks / Trade-offs - [`^осв\b` и «оборотн… ведомост» в тексте формы] → проверка ОСВ идёт первой; регрессия D3 ловит листы форм, ставшие @@ -126,4 +138,10 @@ xlsb) на main `a8c9605` и на ветке. Сравнение по каждо | 2026-10-28 | R-6. Запасной признак переиспользует поиск строки групп и колонок обычного заголовка (`_find_group_row`, `_columns_of_group`) в строгом режиме, а не отдельную функцию | запасной признак и конвейер не должны расходиться в том, что считать заголовком | — | | 2026-10-28 | R-7. Порядок PR: `fixtures` (golden синтетики, отдельный коммит `make golden-update`) → tabular-parser → docs | golden-тесты tabular-parser читают `../fixtures` | — | | 2026-10-28 | R-8. Приёмка — синтетика + `make check-full` + нет ухудшений D3; число исправленных файлов банка — отчёт, не порог | эксперимент автора задач — ориентир, а не обязательство; остаток держат 1720–1723 | — | +| 2026-10-28 | R-9. Написание «Остаток по дебету/кредиту на начало/конец» (6 файлов КТЗ/ЧАЗ) включено в словарь: группа «остаток» с опережающей проверкой, сторона — из остатка ячейки | та же семья «Остаток на начало/конец» (1719), решается записью словаря | лишняя запись, удаляется | +| 2026-10-28 | R-10. В синтетике s50 у SAP-листа обороты «Оборот Дт / Оборот Кт», а не реальное «Дт оборот» | сторона перед группой — AURORA-1722, вне объёма (R-4) | файлы Виолы остаются с ошибкой до 1722 | +| 2026-10-28 | R-11. Запасной признак требует, чтобы между колонками «Дебет» и «Кредит» каждой группы в строках заголовка не было других подписей | иначе 1С «Обороты счета» (Дт/Кт оборота разделены колонками корр. счетов) проходит как ОСВ вопреки R-1; держит негатив s55 | безымянная ОСВ с колонкой между Дт и Кт не распознаётся | +| 2026-10-28 | R-12. Запасной признак проверяется раньше слабого продолжения формы (лист из 1–2 кодов сразу после формы), но после сильных признаков формы и продолжения | на базе лист Navision после формы ББ пакет присоединял к ней как слабое продолжение (синтетика s52); полная таблица ОСВ — сильнее эвристики «1–2 кода» | лист-продолжение формы с полной таблицей ОСВ отделится от формы (у продолжения формы таких групп нет) | +| 2026-10-28 | R-13. Признак «ОСВ …» сужен до `^осв\s+по\s+(\w+\s+по\s+)?сч\b` вместо `^осв\b` | первый прогон банка: строка «ОСВ за квартал по счетам …» перечня документов на листе «Входные данные_ИП» шаблона банка делала лист ОСВ, его разбор падал, и вся книга расшифровок становилась ошибкой (86 файлов); все 20 реальных названий начинаются с «ОСВ по сч.» / «ОСВ по ДЗ по сч.»; держит негатив s55 | «ОСВ» с другим продолжением по названию не узнаётся (остаётся запасной признак) | +| 2026-10-28 | R-14. Два файла «Сводная ОСВ … (забаланс)»: ложное «Готово» расшифровки на базе → ошибка иерархии ОСВ на ветке принято, а не исправлено | это цель изменения (ведомость «Оборотный баланс / Оборотная ведомость» перестаёт быть расшифровкой, 1718); дальше файл держит иерархия «002.01: Название» — AURORA-1721 | у двух книг «Ошибка» вместо фальшивого «Готово» до 1721 | | 2026-10-28 | R-4. AURORA-1722 (SAP со стороной перед группой, НОВО-МАР) не входит | в задачах указан порядок: после 1717/1719; причина варианта НОВО-МАР не разобрана | эти файлы остаются с ошибкой до 1722 | From 57c22547beb4363bd1ab477d08afd161982d470f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:23:27 +0400 Subject: [PATCH 10/10] =?UTF-8?q?=D0=9E=D0=A1=D0=92:=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D0=B0=D1=81=D0=BD=D0=BE=D0=B9=20=D0=BF=D1=80=D0=B8=D0=B7=D0=BD?= =?UTF-8?q?=D0=B0=D0=BA=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D0=B5?= =?UTF-8?q?=D1=82=20=D1=82=D1=83=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D1=83=20?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=BF=D0=BF,=20=D1=87=D1=82=D0=BE=20=D0=B2?= =?UTF-8?q?=D0=BE=D0=B7=D1=8C=D0=BC=D1=91=D1=82=20=D1=80=D0=B0=D0=B7=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=20(#81,=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20I-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Преамбула «Обороты: …» над шапкой давала ОСВ, разбор листа падал и ронял книгу. Негатив s56 «Преамбула». Co-Authored-By: Claude Opus 5.5 --- api/src/parser/detect.py | 16 ++++++++++------ api/tests/synthetic/generate.py | 10 ++++++++++ api/tests/synthetic/test_synthetic_files.py | 8 +++++++- api/tests/unit/parser/test_detect.py | 17 +++++++++++++++++ openspec/changes/osv-other-systems/design.md | 2 +- 5 files changed, 45 insertions(+), 8 deletions(-) diff --git a/api/src/parser/detect.py b/api/src/parser/detect.py index 1570cff..23c6a09 100644 --- a/api/src/parser/detect.py +++ b/api/src/parser/detect.py @@ -184,17 +184,21 @@ def has_osv_table(sheet: Sheet, rules: Rules) -> bool: """Запасной признак листа ОСВ без названия ведомости (#81, design D1): в первых `SHEET_MARKER_ROWS` строках есть строка со всеми тремя группами сумм, и у каждой группы — колонки «Дебет» и «Кредит» (свёрнутое сальдо не годится, R-3), между которыми в строках заголовка нет других подписей (R-11: у отчёта 1С «Обороты счета» между «Оборот Дт» и - «Оборот Кт» — колонки корр. счетов, это не ОСВ). Колонки ищутся тем же разбором, что у обычного заголовка (R-6).""" + «Оборот Кт» — колонки корр. счетов, это не ОСВ). Проверяется та строка групп, которую возьмёт `find_table_header` + (первая с «оборотами» и «концом»), — иначе преамбула «Обороты: …» над шапкой уронила бы разбор листа; колонки + ищутся тем же разбором, что у обычного заголовка (R-6).""" for row in range(1, min(sheet.n_rows, SHEET_MARKER_ROWS) + 1): cells = _group_cells(sheet, row, rules) - if {cell.group for cell in cells} != set(GROUP_ORDER): + groups = {cell.group for cell in cells} + if not REQUIRED_GROUPS <= groups: continue + if groups != set(GROUP_ORDER): + return False try: - groups = [_columns_of_group(sheet, row, cells, group, rules) for group in GROUP_ORDER] + columns = [_columns_of_group(sheet, row, cells, group, rules) for group in GROUP_ORDER] except InvalidFormatError: - continue - if all(_sides_side_by_side(sheet, row, columns) for columns in groups): - return True + return False + return all(_sides_side_by_side(sheet, row, group_columns) for group_columns in columns) return False diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 327c886..4fb19c9 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -1359,9 +1359,19 @@ def _s56() -> list[SheetSpec]: ], ), _navision_sheet("Ниже 30", top_padding=30), + _preamble_navision_sheet(), ] +def _preamble_navision_sheet() -> SheetSpec: + """Полная шапка Navision, над ней преамбула «Обороты за период: … | Остаток на конец: …»: разбор берёт строку + преамбулы, значит, и запасной признак смотрит её — лист прочий, книга не падает (ревью #81, Important-1).""" + sheet = _navision_sheet("Преамбула", top_padding=1) + sheet.put(1, 1, "Обороты за период: 50") + sheet.put(1, 2, "Остаток на конец: 150") + return sheet + + def _s57() -> list[SheetSpec]: """Негатив: форма ББ с примечанием «см. оборотную ведомость …» и текстом «Остаток на начало периода …», за ней лист слабого продолжения (одна строка с кодом) — форма и продолжение остаются формой.""" diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index 2dd4ed3..2660977 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -955,7 +955,13 @@ def test_neg_s56_almost_osv_tables_are_other(fixtures_dir: Path) -> None: outcome = parse_named(fixtures_dir, S56) assert [doc.locator for doc in documents(outcome)] == ["sheet:ОСВ 60"] - assert sheet_other(outcome) == ["sheet:Расшифровка", "sheet:Без начала", "sheet:Свёрнутое начало", "sheet:Ниже 30"] + assert sheet_other(outcome) == [ + "sheet:Расшифровка", + "sheet:Без начала", + "sheet:Свёрнутое начало", + "sheet:Ниже 30", + "sheet:Преамбула", + ] def test_neg_s57_form_with_osv_words_stays_form(fixtures_dir: Path) -> None: diff --git a/api/tests/unit/parser/test_detect.py b/api/tests/unit/parser/test_detect.py index 89f547c..f44ed2d 100644 --- a/api/tests/unit/parser/test_detect.py +++ b/api/tests/unit/parser/test_detect.py @@ -170,6 +170,23 @@ def test_has_osv_table_only_first_rows(rules: Rules, blank_rows: int, expected: assert has_osv_table(sheet, rules) is expected +@pytest.mark.parametrize( + "preamble", + [ + ["Обороты за период: 50", "Остаток на конец: 150"], + ["Остаток на начало: 100", "Обороты: 50", "Остаток на конец: 150"], + ], +) +def test_has_osv_table_checks_the_row_find_table_header_takes(rules: Rules, preamble: list[object]) -> None: + """Финальное ревью, Important-1: преамбула с «оборотами» над полной шапкой — строка, которую возьмёт разбор + (`find_table_header`); она не годится, значит, лист не ОСВ (иначе разбор листа упадёт и уронит книгу).""" + sheet = sheet_from_rows([preamble, NAVISION_HEADER, NAVISION_ROW]) + + with pytest.raises(InvalidFormatError): + find_table_header(sheet, rules) + assert has_osv_table(sheet, rules) is False + + def test_classify_sheet_falls_back_to_table(rules: Rules) -> None: assert classify_sheet(sheet_from_rows([NAVISION_HEADER, NAVISION_ROW]), rules) is SheetKind.OSV diff --git a/openspec/changes/osv-other-systems/design.md b/openspec/changes/osv-other-systems/design.md index 5129fd9..731dcee 100644 --- a/openspec/changes/osv-other-systems/design.md +++ b/openspec/changes/osv-other-systems/design.md @@ -135,7 +135,7 @@ xlsb) на main `a8c9605` и на ветке. Сравнение по каждо | 2026-10-28 | R-2. Запасной признак ищет строку групп только в первых `SHEET_MARKER_ROWS` (30) строках | у безымянных листов таблица начинается сразу; ограничение снижает время и ложные срабатывания на длинных листах «прочее» | безымянный лист с преамбулой длиннее 30 строк не распознаётся; поднимется регрессией D3 | | 2026-10-28 | R-3. Запасной признак не принимает свёрнутое сальдо (одна колонка в группе) | строгость против ложных ОСВ (D1) | ОСВ без названия со свёрнутым сальдо не распознаётся | | 2026-10-28 | R-5. Новые `sheet_markers` якорятся к началу ячейки (`^…`); якорь снимается для конкретного названия, только если по спискам 1717 файлы с ним иначе не находятся | «Оборотная ведомость», «Оборотный баланс» могут встретиться в примечаниях к ББ; признак по названию проверяется раньше форм | часть ведомостей с текстом перед названием не распознаётся; видно в регрессии D3 | -| 2026-10-28 | R-6. Запасной признак переиспользует поиск строки групп и колонок обычного заголовка (`_find_group_row`, `_columns_of_group`) в строгом режиме, а не отдельную функцию | запасной признак и конвейер не должны расходиться в том, что считать заголовком | — | +| 2026-10-28 | R-6. Запасной признак проверяет ту же строку групп, что возьмёт `find_table_header` (первую с «оборотами» и «концом» в первых 30 строках), и колонки ищет тем же `_columns_of_group`; строгие условия — только к этой строке (финальное ревью, I-1: иначе преамбула «Обороты: …» над шапкой давала ОСВ, разбор падал и ронял книгу; негатив s56 «Преамбула») | запасной признак и конвейер не должны расходиться в том, что считать заголовком | безымянная ОСВ с преамбулой «Обороты: …» над шапкой не распознаётся | | 2026-10-28 | R-7. Порядок PR: `fixtures` (golden синтетики, отдельный коммит `make golden-update`) → tabular-parser → docs | golden-тесты tabular-parser читают `../fixtures` | — | | 2026-10-28 | R-8. Приёмка — синтетика + `make check-full` + нет ухудшений D3; число исправленных файлов банка — отчёт, не порог | эксперимент автора задач — ориентир, а не обязательство; остаток держат 1720–1723 | — | | 2026-10-28 | R-9. Написание «Остаток по дебету/кредиту на начало/конец» (6 файлов КТЗ/ЧАЗ) включено в словарь: группа «остаток» с опережающей проверкой, сторона — из остатка ячейки | та же семья «Остаток на начало/конец» (1719), решается записью словаря | лишняя запись, удаляется |