arm-ka-docs/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-4-tabular-parser.md

96 KiB
Raw Blame History

План 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 = <лист>!<A1>) — в адаптере; разбор ОСВ не меняется, меняется только вид его предупреждений и статус листа.

Tech Stack: Python 3.11, pydantic v2, openpyxl / xlrd / pyxlsb (читатели как есть), PyYAML, pytest, ruff, mypy; копия пакета api/arm_statements/ (stdlib + PyYAML); tea (Gitea), OpenSpec, codegraph.

Spec: /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/openspec/changes/unified-statement-normalization/ (ветка feat/116-unified-normalization репозитория arm-ka-docs): главное — specs/tabular-recognition-service/spec.md; шаг 1 для Excel — specs/statement-normalization/spec.md («Шаг 1 — кандидат в форму», «Шаг 1 — шапка кандидата», «Шаг 1 — многостраничная форма», «Шаг 8 — предупреждения», «Шаг 8 — статус документа и конверта», «Шаг 4 — единицы и дробные значения»); контракт — specs/recognition-contract/spec.md и contracts/*.schema.json v2.0, contracts/examples/*.json; правила — context/05-domain-rules.md §1.6, §11.2, §11.9; порядок выкладки — proposal.md (сервисы переходят на 2.0 после ядра, шаг 5). API пакета — раздел «Публичный API пакета» плана 1 (plans/2026-10-18-plan-1-arm-statements.md): только эти имена. design.md — справка (Р-n, S-n); спека важнее.


Место в работе и условия старта

План Что Связь с этим планом
0 тест идентичности копий контракта по MAJOR (в т. ч. tabular-parser) слит в tabular-parser до задачи 7: без него копия схем v2.0 не сверяется с docs
1 пакет arm_statements (репозиторий arm-statements) API — источник имён этого плана; копия пакета приходит в tabular-parser через план 2
2 sync-statements: копия в api/arm_statements/, подготовка хоста 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#<issue>)".
  • П-2. Зеркальный workspace $WS/.wt/normalization-tp/ ($WS = /Users/andrey.epifancev/projects/awp-ca; 00-workspace-layout.md, «Worktree вместо веток»): worktree tabular-parser/ (ветка фичи), worktree fixtures/ (ветка фичи fixtures — golden пишется сюда, поэтому не симлинк), отсоединённый worktree docs/ (в нём не коммитится), симлинки reference-old-arm, CLAUDE.md, .claude, arm-statements (источник для statements-check):
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/<issue>-statements-v2
git -C $M/fixtures branch -m feat/<fx-issue>-statements-v2-golden
cd $M/tabular-parser && codegraph init && make venv
  • П-3. В тексте плана $T = /Users/andrey.epifancev/projects/awp-ca/.wt/normalization-tp/tabular-parser, $F = …/normalization-tp/fixtures, $D = …/normalization-tp/docs. Команды make — из $T, backend — из $T/api, git — git -C <путь>. Перед правкой исполнитель вызывает codegraph_explore с projectPath = $T.
  • П-4. Проверить: в $T есть api/arm_statements/__init__.py и api/arm_statements/VERSION (план 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:<A>[,<B>…]; у ячейки confidence = 100, text — как показывает числовой формат, value — число по формату (Decimal), дата или bool; ref = <лист>!<A1>, имя листа в одинарных кавычках, если в нём есть что-то кроме букв, цифр и _ или оно начинается с цифры, апостроф внутри имени удваивается; одна форма — одна сетка, граница листа — номер строки сетки в segment_breaks.
  • Координат в src/parser/** нет (кроме grid.py, readers/) — AST-линтер tests/unit/parser/test_no_coordinates.py; буквы колонок и адрес ячейки строит только grid.py. Слов форм в коде нет — только словари пакета (Rules.headers.*).
  • Контракт: файл результата и конверт (включая ack processing) — schema_version = "2.0"; запрос остаётся 1.0 (поддерживаемый MAJOR запроса — "1", SCHEMA_MISMATCH по-прежнему). service.statements_version — содержимое api/arm_statements/VERSION, обязателен при statementDocument.
  • Предупреждения форм и файла — только через make_warning (код и уровень — словарь пакета); у ОСВ — код с префиксом 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 <noreply@anthropic.com> и Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W; PR — строкой 🤖 Generated with [Claude Code](https://claude.com/claude-code). Слияние — только по команде человека.
  • Находки и решения несут маркеры (T<n>.<k>, C-n/I-n/M-n, R-n, O-n); решения этого плана — P4-n (раздел в конце), контроллер переносит их в design.md docs (## Decisions made without discussion).

Review Focus

Реальные книги, где адаптер шага 1 встречает то, что сценарии спеки описывают по одному. Каждый пункт закреплён тестом задачи-владельца:

  1. Титульный блок «Коды» выгрузки 1С (Унагранде «Б.Б Лист 1», «ББ. Лист 1 .xls», «ББ. Лист 1 3кв.25», «ОФР. Лист 1» ×2): справа «Коды» с ОКУД, датой, ОКПО, ИНН, ОКОПФ 12267, ОКФС, ОКЕИ. Ожидание: столбец под «Коды» не столбец кодов, шапка берёт okopf = "12267", строки с кодом или текстом кода «12267» нет, ББ needs_check только из-за 1600 = 1700, ОФР done. Тесты: test_code_column_ignores_title_codes_block (задача 4), test_unagrande_title_codes_block (задача 8).
  2. НПЗ — листы разной геометрии: ББ на 5 листах, у «Лист 4» своя строка заголовка и колонки в других столбцах; ОФР на 3 листах, последний — одна строка 2400 с кодом в другой колонке. Ожидание: два кандидата с 5 и 3 листами, segment_breaks на каждой границе, значения листов-продолжений под column_index первого листа, нет SHEET_NOT_JOINED, SEGMENT_*, HEADER_ROW_NOT_FOUND; строка «Реализация иного имущества…» — detail с parent_ref строки 2340; оба done. Тесты: test_npz_candidates (задача 5), test_npz_documents (задача 8).
  3. Межениновская — нумерация и подписи под формой: строка «1 | 2 | 3 | 4 | 5» с «2» в столбце кодов, ниже таблицы — подписи руководителя и дата. Ожидание: строки с line_code_text = "2" нет, строк unreadable / missing нет, ИНН 7016006635 из шапки, ББ и ОФР done. Тест: test_mezheninovskaya_numbering_and_signatures (задача 8). Если тест падает на стороне шагов 2–3 (роли строк) — это находка для пакета (план 1), а не повод править сетку в адаптере.
  4. xlsb без числовых форматов: у каждой формы книги xlsb — XLSB_NO_FORMATS (info); при «тыс. руб.» и ≥ 10 % дробных значений — XLSB_SCALE_SUSPECT (error), документ needs_check; без дробных — done. Тест: test_xlsb_form_scale_suspect (задача 7, синтетика write_xlsb).
  5. «Пояснения»: книга только из пояснений (лист с ОКУД 0710003 и лист «Пояснения к бухгалтерскому балансу» без кодов) — документы DISCLOSURE done, конверт done; лист «Пояснения к бухгалтерскому балансу» с кодами 1110…1700 — BALANCE и TYPE_CONFLICT (error). Тесты: test_disclosures_only_book_is_done, test_disclosure_title_with_balance_codes (задача 7).

Структура изменений

tabular-parser/
├── openspec/changes/unified-normalization-tp/      # задача 1: proposal, tasks, дельты трёх спек
├── 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
git -C $T add openspec/changes/unified-normalization-tp
git -C $T commit -m "openspec: change unified-normalization-tp — адаптер Excel, общий код, контракт v2.0 (#<issue>)"

Task 2: Подготовка хоста (по плану 2 — сверить с планом 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
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 (#<issue>)"

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

# 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
git -C $T add api/src/parser/grid.py api/src/parser/statements/number_format.py api/src/parser/statements/cells.py api/tests/unit
git -C $T commit -m "feat(statements): ячейка Excel → Cell: текст по числовому формату, value, ref <лист>!<A1> (#<issue>)"

Task 4: Блоки форм, вид листа и шапка на словарях пакета

Модель исполнителя: sonnet.

Files:

  • Create: $T/api/src/parser/statements/forms.py, $T/api/src/parser/statements/header_cells.py
  • Test: $T/api/tests/unit/parser/statements/test_forms.py, $T/api/tests/unit/parser/statements/test_header_cells.py

Interfaces:

  • Consumes: arm_statements.Rules (headers.form_titles, headers.okud, headers.okud_code, headers.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=<ref ячейки числа>); okud — ячейка ОКУД блока. Evidence(okud=<ячейка ОКУД>, title=<ячейка заголовка>).

  • Step 1: Write the failing tests (листы — sheet_from_rows; правила — default_rules())
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
git -C $T add api/src/parser/statements/forms.py api/src/parser/statements/header_cells.py api/tests/unit/parser/statements
git -C $T commit -m "feat(statements): блоки форм, вид листа и шапка-ячейки на словарях arm_statements (#<issue>)"

Task 5: Адаптер книги → FormCandidate

Модель исполнителя: sonnet.

Files:

  • Create: $T/api/src/parser/statements/adapter.py
  • Modify: $T/api/src/parser/detect.py (вынести is_osv_sheet, поведение classify_sheet не меняется)
  • Test: $T/api/tests/unit/parser/statements/test_adapter.py, $T/api/tests/fixtures/test_statements_adapter_real.py (маркер fixtures)

Interfaces:

  • Consumes: задачи 3–4; FormCandidate, Evidence, HeaderCells, Source, DocumentType, make_warning, 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
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
git -C $T add api/src/parser/detect.py api/src/parser/statements/adapter.py api/tests
git -C $T commit -m "feat(statements): адаптер шага 1 — книга → FormCandidate, склейка листов, segment_breaks (#<issue>)"

Task 6: Предупреждения ОСВ с кодами OSV_* (вывод пока прежний)

Модель исполнителя: sonnet.

Files:

  • Create: $T/api/src/parser/osv_warnings.py
  • Modify: $T/api/src/parser/checks.py (run_checks), $T/api/src/parser/osv.py (parse_sheet), $T/api/src/parser/book.py (вызов), тесты test_checks.py, test_book_parser.py (только места вызова)
  • Test: $T/api/tests/unit/parser/test_osv_warnings.py

Interfaces:

  • Produces (osv_warnings.py):

    • OsvNote(code: str, message: str) — frozen dataclass
    • коды (P4-12): OSV_HIERARCHY_BY_OUTLINE|INDENT|ACCOUNT_PATTERN|SUMS|TRIVIAL (hierarchy_note(strategy: str) -> OsvNote), OSV_HEADER (предупреждения шапки doc.warnings), OSV_ROWS (предупреждения строк collect_body_rows), OSV_INN_ROWS (glue.warnings), OSV_NO_DATA_ROWS, OSV_BALANCE_IDENTITY, OSV_PARENT_SUM, OSV_TOTAL (итог не найден или не равен корню), OSV_XLSB_NO_OUTLINE, 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

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
git -C $T add api/src/parser api/tests
git -C $T commit -m "refactor(osv): предупреждения листа ОСВ с кодами OSV_* (вывод 1.x прежний) (#<issue>)"

Task 7: Переключение на общий код и контракт v2.0

Модель исполнителя: sonnet (интеграция). Самая крупная задача: модели и копия схем, сборка книги, путь ОСВ и воркер меняются вместе, иначе make check красный (P4-1). Golden и манифест — задача 8; до неё проверка без фикстур.

Files:

  • Modify: $T/api/contracts/** (копия $D/contracts/*.schema.json верхнего уровня и $D/contracts/examples/*.json; файлы *.v1.schema.json и examples/v1/ не копируются), $T/api/src/contract/{common,request,result,message,__init__}.py, $T/api/src/parser/{__init__,book,detect,osv,cli}.py, $T/api/src/parser/readers/__init__.py, $T/api/src/core/worker.py, $T/api/src/app.py
  • Create: $T/api/src/parser/statements/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: Копия контракта
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 по смыслу)
# 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
git -C $T add api
git -C $T commit -m "feat: формы через arm_statements, контракт результата и конверта v2.0, статус и OSV_* у ОСВ (#<issue>)"

Task 8: Golden и манифест реальных форм

Модель исполнителя: sonnet (сверка на реальных данных).

Files:

  • Modify: $F/golden/expected_osv_parse/**, $F/golden/expected_statement_parse/** (только make golden-update)
  • Modify: $T/api/tests/statements_manifest.yaml, $T/api/tests/statement_helpers.py, $T/api/tests/fixtures/test_statements_property.py, $T/api/tests/fixtures/test_statements_number_format.py, прочие tests/fixtures/* по факту падений
  • Create: $T/api/tests/fixtures/test_statements_real.py

Interfaces:

  • Consumes: книга задачи 7.

  • Produces: DocumentExpectation + error_warnings: list[str] | None (P4-18: точное множество кодов предупреждений документа уровня error; None — не проверяется).

  • Step 1: Обновить golden

Run: cd $T && make golden-update Expected: перезаписаны снимки ОСВ и форм, в выводе unified diff.

  • Step 2: Проверить, что ОСВ изменилась только по ключам v2.0
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)
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 (два репозитория, отдельные коммиты)
git -C $F add golden
git -C $F commit -m "golden: снимки ОСВ и форм под контракт v2.0 (arm-tabular-parser#<issue>)"
git -C $T add api/tests
git -C $T commit -m "test: манифест реальных форм и Review Focus под общий код и v2.0 (#<issue>)"

Task 9: Удаление собственного кода шагов 2–8 и словарей форм

Модель исполнителя: haiku (удаление по списку, без нового кода).

Files:

  • Delete: $T/api/src/parser/statements/{checks,columns,detect,header,lines,merge,statement}.py, $T/api/rules/statement_{headers,periods,checks}.yaml, $T/api/tests/unit/parser/statements/{test_checks,test_columns,test_detect,test_header,test_lines,test_statement}.py, $T/api/tests/unit/parser/test_statement_rules.py

  • Modify: $T/api/src/parser/rules.py (убрать StatementHeaderRules, StatementPeriodRules, модели §7, FormType, StatementLabel, поля Rules.statement_* и их загрузку), $T/api/tests/rules_coverage.py (DICTIONARIES), $T/api/tests/unit/parser/test_rules.py, $T/api/tests/fixtures/test_rules_coverage.py (без StatementParser), $T/api/src/parser/numbers.py (удалить функции без вызывающих после удаления — только по grep), $T/api/rules/README.md, $T/README.md (раздел «Парсер форм»), $T/CLAUDE.md (раздел Среза 2b → «Формы через arm_statements»: модули адаптера, словари форм в пакете, контракт 2.0, OSV_*, правило «копию не править»), $T/api/tests/synthetic/generate_statements.py (импорты)

  • Step 1: Удалить файлы и ссылки; затем

Run: cd $T/api && grep -rnE "statement_(headers|periods|checks)|statements\.(checks|columns|detect|header|lines|merge|statement)\b|StatementParser|duplicate_line_codes" src tests rules scripts Expected: пусто (кроме tests/synthetic/generate_statements.py, если он ссылается на имена файлов фикстур — тогда не пусто только там и это не импорт).

  • Step 2: Run tests

Run: cd $T && make check && make golden-check Expected: зелёный; golden-check — 0 (удаление не меняет вывод); test_no_coordinates.py и покрытие словарей ОСВ проходят.

  • Step 3: Commit
git -C $T add -A api README.md CLAUDE.md
git -C $T commit -m "refactor: удалён собственный конвейер форм и словари форм — шаги 2–8 в arm_statements (#<issue>)"

Task 10: Полная проверка, PR tabular-parser и fixtures

Модель исполнителя: sonnet. Перед задачей — финальное ревью ветки (opus) по SDD.

  • Step 1: Полная проверка

Run: cd $T && make check && make check-full && make statements-check; echo "exit=$?"; cd $T && openspec validate unified-normalization-tp --strict Expected: всё зелёное; statements-check — 0 или 10 («копия отстала» — вписать в описание PR); change валиден.

  • Step 2: Push и PR (две ветки, два PR, взаимные ссылки)
git -C $F push -u origin feat/<fx-issue>-statements-v2-golden
tea pulls create --login home --repo RSHB/arm-ka-fixtures --head feat/<fx-issue>-statements-v2-golden --base main \
  --title "Golden ОСВ и форм под контракт v2.0" --description "<closes #<fx-issue>; парный PR arm-tabular-parser>"
git -C $T push -u origin feat/<issue>-statements-v2
tea pulls create --login home --repo RSHB/arm-tabular-parser --head feat/<issue>-statements-v2 --base main \
  --title "Формы через arm_statements, контракт v2.0" --description "<см. ниже>"

Описание PR tabular-parser: цель; ссылки на arm-ka-docs#116, этот план, парный PR fixtures; условие слияния — ядро с приёмом 2.0 выложено (proposal.md, шаг 5), docs #117 слит, PR fixtures сливается сразу вслед; изменения манифеста с причинами (задача 8); доля done на реальных формах до/после; «Rulings I made» (P4-n и R-n контроллера); таблица находок с маркерами и статусами; deferred — отдельным списком.

  • Step 3: Решения в design.md — контроллер переносит P4-n и свои R-n в openspec/changes/unified-statement-normalization/design.md docs (## Decisions made without discussion, дата) — в ветку feat/116-unified-normalization, если PR #117 не слит, иначе отдельным PR docs.

Решения автора плана без обсуждения (P4-n)

Контроллер переносит их в design.md docs, раздел ## Decisions made without discussion (дата 18.10.2026).

  • P4-1. Переход на общий код и на контракт v2.0 — одна задача (7): модели, копия схем, сборка книги, путь ОСВ и воркер меняются вместе; «контракт v2.0 в копии» отдельной последней задачей не вынесен — модели 2.0 без копии 2.0 (или наоборот) валят result_contract и тест идентичности. Golden и манифест — сразу за переключением (задача 8), до удаления старого кода; между задачами 7 и 8 проверка идёт с ARM_KA_FIXTURES=/nonexistent. Цена: одна крупная задача и окно в одну задачу без проверки на реальных файлах.
  • P4-2. Адаптер пишется новыми модулями рядом со старыми (cells, forms, header_cells, adapter, contract, pipeline); старые statements/* не правятся и удаляются целиком задачей 9. Цена: временное дублирование логики поиска блоков и шапки.
  • P4-3. Cell.text числа Excel — как в русской локали: группы по три через пробел, если в формате есть разделитель групп; десятичная запятая; литералы секции (скобки) сохраняются; «-» добавляется только у односекционного формата; General/неразобранный формат/xlsb — число без групп и экспоненты, целое без дробной части; дата — dd.mm.yyyy; логическое — «ИСТИНА»/«ЛОЖЬ». Цена: у экзотических форматов текст может отличаться от экрана Excel; value и статус от этого не зависят (у Excel значение берётся из value). Совпадает с примером docs result-statement-excel.json ("586 260,26").
  • P4-4. Показываемое число вне numeric(18,2) больше не делает формат «неразобранным»: адаптер отдаёт Decimal, а VALUE_OUT_OF_RANGE и amount = null выдаёт пакет (спека «Шаг 4»). Цена: правка поведения #42 для чисел больше 16 целых цифр (на реальных файлах их нет).
  • P4-5. Сетка кандидата: строки блока целиком, включая пустые; объединённая ячейка — только в позиции левой верхней, прочие позиции None; ширина — наибольшая среди листов кандидата. Цена: подпись, объединённая на два столбца, видна пакету только в левом.
  • P4-6. Шапка ищется в строках блока выше первой строки с кодом его столбца кодов (правило (б) по 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 остаётся открытым, а не сливается с флагом.