# План 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 = <лист>!`) — в адаптере; разбор ОСВ не меняется, меняется только вид его предупреждений и статус листа. **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#)"`. - **П-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/-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` (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:[,…]`; у ячейки `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` с членом `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 ` и `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, дельты трёх спек ├── 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 (#)" ``` --- ### 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@ (#)" ``` - [ ] **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 <лист>! (#)" ``` --- ### 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=)`; `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 (#)" ``` --- ### 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, )` → `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 (#)" ``` --- ### 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 прежний) (#)" ``` --- ### 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_* у ОСВ (#)" ``` --- ### 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.** Шапка ищется в строках блока выше первой строки с кодом его столбца кодов (столбец — `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 остаётся открытым, а не сливается с флагом.