Спеки применены: 5 новых capability (arm-core, arm-web, scan-gateway, statement-normalization, tabular-recognition-service) с Purpose, изменения quality-checks и recognition-contract. Ссылки в 05-domain-rules, DBML v5.19, entities, dev/checks.md и dev/qa-statements-v2.md ведут в архив. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
100 KiB
План 4 — tabular-parser: адаптер Excel шага 1, общий код arm_statements, контракт v2.0. План реализации
Для исполнителей-агентов: обязательный навык — superpowers:subagent-driven-development (в плане 10 задач: по
~/.claude/CLAUDE.md— SDD, контроллер — фоновый сабагент на модели sonnet; модели исполнителей указаны в каждой задаче; финальное ревью ветки — opus). Шаги размечены чекбоксами (- [ ]).
Goal: tabular-parser разбирает формы ББ/ОФР из Excel так: свой код читает книгу, классифицирует листы, выделяет блоки
форм и склеивает листы-продолжения в FormCandidate (адаптер шага 1), а шаги 2–8 выполняет копия пакета
arm_statements; результат и конверт — контракт v2.0 (у ОСВ — status и структурные предупреждения OSV_*); собственный
код шагов 2–8 форм и словари форм удалены; манифест реальных форм и golden обновлены.
Architecture: новые модули адаптера пишутся рядом со старыми (statements/cells.py, forms.py, header_cells.py,
adapter.py, pipeline.py); переключение книги на пакет и на контракт v2.0 — одной задачей (модели,
копия схем, сборка книги, путь ОСВ, воркер), сразу за ней — golden и манифест, затем удаление старого кода форм. Пакет
вызывается только через публичный API (from arm_statements import …); ячейка Excel → Cell (текст по числовому формату,
value, ref = <лист>!<A1>) — в адаптере; разбор ОСВ не меняется, меняется только вид его предупреждений и статус листа.
Tech Stack: Python 3.11, pydantic v2, openpyxl / xlrd / pyxlsb (читатели как есть), PyYAML, pytest, ruff, mypy;
копия пакета api/arm_statements/ (stdlib + PyYAML); tea (Gitea), OpenSpec, codegraph.
Spec: /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/openspec/changes/unified-statement-normalization/
(ветка feat/116-unified-normalization репозитория arm-ka-docs): главное — specs/tabular-recognition-service/spec.md;
шаг 1 для Excel — specs/statement-normalization/spec.md («Шаг 1 — кандидат в форму», «Шаг 1 — шапка кандидата», «Шаг 1 —
многостраничная форма», «Шаг 8 — предупреждения», «Шаг 8 — статус документа и конверта», «Шаг 4 — единицы и дробные
значения»); контракт — specs/recognition-contract/spec.md и contracts/*.schema.json v2.0, contracts/examples/*.json;
правила — context/05-domain-rules.md §1.6, §11.2, §11.9; порядок выкладки — proposal.md (сервисы переходят на 2.0
после ядра, шаг 5). API пакета — раздел «Публичный API пакета» плана 1 (plans/2026-10-18-plan-1-arm-statements.md):
только эти имена. design.md — справка (Р-n, S-n); спека важнее.
Место в работе и условия старта
| План | Что | Связь с этим планом |
|---|---|---|
| 0 | тест идентичности копий контракта по MAJOR (в т. ч. tabular-parser) | слит в tabular-parser до задачи 7: без него копия схем v2.0 не сверяется с docs |
| 1 | пакет arm_statements (репозиторий arm-statements) |
API — источник имён этого плана; копия пакета приходит в tabular-parser через план 2 |
| 2 | sync-statements: копия в api/arm_statements/, подготовка хоста (его задача 8) |
PR задачи 8 плана 2 в tabular-parser слит до старта; задача 2 этого плана только сверяет и при нужде обновляет копию --in-place |
| 3 | arm-core части А и Б: приём 2.0 | PR этого плана сливается только после выкладки ядра, принимающего 2.0 (proposal.md, шаг 5) |
| docs PR #117 | контракт v2.0, правила | до слияния — отсоединённый worktree docs на origin/feat/116-unified-normalization; после — на origin/main |
Разработка этого плана может идти параллельно с планом 3; слияние PR tabular-parser и PR fixtures — только по команде
человека после выкладки ядра (иначе первый же результат 2.0 даст файлу ERROR у ядра 1.x).
Предусловия (для контроллера, до задачи 1)
- П-1. Issue в RSHB/arm-tabular-parser:
tea issues create --login home --repo RSHB/arm-tabular-parser --title "Формы ББ/ОФР через общий код arm_statements, контракт результата v2.0 (arm-ka-docs#116)" --description "<цель, ссылка на этот план>". Issue в RSHB/arm-ka-fixtures (golden этого PR):--title "Golden форм и ОСВ под контракт v2.0 (arm-tabular-parser#<issue>)". - П-2. Зеркальный workspace
$WS/.wt/normalization-tp/($WS=/Users/andrey.epifancev/projects/awp-ca;00-workspace-layout.md, «Worktree вместо веток»): worktreetabular-parser/(ветка фичи), worktreefixtures/(ветка фичи fixtures — golden пишется сюда, поэтому не симлинк), отсоединённый worktreedocs/(в нём не коммитится), симлинкиreference-old-arm,CLAUDE.md,.claude(источникarm-statementsскрипт плана 2 находит сам —$WS/arm-statements):
WS=/Users/andrey.epifancev/projects/awp-ca; M=$WS/.wt/normalization-tp
mkdir -p $M
git -C $WS/tabular-parser fetch origin
git -C $WS/tabular-parser worktree add -b tmp/statements-v2 $M/tabular-parser origin/main
git -C $WS/fixtures fetch origin
git -C $WS/fixtures worktree add -b tmp/statements-v2-golden $M/fixtures origin/main
git -C $WS/docs fetch origin
git -C $WS/docs worktree add --detach $M/docs origin/feat/116-unified-normalization
for l in reference-old-arm CLAUDE.md .claude; do ln -s $WS/$l $M/$l; done
git -C $M/tabular-parser branch -m feat/<issue>-statements-v2
git -C $M/fixtures branch -m feat/<fx-issue>-statements-v2-golden
cd $M/tabular-parser && codegraph init && make venv
- П-3. В тексте плана
$T=/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-tp/tabular-parser,$F=…/normalization-tp/fixtures,$D=…/normalization-tp/docs. Командыmake— из$T, backend — из$T/api, git —git -C <путь>. Перед правкой исполнитель вызываетcodegraph_exploreсprojectPath=$T. - П-4. Проверить: в
$Tестьapi/arm_statements/__init__.pyиapi/arm_statements/VERSION(PR задачи 8 плана 2 слит) иapi/tests/contract_identity.py(план 0). Нет копии пакета — стоп и вопрос владельцу (подключение копии — PR плана 2, не этого плана; руками копию не раскладывать). Нет плана 0 — задачи 1–6 можно вести, задача 7 ждёт. - П-5. После слияния docs PR #117:
git -C $D checkout --detach origin/main.
Global Constraints
- Пакет — только через публичный API
from arm_statements import …(имена раздела «Публичный API пакета» плана 1; в т. ч.Rules,default_rules,Cell,Evidence,HeaderCells,FormCandidate,Source,DocumentType,StatementWarning,WarningCode,normalize_candidate,envelope_status,make_warning,type_hint,is_continuation,continuation_type,sheet_joins,code_column,amount_columns,normalize_label,normalize_caption,parse_code,parse_date,parse_header,to_contract,warning_to_contract,to_jsonable,statements_version). Импорт внутренних модулей пакета (arm_statements.codesи т. п.) вsrc/запрещён. Файлыapi/arm_statements/**не правятся (прямая правка — код 20statements-check); нехватка в API — находкаO-n, не правка копии. Правило шага 2 (столбец кодов, колонки сумм) и нормализация меток в адаптере не повторяются — только функции пакета (P4-6, P4-7). - Шаг 1 для Excel (спека):
source = excel;locator = sheet:<A>[,<B>…]; у ячейкиconfidence = 100,text— как показывает числовой формат,value— число по формату (Decimal), дата илиbool;ref = <лист>!<A1>, имя листа в одинарных кавычках, если в нём есть что-то кроме букв, цифр и_или оно начинается с цифры, апостроф внутри имени удваивается; одна форма — одна сетка, граница листа — номер строки сетки вsegment_breaks. - Координат в
src/parser/**нет (кромеgrid.py,readers/) — AST-линтерtests/unit/parser/test_no_coordinates.py; буквы колонок и адрес ячейки строит толькоgrid.py. Слов форм в коде нет — только словари пакета (Rules.headers.*). - Контракт: файл результата и конверт (включая ack
processing) —schema_version = "2.0"; запрос остаётся1.0(поддерживаемый MAJOR запроса —"1",SCHEMA_MISMATCHпо-прежнему).service.statements_version— содержимоеapi/arm_statements/VERSION, обязателен приstatementDocument. - Предупреждения форм и файла — только через
make_warningс членомWarningCode(код и уровень — словарь пакета); у ОСВ — код с префиксомOSV_, уровеньinfo. Строк видаsheet=<имя> <текст>вmetrics.warningsнет. Перевод документов форм и предупреждений в контракт — толькоto_contract/warning_to_contractпакета. - Статус конверта —
envelope_statusпакета по статусам всех документов (ОСВ —done/needs_checkлиста) и предупреждениям файла; документов нет —error INVALID_DOCUMENT. Порядок документов: ОСВ в порядке листов, затем формы. - Разбор ОСВ не меняется (
classify.py,to_money,hierarchy.py,checks.pyпо существу, словари §1): golden ОСВ после перехода отличается только ключамиschema_version,metrics.warnings,documents[].status,documents[].warnings. - Деньги —
Decimal, безfloat; первичка не мутируется. - Golden правится только
make golden-updateс просмотром diff и отдельным коммитом в fixtures. make checkне требует Docker и сети;statements-check— только вmake check-full.- Язык комментариев, docstring, сообщений, коммитов, issue и PR — русский. Коммиты заканчиваются строками
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>иClaude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W; PR — строкой🤖 Generated with [Claude Code](https://claude.com/claude-code). Слияние — только по команде человека. - Находки и решения несут маркеры (
T<n>.<k>,C-n/I-n/M-n,R-n,O-n); решения этого плана —P4-n(раздел в конце), контроллер переносит их вdesign.mddocs (## Decisions made without discussion).
Review Focus
Реальные книги, где адаптер шага 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). - НПЗ — листы разной геометрии: ББ на 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). - Межениновская — нумерация и подписи под формой: строка «1 | 2 | 3 | 4 | 5» с «2» в столбце кодов, ниже таблицы —
подписи руководителя и дата. Ожидание: строки с
line_code_text = "2"нет, строкunreadable/missingнет, ИНН7016006635из шапки, ББ и ОФРdone. Тест:test_mezheninovskaya_numbering_and_signatures(задача 8). Если тест падает на стороне шагов 2–3 (роли строк) — это находка для пакета (план 1), а не повод править сетку в адаптере. - xlsb без числовых форматов: у каждой формы книги xlsb —
XLSB_NO_FORMATS(info); при «тыс. руб.» и ≥ 10 % дробных значений —XLSB_SCALE_SUSPECT(error), документneeds_check; без дробных —done. Тест:test_xlsb_form_scale_suspect(задача 7, синтетикаwrite_xlsb). - «Пояснения»: книга только из пояснений (лист с ОКУД 0710003 и лист «Пояснения к бухгалтерскому балансу» без кодов)
— документы
DISCLOSUREdone, конвертdone; лист «Пояснения к бухгалтерскому балансу» с кодами 1110…1700 —BALANCEиTYPE_CONFLICT(error). Тесты:test_disclosures_only_book_is_done,test_disclosure_title_with_balance_codes(задача 7).
Структура изменений
tabular-parser/
├── openspec/changes/unified-normalization-tp/ # задача 1: proposal, tasks, дельты трёх спек
├── api/arm_statements/** # копия пакета (план 2); задача 2 — только --in-place при нужде
├── api/contracts/** # задача 7: копия docs/contracts v2.0 (без .v1 и examples/v1)
├── api/src/contract/{common,request,result,__init__}.py # задача 7: модели v2.0
├── api/src/parser/
│ ├── grid.py # задача 3: cell_ref()
│ ├── osv_warnings.py # задача 6: OsvNote и коды OSV_*
│ ├── checks.py, osv.py # задача 6: заметки с кодами; задача 7: status/warnings в документе
│ ├── detect.py # задача 5: is_osv_sheet(); задача 7: classify_sheet на словарях пакета
│ ├── readers/__init__.py # задача 7: BookRead.mismatch
│ ├── book.py, __init__.py # задача 7: сборка книги через пакет, require_statements_version()
│ └── statements/
│ ├── number_format.py # задача 3: текст по формату (остаётся)
│ ├── cells.py # задача 3: ячейка Excel → Cell, сетка
│ ├── forms.py # задача 4: блоки форм, вид листа, столбец кодов, колонки сумм
│ ├── header_cells.py # задача 4: шапка блока → HeaderCells, Evidence
│ ├── adapter.py # задача 5: план листов книги, склейка → FormCandidate
│ ├── pipeline.py # задача 7: StatementPipeline (адаптер → normalize_candidate → контракт)
│ └── {checks,columns,detect,header,lines,merge,statement}.py # задача 9: удаляются
├── api/rules/statement_{headers,periods,checks}.yaml # задача 9: удаляются (словари в пакете)
├── api/src/core/worker.py, api/src/app.py, api/src/parser/cli.py # задача 7: statements_version, версии
└── api/tests/… # по задачам; statements_manifest.yaml — задача 8
fixtures/golden/expected_{osv,statement}_parse/** # задача 8 (отдельный PR fixtures)
Task 1: OpenSpec change unified-normalization-tp в tabular-parser
Модель исполнителя: sonnet (перенос требований спеки в дельты).
Files:
- Create:
$T/openspec/changes/unified-normalization-tp/proposal.md,tasks.md,specs/statement-parsing/spec.md,specs/tabular-recognition-service/spec.md,specs/osv-parsing/spec.md
Interfaces:
- Consumes: текущие
$T/openspec/specs/{statement-parsing,tabular-recognition-service,osv-parsing}/spec.md; спека docs (specs/tabular-recognition-service/spec.md, требования шага 1 Excel вspecs/statement-normalization/spec.md). - Produces: change, на который ссылаются коммиты и PR; при закрытии —
openspec archive unified-normalization-tp.
Состав дельт (текст требований — из спеки docs, приведённый к языку спек сервиса; сценарии — дословно из docs):
-
statement-parsing: MODIFIED «Листы форм и их продолжения находятся по содержимому» (классификация: ОСВ → форма → продолжение → прочее, слабое продолжение только сразу после листа формы,SHEET_OTHER), «Шапка документа» (поля какCellсconfidence = 100в границах блока, разбор — общим кодом), «Склейка листов-продолжений и статус документа» (до раскладки,segment_breaks,CONTINUATION_TYPE_CONFLICT,SHEET_NOT_JOINED; статус — функцией пакета), «Словари форм, координаты, golden» (словари форм — в пакете; координат нет; golden и манифест); REMOVED «Заголовок таблицы, колонка кодов и колонки-периоды определяются по тексту», «Строки с кодом и значения», «Соотношения §7 — данные, проверка по колонкам» (Reason: шаги 2–8 выполняет копияarm_statements; Migration: требования —statement-normalizationdocs); ADDED «Адаптер Excel шага 1», «Ячейка Excel», «Строки без кода и неизвестные колонки в Excel», «Манифест ожиданий на реальных формах», «Статус книги» (из docstabular-recognition-service). -
tabular-recognition-service: MODIFIED «Результат по контракту» (v2.0,service.statements_version, структурныеmetrics.warnings, ackprocessing— 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 и свежесть копии
Модель исполнителя: haiku (проверки по списку).
Подготовку хоста делает план 2 (plans/2026-10-18-plan-2-sync-statements.md, задача 8 «tabular-parser — подключение
копии»; раздел «Что должны включить планы 3, 4, 5»), и её PR слит до старта (П-4). Эта задача только сверяет main
с планом 2 и, если пакету нужна версия новее, обновляет копию скриптом. Недостающее из списка ниже — не доделывать
здесь, а остановиться и сообщить контроллеру (находка O-n: правка подключения — PR плана 2).
Files:
- Modify (только если нужна новая версия пакета):
$T/api/arm_statements/**— скриптом, руками не править
Interfaces:
-
Consumes (из плана 2):
make lint→mypy src arm_statements;api/pytest.initestpathsсarm_statements/tests;known-first-party = ["src", "arm_statements"]; строгий блок[[tool.mypy.overrides]]дляarm_statements.*; пиныruff==0.16.10,mypy==2.4.0,types-PyYAML==6.0.12.20260906; цельstatements-checkвcheck-full, не вcheck;.dockerignoreиDockerfileне менялись (словари копии попадают в образ черезCOPY api/ ./). -
Produces: копия, в API которой есть все имена раздела Global Constraints.
-
Step 1: Сверка подключения — каждый пункт Consumes есть в
$T(grepпоMakefile,api/pytest.ini,api/pyproject.toml,api/requirements-dev.txt);api/arm_statements/VERSION— 40 hex-символов. -
Step 2: Свежесть и API копии
Run: cd $T && make statements-check; cd api && .venv/bin/python -c "import arm_statements as a; print(a.statements_version()); [getattr(a, n) for n in ('Rules','default_rules','Cell','Evidence','HeaderCells','FormCandidate','Source','DocumentType','StatementWarning','WarningCode','normalize_candidate','envelope_status','make_warning','type_hint','is_continuation','continuation_type','sheet_joins','code_column','amount_columns','normalize_label','normalize_caption','parse_code','parse_date','parse_amount','parse_header','to_contract','warning_to_contract','to_jsonable','statements_version')]"
Expected: statements-check — 0 или 10; хэш печатается, AttributeError нет.
- Step 3 (только при
10«копия отстала» или нехватке имени): обновить копию в worktree фичи
Run: cd $T && ../docs/scripts/sync-statements.sh --in-place --host-dir . && make check
Expected: statements-check tabular-parser: 0; make check зелёный.
git -C $T add api/arm_statements
git -C $T commit -m "chore(arm_statements): копия пакета arm-statements@<v7> (#<issue>)"
- Step 4: Базовая проверка —
cd $T && make checkзелёный, в выводе pytest естьarm_statements/tests/….
Task 3: Ячейка Excel → Cell: ссылка, текст по формату, сетка
Модель исполнителя: sonnet.
Files:
- Modify:
$T/api/src/parser/grid.py(cell_ref),$T/api/src/parser/statements/number_format.py(текст) - Create:
$T/api/src/parser/statements/cells.py - Test:
$T/api/tests/unit/parser/test_grid.py,$T/api/tests/unit/parser/statements/test_number_format.py,$T/api/tests/unit/parser/statements/test_cells.py
Interfaces:
- Consumes:
Sheet,Cell(grid),display_amount,arm_statements.Cell. - Produces:
grid.cell_ref(sheet_name: str, row: int, col: int) -> str— 1-based; правило кавычек — Global Constraints («буква» —str.isalpha(), цифра —str.isdigit()).number_format.FormatResult(kind, value: Decimal | None = None, text: str | None = None)—textзаполнен уkind == "value";display_amount(raw: Decimal, fmt: str) -> FormatResult;general_text(raw: Decimal) -> str.cells.excel_cell(sheet: Sheet, row: int, col: int, unparsed_formats: set[str]) -> arm_statements.Cell | Nonecells.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; логическое —valuebool,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.code_caption,headers.labels,headers.auditor,headers.legal_form,periods.relative),type_hint,is_continuation,continuation_type,code_column,amount_columns,normalize_label,normalize_caption,parse_code,parse_date,DocumentType,grid_rows(задача 3). Нормализация текста под шаблон — таблица «Словари» раздела API плана 1 (P4-7): метки шапки, заголовки, ОКУД,auditor,legal_form—normalize_label; подпись «Код» и относительные периоды —normalize_caption.normalize_textизsrc.parser.rulesздесь не используется. - Produces (
forms.py;StatementRules=arm_statements.Rules):matches(patterns: Iterable[re.Pattern[str]], text: str) -> bool—searchпоnormalize_label(text)caption_matches(patterns: Iterable[re.Pattern[str]], text: str) -> bool—searchпоnormalize_caption(text)sheet_grid(sheet: Sheet, first_row: int, last_row: int) -> list[tuple[Cell | None, ...]]—grid_rows(sheet, first_row, last_row, <ширина листа>, set())(неразобранные форматы здесь не собираются — их собираетbuild_candidates)sheet_code_column(sheet: Sheet, rules: StatementRules, first_row: int, last_row: int, *, relaxed: DocumentType | None = None) -> int | None— столбец листа (1-based) =code_column(sheet_grid(...), rules, relaxed=relaxed) + 1(None→None): правило (а)/(б) шага 2 пакета (с защитой от блока «Коды»);relaxed— ослабленный поиск в отрезке формы, минимумRules.code_ranges.min_codes_continuationFormBlock(type_hint: DocumentType, title: tuple[int, int] | None, okud: tuple[int, int] | None, first_row: int, last_row: int)—title/okud—(row, col)ячеек-признаковfind_blocks(sheet: Sheet, rules: StatementRules) -> list[FormBlock]— строки с заголовком (type_hint(None, text)неNone) или ОКУД (type_hint(text, None); ячейка, для которойis_continuation(text), ОКУДом блока не считается) — кандидаты; между соседними без строки с кодом (code_rowsпоsheet_code_columnлиста) — один блок; первый блок начинается с первой строки листа, каждый — до строки перед следующим;type_hintблока —type_hint(okud_text, title_text, rules)code_rows(sheet: Sheet, rules: StatementRules, col: int, first_row: int, last_row: int) -> list[int]— строки, гдеparse_code(text).codeячейки столбцаcolнеNonecodes_of(sheet: Sheet, rules: StatementRules, col: int, first_row: int, last_row: int) -> list[str]sheet_amount_columns(sheet: Sheet, rules: StatementRules, code_col: int, first_row: int, last_row: int) -> int—len(amount_columns(sheet_grid(...), code_col - 1, rules)): столбцы правее столбца кодов, где в строке с кодом есть число (то же правило, что колонки сегмента без заголовка в шаге 2 пакета)continuation_marker(sheet: Sheet, rules: StatementRules) -> Cell | None— первая ячейка, для которойis_continuation(text, rules)(маркер с ОКУД вне словаря — тоже маркер; тип —continuation_type(cell.text, rules), может бытьNone)StatementSheetKind = Literal["statement", "continuation", "other"];classify_statement_sheet(sheet: Sheet, rules: StatementRules) -> StatementSheetKind— есть блок →statement; маркер, или ячейка сcaption_matches(headers.code_caption)и подписью-периодом правее (parse_dateнеNoneилиcaption_matches(periods.relative)), илиsheet_code_column(...)неNone→continuation; иначеotheris_weak_continuation(sheet: Sheet, rules: StatementRules, form_type: DocumentType) -> bool—sheet_code_column(…, relaxed=form_type)неNone;form_typeнеBALANCE/INCOME→False
- Produces (
header_cells.py):header_cells(sheet: Sheet, rules: StatementRules, block: FormBlock, last_row: int, unparsed_formats: set[str]) -> tuple[HeaderCells, Evidence]— поиск в строкахblock.first_row..last_row;last_rowзадаёт адаптер (P4-6)
Правила шапки (как сейчас в statements/header.py, но результат — Cell ячейки-значения через excel_cell):
организация — значение правее метки organization или в следующей строке; пусто, число или метка ОКПО — ближайшая
строка выше с признаком legal_form; ИНН — первая ячейка правее метки inn, в тексте которой 10 или 12 цифр (сам разбор
— пакет); строки ниже первой метки auditor для организации, ИНН и адреса не смотрятся; okpo, okfs, okopf, okei,
address — значение у своей метки; пара okopf_okfs — два числа правее в той же строке или строкой выше; units —
первая ячейка правее метки units, кроме метки ОКЕИ и чистых цифр; report_date — ячейка строки сразу под заголовком
формы, у которой parse_date(text) не None, иначе тройка «число | месяц | год» правее метки date →
Cell(text="<д>.<мм>.<гггг>" для числового месяца или "<д> <месяц> <гггг>", value=None, confidence=100.0, ref=<ref ячейки числа>); okud — ячейка ОКУД блока. Evidence(okud=<ячейка ОКУД>, title=<ячейка заголовка>).
- Step 1: Write the failing tests (листы —
sheet_from_rows; правила —default_rules())
def test_title_and_okud_without_codes_between_form_one_block(rules: StatementRules) -> None: ...
# «Бухгалтерский баланс» (стр. 3), «Форма по ОКУД | 0710001» (стр. 5), таблица ниже → один блок BALANCE,
# title=(3, c), okud=(5, c2), first_row=1
def test_second_title_after_codes_opens_new_block(rules: StatementRules) -> None: ...
# ББ с кодами 1110…1600, ниже «Отчет о финансовых результатах» и 2110… → блоки BALANCE и INCOME, граница — строка заголовка ОФР
def test_okud_wins_over_title(rules: StatementRules) -> None:
blocks = find_blocks(sheet_from_rows([["Бухгалтерский баланс"], ["по ОКУД", "0710002"]]), rules)
assert [block.type_hint for block in blocks] == [DocumentType.INCOME]
def test_report_title_alone_is_not_a_form(rules: StatementRules) -> None:
assert find_blocks(sheet_from_rows([["Бухгалтерская отчетность"]]), rules) == []
def test_continuation_marker_is_not_a_block(rules: StatementRules) -> None:
sheet = sheet_from_rows([["Форма 0710001 с.2"], ["Пассив", "Код", "На 30.09.2025"], ["Капитал", "1310", 10]])
assert find_blocks(sheet, rules) == []
assert classify_statement_sheet(sheet, rules) == "continuation"
def test_code_column_ignores_title_codes_block(rules: StatementRules) -> None: # Review Focus 1
# столбец F: «Коды», 0710001, 30, 09, 2025, 12345678, 7700000000, 12267, 16, 384 (один код из девяти);
# столбец C: 1110, 1150, 1170, 1100, 1210, 1230 → sheet_code_column(...) == 3
def test_amount_columns_counts_numeric_columns_right_of_code(rules: StatementRules) -> None: ...
# код в C, числа в D, E, F в строках с кодами, текст «Изменение» в G без чисел → sheet_amount_columns(...) == 3
def test_weak_and_other_sheets(rules: StatementRules) -> None: ...
# лист с единственной строкой «Чистая прибыль | 2400 | 10 | 8» → classify «other»,
# is_weak_continuation(…, DocumentType.INCOME) True, (…, DocumentType.BALANCE) False;
# лист «Примечания» с текстом → «other», is_weak_continuation(…, INCOME) False
def test_marker_with_unknown_okud_is_continuation(rules: StatementRules) -> None: ...
# лист «Форма 0710000 с.2» + таблица с кодами → find_blocks == [], classify «continuation»,
# continuation_marker(...).text == "Форма 0710000 с.2", continuation_type(...) is None
def test_labels_with_punctuation_match(rules: StatementRules) -> None:
assert matches(rules.headers.labels["address"], "Местонахождение (адрес)")
assert matches(rules.headers.labels["okopf_okfs"], "по ОКОПФ / ОКФС")
assert matches(rules.headers.legal_form, "АО «Пример»")
def test_header_cells_of_1c_block(rules: StatementRules) -> None: ...
# «Организация | АО «Пример»», «ИНН | 7700000000», «по ОКПО | 12345678», «по ОКОПФ / ОКФС | 12267 | 16» строкой выше
# метки, «Единица измерения: | в тыс. рублей | по ОКЕИ | 384», подзаголовок «на 30 сентября 2025 г.»:
# cells.organization_name.text == "АО «Пример»", .inn.ref == "Баланс!C5", (.okopf.text, .okfs.text) == ("12267", "16"),
# .report_date.text == "на 30 сентября 2025 г."; parse_header(cells, rules) → units "тыс. руб.", report_date 2025-09-30
def test_auditor_inn_is_not_taken(rules: StatementRules) -> None: ...
def test_organization_by_legal_form_above_label(rules: StatementRules) -> None: ... # НПЗ
def test_report_date_from_triple(rules: StatementRules) -> None: ...
# «Дата (число, месяц, год) | 30 | 09 | 2025» → text "30.09.2025"; «30 | сентября | 2025» → "30 сентября 2025";
# parse_header даёт 2025-09-30 в обоих случаях
def test_units_only_by_okei(rules: StatementRules) -> None: # спека: «Единицы только кодом ОКЕИ»
# «по ОКЕИ | 384», метки «Единица измерения» нет → cells.units is None, cells.okei.text == "384";
# head = parse_header(cells, rules): (head.units.value, head.okei.value) == ("тыс. руб.", "384")
- Step 2: Run tests to verify they fail
Run: cd $T/api && .venv/bin/pytest tests/unit/parser/statements/test_forms.py tests/unit/parser/statements/test_header_cells.py -v
Expected: FAIL — модулей нет.
- Step 3: Implement
forms.py,header_cells.pyпо Interfaces (донор логики —statements/detect.pyиstatements/header.py; сами модули-доноры не правятся, их удаляет задача 9). Если метка шапки не находится словарём пакета, а словарём tabular-parser находится — это расхождение словарей: находкаO-n, словарь пакета не правится. - Step 4: Run tests
Run: cd $T && make check
Expected: зелёный; test_no_coordinates.py проходит (новые модули без координат).
- Step 5: Commit
git -C $T add api/src/parser/statements/forms.py api/src/parser/statements/header_cells.py api/tests/unit/parser/statements
git -C $T commit -m "feat(statements): блоки форм, вид листа и шапка-ячейки на словарях arm_statements (#<issue>)"
Task 5: Адаптер книги → FormCandidate
Модель исполнителя: sonnet.
Files:
- Create:
$T/api/src/parser/statements/adapter.py - Modify:
$T/api/src/parser/detect.py(вынестиis_osv_sheet, поведениеclassify_sheetне меняется) - Test:
$T/api/tests/unit/parser/statements/test_adapter.py,$T/api/tests/fixtures/test_statements_adapter_real.py(маркерfixtures)
Interfaces:
- Consumes: задачи 3–4;
FormCandidate,Evidence,HeaderCells,Source,DocumentType,make_warning,WarningCode,sheet_joins,continuation_type,Deadline,SheetKind. - Produces:
detect.is_osv_sheet(sheet: Sheet, rules: Rules) -> bool— признак ведомости в первыхSHEET_MARKER_ROWSстрокахadapter.SheetPlan(osv: tuple[Sheet, ...], statements: tuple[tuple[Sheet, SheetKind], ...], other: tuple[Sheet, ...])adapter.plan_sheets(sheets: Sequence[Sheet], is_osv: Callable[[Sheet], bool], rules: StatementRules) -> SheetPlan— по порядку: ОСВ;statement→SheetKind.STATEMENT;continuation→STATEMENT_CONTINUATION;otherсразу после листа формы или продолжения иis_weak_continuation(лист, rules, <type_hint последнего блока последнего листа формы>)→STATEMENT_CONTINUATION; иначеother. Лист ОСВ или «прочее» прерывает «сразу после».adapter.build_candidates(sheets: Sequence[tuple[Sheet, SheetKind]], rules: StatementRules, deadline: Deadline, *, xlsb: bool) -> list[FormCandidate]
Алгоритм build_candidates (спека «Шаг 1 — многостраничная форма», §11.2 п. 2–4; P4-6, P4-8, P4-10):
- Лист
STATEMENT: для каждого блока — новый кандидат:source=EXCEL,locator="sheet:<имя>",type_hintблока,evidenceиheader—header_cells(…, last_row=<строка перед первой строкой с кодом столбца кодов блока>)(столбец —sheet_code_columnблока, строки —code_rows; кодов нет —block.last_row),rows = grid_rows(sheet, block.first_row, block.last_row, …). - Лист
STATEMENT_CONTINUATION: открытая форма — последний кандидат сtype_hintBALANCE/INCOME. Маркер есть и его тип неNoneи не равен типу формы → отдельный кандидат (type_hint= тип маркера) +CONTINUATION_TYPE_CONFLICT. Маркера нет →sheet_joins(type, codes, sheet_amount_columns(лист), sheet_amount_columns(первый лист формы в её блоке), rules), где столбец кодов листа —sheet_code_column(лист, …), а если его нет —sheet_code_column(лист, …, relaxed=type)(как сегмент шага 2 пакета),codes—codes_ofпо нему;False→ отдельный кандидат (type_hint=None) +SHEET_NOT_JOINED. Открытой формы нет → отдельный кандидат (type_hint= тип маркера илиNone) без предупреждения. У отдельного кандидатаheader = HeaderCells(),evidence = Evidence(),rows— все строки листа. Иначе — присоединение:segment_breaks+= текущее число строк сетки, строки листа (все строки листа) дописываются,locator+=,<имя>. - Ширина: строки всех листов кандидата выравниваются по наибольшей ширине (дописываются
None). - Предупреждения кандидата (
make_warningсWarningCode, документа):xlsb→XLSB_NO_FORMATSу каждого кандидата; непустой набор неразобранных форматов всех листов кандидата → одноNUMBER_FORMAT_UNPARSEDс перечнем («fmt»через запятую, длинные — до 30 символов с «…»). 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(заметка читателя, задача 7; «xls без кодовой страницы» — не код ОСВ, а предупреждение файлаXLS_NO_CODEPAGEпакета, P4-13) legacy_line(sheet_name: str, note: OsvNote) -> str—f"sheet={sheet_name} {note.message}"(вывод 1.x до задачи 7)
-
Produces (
checks.py):run_checks(rows, exempt=None) -> tuple[OsvChecks, list[OsvNote]]— тексты прежние, коды по виду проверки. -
Produces (
osv.py):SheetResult(document: OsvDocument, notes: tuple[OsvNote, ...], ok: bool);OsvParser.parse_sheet(sheet, deadline) -> SheetResult; сообщениеhierarchy_noteпокаhierarchy=<стратегия>(текст меняет задача 7). -
Step 1: Write the failing tests
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/pipeline.py - Test:
$T/api/tests/result_contract.py,$T/api/tests/golden_helpers.py(VOLATILE),$T/api/tests/unit/test_contract.py,$T/api/tests/unit/test_contract_v12.py→test_contract_v20.py,$T/api/tests/unit/parser/test_book_parser.py,$T/api/tests/unit/parser/statements/test_pipeline.py(новый),$T/api/tests/unit/test_worker.py,$T/api/tests/unit/parser/test_cli.py,$T/api/tests/unit/parser/test_readers.py,$T/api/tests/synthetic/test_statement_synthetic_files.py,$T/api/tests/synthetic/test_synthetic_files.py,$T/api/tests/integration/test_roundtrip.py
Interfaces:
- Consumes: задачи 3–6;
normalize_candidate,envelope_status,make_warning,WarningCode,to_contract,warning_to_contract,default_rules,statements_version,DocStatus,RulesError. - Produces (контракт,
src/contract/):CONTRACT_VERSION = "2.0"(результат и конверт);SCHEMA_VERSION_PATTERN = r"^2\.\d+$";REQUEST_SCHEMA_VERSION_PATTERN = r"^1\.\d+$",RequestSchemaVersion— уRecognitionRequestResultWarning(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] | NoneServiceInfo.statements_version: str | None(min_length=1);RecognitionResult— валидатор: при наличииStatementDocumentполе обязательно (какallOfсхемы)OsvDocument+status: DocumentStatus,warnings: list[ResultWarning]StatementHeader— поля схемы v2.0 (form_name, по каждому полю<поле>,<поле>_text,<поле>_confidence,needs_review_fields);StatementColumn(+label_confidence,period_kind);StatementValue(+text,needs_review);StatementLine(row_index,ref,line_status,line_code: str | None,line_code_text,line_code_confidence,line_name: str | None,line_name_confidence,parent_ref,needs_review,values; валидатор:line_codeнеNone⇔line_status == "ok");FailedRelation(+codes,expected/actual—Money | None:null— цель или все члены пусты, план 1 P1-21);StatementChecksбезduplicate_line_codes;StatementDocument(document_type: Literal["BALANCE", "INCOME", "DISCLOSURE"],form_variant: Literal["full", "simplified"] | Noneобязателен,status,warnings; валидаторchecksпоallOfсхемы)
- Перевод в контракт — пакетом (план 1, задача 19), своего
statements/contract.pyнет: документ формы —StatementDocument.model_validate(to_contract(document))(шапка плоская,form_nameизdocument.header.form_name, безcolumn_key,checksнет ⇒ ключа нет); предупреждение файла —FileWarning.model_validate(warning_to_contract(w, file_level=True)); предупреждение листа ОСВ (OsvNote→ResultWarning(code, severity="info", message)) строится моделью напрямую (кодыOSV_*не в словаре пакета) - Produces (
statements/pipeline.py):StatementsOutcome(documents: tuple[StatementDocument, ...], file_warnings: tuple[FileWarning, ...])StatementPipeline(rules: StatementRules)с методомparse(sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline, *, xlsb: bool) -> StatementsOutcome—build_candidates→ для каждого кандидатаdeadline.checkиnormalize_candidate(P4-11) →to_contract/warning_to_contract(заголовок формы — уже вdocument.header.form_name)
- Produces (книга):
BookParser(rules, *, osv: OsvParser, statements: StatementPipeline, statement_rules: StatementRules, max_bytes, timeout_seconds, clock);ParseOutcomeпрежний (документы — модели v2.0,metrics.warnings—FileWarning);readers.BookRead(sheets, warnings, detected, mismatch: str | None)— строка несовпадения формата больше не вwarnings;detect.classify_sheet(sheet, rules, statement_rules: StatementRules | None = None)— ветка форм черезforms.classify_statement_sheet(None→default_rules()) - Produces (
src/parser/__init__.py):require_statements_version(path: Path | None = None) -> str—statements_version(path),None→RuntimeError("копия arm_statements без VERSION: разложите её sync-statements")(P4-16);get_parserгрузитdefault_rules()(RulesError→RuntimeErrorс текстом ошибки) и собираетStatementPipeline - Produces (воркер):
RecognitionWorker(..., statements_version: str)→ServiceInfo(name, version, statements_version)в конверте и файле результата;app.pyпередаётrequire_statements_version(); CLI — тоже.
Сборка книги (BookParser.parse; спека «Статус книги», «Путь ОСВ в контракте v2.0»):
- лимиты и чтение — как сейчас;
read.mismatch→FILE_TYPE_MISMATCH(файл,info, сообщение — прежний текст «file_type=…, но содержимое …: прочитано как …»); plan_sheets(read.sheets, is_osv_sheet, statement_rules); листы ОСВ — по порядкуparse_sheet: документ получаетstatus = done if ok else needs_checkиwarningsиз заметок (severity = info), плюс заметка читателяOSV_XLSB_NO_OUTLINEв каждый документ ОСВ книги (P4-13); заметка «xls без кодовой страницы» — предупреждение файлаmake_warning(WarningCode.XLS_NO_CODEPAGE, …)(info, безlocator) вmetrics.warnings, в любой книге xls (с ОСВ, формами или и тем, и другим); сообщение иерархии — «иерархия строк — по группировке листа (outline)» / «по отступам (indent)» / «по кодам счетов (account_pattern)» / «по суммам (sums)» / «плоский список (trivial)»;- листы
other→SHEET_OTHER(файл,info,locator = sheet:<имя>, сообщение «лист без признаков ОСВ и формы»); - формы —
StatementPipeline.parse(plan.statements, deadline, xlsb=read.detected == "XLSB"); - документов нет →
INVALID_DOCUMENT(сообщение прежнее); иначеstatus = envelope_status(статусы ОСВ и форм как DocStatus, предупреждения файла);metrics.warnings— все предупреждения файла по порядку листов;rows— как сейчас (строки данных ОСВ +len(lines)); - ошибка листа ОСВ (
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_xls_without_codepage_is_file_warning() -> None: ...
# книга xls (ОСВ + ББ) с заметкой читателя «нет кодовой страницы» → metrics.warnings содержит
# {code: "XLS_NO_CODEPAGE", severity: "info"}; у документов ОСВ нет OSV_XLS_NO_CODEPAGE
def test_disclosures_only_book_is_done() -> None: # спека: «Книга только из пояснений»; Review Focus 5
# листы «0710003» (ОКУД, без кодов) и «Пояснения к бухгалтерскому балансу» (без кодов) →
# [(d.document_type, d.status) for d in docs] == [("DISCLOSURE", "done")] * 2, outcome.status is DONE
def test_disclosure_title_with_balance_codes() -> None: # Review Focus 5
# «Пояснения к бухгалтерскому балансу» + коды 1110…1700 → BALANCE и TYPE_CONFLICT (error)
def test_xlsb_form_scale_suspect() -> None: # Review Focus 4
# write_xlsb({"Баланс": форма ББ, «тыс. руб.», 40 % дробных}) → warnings ⊇ {XLSB_NO_FORMATS info,
# XLSB_SCALE_SUSPECT error}, status needs_check; без дробных → только XLSB_NO_FORMATS, status done
def test_book_without_osv_and_forms_is_invalid_document() -> None: ...
def test_documents_order_osv_then_forms() -> None: ...
# tests/unit/test_worker.py
def test_result_carries_statements_version() -> None: # спека: «Версия общего кода в результате»
# RecognitionWorker(..., statements_version="abc1234") → result.service.statements_version == "abc1234";
# конверт и ack processing — schema_version "2.0"; запрос "1.0" принимается, "2.0" → SCHEMA_MISMATCH без ack
def test_require_statements_version_fails_without_file(tmp_path: Path) -> None:
with pytest.raises(RuntimeError, match="VERSION"):
require_statements_version(tmp_path / "VERSION")
- Step 3: Run tests to verify they fail
Run: cd $T/api && ARM_KA_FIXTURES=/nonexistent .venv/bin/pytest tests/unit tests/synthetic -m "not integration" -x -q
Expected: FAIL (модели 1.2, конвейер старый).
- Step 4: Implement по Interfaces и «Сборке книги».
tests/result_contract.py:serviceс"statements_version": "test";golden_helpers.VOLATILE+=("service", "statements_version");renderи CLI получают версию черезrequire_statements_version(). Старые модули форм (statement.pyи др.) больше не вызываются книгой, но остаются до задачи 9 вместе со своими тестами. - Step 5: Run tests
Run: cd $T && ARM_KA_FIXTURES=/nonexistent make check
Expected: зелёный (тесты на реальных фикстурах пропущены — их чинит задача 8; тест идентичности копии контракта против
$D — PASSED, не SKIPPED: cd api && .venv/bin/pytest -rs -k vendored_contracts).
- Step 6: Commit
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.mddocs (## 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). Совпадает с примером docsresult-statement-excel.json("586 260,26"). - P4-4. Показываемое число вне
numeric(18,2)больше не делает формат «неразобранным»: адаптер отдаётDecimal, аVALUE_OUT_OF_RANGEиamount = nullвыдаёт пакет (спека «Шаг 4»). Цена: правка поведения #42 для чисел больше 16 целых цифр (на реальных файлах их нет). - P4-5. Сетка кандидата: строки блока целиком, включая пустые; объединённая ячейка — только в позиции левой верхней,
прочие позиции
None; ширина — наибольшая среди листов кандидата. Цена: подпись, объединённая на два столбца, видна пакету только в левом. - P4-6. Шапка ищется в строках блока выше первой строки с кодом его столбца кодов (столбец —
code_columnпакета: то же правило (а)/(б) шага 2 с защитой от блока «Коды», своего правила у адаптера нет), без кодов — во всём блоке: так титульный блок «Коды» (ОКОПФ 12267) не мешает, а подписи под формой (ИНН и ФИО в блоке подписей) не попадают в шапку. Цена: метка шапки ниже начала таблицы не найдётся. - P4-7. Шаблоны словаря пакета адаптер применяет к тексту, нормализованному функцией пакета по таблице «Словари»
раздела API плана 1: метки шапки, заголовки, ОКУД,
auditor,legal_form—normalize_label(= прежнийnormalize_texttabular-parser, P1-22); подпись «Код» и относительные периоды —normalize_caption; маркер продолжения —is_continuation. Цена: нет — смена нормализации в пакете меняет иtype_hint, и адаптер. - P4-8. Склейка: открытая форма — последний кандидат с
type_hintBALANCE/INCOME; лист с маркером сверяется только по типу; лист без маркера (сильное продолжение — столбец кодов по правилу шага 2 или своя строка заголовка; слабое — столбец кодов ослабленным поискомcode_column(…, relaxed=<тип формы>), минимумmin_codes_continuationкодов, сразу после листа формы) —sheet_joinsс числом колонок суммlen(amount_columns(...))пакета («столбцы правее столбца кодов, где в строках с кодами есть число»); открытой формы нет — отдельный кандидат без предупреждения (пакет дастNO_ORGANIZATION_NO_INN). Цена: «Изменение, %» с числами считается колонкой сумм с обеих сторон сравнения; лист с иной шапкой, но тем же числом колонок, присоединится и будет сопоставлен пакетом по датам. - P4-9.
SHEET_OTHERдля листов «прочее» выдаёт книга сама (документа и кандидата нет), а не пакет по кандидату безtype_hint. Цена: два места порождения одного кода (у пакета — для кандидатов, не нашедших ни типа, ни кодов). - P4-10.
XLSB_NO_FORMATS— у каждого кандидата книги xlsb (P1-5);NUMBER_FORMAT_UNPARSED— одно на кандидата с перечнем форматов всех его листов. Цена: у пояснений xlsb тоже естьXLSB_NO_FORMATS(info). - P4-11. Книга вызывает
normalize_candidateпо одному кандидату, а неnormalize: так проверяется дедлайн между кандидатами (header.form_nameпакет ставит сам изevidence.title, пара «кандидат → документ» не нужна). Исключение пакета на кандидате —RECOGNITION_FAILEDвсей книги, как сейчас (TABLE_PARSE_ERROR— код таблицы ИС). Цена: одна сломанная форма роняет книгу с ОСВ. - P4-12. Коды ОСВ — по источнику предупреждения, не по тексту:
OSV_HIERARCHY_BY_<СТРАТЕГИЯ>,OSV_HEADER,OSV_ROWS,OSV_INN_ROWS,OSV_NO_DATA_ROWS,OSV_BALANCE_IDENTITY,OSV_PARENT_SUM,OSV_TOTAL,OSV_XLSB_NO_OUTLINE; тексты сообщений прежние, кроме иерархии. Цена: внутриOSV_HEADERиOSV_ROWSразные случаи различаются только текстом. - P4-13. Заметки читателя: несовпадение формата —
FILE_TYPE_MISMATCHфайла; «xls без кодовой страницы» —XLS_NO_CODEPAGEфайла (info, код пакета, P1-23) в любой книге xls — касается и форм, и ОСВ; «xlsb без outline» — в каждый документ ОСВ книги (OSV_XLSB_NO_OUTLINE), в книге без ОСВ только логируется (иерархия строк — только у ОСВ). Цена: предупреждение о кодировке видно в карточке файла, а не у листа ОСВ. - P4-14. Предупреждение «форма не разобрана: книга завершилась ошибкой листа ОСВ» снято: в v2.0 для него нет кода, а текст ошибки и так называет лист ОСВ. Цена: аналитик не видит списка листов форм, оставшихся неразобранными.
- P4-15. Статус документа ОСВ — прежние условия листа: контрольные суммы §1.4 сошлись и строки ИНН без нарушений
(
glue.ok, #35). Цена: формулировка спеки «когда контрольные суммы сошлись» читается шире. - P4-16. Нет
api/arm_statements/VERSION— сервис не стартует (RuntimeError), как при ошибке словаря; в goldenservice.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 checkmainодного из них красный на golden. - P4-22. Две формы на одном листе дают два документа с одинаковым
locator(спека уникальностиlocatorне требует;refстрок различаются).
Чего не хватает в API пакета для адаптера Excel (O-n)
Пункты O-1…O-7 и O-9 закрыты сведением с планом 1 (18.10.2026, его P1-21…P1-31); маркеры сохранены.
- O-1. Закрыт:
code_column(rows, rules, *, relaxed)иamount_columns(rows, code_col, rules, *, source) -> tuple[int, ...]в API (P1-28); адаптер зовёт их черезsheet_code_column/sheet_amount_columns(задача 4), своего правила (б) нет. - O-2. Закрыт:
normalize_labelиnormalize_captionв API, таблица «ключ словаря → нормализация» (P1-22); P4-7. - O-3. Закрыт:
StatementHeader.form_nameв пакете (P1-29); адаптерform_nameне ставит. - O-4. Закрыт:
is_continuation(text, rules) -> boolв API;continuation_markerна нём (задача 4). - O-5. Закрыт: код
XLS_NO_CODEPAGE(info, файл) в словаре пакета, таблице спеки и §11.9 (P1-23); P4-13. - O-6. Закрыт: ключи
Rules.headers.labelsперечислены в API плана 1 (LABEL_KEYS), загрузчик пакета их проверяет. - O-7. Закрыт: описка «плана 3» в плане 1 исправлена на «плана 4».
- O-8. Покрытие словарей форм реальными файлами теряется (P4-19): в пакете тесты только на синтетике. Предложение:
тест покрытия
Rules.headers.*в tabular-parser наstatements-xlsx/**(как покрытие словарей ОСВ) — follow-up. - O-9. Закрыт:
min_codes_continuation: 1— вstatement_code_ranges.yamlпакета (Rules.code_ranges), им пользуются ослабленный поискcode_columnиsheet_joins(P1-28). - O-10. Сверено с планом 2 (появился в ветке во время написания): подготовку хоста делает его задача 8, задача 2
этого плана сведена к сверке и
--in-place;.dockerignoreне меняется (план 2: «api/arm_statementsне добавлять в.dockerignore» — тесты копии попадают в образ, это его решение). Пинpytestплан 2 не вводит — здесь тоже нет.
Открытые вопросы владельцу
- В-1. Гранулярность кодов
OSV_*(P4-12): хватает ли кодов по источнику, или экрану нужны отдельные коды для случаев внутриOSV_HEADER(организация, счёт, период, валюта) иOSV_ROWS? Ответ меняет толькоosv_warnings.pyи golden. - В-2. Межениновская и НПЗ должны остаться
done(спека манифеста). Если прогон задачи 8 покажет, что подписи под формой Межениновской или лист 4 НПЗ даютmissing/unreadableилиSEGMENT_*по правилам шагов 2–3 пакета — это противоречие спеки и реального файла: решение (правка пакета или манифеста) — за владельцем, адаптер под файл не подгоняется. - В-3. Слияние: PR tabular-parser и PR fixtures — после выкладки ядра с приёмом 2.0 (части А и Б плана 3) и слияния docs #117; порядок относительно scan-gateway свободный. Подтвердить, что до этого PR остаётся открытым, а не сливается с флагом.