arm-ka-docs/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-4-tabular-parser.md
Andrey Epifancev 37d58a4869 plan(#116): планы 4 и 5 — потребители дополненного API пакета
tabular-parser: столбец кодов и колонки сумм — code_column / amount_columns пакета, метки —
normalize_label / normalize_caption, маркер — is_continuation, контракт — to_contract /
warning_to_contract (statements/contract.py не пишется), XLS_NO_CODEPAGE — предупреждение файла
вместо OSV_XLS_NO_CODEPAGE; scan-gateway: contract_map.py не пишется, form_name из пакета,
WarningCode в make_warning, FailedRelation.expected/actual — Money | None. Закрыты O-1…O-7, O-9
плана 4 и O-2…O-7 плана 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 12:56:04 +04:00

1134 lines
100 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# План 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`, `pipeline.py`); переключение книги на пакет и на контракт v2.0 — одной задачей (модели,
копия схем, сборка книги, путь ОСВ, воркер), сразу за ней — golden и манифест, затем удаление старого кода форм. Пакет
вызывается только через публичный API (`from arm_statements import …`); ячейка Excel → `Cell` (текст по числовому формату,
`value`, `ref = <лист>!<A1>`) — в адаптере; разбор ОСВ не меняется, меняется только вид его предупреждений и статус листа.
**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/`, подготовка хоста (его задача 8) | PR задачи 8 плана 2 в tabular-parser слит **до старта**; задача 2 этого плана только сверяет и при нужде обновляет копию `--in-place` |
| 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#<issue>)"`.
- **П-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` скрипт плана 2 находит
сам — `$WS/arm-statements`):
```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; do ln -s $WS/$l $M/$l; done
git -C $M/tabular-parser branch -m feat/<issue>-statements-v2
git -C $M/fixtures branch -m feat/<fx-issue>-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` (PR задачи 8 плана 2
слит) и `api/tests/contract_identity.py` (план 0). Нет копии пакета — стоп и вопрос владельцу (подключение копии —
PR плана 2, не этого плана; руками копию не раскладывать). Нет плана 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`, `WarningCode`, `normalize_candidate`, `envelope_status`, `make_warning`, `type_hint`,
`is_continuation`, `continuation_type`, `sheet_joins`, `code_column`, `amount_columns`, `normalize_label`,
`normalize_caption`, `parse_code`, `parse_date`, `parse_header`, `to_contract`, `warning_to_contract`, `to_jsonable`,
`statements_version`). Импорт внутренних модулей пакета (`arm_statements.codes` и т. п.) в `src/` запрещён. Файлы
`api/arm_statements/**` не правятся (прямая правка — код 20 `statements-check`); нехватка в API — находка `O-n`, не
правка копии. Правило шага 2 (столбец кодов, колонки сумм) и нормализация меток в адаптере не повторяются — только
функции пакета (P4-6, P4-7).
- Шаг 1 для Excel (спека): `source = excel`; `locator = sheet:<A>[,<B>…]`; у ячейки `confidence = 100`, `text` — как
показывает числовой формат, `value` — число по формату (`Decimal`), дата или `bool`; `ref = <лист>!<A1>`, имя листа в
одинарных кавычках, если в нём есть что-то кроме букв, цифр и `_` или оно начинается с цифры, апостроф внутри имени
удваивается; одна форма — одна сетка, граница листа — номер строки сетки в `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` с членом `WarningCode` (код и уровень — словарь пакета); у
ОСВ — код с префиксом `OSV_`, уровень `info`. Строк вида `sheet=<имя> <текст>` в `metrics.warnings` нет. Перевод
документов форм и предупреждений в контракт — только `to_contract` / `warning_to_contract` пакета.
- Статус конверта — `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 <noreply@anthropic.com>` и
`Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W`; PR — строкой
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`. Слияние — только по команде человека.
- Находки и решения несут маркеры (`T<n>.<k>`, `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, дельты трёх спек
├── api/arm_statements/** # копия пакета (план 2); задача 2 — только --in-place при нужде
├── 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
│ ├── 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 (#<issue>)"
```
---
### Task 2: Подготовка хоста — сверка с планом 2 и свежесть копии
Модель исполнителя: haiku (проверки по списку).
Подготовку хоста делает план 2 (`plans/2026-10-18-plan-2-sync-statements.md`, задача 8 «tabular-parser — подключение
копии»; раздел «Что должны включить планы 3, 4, 5»), и её PR слит до старта (П-4). Эта задача только сверяет `main`
с планом 2 и, если пакету нужна версия новее, обновляет копию скриптом. Недостающее из списка ниже — **не** доделывать
здесь, а остановиться и сообщить контроллеру (находка `O-n`: правка подключения — PR плана 2).
**Files:**
- Modify (только если нужна новая версия пакета): `$T/api/arm_statements/**` — скриптом, руками не править
**Interfaces:**
- Consumes (из плана 2): `make lint` → `mypy src arm_statements`; `api/pytest.ini` `testpaths` с `arm_statements/tests`;
`known-first-party = ["src", "arm_statements"]`; строгий блок `[[tool.mypy.overrides]]` для `arm_statements.*`;
пины `ruff==0.16.10`, `mypy==2.4.0`, `types-PyYAML==6.0.12.20260906`; цель `statements-check` в `check-full`, не в
`check`; `.dockerignore` и `Dockerfile` не менялись (словари копии попадают в образ через `COPY api/ ./`).
- Produces: копия, в API которой есть все имена раздела Global Constraints.
- [ ] **Step 1: Сверка подключения** — каждый пункт Consumes есть в `$T` (`grep` по `Makefile`, `api/pytest.ini`,
`api/pyproject.toml`, `api/requirements-dev.txt`); `api/arm_statements/VERSION` — 40 hex-символов.
- [ ] **Step 2: Свежесть и API копии**
Run: `cd $T && make statements-check; cd api && .venv/bin/python -c "import arm_statements as a; print(a.statements_version()); [getattr(a, n) for n in ('Rules','default_rules','Cell','Evidence','HeaderCells','FormCandidate','Source','DocumentType','StatementWarning','WarningCode','normalize_candidate','envelope_status','make_warning','type_hint','is_continuation','continuation_type','sheet_joins','code_column','amount_columns','normalize_label','normalize_caption','parse_code','parse_date','parse_amount','parse_header','to_contract','warning_to_contract','to_jsonable','statements_version')]"`
Expected: `statements-check` — `0` или `10`; хэш печатается, `AttributeError` нет.
- [ ] **Step 3 (только при `10` «копия отстала» или нехватке имени):** обновить копию в worktree фичи
Run: `cd $T && ../docs/scripts/sync-statements.sh --in-place --host-dir . && make check`
Expected: `statements-check tabular-parser: 0`; `make check` зелёный.
```bash
git -C $T add api/arm_statements
git -C $T commit -m "chore(arm_statements): копия пакета arm-statements@<v7> (#<issue>)"
```
- [ ] **Step 4: Базовая проверка** — `cd $T && make check` зелёный, в выводе pytest есть `arm_statements/tests/…`.
---
### 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 <лист>!<A1> (#<issue>)"
```
---
### 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.code_caption`,
`headers.labels`, `headers.auditor`, `headers.legal_form`, `periods.relative`), `type_hint`, `is_continuation`,
`continuation_type`, `code_column`, `amount_columns`, `normalize_label`, `normalize_caption`, `parse_code`,
`parse_date`, `DocumentType`, `grid_rows` (задача 3). Нормализация текста под шаблон — таблица «Словари» раздела API
плана 1 (P4-7): метки шапки, заголовки, ОКУД, `auditor`, `legal_form` — `normalize_label`; подпись «Код» и
относительные периоды — `normalize_caption`. `normalize_text` из `src.parser.rules` здесь не используется.
- Produces (`forms.py`; `StatementRules` = `arm_statements.Rules`):
- `matches(patterns: Iterable[re.Pattern[str]], text: str) -> bool` — `search` по `normalize_label(text)`
- `caption_matches(patterns: Iterable[re.Pattern[str]], text: str) -> bool` — `search` по `normalize_caption(text)`
- `sheet_grid(sheet: Sheet, first_row: int, last_row: int) -> list[tuple[Cell | None, ...]]` — `grid_rows(sheet,
first_row, last_row, <ширина листа>, set())` (неразобранные форматы здесь не собираются — их собирает
`build_candidates`)
- `sheet_code_column(sheet: Sheet, rules: StatementRules, first_row: int, last_row: int, *, relaxed: DocumentType |
None = None) -> int | None` — столбец листа (1-based) = `code_column(sheet_grid(...), rules, relaxed=relaxed) + 1` (`None` → `None`):
правило (а)/(б) шага 2 пакета (с защитой от блока «Коды»); `relaxed` — ослабленный поиск в отрезке формы, минимум
`Rules.code_ranges.min_codes_continuation`
- `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)`; ячейка, для которой `is_continuation(text)`, ОКУДом блока не
считается) — кандидаты; между соседними без строки с кодом (`code_rows` по `sheet_code_column` листа) — один блок;
первый блок начинается с первой строки листа, каждый — до строки перед следующим; `type_hint` блока —
`type_hint(okud_text, title_text, rules)`
- `code_rows(sheet: Sheet, rules: StatementRules, col: int, first_row: int, last_row: int) -> list[int]` — строки, где
`parse_code(text).code` ячейки столбца `col` не `None`
- `codes_of(sheet: Sheet, rules: StatementRules, col: int, first_row: int, last_row: int) -> list[str]`
- `sheet_amount_columns(sheet: Sheet, rules: StatementRules, code_col: int, first_row: int, last_row: int) -> int` —
`len(amount_columns(sheet_grid(...), code_col - 1, rules))`: столбцы правее столбца кодов, где в строке с кодом есть
число (то же правило, что колонки сегмента без заголовка в шаге 2 пакета)
- `continuation_marker(sheet: Sheet, rules: StatementRules) -> Cell | None` — первая ячейка, для которой
`is_continuation(text, rules)` (маркер с ОКУД вне словаря — тоже маркер; тип — `continuation_type(cell.text, rules)`,
может быть `None`)
- `StatementSheetKind = Literal["statement", "continuation", "other"]`;
`classify_statement_sheet(sheet: Sheet, rules: StatementRules) -> StatementSheetKind` — есть блок → `statement`;
маркер, или ячейка с `caption_matches(headers.code_caption)` и подписью-периодом правее (`parse_date` не `None` или
`caption_matches(periods.relative)`), или `sheet_code_column(...)` не `None` → `continuation`; иначе `other`
- `is_weak_continuation(sheet: Sheet, rules: StatementRules, form_type: DocumentType) -> bool` —
`sheet_code_column(…, relaxed=form_type)` не `None`; `form_type` не `BALANCE`/`INCOME` → `False`
- 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=<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 → sheet_code_column(...) == 3
def test_amount_columns_counts_numeric_columns_right_of_code(rules: StatementRules) -> None: ...
# код в C, числа в D, E, F в строках с кодами, текст «Изменение» в G без чисел → sheet_amount_columns(...) == 3
def test_weak_and_other_sheets(rules: StatementRules) -> None: ...
# лист с единственной строкой «Чистая прибыль | 2400 | 10 | 8» → classify «other»,
# is_weak_continuation(…, DocumentType.INCOME) True, (…, DocumentType.BALANCE) False;
# лист «Примечания» с текстом → «other», is_weak_continuation(…, INCOME) False
def test_marker_with_unknown_okud_is_continuation(rules: StatementRules) -> None: ...
# лист «Форма 0710000 с.2» + таблица с кодами → find_blocks == [], classify «continuation»,
# continuation_marker(...).text == "Форма 0710000 с.2", continuation_type(...) is None
def test_labels_with_punctuation_match(rules: StatementRules) -> None:
assert matches(rules.headers.labels["address"], "Местонахождение (адрес)")
assert matches(rules.headers.labels["okopf_okfs"], "по ОКОПФ / ОКФС")
assert matches(rules.headers.legal_form, "АО «Пример»")
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 (#<issue>)"
```
---
### 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`,
`WarningCode`, `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` сразу после
листа формы или продолжения и `is_weak_continuation(лист, rules, <type_hint последнего блока последнего листа
формы>)` → `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=<строка перед первой строкой с кодом столбца кодов блока>)`
(столбец — `sheet_code_column` блока, строки — `code_rows`; кодов нет — `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, sheet_amount_columns(лист), sheet_amount_columns(первый лист формы в её
блоке), rules)`, где столбец кодов листа — `sheet_code_column(лист, …)`, а если его нет —
`sheet_code_column(лист, …, relaxed=type)` (как сегмент шага 2 пакета), `codes` — `codes_of` по нему;
`False` → отдельный кандидат (`type_hint=None`) + `SHEET_NOT_JOINED`. Открытой формы нет → отдельный кандидат
(`type_hint` = тип маркера или `None`) без предупреждения. У отдельного кандидата `header = HeaderCells()`,
`evidence = Evidence()`, `rows` — все строки листа. Иначе — присоединение: `segment_breaks` += текущее число строк
сетки, строки листа (все строки листа) дописываются, `locator` += `,<имя>`.
3. Ширина: строки всех листов кандидата выравниваются по наибольшей ширине (дописываются `None`).
4. Предупреждения кандидата (`make_warning` с `WarningCode`, документа): `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 (#<issue>)"
```
---
### 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` (заметка читателя, задача 7; «xls без кодовой страницы» — не
код ОСВ, а предупреждение файла `XLS_NO_CODEPAGE` пакета, P4-13)
- `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 прежний) (#<issue>)"
```
---
### 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/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`, `WarningCode`, `to_contract`,
`warning_to_contract`, `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 | None`: `null` — цель или все члены пусты, план 1 P1-21); `StatementChecks` без `duplicate_line_codes`; `StatementDocument` (`document_type: Literal["BALANCE",
"INCOME", "DISCLOSURE"]`, `form_variant: Literal["full", "simplified"] | None` обязателен, `status`, `warnings`;
валидатор `checks` по `allOf` схемы)
- Перевод в контракт — пакетом (план 1, задача 19), своего `statements/contract.py` нет: документ формы —
`StatementDocument.model_validate(to_contract(document))` (шапка плоская, `form_name` из `document.header.form_name`,
без `column_key`, `checks` нет ⇒ ключа нет); предупреждение файла — `FileWarning.model_validate(warning_to_contract(w,
file_level=True))`; предупреждение листа ОСВ (`OsvNote` → `ResultWarning(code, severity="info", message)`) строится
моделью напрямую (коды `OSV_*` не в словаре пакета)
- 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) → `to_contract` / `warning_to_contract` (заголовок формы — уже в
`document.header.form_name`)
- 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` в каждый документ ОСВ книги (P4-13); заметка «xls без кодовой страницы» — предупреждение
файла `make_warning(WarningCode.XLS_NO_CODEPAGE, …)` (`info`, без `locator`) в `metrics.warnings`, в любой книге xls
(с ОСВ, формами или и тем, и другим); сообщение иерархии — «иерархия строк
— по группировке листа (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_xls_without_codepage_is_file_warning() -> None: ...
# книга xls (ОСВ + ББ) с заметкой читателя «нет кодовой страницы» → metrics.warnings содержит
# {code: "XLS_NO_CODEPAGE", severity: "info"}; у документов ОСВ нет OSV_XLS_NO_CODEPAGE
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_* у ОСВ (#<issue>)"
```
---
### 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#<issue>)"
git -C $T add api/tests
git -C $T commit -m "test: манифест реальных форм и Review Focus под общий код и v2.0 (#<issue>)"
```
---
### 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 (#<issue>)"
```
---
### 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/<fx-issue>-statements-v2-golden
tea pulls create --login home --repo RSHB/arm-ka-fixtures --head feat/<fx-issue>-statements-v2-golden --base main \
--title "Golden ОСВ и форм под контракт v2.0" --description "<closes #<fx-issue>; парный PR arm-tabular-parser>"
git -C $T push -u origin feat/<issue>-statements-v2
tea pulls create --login home --repo RSHB/arm-tabular-parser --head feat/<issue>-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.** Шапка ищется в строках блока выше первой строки с кодом его столбца кодов (столбец — `code_column` пакета:
то же правило (а)/(б) шага 2 с защитой от блока «Коды», своего правила у адаптера нет), без кодов — во всём блоке: так титульный блок «Коды» (ОКОПФ 12267) не мешает, а подписи под формой (ИНН и ФИО в блоке
подписей) не попадают в шапку. Цена: метка шапки ниже начала таблицы не найдётся.
- **P4-7.** Шаблоны словаря пакета адаптер применяет к тексту, нормализованному функцией пакета по таблице «Словари»
раздела API плана 1: метки шапки, заголовки, ОКУД, `auditor`, `legal_form` — `normalize_label` (= прежний
`normalize_text` tabular-parser, P1-22); подпись «Код» и относительные периоды — `normalize_caption`; маркер
продолжения — `is_continuation`. Цена: нет — смена нормализации в пакете меняет и `type_hint`, и адаптер.
- **P4-8.** Склейка: открытая форма — последний кандидат с `type_hint` `BALANCE`/`INCOME`; лист с маркером сверяется только
по типу; лист без маркера (сильное продолжение — столбец кодов по правилу шага 2 или своя строка заголовка; слабое —
столбец кодов ослабленным поиском `code_column(…, relaxed=<тип формы>)`, минимум `min_codes_continuation` кодов,
сразу после листа формы) — `sheet_joins` с числом колонок сумм `len(amount_columns(...))` пакета («столбцы правее
столбца кодов, где в строках с кодами есть число»); открытой формы нет — отдельный кандидат без предупреждения (пакет даст `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_HEADER` и
`OSV_ROWS` разные случаи различаются только текстом.
- **P4-13.** Заметки читателя: несовпадение формата — `FILE_TYPE_MISMATCH` файла; «xls без кодовой страницы» —
`XLS_NO_CODEPAGE` файла (`info`, код пакета, P1-23) в любой книге xls — касается и форм, и ОСВ; «xlsb без outline» — в
каждый документ ОСВ книги (`OSV_XLSB_NO_OUTLINE`), в книге без ОСВ только логируется (иерархия строк — только у ОСВ).
Цена: предупреждение о кодировке видно в карточке файла, а не у листа ОСВ.
- **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)
Пункты O-1…O-7 и O-9 закрыты сведением с планом 1 (18.10.2026, его P1-21…P1-31); маркеры сохранены.
- **O-1.** Закрыт: `code_column(rows, rules, *, relaxed)` и `amount_columns(rows, code_col, rules, *, source) ->
tuple[int, ...]` в API (P1-28); адаптер зовёт их через `sheet_code_column` / `sheet_amount_columns` (задача 4), своего
правила (б) нет.
- **O-2.** Закрыт: `normalize_label` и `normalize_caption` в API, таблица «ключ словаря → нормализация» (P1-22); P4-7.
- **O-3.** Закрыт: `StatementHeader.form_name` в пакете (P1-29); адаптер `form_name` не ставит.
- **O-4.** Закрыт: `is_continuation(text, rules) -> bool` в API; `continuation_marker` на нём (задача 4).
- **O-5.** Закрыт: код `XLS_NO_CODEPAGE` (`info`, файл) в словаре пакета, таблице спеки и §11.9 (P1-23); P4-13.
- **O-6.** Закрыт: ключи `Rules.headers.labels` перечислены в API плана 1 (`LABEL_KEYS`), загрузчик пакета их проверяет.
- **O-7.** Закрыт: описка «плана 3» в плане 1 исправлена на «плана 4».
- **O-8.** Покрытие словарей форм реальными файлами теряется (P4-19): в пакете тесты только на синтетике. Предложение:
тест покрытия `Rules.headers.*` в tabular-parser на `statements-xlsx/**` (как покрытие словарей ОСВ) — follow-up.
- **O-9.** Закрыт: `min_codes_continuation: 1` — в `statement_code_ranges.yaml` пакета (`Rules.code_ranges`), им
пользуются ослабленный поиск `code_column` и `sheet_joins` (P1-28).
- **O-10.** Сверено с планом 2 (появился в ветке во время написания): подготовку хоста делает его задача 8, задача 2
этого плана сведена к сверке и `--in-place`; `.dockerignore` не меняется (план 2: «`api/arm_statements` не добавлять в
`.dockerignore`» — тесты копии попадают в образ, это его решение). Пин `pytest` план 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 остаётся открытым, а не
сливается с флагом.