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