From 7b99c6537362e0b8a6fd956db80bc9b1bd316006 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 12:33:09 +0400 Subject: [PATCH] =?UTF-8?q?plan(#116):=20=D0=BF=D0=BB=D0=B0=D0=BD=204=20?= =?UTF-8?q?=E2=80=94=20tabular-parser:=20=D0=B0=D0=B4=D0=B0=D0=BF=D1=82?= =?UTF-8?q?=D0=B5=D1=80=20Excel=20=D1=88=D0=B0=D0=B3=D0=B0=201,=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B8=D0=B9=20=D0=BA=D0=BE=D0=B4=20arm=5Fstatement?= =?UTF-8?q?s,=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=20v2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- .../plans/2026-10-18-plan-4-tabular-parser.md | 1111 +++++++++++++++++ 1 file changed, 1111 insertions(+) create mode 100644 openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-4-tabular-parser.md diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-4-tabular-parser.md b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-4-tabular-parser.md new file mode 100644 index 0000000..e74010a --- /dev/null +++ b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-4-tabular-parser.md @@ -0,0 +1,1111 @@ +# План 4 — tabular-parser: адаптер Excel шага 1, общий код `arm_statements`, контракт v2.0. План реализации + +> **Для исполнителей-агентов:** обязательный навык — superpowers:subagent-driven-development (в плане 10 задач: по +> `~/.claude/CLAUDE.md` — SDD, контроллер — фоновый сабагент на модели sonnet; модели исполнителей указаны в каждой +> задаче; финальное ревью ветки — opus). Шаги размечены чекбоксами (`- [ ]`). + +**Goal:** tabular-parser разбирает формы ББ/ОФР из Excel так: свой код читает книгу, классифицирует листы, выделяет блоки +форм и склеивает листы-продолжения в `FormCandidate` (адаптер шага 1), а шаги 2–8 выполняет копия пакета +`arm_statements`; результат и конверт — контракт v2.0 (у ОСВ — `status` и структурные предупреждения `OSV_*`); собственный +код шагов 2–8 форм и словари форм удалены; манифест реальных форм и golden обновлены. + +**Architecture:** новые модули адаптера пишутся рядом со старыми (`statements/cells.py`, `forms.py`, `header_cells.py`, +`adapter.py`, `contract.py`, `pipeline.py`); переключение книги на пакет и на контракт v2.0 — одной задачей (модели, +копия схем, сборка книги, путь ОСВ, воркер), сразу за ней — golden и манифест, затем удаление старого кода форм. Пакет +вызывается только через публичный API (`from arm_statements import …`); ячейка Excel → `Cell` (текст по числовому формату, +`value`, `ref = <лист>!`) — в адаптере; разбор ОСВ не меняется, меняется только вид его предупреждений и статус листа. + +**Tech Stack:** Python 3.11, pydantic v2, openpyxl / xlrd / pyxlsb (читатели как есть), PyYAML, pytest, ruff, mypy; +копия пакета `api/arm_statements/` (stdlib + PyYAML); `tea` (Gitea), OpenSpec, codegraph. + +**Spec:** `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/openspec/changes/unified-statement-normalization/` +(ветка `feat/116-unified-normalization` репозитория arm-ka-docs): главное — `specs/tabular-recognition-service/spec.md`; +шаг 1 для Excel — `specs/statement-normalization/spec.md` («Шаг 1 — кандидат в форму», «Шаг 1 — шапка кандидата», «Шаг 1 — +многостраничная форма», «Шаг 8 — предупреждения», «Шаг 8 — статус документа и конверта», «Шаг 4 — единицы и дробные +значения»); контракт — `specs/recognition-contract/spec.md` и `contracts/*.schema.json` v2.0, `contracts/examples/*.json`; +правила — `context/05-domain-rules.md` §1.6, §11.2, §11.9; порядок выкладки — `proposal.md` (сервисы переходят на 2.0 +после ядра, шаг 5). API пакета — раздел «Публичный API пакета» плана 1 (`plans/2026-10-18-plan-1-arm-statements.md`): +только эти имена. `design.md` — справка (Р-n, S-n); спека важнее. + +--- + +## Место в работе и условия старта + +| План | Что | Связь с этим планом | +|---|---|---| +| 0 | тест идентичности копий контракта по MAJOR (в т. ч. tabular-parser) | слит в tabular-parser **до** задачи 7: без него копия схем v2.0 не сверяется с docs | +| 1 | пакет `arm_statements` (репозиторий `arm-statements`) | API — источник имён этого плана; копия пакета приходит в tabular-parser через план 2 | +| 2 | `sync-statements`: копия в `api/arm_statements/`, подготовка хоста | PR плана 2 в tabular-parser слит **до** задачи 3; задача 2 сверяет и доделывает подготовку хоста | +| 3 | arm-core части А и Б: приём 2.0 | PR этого плана **сливается** только после выкладки ядра, принимающего 2.0 (`proposal.md`, шаг 5) | +| docs PR #117 | контракт v2.0, правила | до слияния — отсоединённый worktree docs на `origin/feat/116-unified-normalization`; после — на `origin/main` | + +Разработка этого плана может идти параллельно с планом 3; **слияние** PR tabular-parser и PR fixtures — только по команде +человека после выкладки ядра (иначе первый же результат 2.0 даст файлу `ERROR` у ядра 1.x). + +## Предусловия (для контроллера, до задачи 1) + +- **П-1.** Issue в RSHB/arm-tabular-parser: `tea issues create --login home --repo RSHB/arm-tabular-parser --title + "Формы ББ/ОФР через общий код arm_statements, контракт результата v2.0 (arm-ka-docs#116)" --description "<цель, ссылка на + этот план>"`. Issue в RSHB/arm-ka-fixtures (golden этого PR): `--title "Golden форм и ОСВ под контракт v2.0 + (arm-tabular-parser#)"`. +- **П-2.** Зеркальный workspace `$WS/.wt/normalization-tp/` (`$WS` = `/Users/andrey.epifancev/projects/awp-ca`; + `00-workspace-layout.md`, «Worktree вместо веток»): worktree `tabular-parser/` (ветка фичи), worktree `fixtures/` + (ветка фичи fixtures — golden пишется сюда, поэтому **не** симлинк), отсоединённый worktree `docs/` (в нём не + коммитится), симлинки `reference-old-arm`, `CLAUDE.md`, `.claude`, `arm-statements` (источник для `statements-check`): + +```bash +WS=/Users/andrey.epifancev/projects/awp-ca; M=$WS/.wt/normalization-tp +mkdir -p $M +git -C $WS/tabular-parser fetch origin +git -C $WS/tabular-parser worktree add -b tmp/statements-v2 $M/tabular-parser origin/main +git -C $WS/fixtures fetch origin +git -C $WS/fixtures worktree add -b tmp/statements-v2-golden $M/fixtures origin/main +git -C $WS/docs fetch origin +git -C $WS/docs worktree add --detach $M/docs origin/feat/116-unified-normalization +for l in reference-old-arm CLAUDE.md .claude arm-statements; do ln -s $WS/$l $M/$l; done +git -C $M/tabular-parser branch -m feat/-statements-v2 +git -C $M/fixtures branch -m feat/-statements-v2-golden +cd $M/tabular-parser && codegraph init && make venv +``` + +- **П-3.** В тексте плана `$T` = `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-tp/tabular-parser`, + `$F` = `…/normalization-tp/fixtures`, `$D` = `…/normalization-tp/docs`. Команды `make` — из `$T`, backend — из `$T/api`, + git — `git -C <путь>`. Перед правкой исполнитель вызывает `codegraph_explore` с `projectPath` = `$T`. +- **П-4.** Проверить: в `$T` есть `api/arm_statements/__init__.py` и `api/arm_statements/VERSION` (план 2) и + `api/tests/contract_identity.py` (план 0). Нет копии пакета — стоп и вопрос владельцу (копию раскладывает только + `sync-statements`, не агент руками). Нет плана 0 — задачи 1–6 можно вести, задача 7 ждёт. +- **П-5.** После слияния docs PR #117: `git -C $D checkout --detach origin/main`. + +## Global Constraints + +- Пакет — только через публичный API `from arm_statements import …` (имена раздела «Публичный API пакета» плана 1; в т. ч. + `Rules`, `default_rules`, `Cell`, `Evidence`, `HeaderCells`, `FormCandidate`, `Source`, `DocumentType`, + `StatementWarning`, `normalize_candidate`, `envelope_status`, `make_warning`, `type_hint`, `continuation_type`, + `sheet_joins`, `parse_code`, `parse_date`, `parse_header`, `to_jsonable`, `statements_version`). Импорт внутренних модулей + пакета (`arm_statements.codes` и т. п.) в `src/` запрещён. Файлы `api/arm_statements/**` не правятся (прямая правка — + код 20 `statements-check`); нехватка в API — находка `O-n`, не правка копии. +- Шаг 1 для Excel (спека): `source = excel`; `locator = sheet:[,…]`; у ячейки `confidence = 100`, `text` — как + показывает числовой формат, `value` — число по формату (`Decimal`), дата или `bool`; `ref = <лист>!`, имя листа в + одинарных кавычках, если в нём есть что-то кроме букв, цифр и `_` или оно начинается с цифры, апостроф внутри имени + удваивается; одна форма — одна сетка, граница листа — номер строки сетки в `segment_breaks`. +- Координат в `src/parser/**` нет (кроме `grid.py`, `readers/`) — AST-линтер `tests/unit/parser/test_no_coordinates.py`; + буквы колонок и адрес ячейки строит только `grid.py`. Слов форм в коде нет — только словари пакета (`Rules.headers.*`). +- Контракт: файл результата и конверт (включая ack `processing`) — `schema_version = "2.0"`; запрос остаётся `1.0` + (поддерживаемый MAJOR запроса — `"1"`, `SCHEMA_MISMATCH` по-прежнему). `service.statements_version` — содержимое + `api/arm_statements/VERSION`, обязателен при `statementDocument`. +- Предупреждения форм и файла — только через `make_warning` (код и уровень — словарь пакета); у ОСВ — код с префиксом + `OSV_`, уровень `info`. Строк вида `sheet=<имя> <текст>` в `metrics.warnings` нет. +- Статус конверта — `envelope_status` пакета по статусам всех документов (ОСВ — `done`/`needs_check` листа) и + предупреждениям файла; документов нет — `error INVALID_DOCUMENT`. Порядок документов: ОСВ в порядке листов, затем формы. +- Разбор ОСВ не меняется (`classify.py`, `to_money`, `hierarchy.py`, `checks.py` по существу, словари §1): golden ОСВ + после перехода отличается только ключами `schema_version`, `metrics.warnings`, `documents[].status`, + `documents[].warnings`. +- Деньги — `Decimal`, без `float`; первичка не мутируется. +- Golden правится только `make golden-update` с просмотром diff и отдельным коммитом в fixtures. +- `make check` не требует Docker и сети; `statements-check` — только в `make check-full`. +- Язык комментариев, docstring, сообщений, коммитов, issue и PR — русский. Коммиты заканчиваются строками + `Co-Authored-By: Claude Opus 5.5 ` и + `Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W`; PR — строкой + `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. Слияние — только по команде человека. +- Находки и решения несут маркеры (`T.`, `C-n`/`I-n`/`M-n`, `R-n`, `O-n`); решения этого плана — `P4-n` (раздел в + конце), контроллер переносит их в `design.md` docs (`## Decisions made without discussion`). + +## Review Focus + +Реальные книги, где адаптер шага 1 встречает то, что сценарии спеки описывают по одному. Каждый пункт закреплён тестом +задачи-владельца: + +1. **Титульный блок «Коды» выгрузки 1С** (Унагранде «Б.Б Лист 1», «ББ. Лист 1 .xls», «ББ. Лист 1 3кв.25», «ОФР. Лист 1» ×2): + справа «Коды» с ОКУД, датой, ОКПО, ИНН, ОКОПФ 12267, ОКФС, ОКЕИ. Ожидание: столбец под «Коды» не столбец кодов, шапка + берёт `okopf = "12267"`, строки с кодом или текстом кода «12267» нет, ББ `needs_check` только из-за `1600 = 1700`, ОФР + `done`. Тесты: `test_code_column_ignores_title_codes_block` (задача 4), `test_unagrande_title_codes_block` (задача 8). +2. **НПЗ — листы разной геометрии**: ББ на 5 листах, у «Лист 4» своя строка заголовка и колонки в других столбцах; ОФР на 3 + листах, последний — одна строка 2400 с кодом в другой колонке. Ожидание: два кандидата с 5 и 3 листами, `segment_breaks` + на каждой границе, значения листов-продолжений под `column_index` первого листа, нет `SHEET_NOT_JOINED`, + `SEGMENT_*`, `HEADER_ROW_NOT_FOUND`; строка «Реализация иного имущества…» — `detail` с `parent_ref` строки 2340; оба + `done`. Тесты: `test_npz_candidates` (задача 5), `test_npz_documents` (задача 8). +3. **Межениновская — нумерация и подписи под формой**: строка «1 | 2 | 3 | 4 | 5» с «2» в столбце кодов, ниже таблицы — + подписи руководителя и дата. Ожидание: строки с `line_code_text = "2"` нет, строк `unreadable` / `missing` нет, ИНН + `7016006635` из шапки, ББ и ОФР `done`. Тест: `test_mezheninovskaya_numbering_and_signatures` (задача 8). Если тест + падает на стороне шагов 2–3 (роли строк) — это находка для пакета (план 1), а не повод править сетку в адаптере. +4. **xlsb без числовых форматов**: у каждой формы книги xlsb — `XLSB_NO_FORMATS` (`info`); при «тыс. руб.» и ≥ 10 % + дробных значений — `XLSB_SCALE_SUSPECT` (`error`), документ `needs_check`; без дробных — `done`. Тест: + `test_xlsb_form_scale_suspect` (задача 7, синтетика `write_xlsb`). +5. **«Пояснения»**: книга только из пояснений (лист с ОКУД 0710003 и лист «Пояснения к бухгалтерскому балансу» без кодов) + — документы `DISCLOSURE` `done`, конверт `done`; лист «Пояснения к бухгалтерскому балансу» с кодами 1110…1700 — + `BALANCE` и `TYPE_CONFLICT` (`error`). Тесты: `test_disclosures_only_book_is_done`, + `test_disclosure_title_with_balance_codes` (задача 7). + +--- + +## Структура изменений + +``` +tabular-parser/ +├── openspec/changes/unified-normalization-tp/ # задача 1: proposal, tasks, дельты трёх спек +├── Makefile, api/pyproject.toml, api/pytest.ini, # задача 2: подготовка хоста (план 2) +│ api/requirements-dev.txt, .dockerignore +├── api/contracts/** # задача 7: копия docs/contracts v2.0 (без .v1 и examples/v1) +├── api/src/contract/{common,request,result,__init__}.py # задача 7: модели v2.0 +├── api/src/parser/ +│ ├── grid.py # задача 3: cell_ref() +│ ├── osv_warnings.py # задача 6: OsvNote и коды OSV_* +│ ├── checks.py, osv.py # задача 6: заметки с кодами; задача 7: status/warnings в документе +│ ├── detect.py # задача 5: is_osv_sheet(); задача 7: classify_sheet на словарях пакета +│ ├── readers/__init__.py # задача 7: BookRead.mismatch +│ ├── book.py, __init__.py # задача 7: сборка книги через пакет, require_statements_version() +│ └── statements/ +│ ├── number_format.py # задача 3: текст по формату (остаётся) +│ ├── cells.py # задача 3: ячейка Excel → Cell, сетка +│ ├── forms.py # задача 4: блоки форм, вид листа, столбец кодов, колонки сумм +│ ├── header_cells.py # задача 4: шапка блока → HeaderCells, Evidence +│ ├── adapter.py # задача 5: план листов книги, склейка → FormCandidate +│ ├── contract.py # задача 7: документ пакета → модель контракта v2.0 +│ ├── pipeline.py # задача 7: StatementPipeline (адаптер → normalize_candidate → контракт) +│ └── {checks,columns,detect,header,lines,merge,statement}.py # задача 9: удаляются +├── api/rules/statement_{headers,periods,checks}.yaml # задача 9: удаляются (словари в пакете) +├── api/src/core/worker.py, api/src/app.py, api/src/parser/cli.py # задача 7: statements_version, версии +└── api/tests/… # по задачам; statements_manifest.yaml — задача 8 +fixtures/golden/expected_{osv,statement}_parse/** # задача 8 (отдельный PR fixtures) +``` + +--- + +### Task 1: OpenSpec change `unified-normalization-tp` в tabular-parser + +Модель исполнителя: sonnet (перенос требований спеки в дельты). + +**Files:** +- Create: `$T/openspec/changes/unified-normalization-tp/proposal.md`, `tasks.md`, + `specs/statement-parsing/spec.md`, `specs/tabular-recognition-service/spec.md`, `specs/osv-parsing/spec.md` + +**Interfaces:** +- Consumes: текущие `$T/openspec/specs/{statement-parsing,tabular-recognition-service,osv-parsing}/spec.md`; спека docs + (`specs/tabular-recognition-service/spec.md`, требования шага 1 Excel в `specs/statement-normalization/spec.md`). +- Produces: change, на который ссылаются коммиты и PR; при закрытии — `openspec archive unified-normalization-tp`. + +Состав дельт (текст требований — из спеки docs, приведённый к языку спек сервиса; сценарии — дословно из docs): +- `statement-parsing`: **MODIFIED** «Листы форм и их продолжения находятся по содержимому» (классификация: ОСВ → форма → + продолжение → прочее, слабое продолжение только сразу после листа формы, `SHEET_OTHER`), «Шапка документа» (поля как + `Cell` с `confidence = 100` в границах блока, разбор — общим кодом), «Склейка листов-продолжений и статус документа» + (до раскладки, `segment_breaks`, `CONTINUATION_TYPE_CONFLICT`, `SHEET_NOT_JOINED`; статус — функцией пакета), «Словари + форм, координаты, golden» (словари форм — в пакете; координат нет; golden и манифест); **REMOVED** «Заголовок таблицы, + колонка кодов и колонки-периоды определяются по тексту», «Строки с кодом и значения», «Соотношения §7 — данные, + проверка по колонкам» (Reason: шаги 2–8 выполняет копия `arm_statements`; Migration: требования — + `statement-normalization` docs); **ADDED** «Адаптер Excel шага 1», «Ячейка Excel», «Строки без кода и неизвестные + колонки в Excel», «Манифест ожиданий на реальных формах», «Статус книги» (из docs `tabular-recognition-service`). +- `tabular-recognition-service`: **MODIFIED** «Результат по контракту» (v2.0, `service.statements_version`, структурные + `metrics.warnings`, ack `processing` — 2.0, запрос 1.0). +- `osv-parsing`: **MODIFIED** «Контрольные суммы определяют статус» (`status` листа и `warnings[]` `OSV_*` `info` в + документе, строк `sheet=` нет) и «Форматы, лимиты, время» (`FILE_TYPE_MISMATCH` — предупреждение файла `info`). + +- [ ] **Step 1: Прочитать три текущие спеки сервиса и требования docs; написать proposal.md** (Why — ссылка на docs + change и этот план; What Changes — по списку выше; Impact — модули из «Структуры изменений»). +- [ ] **Step 2: Написать дельты** — MODIFIED с полным новым текстом требования и всеми его сценариями. +- [ ] **Step 3: `tasks.md`** — задачи 1–10 этого плана одной строкой каждая. +- [ ] **Step 4: Проверка** + +Run: `cd $T && openspec validate unified-normalization-tp --strict` +Expected: `Change 'unified-normalization-tp' is valid` + +- [ ] **Step 5: Commit** + +```bash +git -C $T add openspec/changes/unified-normalization-tp +git -C $T commit -m "openspec: change unified-normalization-tp — адаптер Excel, общий код, контракт v2.0 (#)" +``` + +--- + +### Task 2: Подготовка хоста (по плану 2 — сверить с планом 2) + +Модель исполнителя: haiku (конфигурация по списку). + +План 2 (`plans/2026-10-18-plan-2-sync-statements.md`) на момент написания этого плана ещё не готов: пункты ниже — +ожидаемый состав. Исполнитель сначала сверяет каждый пункт с планом 2 и с `main` tabular-parser; уже сделанное планом 2 +не повторяет, расхождение формулировок — находка `O-n` в отчёт, решает план 2. + +**Files:** +- Modify: `$T/Makefile` (`lint`, `check-full`, цель `statements-check`), `$T/api/pytest.ini` (`testpaths`), + `$T/api/pyproject.toml` (`known-first-party`), `$T/api/requirements-dev.txt` (пины), `$T/.dockerignore` + +**Interfaces:** +- Produces: `make lint` проверяет `mypy src arm_statements`; `make test` запускает `arm_statements/tests`; + `make statements-check` (`$(ARM_KA_DOCS)/scripts/sync-statements.sh --check --host-dir .`, `ARM_KA_DOCS ?= ../docs`) + входит в `check-full`, не в `check`. + +- [ ] **Step 1: Сверка** — `api/arm_statements/VERSION` есть и это 40 hex-символов; `PyYAML==6.0.3` в + `api/requirements.txt` (есть); `conftest.py` без `autouse` вне `tests/` (сейчас `autouse` нет — оставить). +- [ ] **Step 2: Правки (только недостающее):** + - `api/pytest.ini`: `testpaths = tests arm_statements/tests`; + - `api/pyproject.toml`: `[tool.ruff.lint.isort] known-first-party = ["src", "arm_statements"]`; + - `Makefile`: в `lint` — `$(BIN)/mypy src arm_statements`; `ARM_KA_DOCS ?= ../docs`; цель `statements-check`; + `check-full: check statements-check test-integration docker-build`; цель в `.PHONY`; + - `api/requirements-dev.txt`: `ruff==0.16.10`, `mypy==2.4.0`, `pytest==9.1.1` (пины пакета, P1-2; если план 2 решил + иначе — как в плане 2); + - `.dockerignore`: `api/arm_statements/tests` (словари `api/arm_statements/rules/*.yaml` в образ попадают — + `COPY api/ ./`). +- [ ] **Step 3: Проверка** + +Run: `cd $T && make venv && make check && make statements-check; echo "exit=$?"` +Expected: `make check` зелёный, в выводе pytest есть `arm_statements/tests/…` PASSED; `statements-check` — `exit=0` или +`exit=10` с предупреждением «копия отстала» (зелёная). + +- [ ] **Step 4: Commit** + +```bash +git -C $T add Makefile api/pytest.ini api/pyproject.toml api/requirements-dev.txt .dockerignore +git -C $T commit -m "build: копия arm_statements в lint, test и statements-check (#)" +``` + +--- + +### Task 3: Ячейка Excel → `Cell`: ссылка, текст по формату, сетка + +Модель исполнителя: sonnet. + +**Files:** +- Modify: `$T/api/src/parser/grid.py` (`cell_ref`), `$T/api/src/parser/statements/number_format.py` (текст) +- Create: `$T/api/src/parser/statements/cells.py` +- Test: `$T/api/tests/unit/parser/test_grid.py`, `$T/api/tests/unit/parser/statements/test_number_format.py`, + `$T/api/tests/unit/parser/statements/test_cells.py` + +**Interfaces:** +- Consumes: `Sheet`, `Cell` (grid), `display_amount`, `arm_statements.Cell`. +- Produces: + - `grid.cell_ref(sheet_name: str, row: int, col: int) -> str` — 1-based; правило кавычек — Global Constraints + («буква» — `str.isalpha()`, цифра — `str.isdigit()`). + - `number_format.FormatResult(kind, value: Decimal | None = None, text: str | None = None)` — `text` заполнен у + `kind == "value"`; `display_amount(raw: Decimal, fmt: str) -> FormatResult`; `general_text(raw: Decimal) -> str`. + - `cells.excel_cell(sheet: Sheet, row: int, col: int, unparsed_formats: set[str]) -> arm_statements.Cell | None` + - `cells.grid_rows(sheet: Sheet, first_row: int, last_row: int, width: int, unparsed_formats: set[str]) -> + list[tuple[arm_statements.Cell | None, ...]]` — строки `first_row..last_row` включительно, позиция = `col - 1`, длина + каждой строки = `width`. + +Правила текста и значения (P4-3, P4-4, P4-5): +- числовая ячейка с форматом, разобранным в цифровую секцию: `value` — показываемое число (знак — от сырого, R-3), + **без** проверки диапазона `numeric(18,2)` (больше 16 целых цифр — `VALUE_OUT_OF_RANGE` выдаёт пакет); `text` — + литералы секции до и после плейсхолдеров + модуль числа с группами по три через пробел (если в секции есть + разделитель групп) и `decimals` знаками после **запятой**; «-» впереди — только если сырое отрицательно и в формате одна + секция без условия; +- секция-литерал (`[=0]"-"`, `"(647)"`): `text` — литерал, `value` — как разбирает `to_money` литерал (прочерк — `None`); +- `General`, пустой формат, формат не разобран, xlsb (формата нет): `value` — сырое `Decimal`, `text` — + `general_text(raw)`: целое — без дробной части, иначе без хвостовых нулей, разделитель — запятая, без групп и + экспоненты; неразобранный формат кладётся в `unparsed_formats`; +- дата/дата-время: `value` — `date`, `text` — `dd.mm.yyyy`; логическое — `value` bool, `text` «ИСТИНА»/«ЛОЖЬ»; текст — + `value = None`, `text = Cell.text`; пустая ячейка и не-левая-верхняя позиция объединённого диапазона — `None`; +- `confidence = 100.0`, `ref = cell_ref(sheet.name, row, col)` (у объединённого — адрес левой верхней). + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/unit/parser/test_grid.py +def test_cell_ref_quotes_sheet_name_by_excel_rule() -> None: + assert cell_ref("Баланс", 27, 3) == "Баланс!C27" + assert cell_ref("Лист_1", 2, 2) == "Лист_1!B2" + assert cell_ref("Бухгалтерский баланс. Лист 1", 14, 3) == "'Бухгалтерский баланс. Лист 1'!C14" + assert cell_ref("2024", 1, 1) == "'2024'!A1" + assert cell_ref("Отчёт д'Артаньяна", 1, 28) == "'Отчёт д''Артаньяна'!AB1" + +# tests/unit/parser/statements/test_number_format.py +@pytest.mark.parametrize( + ("raw", "fmt", "value", "text"), + [ + ("106311116.46", "#,##0,", "106311", "106 311"), + ("-646532.4", "#,##0;(#,##0)", "-646532", "(646 532)"), + ("-1500", "#,##0", "-1500", "-1 500"), + ("-1500", "#,##0;#,##0", "-1500", "1 500"), + ("1.5", "0.00", "1.50", "1,50"), + ("0", '#,##0;-#,##0;"-"', None, "-"), + ("12345678901234567", "0", "12345678901234567", "12345678901234567"), + ], +) +def test_display_text(raw: str, fmt: str, value: str | None, text: str) -> None: + shown = display_amount(D(raw), fmt) + assert (shown.kind, shown.value, shown.text) == ("value", None if value is None else D(value), text) + +def test_general_text() -> None: + assert [general_text(D(x)) for x in ("7016006635.0", "106311116.460", "-0.5")] == ["7016006635", "106311116,46", "-0,5"] + +# tests/unit/parser/statements/test_cells.py — листы собирать gridbuilder.sheet_from_rows / with_formats +def test_excel_cell_by_number_format() -> None: # спека: «Excel-ячейка с числовым форматом» + cell = excel_cell(with_formats(balance_sheet_with(row=27, col=4, value=D("106311116.46")), {(27, 4): "#,##0,"}), + 27, 4, set()) + assert cell == Cell(text="106 311", value=D("106311"), confidence=100.0, ref="Баланс!D27") + +def test_unparsed_format_keeps_raw_and_is_recorded() -> None: + formats: set[str] = set() + cell = excel_cell(with_formats(sheet_one(D("0.15")), {(1, 1): "0.0%"}), 1, 1, formats) + assert (cell.value, cell.text, formats) == (D("0.15"), "0,15", {"0.0%"}) + +def test_date_text_bool_and_merged() -> None: ... + # дата 2025-09-30 → text "30.09.2025", value date; текст «Итого» → value None; позиция внутри merged (не origin) → None + +def test_grid_rows_dense_width() -> None: ... + # 3 строки × width=6: каждая строка — кортеж длины 6, пустые позиции None, cell в позиции col-1 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd $T/api && .venv/bin/pytest tests/unit/parser/test_grid.py tests/unit/parser/statements/test_number_format.py tests/unit/parser/statements/test_cells.py -v` +Expected: FAIL — `cell_ref`, `general_text`, `cells` не определены, у `FormatResult` нет `text`. + +- [ ] **Step 3: Implement** `cell_ref` (через `column_letter`), текст в `number_format` (в `_Section` добавить + `grouping: bool`, `prefix: str`, `suffix: str`; `_scaled` больше не вызывает `quantize_money` — квантование и диапазон + в пакете), `cells.py`. Старые потребители `display_amount` (`statements/lines.py`) продолжают работать: + `kind`/`value` для чисел в пределах диапазона не меняются. +- [ ] **Step 4: Run tests** + +Run: `cd $T && make check` +Expected: зелёный (старый конвейер форм и golden не изменились: старые тесты `test_number_format` по `value` проходят; +если старый тест ждал `unparsed` для числа вне диапазона — переписать его на новое правило, это P4-4). + +- [ ] **Step 5: Commit** + +```bash +git -C $T add api/src/parser/grid.py api/src/parser/statements/number_format.py api/src/parser/statements/cells.py api/tests/unit +git -C $T commit -m "feat(statements): ячейка Excel → Cell: текст по числовому формату, value, ref <лист>! (#)" +``` + +--- + +### Task 4: Блоки форм, вид листа и шапка на словарях пакета + +Модель исполнителя: sonnet. + +**Files:** +- Create: `$T/api/src/parser/statements/forms.py`, `$T/api/src/parser/statements/header_cells.py` +- Test: `$T/api/tests/unit/parser/statements/test_forms.py`, `$T/api/tests/unit/parser/statements/test_header_cells.py` + +**Interfaces:** +- Consumes: `arm_statements.Rules` (`headers.form_titles`, `headers.okud`, `headers.okud_code`, `headers.continuation`, + `headers.code_caption`, `headers.labels`, `headers.auditor`, `headers.legal_form`, `periods.relative`, + `code_ranges.min_share`, `code_ranges.min_codes`), `type_hint`, `continuation_type`, `parse_code`, `parse_date`, + `parse_amount`, `Source`, `excel_cell` (задача 3), + `normalize_text` из `src.parser.rules` (P4-7). +- Produces (`forms.py`; `StatementRules` = `arm_statements.Rules`): + - `matches(patterns: Iterable[re.Pattern[str]], text: str) -> bool` — `search` по `normalize_text(text)` + - `FormBlock(type_hint: DocumentType, title: tuple[int, int] | None, okud: tuple[int, int] | None, first_row: int, + last_row: int)` — `title`/`okud` — `(row, col)` ячеек-признаков + - `find_blocks(sheet: Sheet, rules: StatementRules) -> list[FormBlock]` — строки с заголовком (`type_hint(None, text)` + не `None`) или ОКУД (`type_hint(text, None)`; ячейка-маркер продолжения ОКУДом блока не считается) — кандидаты; между + соседними без строки с кодом (`code_rows` столбца кодов листа) — один блок; первый блок начинается с первой строки + листа, каждый — до строки перед следующим; `type_hint` блока — `type_hint(okud_text, title_text, rules)` + - `code_column(sheet: Sheet, rules: StatementRules, first_row: int, last_row: int, *, min_codes: int) -> int | None` — + столбец с наибольшим числом ячеек, где `parse_code(text).code` не `None`, при кодах ≥ `min_codes` и доле среди + непустых ≥ `code_ranges.min_share` + - `code_rows(sheet: Sheet, rules: StatementRules, col: int, first_row: int, last_row: int) -> list[int]` + - `codes_of(sheet: Sheet, rules: StatementRules, col: int, first_row: int, last_row: int) -> list[str]` + - `amount_columns(sheet: Sheet, rules: StatementRules, code_col: int, first_row: int, last_row: int) -> int` — число + столбцов правее `code_col`, где хотя бы в одной строке с кодом есть число (числовая ячейка или текст, у которого + `parse_amount(text, Source.EXCEL).amount` не `None`) + - `continuation_marker(sheet: Sheet, rules: StatementRules) -> Cell | None` — первая ячейка, совпавшая с + `headers.continuation` (тип — `continuation_type(cell.text, rules)`) + - `StatementSheetKind = Literal["statement", "continuation", "other"]`; + `classify_statement_sheet(sheet: Sheet, rules: StatementRules) -> StatementSheetKind` — есть блок → `statement`; + маркер, или подпись `headers.code_caption` с подписью-периодом правее (`parse_date` не `None` или совпадение + `periods.relative`), или `code_column(min_codes=code_ranges.min_codes)` → `continuation`; иначе `other` + - `is_weak_continuation(sheet: Sheet, rules: StatementRules) -> bool` — `code_column(min_codes=1)` не `None` +- Produces (`header_cells.py`): + - `header_cells(sheet: Sheet, rules: StatementRules, block: FormBlock, last_row: int, unparsed_formats: set[str]) -> + tuple[HeaderCells, Evidence]` — поиск в строках `block.first_row..last_row`; `last_row` задаёт адаптер (P4-6) + +Правила шапки (как сейчас в `statements/header.py`, но результат — `Cell` ячейки-значения через `excel_cell`): +организация — значение правее метки `organization` или в следующей строке; пусто, число или метка ОКПО — ближайшая +строка выше с признаком `legal_form`; ИНН — первая ячейка правее метки `inn`, в тексте которой 10 или 12 цифр (сам разбор +— пакет); строки ниже первой метки `auditor` для организации, ИНН и адреса не смотрятся; `okpo`, `okfs`, `okopf`, `okei`, +`address` — значение у своей метки; пара `okopf_okfs` — два числа правее в той же строке или строкой выше; `units` — +первая ячейка правее метки `units`, кроме метки ОКЕИ и чистых цифр; `report_date` — ячейка строки сразу под заголовком +формы, у которой `parse_date(text)` не `None`, иначе тройка «число | месяц | год» правее метки `date` → +`Cell(text="<д>.<мм>.<гггг>" для числового месяца или "<д> <месяц> <гггг>", value=None, confidence=100.0, ref=)`; `okud` — ячейка ОКУД блока. `Evidence(okud=<ячейка ОКУД>, title=<ячейка заголовка>)`. + +- [ ] **Step 1: Write the failing tests** (листы — `sheet_from_rows`; правила — `default_rules()`) + +```python +def test_title_and_okud_without_codes_between_form_one_block(rules: StatementRules) -> None: ... + # «Бухгалтерский баланс» (стр. 3), «Форма по ОКУД | 0710001» (стр. 5), таблица ниже → один блок BALANCE, + # title=(3, c), okud=(5, c2), first_row=1 + +def test_second_title_after_codes_opens_new_block(rules: StatementRules) -> None: ... + # ББ с кодами 1110…1600, ниже «Отчет о финансовых результатах» и 2110… → блоки BALANCE и INCOME, граница — строка заголовка ОФР + +def test_okud_wins_over_title(rules: StatementRules) -> None: + blocks = find_blocks(sheet_from_rows([["Бухгалтерский баланс"], ["по ОКУД", "0710002"]]), rules) + assert [block.type_hint for block in blocks] == [DocumentType.INCOME] + +def test_report_title_alone_is_not_a_form(rules: StatementRules) -> None: + assert find_blocks(sheet_from_rows([["Бухгалтерская отчетность"]]), rules) == [] + +def test_continuation_marker_is_not_a_block(rules: StatementRules) -> None: + sheet = sheet_from_rows([["Форма 0710001 с.2"], ["Пассив", "Код", "На 30.09.2025"], ["Капитал", "1310", 10]]) + assert find_blocks(sheet, rules) == [] + assert classify_statement_sheet(sheet, rules) == "continuation" + +def test_code_column_ignores_title_codes_block(rules: StatementRules) -> None: # Review Focus 1 + # столбец F: «Коды», 0710001, 30, 09, 2025, 12345678, 7700000000, 12267, 16, 384 (один код из девяти); + # столбец C: 1110, 1150, 1170, 1100, 1210, 1230 → code_column(...) == 3 + +def test_amount_columns_counts_numeric_columns_right_of_code(rules: StatementRules) -> None: ... + # код в C, числа в D, E, F в строках с кодами, текст «Изменение» в G без чисел → 3 + +def test_weak_and_other_sheets(rules: StatementRules) -> None: ... + # лист с единственной строкой «Чистая прибыль | 2400 | 10 | 8» → classify «other», is_weak_continuation True; + # лист «Примечания» с текстом → «other», is_weak_continuation False + +def test_header_cells_of_1c_block(rules: StatementRules) -> None: ... + # «Организация | АО «Пример»», «ИНН | 7700000000», «по ОКПО | 12345678», «по ОКОПФ / ОКФС | 12267 | 16» строкой выше + # метки, «Единица измерения: | в тыс. рублей | по ОКЕИ | 384», подзаголовок «на 30 сентября 2025 г.»: + # cells.organization_name.text == "АО «Пример»", .inn.ref == "Баланс!C5", (.okopf.text, .okfs.text) == ("12267", "16"), + # .report_date.text == "на 30 сентября 2025 г."; parse_header(cells, rules) → units "тыс. руб.", report_date 2025-09-30 + +def test_auditor_inn_is_not_taken(rules: StatementRules) -> None: ... +def test_organization_by_legal_form_above_label(rules: StatementRules) -> None: ... # НПЗ +def test_report_date_from_triple(rules: StatementRules) -> None: ... + # «Дата (число, месяц, год) | 30 | 09 | 2025» → text "30.09.2025"; «30 | сентября | 2025» → "30 сентября 2025"; + # parse_header даёт 2025-09-30 в обоих случаях + +def test_units_only_by_okei(rules: StatementRules) -> None: # спека: «Единицы только кодом ОКЕИ» + # «по ОКЕИ | 384», метки «Единица измерения» нет → cells.units is None, cells.okei.text == "384"; + # head = parse_header(cells, rules): (head.units.value, head.okei.value) == ("тыс. руб.", "384") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd $T/api && .venv/bin/pytest tests/unit/parser/statements/test_forms.py tests/unit/parser/statements/test_header_cells.py -v` +Expected: FAIL — модулей нет. + +- [ ] **Step 3: Implement `forms.py`, `header_cells.py`** по Interfaces (донор логики — `statements/detect.py` и + `statements/header.py`; сами модули-доноры не правятся, их удаляет задача 9). Если метка шапки не находится словарём + пакета, а словарём tabular-parser находится — это расхождение словарей: находка `O-n`, словарь пакета не правится. +- [ ] **Step 4: Run tests** + +Run: `cd $T && make check` +Expected: зелёный; `test_no_coordinates.py` проходит (новые модули без координат). + +- [ ] **Step 5: Commit** + +```bash +git -C $T add api/src/parser/statements/forms.py api/src/parser/statements/header_cells.py api/tests/unit/parser/statements +git -C $T commit -m "feat(statements): блоки форм, вид листа и шапка-ячейки на словарях arm_statements (#)" +``` + +--- + +### Task 5: Адаптер книги → `FormCandidate` + +Модель исполнителя: sonnet. + +**Files:** +- Create: `$T/api/src/parser/statements/adapter.py` +- Modify: `$T/api/src/parser/detect.py` (вынести `is_osv_sheet`, поведение `classify_sheet` не меняется) +- Test: `$T/api/tests/unit/parser/statements/test_adapter.py`, + `$T/api/tests/fixtures/test_statements_adapter_real.py` (маркер `fixtures`) + +**Interfaces:** +- Consumes: задачи 3–4; `FormCandidate`, `Evidence`, `HeaderCells`, `Source`, `DocumentType`, `make_warning`, + `sheet_joins`, `continuation_type`, `Deadline`, `SheetKind`. +- Produces: + - `detect.is_osv_sheet(sheet: Sheet, rules: Rules) -> bool` — признак ведомости в первых `SHEET_MARKER_ROWS` строках + - `adapter.SheetPlan(osv: tuple[Sheet, ...], statements: tuple[tuple[Sheet, SheetKind], ...], other: tuple[Sheet, ...])` + - `adapter.plan_sheets(sheets: Sequence[Sheet], is_osv: Callable[[Sheet], bool], rules: StatementRules) -> SheetPlan` — + по порядку: ОСВ; `statement` → `SheetKind.STATEMENT`; `continuation` → `STATEMENT_CONTINUATION`; `other` со слабым + продолжением сразу после листа формы или продолжения → `STATEMENT_CONTINUATION`; иначе `other`. Лист ОСВ или «прочее» + прерывает «сразу после». + - `adapter.build_candidates(sheets: Sequence[tuple[Sheet, SheetKind]], rules: StatementRules, deadline: Deadline, *, + xlsb: bool) -> list[FormCandidate]` + +Алгоритм `build_candidates` (спека «Шаг 1 — многостраничная форма», §11.2 п. 2–4; P4-6, P4-8, P4-10): +1. Лист `STATEMENT`: для каждого блока — новый кандидат: `source=EXCEL`, `locator="sheet:<имя>"`, `type_hint` блока, + `evidence` и `header` — `header_cells(…, last_row=<строка перед первой строкой с кодом столбца кодов блока>)` (кодов + нет — `block.last_row`), `rows = grid_rows(sheet, block.first_row, block.last_row, …)`. +2. Лист `STATEMENT_CONTINUATION`: открытая форма — последний кандидат с `type_hint` `BALANCE`/`INCOME`. Маркер есть и его + тип не `None` и не равен типу формы → отдельный кандидат (`type_hint` = тип маркера) + `CONTINUATION_TYPE_CONFLICT`. + Маркера нет → `sheet_joins(type, codes, amount_columns(лист), amount_columns(первый лист формы в её блоке), rules)`; + `False` → отдельный кандидат (`type_hint=None`) + `SHEET_NOT_JOINED`. Открытой формы нет → отдельный кандидат + (`type_hint` = тип маркера или `None`) без предупреждения. У отдельного кандидата `header = HeaderCells()`, + `evidence = Evidence()`, `rows` — все строки листа. Иначе — присоединение: `segment_breaks` += текущее число строк + сетки, строки листа (все строки листа) дописываются, `locator` += `,<имя>`. +3. Ширина: строки всех листов кандидата выравниваются по наибольшей ширине (дописываются `None`). +4. Предупреждения кандидата (`make_warning`, документа): `xlsb` → `XLSB_NO_FORMATS` у каждого кандидата; непустой набор + неразобранных форматов всех листов кандидата → одно `NUMBER_FORMAT_UNPARSED` с перечнем (`«fmt»` через запятую, + длинные — до 30 символов с «…»). +5. `deadline.check(f"лист «{name}»: форма")` перед каждым листом. + +- [ ] **Step 1: Write the failing tests** + +```python +def test_balance_with_marked_continuation_and_income(rules: StatementRules) -> None: # спека: «Книга ББ + ОФР» + candidates = build_candidates([(balance, STATEMENT), (balance_2_marked, STATEMENT_CONTINUATION), (ofr, STATEMENT)], + rules, NO_DEADLINE, xlsb=False) + assert [(c.type_hint, c.locator) for c in candidates] == [ + (DocumentType.BALANCE, "sheet:Баланс,Баланс (2)"), (DocumentType.INCOME, "sheet:ОФР")] + assert candidates[0].segment_breaks == (balance.n_rows,) + assert len({len(row) for row in candidates[0].rows}) == 1 + assert candidates[0].source is Source.EXCEL + +def test_refs_of_cells(rules: StatementRules) -> None: # спека: «Ссылки ячеек» + # код 1110 в C27 листа «Баланс» → ячейка сетки с text "1110" имеет ref "Баланс!C27"; + # у листа «Бухгалтерский баланс. Лист 1» — "'Бухгалтерский баланс. Лист 1'!C27" + +def test_marker_type_conflict_is_separate_candidate(rules: StatementRules) -> None: ... + # ББ, затем лист с «Форма 0710002 с.2» → 2 кандидата; у второго type_hint INCOME и + # [(w.code, w.severity) for w in c.warnings] == [("CONTINUATION_TYPE_CONFLICT", Severity.ERROR)] + +def test_single_line_continuation_joins(rules: StatementRules) -> None: # спека: «Лист-продолжение с одной строкой» + # ОФР (3 колонки сумм), лист без шапки с единственной строкой 2400 → 1 кандидат, segment_breaks == (n_rows ОФР,) + +def test_wider_sheet_with_three_codes_is_not_joined(rules: StatementRules) -> None: # «Лист другой ширины с тремя кодами» + # ББ (3 колонки сумм), лист без маркера и шапки с 1210, 1230, 1250 и 4 колонками → 2 кандидата, + # у второго SHEET_NOT_JOINED (error), type_hint None + +def test_lonely_continuation_has_no_warning(rules: StatementRules) -> None: ... +def test_two_forms_on_one_sheet_share_locator(rules: StatementRules) -> None: ... +def test_xlsb_and_unparsed_formats_warnings(rules: StatementRules) -> None: ... + # xlsb=True → XLSB_NO_FORMATS у каждого кандидата; форматы «0.0%» на двух листах формы → одно NUMBER_FORMAT_UNPARSED + +def test_plan_sheets_weak_continuation_only_after_form(rules: StatementRules) -> None: ... + # [ОФР, лист «2400»] → второй — STATEMENT_CONTINUATION; [ОСВ, лист «2400»] → other; [ОФР, «Примечания», «2400»] → other ×2 + +# tests/fixtures/test_statements_adapter_real.py +def test_npz_candidates(fixtures_dir: Path) -> None: # Review Focus 2 + # read_book(НПЗ) → plan_sheets → build_candidates: [(type_hint, locator.count(",") + 1, len(segment_breaks))] == + # [(BALANCE, 5, 4), (INCOME, 3, 2)]; ни у одного кандидата нет SHEET_NOT_JOINED / CONTINUATION_TYPE_CONFLICT + +def test_unagrande_title_block_header(fixtures_dir: Path) -> None: # Review Focus 1 + # «unagrande-group/2025-Q3/ББ. Лист 1 3кв.25.xls» → header.okopf.text == "12267", header.inn.text содержит "7728757141" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd $T/api && .venv/bin/pytest tests/unit/parser/statements/test_adapter.py tests/fixtures/test_statements_adapter_real.py -v` +Expected: FAIL — `adapter`, `is_osv_sheet` не определены. + +- [ ] **Step 3: Implement** `is_osv_sheet` (`classify_sheet` вызывает его, поведение то же) и `adapter.py`. +- [ ] **Step 4: Run tests** + +Run: `cd $T && make check` +Expected: зелёный; golden и манифест не меняются (книга ещё идёт старым конвейером). + +- [ ] **Step 5: Commit** + +```bash +git -C $T add api/src/parser/detect.py api/src/parser/statements/adapter.py api/tests +git -C $T commit -m "feat(statements): адаптер шага 1 — книга → FormCandidate, склейка листов, segment_breaks (#)" +``` + +--- + +### Task 6: Предупреждения ОСВ с кодами `OSV_*` (вывод пока прежний) + +Модель исполнителя: sonnet. + +**Files:** +- Create: `$T/api/src/parser/osv_warnings.py` +- Modify: `$T/api/src/parser/checks.py` (`run_checks`), `$T/api/src/parser/osv.py` (`parse_sheet`), `$T/api/src/parser/book.py` + (вызов), тесты `test_checks.py`, `test_book_parser.py` (только места вызова) +- Test: `$T/api/tests/unit/parser/test_osv_warnings.py` + +**Interfaces:** +- Produces (`osv_warnings.py`): + - `OsvNote(code: str, message: str)` — frozen dataclass + - коды (P4-12): `OSV_HIERARCHY_BY_OUTLINE|INDENT|ACCOUNT_PATTERN|SUMS|TRIVIAL` (`hierarchy_note(strategy: str) -> + OsvNote`), `OSV_HEADER` (предупреждения шапки `doc.warnings`), `OSV_ROWS` (предупреждения строк `collect_body_rows`), + `OSV_INN_ROWS` (`glue.warnings`), `OSV_NO_DATA_ROWS`, `OSV_BALANCE_IDENTITY`, `OSV_PARENT_SUM`, `OSV_TOTAL` (итог не + найден или не равен корню), `OSV_XLSB_NO_OUTLINE`, `OSV_XLS_NO_CODEPAGE` (заметки читателя, задача 7) + - `legacy_line(sheet_name: str, note: OsvNote) -> str` — `f"sheet={sheet_name} {note.message}"` (вывод 1.x до задачи 7) +- Produces (`checks.py`): `run_checks(rows, exempt=None) -> tuple[OsvChecks, list[OsvNote]]` — тексты прежние, коды по + виду проверки. +- Produces (`osv.py`): `SheetResult(document: OsvDocument, notes: tuple[OsvNote, ...], ok: bool)`; + `OsvParser.parse_sheet(sheet, deadline) -> SheetResult`; сообщение `hierarchy_note` пока `hierarchy=<стратегия>` + (текст меняет задача 7). + +- [ ] **Step 1: Write the failing tests** + +```python +def test_check_notes_have_codes() -> None: + _, notes = run_checks(rows_with_identity_violation_and_parent_mismatch()) + assert [note.code for note in notes] == ["OSV_BALANCE_IDENTITY", "OSV_PARENT_SUM"] + +def test_parse_sheet_notes_start_with_hierarchy(osv_sheet: Sheet) -> None: + result = OsvParser(load_rules()).parse_sheet(osv_sheet, NO_DEADLINE) + assert result.notes[0].code.startswith("OSV_HIERARCHY_BY_") + assert all(note.code.startswith("OSV_") for note in result.notes) + +def test_legacy_lines_are_unchanged(osv_sheet: Sheet) -> None: + # book.parse(...).metrics.warnings на синтетике ОСВ — те же строки, что до задачи (сравнить с литералом из текущего теста) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd $T/api && .venv/bin/pytest tests/unit/parser/test_osv_warnings.py -v` +Expected: FAIL — модуля нет. + +- [ ] **Step 3: Implement** по Interfaces; `book.py` рендерит заметки `legacy_line`, порядок прежний. +- [ ] **Step 4: Run tests** + +Run: `cd $T && make check && make golden-check` +Expected: зелёный; `golden-check` — код 0 (снимки ОСВ и форм не изменились). + +- [ ] **Step 5: Commit** + +```bash +git -C $T add api/src/parser api/tests +git -C $T commit -m "refactor(osv): предупреждения листа ОСВ с кодами OSV_* (вывод 1.x прежний) (#)" +``` + +--- + +### Task 7: Переключение на общий код и контракт v2.0 + +Модель исполнителя: sonnet (интеграция). Самая крупная задача: модели и копия схем, сборка книги, путь ОСВ и воркер +меняются вместе, иначе `make check` красный (P4-1). Golden и манифест — задача 8; до неё проверка **без фикстур**. + +**Files:** +- Modify: `$T/api/contracts/**` (копия `$D/contracts/*.schema.json` верхнего уровня и `$D/contracts/examples/*.json`; файлы + `*.v1.schema.json` и `examples/v1/` не копируются), `$T/api/src/contract/{common,request,result,message,__init__}.py`, + `$T/api/src/parser/{__init__,book,detect,osv,cli}.py`, `$T/api/src/parser/readers/__init__.py`, + `$T/api/src/core/worker.py`, `$T/api/src/app.py` +- Create: `$T/api/src/parser/statements/contract.py`, `$T/api/src/parser/statements/pipeline.py` +- Test: `$T/api/tests/result_contract.py`, `$T/api/tests/golden_helpers.py` (`VOLATILE`), + `$T/api/tests/unit/test_contract.py`, `$T/api/tests/unit/test_contract_v12.py` → `test_contract_v20.py`, + `$T/api/tests/unit/parser/test_book_parser.py`, `$T/api/tests/unit/parser/statements/test_pipeline.py` (новый), + `$T/api/tests/unit/test_worker.py`, `$T/api/tests/unit/parser/test_cli.py`, `$T/api/tests/unit/parser/test_readers.py`, + `$T/api/tests/synthetic/test_statement_synthetic_files.py`, `$T/api/tests/synthetic/test_synthetic_files.py`, + `$T/api/tests/integration/test_roundtrip.py` + +**Interfaces:** +- Consumes: задачи 3–6; `normalize_candidate`, `envelope_status`, `make_warning`, `to_jsonable`, `default_rules`, + `statements_version`, `DocStatus`, `RulesError`. +- Produces (контракт, `src/contract/`): + - `CONTRACT_VERSION = "2.0"` (результат и конверт); `SCHEMA_VERSION_PATTERN = r"^2\.\d+$"`; + `REQUEST_SCHEMA_VERSION_PATTERN = r"^1\.\d+$"`, `RequestSchemaVersion` — у `RecognitionRequest` + - `ResultWarning(code: str (pattern ^[A-Z][A-Z0-9_]*$), severity: Literal["error", "info"], message: str, + line_refs: list[str] | None, column_index: int | None, field: HeaderFieldName | None)`; `FileWarning(ResultWarning)` + + `locator: str | None`; `Metrics.warnings: list[FileWarning] | None` + - `ServiceInfo.statements_version: str | None` (`min_length=1`); `RecognitionResult` — валидатор: при наличии + `StatementDocument` поле обязательно (как `allOf` схемы) + - `OsvDocument` + `status: DocumentStatus`, `warnings: list[ResultWarning]` + - `StatementHeader` — поля схемы v2.0 (`form_name`, по каждому полю `<поле>`, `<поле>_text`, `<поле>_confidence`, + `needs_review_fields`); `StatementColumn` (+ `label_confidence`, `period_kind`); `StatementValue` (+ `text`, + `needs_review`); `StatementLine` (`row_index`, `ref`, `line_status`, `line_code: str | None`, `line_code_text`, + `line_code_confidence`, `line_name: str | None`, `line_name_confidence`, `parent_ref`, `needs_review`, `values`; + валидатор: `line_code` не `None` ⇔ `line_status == "ok"`); `FailedRelation` (+ `codes`, `expected`/`actual` — + `Money`); `StatementChecks` без `duplicate_line_codes`; `StatementDocument` (`document_type: Literal["BALANCE", + "INCOME", "DISCLOSURE"]`, `form_variant: Literal["full", "simplified"] | None` обязателен, `status`, `warnings`; + валидатор `checks` по `allOf` схемы) +- Produces (`statements/contract.py`): + - `to_contract_warning(warning: StatementWarning) -> ResultWarning`; `to_file_warning(warning: StatementWarning) -> FileWarning` + - `to_contract_document(document: arm_statements.StatementDocument, *, form_name: str | None) -> StatementDocument` — + из `to_jsonable(document)`: шапка `HeaderField {text, confidence, value}` → плоские `<поле>`, `<поле>_text`, + `<поле>_confidence`; у `FailedRelation` отбрасывается `column_key`; `checks = None` — ключа нет +- Produces (`statements/pipeline.py`): + - `StatementsOutcome(documents: tuple[StatementDocument, ...], file_warnings: tuple[FileWarning, ...])` + - `StatementPipeline(rules: StatementRules)` с методом `parse(sheets: Sequence[tuple[Sheet, SheetKind]], deadline: + Deadline, *, xlsb: bool) -> StatementsOutcome` — `build_candidates` → для каждого кандидата `deadline.check` и + `normalize_candidate` (P4-11; `form_name` = `candidate.evidence.title.text`, если есть) → конвертация +- Produces (книга): `BookParser(rules, *, osv: OsvParser, statements: StatementPipeline, statement_rules: + StatementRules, max_bytes, timeout_seconds, clock)`; `ParseOutcome` прежний (документы — модели v2.0, + `metrics.warnings` — `FileWarning`); `readers.BookRead(sheets, warnings, detected, mismatch: str | None)` — строка + несовпадения формата больше не в `warnings`; `detect.classify_sheet(sheet, rules, statement_rules: StatementRules | + None = None)` — ветка форм через `forms.classify_statement_sheet` (`None` → `default_rules()`) +- Produces (`src/parser/__init__.py`): `require_statements_version(path: Path | None = None) -> str` — + `statements_version(path)`, `None` → `RuntimeError("копия arm_statements без VERSION: разложите её sync-statements")` + (P4-16); `get_parser` грузит `default_rules()` (`RulesError` → `RuntimeError` с текстом ошибки) и собирает + `StatementPipeline` +- Produces (воркер): `RecognitionWorker(..., statements_version: str)` → `ServiceInfo(name, version, + statements_version)` в конверте и файле результата; `app.py` передаёт `require_statements_version()`; CLI — тоже. + +Сборка книги (`BookParser.parse`; спека «Статус книги», «Путь ОСВ в контракте v2.0»): +1. лимиты и чтение — как сейчас; `read.mismatch` → `FILE_TYPE_MISMATCH` (файл, `info`, сообщение — прежний текст + «file_type=…, но содержимое …: прочитано как …»); +2. `plan_sheets(read.sheets, is_osv_sheet, statement_rules)`; листы ОСВ — по порядку `parse_sheet`: документ получает + `status = done if ok else needs_check` и `warnings` из заметок (`severity = info`), плюс заметки читателя + `OSV_XLSB_NO_OUTLINE` / `OSV_XLS_NO_CODEPAGE` в каждый документ ОСВ книги (P4-13); сообщение иерархии — «иерархия строк + — по группировке листа (outline)» / «по отступам (indent)» / «по кодам счетов (account_pattern)» / «по суммам (sums)» / + «плоский список (trivial)»; +3. листы `other` → `SHEET_OTHER` (файл, `info`, `locator = sheet:<имя>`, сообщение «лист без признаков ОСВ и формы»); +4. формы — `StatementPipeline.parse(plan.statements, deadline, xlsb=read.detected == "XLSB")`; +5. документов нет → `INVALID_DOCUMENT` (сообщение прежнее); иначе `status = envelope_status(статусы ОСВ и форм как + DocStatus, предупреждения файла)`; `metrics.warnings` — все предупреждения файла по порядку листов; `rows` — как сейчас + (строки данных ОСВ + `len(lines)`); +6. ошибка листа ОСВ (`ParserError`) — исход `error` как сейчас; предупреждение «форма не разобрана: книга завершилась + ошибкой листа ОСВ» больше не пишется (P4-14), `metrics.warnings` ошибки — собранные до неё предупреждения файла. + +- [ ] **Step 1: Копия контракта** + +```bash +cp $D/contracts/recognition-request.schema.json $D/contracts/recognition-result.schema.json \ + $D/contracts/recognition-result-message.schema.json $T/api/contracts/ +cp $D/contracts/examples/*.json $T/api/contracts/examples/ +``` + +- [ ] **Step 2: Write the failing tests** (выборка; остальные тесты файлов из списка правятся под v2.0 по смыслу) + +```python +# tests/unit/test_contract_v20.py +def test_docs_examples_validate_with_models() -> None: + for name in ("result-osv-minimal", "result-osv-needs-check", "result-statement-excel", "result-statement-minimal", + "result-error"): + payload = json.loads((EXAMPLES_DIR / f"{name}.json").read_text(encoding="utf-8")) + assert RecognitionResult.model_validate(payload).to_wire() == payload + +def test_statement_without_statements_version_is_rejected() -> None: ... +def test_unreadable_line_with_code_is_rejected() -> None: ... # спека: «Строка без кода с кодом в поле» +def test_request_stays_v1_and_result_is_v2() -> None: + assert CONTRACT_VERSION == "2.0" + RecognitionRequest.model_validate(load_example("request-tabular")) # schema_version "1.0" проходит + +# tests/unit/parser/statements/test_pipeline.py +def test_document_converts_to_contract(statement_rules: StatementRules) -> None: + outcome = StatementPipeline(statement_rules).parse([(balance_sheet, SheetKind.STATEMENT)], NO_DEADLINE, xlsb=False) + document = outcome.documents[0] + assert document.header.form_name == "Бухгалтерский баланс" + assert (document.header.inn, document.header.inn_text, document.header.inn_confidence) == ("7700000000", "7700000000", 100) + assert document.lines[0].ref == "Баланс!C10" + assert_valid_result(ParseOutcome(status=ResultStatus.DONE, documents=outcome.documents)) + +# tests/unit/parser/test_book_parser.py +def test_osv_document_has_status_and_osv_warnings() -> None: # спека: «Предупреждение ОСВ» + outcome = parse_book(osv_with_identity_violation) + doc = outcome.documents[0] + assert doc.status == "needs_check" + assert all(w.severity == "info" and w.code.startswith("OSV_") for w in doc.warnings) + assert "OSV_BALANCE_IDENTITY" in {w.code for w in doc.warnings} + assert not any(w.message.startswith("sheet=") for w in outcome.metrics.warnings) + +def test_other_sheet_is_file_warning() -> None: # спека: «Лист «прочее»» + # книга [ББ, «Примечания»] → документов 1, metrics.warnings == [SHEET_OTHER info, locator "sheet:Примечания"] + +def test_file_type_mismatch_is_info_file_warning() -> None: ... +def test_disclosures_only_book_is_done() -> None: # спека: «Книга только из пояснений»; Review Focus 5 + # листы «0710003» (ОКУД, без кодов) и «Пояснения к бухгалтерскому балансу» (без кодов) → + # [(d.document_type, d.status) for d in docs] == [("DISCLOSURE", "done")] * 2, outcome.status is DONE + +def test_disclosure_title_with_balance_codes() -> None: # Review Focus 5 + # «Пояснения к бухгалтерскому балансу» + коды 1110…1700 → BALANCE и TYPE_CONFLICT (error) + +def test_xlsb_form_scale_suspect() -> None: # Review Focus 4 + # write_xlsb({"Баланс": форма ББ, «тыс. руб.», 40 % дробных}) → warnings ⊇ {XLSB_NO_FORMATS info, + # XLSB_SCALE_SUSPECT error}, status needs_check; без дробных → только XLSB_NO_FORMATS, status done + +def test_book_without_osv_and_forms_is_invalid_document() -> None: ... +def test_documents_order_osv_then_forms() -> None: ... + +# tests/unit/test_worker.py +def test_result_carries_statements_version() -> None: # спека: «Версия общего кода в результате» + # RecognitionWorker(..., statements_version="abc1234") → result.service.statements_version == "abc1234"; + # конверт и ack processing — schema_version "2.0"; запрос "1.0" принимается, "2.0" → SCHEMA_MISMATCH без ack + +def test_require_statements_version_fails_without_file(tmp_path: Path) -> None: + with pytest.raises(RuntimeError, match="VERSION"): + require_statements_version(tmp_path / "VERSION") +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `cd $T/api && ARM_KA_FIXTURES=/nonexistent .venv/bin/pytest tests/unit tests/synthetic -m "not integration" -x -q` +Expected: FAIL (модели 1.2, конвейер старый). + +- [ ] **Step 4: Implement** по Interfaces и «Сборке книги». `tests/result_contract.py`: `service` с + `"statements_version": "test"`; `golden_helpers.VOLATILE` += `("service", "statements_version")`; `render` и CLI + получают версию через `require_statements_version()`. Старые модули форм (`statement.py` и др.) больше не вызываются + книгой, но остаются до задачи 9 вместе со своими тестами. +- [ ] **Step 5: Run tests** + +Run: `cd $T && ARM_KA_FIXTURES=/nonexistent make check` +Expected: зелёный (тесты на реальных фикстурах пропущены — их чинит задача 8; тест идентичности копии контракта против +`$D` — PASSED, не SKIPPED: `cd api && .venv/bin/pytest -rs -k vendored_contracts`). + +- [ ] **Step 6: Commit** + +```bash +git -C $T add api +git -C $T commit -m "feat: формы через arm_statements, контракт результата и конверта v2.0, статус и OSV_* у ОСВ (#)" +``` + +--- + +### Task 8: Golden и манифест реальных форм + +Модель исполнителя: sonnet (сверка на реальных данных). + +**Files:** +- Modify: `$F/golden/expected_osv_parse/**`, `$F/golden/expected_statement_parse/**` (только `make golden-update`) +- Modify: `$T/api/tests/statements_manifest.yaml`, `$T/api/tests/statement_helpers.py`, + `$T/api/tests/fixtures/test_statements_property.py`, `$T/api/tests/fixtures/test_statements_number_format.py`, + прочие `tests/fixtures/*` по факту падений +- Create: `$T/api/tests/fixtures/test_statements_real.py` + +**Interfaces:** +- Consumes: книга задачи 7. +- Produces: `DocumentExpectation` + `error_warnings: list[str] | None` (P4-18: точное множество кодов предупреждений + документа уровня `error`; `None` — не проверяется). + +- [ ] **Step 1: Обновить golden** + +Run: `cd $T && make golden-update` +Expected: перезаписаны снимки ОСВ и форм, в выводе unified diff. + +- [ ] **Step 2: Проверить, что ОСВ изменилась только по ключам v2.0** + +```bash +cd $F && git diff --name-only -- golden/expected_osv_parse | while read -r f; do + git show "HEAD:$f" > /tmp/old.json + python3 - /tmp/old.json "$f" <<'EOF' +import json, sys +old, new = (json.load(open(p, encoding="utf-8")) for p in sys.argv[1:]) +assert old["status"] == new["status"], (sys.argv[2], old["status"], new["status"]) +for d in (old, new): + d.pop("schema_version", None); d.get("metrics", {}).pop("warnings", None) + for doc in d.get("documents", []): doc.pop("status", None); doc.pop("warnings", None) +assert old == new, sys.argv[2] +EOF +done; echo "osv ok" +``` +Expected: `osv ok` без `AssertionError` (временный файл — в scratchpad исполнителя, не `/tmp`, если окружение требует). + +- [ ] **Step 3: Просмотреть снимки форм** по каждому файлу `expected_statement_parse/**`: статусы документов против + манифеста, предупреждения `error` с объяснением, строки `detail`/`unreadable`/`missing` — глазами против Review Focus + 1–3. Каждое изменение статуса документа против манифеста — с причиной в отчёте задачи (пойдёт в описание PR). +- [ ] **Step 4: Манифест** (спека «Манифест ожиданий на реальных формах»): `failed_relations: ["1600 = 1700"]` у + `unagrande-co/2024-FY/Б.Б. Лист 2.xls`, `unagrande-group/2024-FY/ББ. Лист 2 (2).xls`, + `unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` (было `[]`, причина «страница 2 без шапки» остаётся); страницы 1 «только + актив» сохраняют `["1600 = 1700"]`; статусы `done` (Межениновская ×2, НПЗ ×2, ОФР «Лист 1» Унагранде ×2) не меняются; + `error_warnings` — у каждого документа по прогону (например, страницы 2 — `["NO_ORGANIZATION_NO_INN"]` или как покажет + прогон, с объяснением); остальные записи — по прогону, каждое изменение — строкой в отчёт. +- [ ] **Step 5: Property-тест под v2.0** (`test_statements_property.py`): статус — `document.status`; удалить проверку + уникальности кодов (повторы законны); `value.column_index ∈ {c.column_index for c in document.columns}`; + `needs_check` объясним: есть предупреждение `error`, или `checks.relations_failed`, или `needs_review` у строки/значения, + или `header.needs_review_fields`, или строка `unreadable`/`missing`; `ref` уникальны в документе; `error_warnings` + манифеста сверяются точным множеством. +- [ ] **Step 6: Тесты Review Focus на реальных книгах** (`tests/fixtures/test_statements_real.py`, маркер `fixtures`) + +```python +def test_unagrande_title_codes_block(fixtures_dir: Path) -> None: # Review Focus 1 + for key in TITLE_BLOCK_FILES: # пять файлов «Лист 1» Унагранде + for doc in statement_docs(parse(fixtures_dir, key)): + assert doc.header.okopf == "12267" + assert not any("12267" in (line.line_code_text or "") for line in doc.lines) + +def test_npz_documents(fixtures_dir: Path) -> None: # Review Focus 2; спека «НПЗ «Реализация иного имущества»» + balance, income = statement_docs(parse(fixtures_dir, NPZ)) + assert (balance.status, income.status) == ("done", "done") + bad = {"SHEET_NOT_JOINED", "SEGMENT_COLUMNS_MISMATCH", "SEGMENT_WITHOUT_CODES", "HEADER_ROW_NOT_FOUND"} + assert not bad & {w.code for d in (balance, income) for w in d.warnings} + cols = {c.column_index for c in balance.columns} + assert all(v.column_index in cols for line in balance.lines for v in line.values) + by_ref = {line.ref: line for line in income.lines} + other = next(l for l in income.lines if l.line_name and l.line_name.startswith("Реализация иного имущества")) + assert other.line_status == "detail" and by_ref[other.parent_ref].line_code == "2340" + assert any(l.line_code == "2400" and l.ref.startswith("'Отчет о финансовых результата_2'") for l in income.lines) + +def test_mezheninovskaya_numbering_and_signatures(fixtures_dir: Path) -> None: # Review Focus 3 + for key in MEZHENINOVSKAYA: + (doc,) = statement_docs(parse(fixtures_dir, key)) + assert doc.status == "done" and doc.header.inn == "7016006635" + assert not any(l.line_code_text == "2" for l in doc.lines) + assert not any(l.line_status in ("unreadable", "missing") for l in doc.lines) +``` + (точное имя листа ОФР НПЗ — по golden; если тест 2 или 3 падает на ролях строк — стоп, находка для плана 1.) + +- [ ] **Step 7: Run tests** + +Run: `cd $T && make check && make golden-check` +Expected: зелёный с фикстурами; `golden-check` — 0. + +- [ ] **Step 8: Commit** (два репозитория, отдельные коммиты) + +```bash +git -C $F add golden +git -C $F commit -m "golden: снимки ОСВ и форм под контракт v2.0 (arm-tabular-parser#)" +git -C $T add api/tests +git -C $T commit -m "test: манифест реальных форм и Review Focus под общий код и v2.0 (#)" +``` + +--- + +### Task 9: Удаление собственного кода шагов 2–8 и словарей форм + +Модель исполнителя: haiku (удаление по списку, без нового кода). + +**Files:** +- Delete: `$T/api/src/parser/statements/{checks,columns,detect,header,lines,merge,statement}.py`, + `$T/api/rules/statement_{headers,periods,checks}.yaml`, + `$T/api/tests/unit/parser/statements/{test_checks,test_columns,test_detect,test_header,test_lines,test_statement}.py`, + `$T/api/tests/unit/parser/test_statement_rules.py` +- Modify: `$T/api/src/parser/rules.py` (убрать `StatementHeaderRules`, `StatementPeriodRules`, модели §7, `FormType`, + `StatementLabel`, поля `Rules.statement_*` и их загрузку), `$T/api/tests/rules_coverage.py` (`DICTIONARIES`), + `$T/api/tests/unit/parser/test_rules.py`, `$T/api/tests/fixtures/test_rules_coverage.py` (без `StatementParser`), + `$T/api/src/parser/numbers.py` (удалить функции без вызывающих после удаления — только по `grep`), `$T/api/rules/README.md`, + `$T/README.md` (раздел «Парсер форм»), `$T/CLAUDE.md` (раздел Среза 2b → «Формы через arm_statements»: модули адаптера, + словари форм в пакете, контракт 2.0, OSV_*, правило «копию не править»), `$T/api/tests/synthetic/generate_statements.py` + (импорты) + +- [ ] **Step 1: Удалить файлы и ссылки**; затем + +Run: `cd $T/api && grep -rnE "statement_(headers|periods|checks)|statements\.(checks|columns|detect|header|lines|merge|statement)\b|StatementParser|duplicate_line_codes" src tests rules scripts` +Expected: пусто (кроме `tests/synthetic/generate_statements.py`, если он ссылается на имена файлов фикстур — тогда не пусто +только там и это не импорт). + +- [ ] **Step 2: Run tests** + +Run: `cd $T && make check && make golden-check` +Expected: зелёный; `golden-check` — 0 (удаление не меняет вывод); `test_no_coordinates.py` и покрытие словарей ОСВ проходят. + +- [ ] **Step 3: Commit** + +```bash +git -C $T add -A api README.md CLAUDE.md +git -C $T commit -m "refactor: удалён собственный конвейер форм и словари форм — шаги 2–8 в arm_statements (#)" +``` + +--- + +### Task 10: Полная проверка, PR tabular-parser и fixtures + +Модель исполнителя: sonnet. Перед задачей — финальное ревью ветки (opus) по SDD. + +- [ ] **Step 1: Полная проверка** + +Run: `cd $T && make check && make check-full && make statements-check; echo "exit=$?"; cd $T && openspec validate unified-normalization-tp --strict` +Expected: всё зелёное; `statements-check` — `0` или `10` («копия отстала» — вписать в описание PR); change валиден. + +- [ ] **Step 2: Push и PR** (две ветки, два PR, взаимные ссылки) + +```bash +git -C $F push -u origin feat/-statements-v2-golden +tea pulls create --login home --repo RSHB/arm-ka-fixtures --head feat/-statements-v2-golden --base main \ + --title "Golden ОСВ и форм под контракт v2.0" --description "; парный PR arm-tabular-parser>" +git -C $T push -u origin feat/-statements-v2 +tea pulls create --login home --repo RSHB/arm-tabular-parser --head feat/-statements-v2 --base main \ + --title "Формы через arm_statements, контракт v2.0" --description "<см. ниже>" +``` + + Описание PR tabular-parser: цель; ссылки на arm-ka-docs#116, этот план, парный PR fixtures; **условие слияния** — ядро с + приёмом 2.0 выложено (`proposal.md`, шаг 5), docs #117 слит, PR fixtures сливается сразу вслед; изменения манифеста с + причинами (задача 8); доля `done` на реальных формах до/после; «Rulings I made» (P4-n и R-n контроллера); таблица + находок с маркерами и статусами; `deferred` — отдельным списком. +- [ ] **Step 3: Решения в design.md** — контроллер переносит P4-n и свои R-n в + `openspec/changes/unified-statement-normalization/design.md` docs (`## Decisions made without discussion`, дата) — в + ветку `feat/116-unified-normalization`, если PR #117 не слит, иначе отдельным PR docs. + +--- + +## Решения автора плана без обсуждения (P4-n) + +Контроллер переносит их в `design.md` docs, раздел `## Decisions made without discussion` (дата 18.10.2026). + +- **P4-1.** Переход на общий код и на контракт v2.0 — одна задача (7): модели, копия схем, сборка книги, путь ОСВ и + воркер меняются вместе; «контракт v2.0 в копии» отдельной последней задачей не вынесен — модели 2.0 без копии 2.0 (или + наоборот) валят `result_contract` и тест идентичности. Golden и манифест — сразу за переключением (задача 8), до + удаления старого кода; между задачами 7 и 8 проверка идёт с `ARM_KA_FIXTURES=/nonexistent`. Цена: одна крупная задача и + окно в одну задачу без проверки на реальных файлах. +- **P4-2.** Адаптер пишется новыми модулями рядом со старыми (`cells`, `forms`, `header_cells`, `adapter`, `contract`, + `pipeline`); старые `statements/*` не правятся и удаляются целиком задачей 9. Цена: временное дублирование логики + поиска блоков и шапки. +- **P4-3.** `Cell.text` числа Excel — как в русской локали: группы по три через пробел, если в формате есть разделитель + групп; десятичная запятая; литералы секции (скобки) сохраняются; «-» добавляется только у односекционного формата; + `General`/неразобранный формат/xlsb — число без групп и экспоненты, целое без дробной части; дата — `dd.mm.yyyy`; + логическое — «ИСТИНА»/«ЛОЖЬ». Цена: у экзотических форматов текст может отличаться от экрана Excel; `value` и статус + от этого не зависят (у Excel значение берётся из `value`). Совпадает с примером docs + `result-statement-excel.json` (`"586 260,26"`). +- **P4-4.** Показываемое число вне `numeric(18,2)` больше не делает формат «неразобранным»: адаптер отдаёт `Decimal`, а + `VALUE_OUT_OF_RANGE` и `amount = null` выдаёт пакет (спека «Шаг 4»). Цена: правка поведения #42 для чисел больше 16 + целых цифр (на реальных файлах их нет). +- **P4-5.** Сетка кандидата: строки блока целиком, включая пустые; объединённая ячейка — только в позиции левой верхней, + прочие позиции `None`; ширина — наибольшая среди листов кандидата. Цена: подпись, объединённая на два столбца, видна + пакету только в левом. +- **P4-6.** Шапка ищется в строках блока выше первой строки с кодом его столбца кодов (правило (б) по `parse_code`), без + кодов — во всём блоке: так титульный блок «Коды» (ОКОПФ 12267) не мешает, а подписи под формой (ИНН и ФИО в блоке + подписей) не попадают в шапку. Цена: метка шапки ниже начала таблицы не найдётся. +- **P4-7.** Регулярные выражения словаря пакета (`Rules.headers.*`) адаптер применяет к `normalize_text` tabular-parser + (нижний регистр, «ё» → «е», дефисы/тире/переносы → пробел) — так записаны и перенесены словари (P1-13), так же + нормализует `type_hint`. Цена: если пакет сменит нормализацию своих меток, адаптер разойдётся молча (O-2). +- **P4-8.** Склейка: открытая форма — последний кандидат с `type_hint` `BALANCE`/`INCOME`; лист с маркером сверяется только + по типу; лист без маркера (сильное продолжение — колонка кодов или своя строка заголовка; слабое — 1–2 кода сразу после + листа формы) — `sheet_joins` с числом колонок сумм, посчитанным адаптером («столбцы правее столбца кодов, где в строках с + кодами есть число»); открытой формы нет — отдельный кандидат без предупреждения (пакет даст `NO_ORGANIZATION_NO_INN`). + Цена: «Изменение, %» с числами считается колонкой сумм с обеих сторон сравнения; лист с иной шапкой, но тем же числом + колонок, присоединится и будет сопоставлен пакетом по датам. +- **P4-9.** `SHEET_OTHER` для листов «прочее» выдаёт книга сама (документа и кандидата нет), а не пакет по кандидату без + `type_hint`. Цена: два места порождения одного кода (у пакета — для кандидатов, не нашедших ни типа, ни кодов). +- **P4-10.** `XLSB_NO_FORMATS` — у каждого кандидата книги xlsb (P1-5); `NUMBER_FORMAT_UNPARSED` — одно на кандидата с + перечнем форматов всех его листов. Цена: у пояснений xlsb тоже есть `XLSB_NO_FORMATS` (`info`). +- **P4-11.** Книга вызывает `normalize_candidate` по одному кандидату, а не `normalize`: так сохраняется пара «кандидат → + документ» для `header.form_name` (текст заголовка из `evidence.title`) и проверяется дедлайн между кандидатами. + Исключение пакета на кандидате — `RECOGNITION_FAILED` всей книги, как сейчас (`TABLE_PARSE_ERROR` — код таблицы ИС). + Цена: одна сломанная форма роняет книгу с ОСВ. +- **P4-12.** Коды ОСВ — по источнику предупреждения, не по тексту: `OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`, `OSV_HEADER`, + `OSV_ROWS`, `OSV_INN_ROWS`, `OSV_NO_DATA_ROWS`, `OSV_BALANCE_IDENTITY`, `OSV_PARENT_SUM`, `OSV_TOTAL`, + `OSV_XLSB_NO_OUTLINE`, `OSV_XLS_NO_CODEPAGE`; тексты сообщений прежние, кроме иерархии. Цена: внутри `OSV_HEADER` и + `OSV_ROWS` разные случаи различаются только текстом. +- **P4-13.** Заметки читателя: несовпадение формата — `FILE_TYPE_MISMATCH` файла; «xlsb без outline» и «xls без кодовой + страницы» — в каждый документ ОСВ книги (`OSV_XLSB_NO_OUTLINE`, `OSV_XLS_NO_CODEPAGE`); в книге без ОСВ они только + логируются. Цена: у форм из xls без кодовой страницы предупреждения нет (O-5). +- **P4-14.** Предупреждение «форма не разобрана: книга завершилась ошибкой листа ОСВ» снято: в v2.0 для него нет кода, а + текст ошибки и так называет лист ОСВ. Цена: аналитик не видит списка листов форм, оставшихся неразобранными. +- **P4-15.** Статус документа ОСВ — прежние условия листа: контрольные суммы §1.4 сошлись и строки ИНН без нарушений + (`glue.ok`, #35). Цена: формулировка спеки «когда контрольные суммы сошлись» читается шире. +- **P4-16.** Нет `api/arm_statements/VERSION` — сервис не стартует (`RuntimeError`), как при ошибке словаря; в golden + `service.statements_version` — volatile-поле. Цена: копия без `VERSION` не запустится даже локально. +- **P4-17.** Новые синтетические случаи (форма xlsb, книга из пояснений, конфликты продолжений) — в памяти в юнит-тестах + (`sheet_from_rows`, `write_xlsb`), новых файлов в `fixtures/statements-xlsx/synthetic` нет; PR fixtures — только golden. +- **P4-18.** Манифест получает необязательное поле `error_warnings` (точное множество кодов `error` документа); + проверка уникальности кодов строк снята (повторы законны, Р-4). Цена: при правке пакета, меняющей набор + предупреждений, манифест правится вместе с golden. +- **P4-19.** Тест покрытия словарей tabular-parser больше не охватывает словари форм — они в пакете. Цена: «мёртвый» + синоним заголовка или метки шапки на реальных файлах не ловится (O-8). +- **P4-20.** В tabular-parser — свой change `unified-normalization-tp` с дельтами `statement-parsing`, + `tabular-recognition-service`, `osv-parsing` (по образцу P0-5); требование «Копия контракта идентична источнику» правит + план 0, здесь не трогается. +- **P4-21.** Golden — отдельные issue, ветка и PR в RSHB/arm-ka-fixtures (один PR на репозиторий); сливается сразу за PR + tabular-parser. Цена: короткое окно, когда `make check` `main` одного из них красный на golden. +- **P4-22.** Две формы на одном листе дают два документа с одинаковым `locator` (спека уникальности `locator` не требует; + `ref` строк различаются). + +## Чего не хватает в API пакета для адаптера Excel (O-n) + +План 1 этим планом не правится; контроллер передаёт пункты владельцу плана 1. + +- **O-1.** Нет публичной функции «столбец кодов листа / сетки» и «число колонок сумм»: адаптеру они нужны для вида листа, + границы шапки и аргументов `sheet_joins(…, sheet_amount_columns, form_amount_columns, …)`. Адаптер повторяет + упрощённое правило (б) на `parse_code` и `Rules.code_ranges.min_share/min_codes` (P4-6, P4-8) — риск разойтись с шагом 2 + пакета. Предложение: экспортировать `code_column(rows, rules) -> int | None` и `amount_columns(rows, code_col, rules) -> + int` над `rows` кандидата. +- **O-2.** Не задана и не экспортирована нормализация текста, под которую записаны `Rules.headers.labels`, `auditor`, + `legal_form`, `continuation`, `form_titles`, `okud`. `normalize_caption` (внутренний) заменяет всю пунктуацию пробелом — + под ним не сработают перенесённые метки `местонахождение\s*\(адрес\)`, `по\s+окопф\s*/\s*окфс`, `с\.?` маркера и + `legal_form` `"`, `«`. Нужно: в плане 1 зафиксировать нормализацию меток (как `normalize_text` tabular-parser) и + экспортировать её (`normalize_label`), иначе P4-7. +- **O-3.** У `StatementDocument` пакета нет `form_name`, а в схеме v2.0 `header.form_name` есть: хост берёт его из + `evidence.title` кандидата (P4-11). Если поле нужно и scan-gateway — его место в пакете. +- **O-4.** `continuation_type` возвращает `None` и для «не маркер», и для «маркер без ОКУД» — адаптер узнаёт маркер по + `Rules.headers.continuation` напрямую. Предложение: `is_continuation(text, rules) -> bool` в API. +- **O-5.** Для заметок чтения книги, относящихся и к формам (xls без кодовой страницы), нет кода в + `statement_warnings.yaml` / таблице §11 (P4-13). Предложение: `XLS_NO_CODEPAGE` (`info`, файл). +- **O-6.** Ключи `Rules.headers.labels` (`organization`, `inn`, `okpo`, `okopf`, `okfs`, `okopf_okfs`, `okei`, `units`, + `address`, `date`) — контракт между пакетом и адаптером, но в API плана 1 они не перечислены (сказано лишь «дословно из + tabular-parser»). Нужна строка в разделе API и тест ключей в пакете. +- **O-7.** В плане 1 у `sheet_joins` и `continuation_type` написано «для адаптера Excel плана 3» — адаптер Excel в плане 4 + (план 3 — arm-core). Описка, на имена не влияет. +- **O-8.** Покрытие словарей форм реальными файлами теряется (P4-19): в пакете тесты только на синтетике. Предложение: + тест покрытия `Rules.headers.*` в tabular-parser на `statements-xlsx/**` (как покрытие словарей ОСВ) — follow-up. +- **O-9.** Порог слабого продолжения (`min_codes_continuation: 1` в словаре tabular-parser) в словарь пакета не + перенесён; адаптер использует 1 и `code_ranges.min_codes` как границы «1–2 кода». Если порог должен быть данными — + ключ в `statement_code_ranges.yaml`. +- **O-10.** План 2 на момент написания не готов: задача 2 перечисляет ожидаемую подготовку хоста (пины, `testpaths`, + `mypy src arm_statements`, `statements-check`, `.dockerignore`) — сверить с планом 2, решает он. + +## Открытые вопросы владельцу + +- **В-1.** Гранулярность кодов `OSV_*` (P4-12): хватает ли кодов по источнику, или экрану нужны отдельные коды для + случаев внутри `OSV_HEADER` (организация, счёт, период, валюта) и `OSV_ROWS`? Ответ меняет только `osv_warnings.py` и + golden. +- **В-2.** Межениновская и НПЗ должны остаться `done` (спека манифеста). Если прогон задачи 8 покажет, что подписи под + формой Межениновской или лист 4 НПЗ дают `missing`/`unreadable` или `SEGMENT_*` по правилам шагов 2–3 пакета — это + противоречие спеки и реального файла: решение (правка пакета или манифеста) — за владельцем, адаптер под файл не + подгоняется. +- **В-3.** Слияние: PR tabular-parser и PR fixtures — после выкладки ядра с приёмом 2.0 (части А и Б плана 3) и слияния + docs #117; порядок относительно scan-gateway свободный. Подтвердить, что до этого PR остаётся открытым, а не + сливается с флагом.