From 94846cbd4c6e4bdb86052188f1c9d4dc73aa308c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 13:21:30 +0400 Subject: [PATCH 01/12] =?UTF-8?q?openspec:=20=D1=81=D0=BA=D0=B2=D0=BE?= =?UTF-8?q?=D0=B7=D0=BD=D0=B0=D1=8F=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=A1=D1=80=D0=B5=D0=B7=D0=B0=202b=20=E2=80=94=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=20=D1=80=D0=B5=D0=B7=D1=83?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D0=B0=20v1.2,=20=C2=A712,=20OQ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proposal, design (состав v1.2: DISCLOSURE, statementDocument.checks, границы периода ОСВ; примеры; порядок раскатки потребители → производитель), дельта recognition-contract «Файл результата» v1.2, tasks. Refs #26 Co-Authored-By: Claude Fable 5.1 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- .../slice-2b-statement-parser/design.md | 53 +++++++++++++++++ .../slice-2b-statement-parser/proposal.md | 58 +++++++++++++++++++ .../specs/recognition-contract/spec.md | 39 +++++++++++++ .../slice-2b-statement-parser/tasks.md | 17 ++++++ 4 files changed, 167 insertions(+) create mode 100644 openspec/changes/slice-2b-statement-parser/design.md create mode 100644 openspec/changes/slice-2b-statement-parser/proposal.md create mode 100644 openspec/changes/slice-2b-statement-parser/specs/recognition-contract/spec.md create mode 100644 openspec/changes/slice-2b-statement-parser/tasks.md diff --git a/openspec/changes/slice-2b-statement-parser/design.md b/openspec/changes/slice-2b-statement-parser/design.md new file mode 100644 index 0000000..20cba7c --- /dev/null +++ b/openspec/changes/slice-2b-statement-parser/design.md @@ -0,0 +1,53 @@ +# Design: Срез 2b (сквозное) — контракт v1.2, §12, OQ + +## Context + +Контракт `recognition-result.schema.json` v1.1: `statementDocument` рассчитан на OCR (поля `*_confidence`, +`is_recognized`), `document_type ∈ {BALANCE, INCOME, OTHER}`, результатов проверок у форм нет (у `osvDocument` есть +`checks`); `osvDocument.header` описывает период текстом, годом и кварталом, границы диапазона месяцев выразить нельзя +(OQ-131). Правило версионирования: MINOR — необязательные поля и новые значения enum; потребители обновляют копии раньше +производителя. Потребители копий: `tabular-parser` (тест идентичности в `make check`), `arm-core` (тест идентичности, +pydantic-модели результата в `services/result_mapping.py`), `scan-gateway` (копия и модели). + +## Decisions + +### D-1. Состав v1.2 +- `$defs.statementDocument.properties.document_type.enum`: `["BALANCE", "INCOME", "DISCLOSURE", "OTHER"]`. +- `$defs.statementDocument.properties.checks` (object, необязательное): `relations_failed` и `soft_relations_failed` — + массивы объектов `{column_index: integer, relation: string, expected: money, actual: money}` (все четыре обязательны); + `duplicate_line_codes` — массив строк. Описание: отсутствие объекта — «не проверялось» (OCR-путь), пустые массивы — + «проверено, сошлось». +- `$defs.osvDocument.properties.header.properties`: `period_start`, `period_end` — `["string", "null"]`, `format: date`; + `is_from_year_start` — `["boolean", "null"]`. Описание: границы периода по шапке; для квартала/года — первое и последнее + число; для диапазона месяцев — его границы; `is_from_year_start` = `period_start` — 1 января. +- `title`, `description` схемы и `schema_version.description` — «1.2». `$comment` о версионировании не меняется. +- `recognition-request.schema.json` и `recognition-result-message.schema.json` не меняются; `result_schema_version` + в сообщении — строка, новых значений не требует. + +### D-2. Примеры +`result-statement-minimal.json` получает `checks: {relations_failed: [], soft_relations_failed: [], duplicate_line_codes: []}` +и `schema_version: "1.2"`. Новый `result-statement-excel.json`: `service.name = tabular-parser`, три документа — +`BALANCE` с `locator = "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2"`, тремя колонками, строками +1110/11101/1100/1600 и `checks` с одним нарушением в `soft_relations_failed`; `INCOME` на одном листе; `DISCLOSURE` без +`columns`/`lines`; `status = needs_check` с предупреждением о единицах. `result-osv-minimal.json` — `period_start`, +`period_end`, `is_from_year_start`. Все примеры валидируются тестами контракта в трёх сервисах. + +### D-3. §12 — текст решений владельца +§12 переписывается по пунктам 1–8 с сохранением нумерации и добавлением п. 9 «Склейка листов и страниц», п. 10 «Статус +документа и книги», п. 11 «Что отложено» (запасной путь по именам, нормализация единиц, признак продолжения). Формулы §7 +остаются в §7; в §12 п. 7 — ссылка и правило «жёсткие/мягкие, допуск ±1, двойной расчёт знаков». Текст решений — из +brainstorming 06.10.2026 (вопросы 2–6) и design дочернего change (D-2…D-8). + +### D-4. Порядок раскатки +docs (этот change) → bounded-PR в arm-core и scan-gateway «копия схемы v1.2 + модели, без логики» → tabular-parser +(производитель 1.2) → fixtures (golden). Тест идентичности в сервисах пропускается только при недоступном docs, поэтому +между мержем docs и мержем копий тесты сервисов на main падают; окно минимизируется одновременным открытием PR. + +## Decisions made without discussion + +| № | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| 1 | 2026-10-06 | `checks` форм — отдельная структура, не переиспользование `osvDocument.checks` | у форм нарушения по колонкам и соотношениям, у ОСВ — по строкам | поле остаётся необязательным | +| 2 | 2026-10-06 | `expected`/`actual` в нарушении — `money` (строка с двумя знаками), не число | единый тип денег контракта | смена типа — MAJOR, поэтому сразу строка | +| 3 | 2026-10-06 | `is_from_year_start` — отдельное поле, `is_cumulative` не переопределяется | `is_cumulative` ядро уже трактует по OQ-02/124; новое поле не ломает семантику | два похожих поля | +| 4 | 2026-10-06 | Пример `result-statement-excel.json` со статусом `needs_check` и `DISCLOSURE` | иллюстрирует все новые элементы одним файлом | нет | diff --git a/openspec/changes/slice-2b-statement-parser/proposal.md b/openspec/changes/slice-2b-statement-parser/proposal.md new file mode 100644 index 0000000..69f8280 --- /dev/null +++ b/openspec/changes/slice-2b-statement-parser/proposal.md @@ -0,0 +1,58 @@ +# Proposal: Срез 2b (сквозное) — контракт результата v1.2, §12 ББ/ОФР из Excel, OQ + +**Срез:** 2, подсрез 2b «парсер ББ/ОФР из Excel» (решение владельца 06.10.2026: только парсер и контракт; маппинг в ядро — +Срез 3). **Репозиторий:** arm-ka-docs. **Issue:** RSHB/arm-ka-docs#26. **Дочерний change:** +`tabular-parser/openspec/changes/slice-2b-statement-parser` (RSHB/arm-tabular-parser#14). Golden и синтетика — +arm-ka-fixtures (отдельный PR). Копии схемы v1.2 — bounded-PR в arm-core и arm-scan-gateway. + +## Why + +§12 написан до появления реальных файлов. Пакет ДКБ 06.10.2026 принёс 15 форм в трёх раскладках (постраничные xls 1С, +многолистовая книга ФСБУ 4/2023, `TDSheet`) и показал пять решений, которых в §12 нет: страницы одной формы в разных +файлах, единицы «тыс. руб.» против значений с копейками, пятизначные подстроки «в том числе», соотношения §7 как данные, +формы без колонки кодов. Контракту v1.1 не хватает структурного места для результатов проверок §7 и типа «расшифровка» +(DBML `document_type.DISCLOSURE`), а ОСВ со скользящим периодом (OQ-131) — границ периода. Всё это MINOR. + +## What Changes + +- **Контракт v1.2** (`docs/contracts/recognition-result.schema.json`): `statementDocument.document_type` + `DISCLOSURE`; + необязательный `statementDocument.checks` {`relations_failed[]`, `soft_relations_failed[]` — объекты `{column_index, + relation, expected, actual}`; `duplicate_line_codes[]`}; необязательные `osvDocument.header.period_start`, + `period_end` (date|null), `is_from_year_start` (boolean|null). `title`/`description` `schema_version` — «1.2». + Примеры: `result-statement-minimal.json` дополняется `checks`; новый `result-statement-excel.json` (книга с `BALANCE` из + нескольких листов, `INCOME`, `DISCLOSURE`); `result-osv-minimal.json` — новые поля шапки. Сообщение и запрос не меняются. + Спека `recognition-contract`: «Файл результата» — v1.2. +- **`05-domain-rules.md` §12**: решения владельца 06.10.2026 (вопросы 2–6 brainstorming) и уточнения по факту файлов: + классификация листов и продолжений, титул «Бухгалтерская отчетность», склейка листов внутри книги и запрет склейки + между файлами (страница без шапки → `needs_check`), единицы как есть + предупреждение, пятизначные подстроки как строки, + §7 по четырёхзначным с допуском ±1 и двойным расчётом знаков, соотношения — данные `statement_checks.yaml`, отказ от + запасного пути по именам в первой очереди, критерий `ok` документа, порядок документов книги. §7 — пометка, какие + соотношения парсер считает жёсткими, какие мягкими (`ASSUMPTION(OQ-12)`). +- **`06-test-data.md`**: раздел по `statements-xlsx/**` (раскладки, манифест ожиданий, golden `expected_statement_parse/`, + синтетика `st01…st05`). +- **`03-architecture.md`**: версия контракта 1.2, `BookParser` как реализация по умолчанию tabular-parser, порядок + раскатки MINOR (потребители раньше производителя). +- **`09-delivery-plan.md`**: Срез 2b — статус и объём по решению владельца; маппинг форм — Срез 3 (без изменений). +- **`07-open-questions.md`**: новые OQ — единицы «тыс. руб.» против значений с копейками в 1С-выгрузках (кто и где + приводит); форма без колонки кодов (словарь имён из шаблонов); страница-продолжение в отдельном файле (склейка в ядре, + признак `is_continuation` в контракте); строка без кода с суммами в ОФР ФСБУ 4/2023; образец ФСБУ с «АКТИВ» и 1110 в + одной строке. Статус OQ-131: границы периода в контракте v1.2, заполнение — парсер (#11 / 2b). +- **`docs/dev/qa-guide.md` §6.7**: абзац про прогон парсера на формах. + +## Capabilities + +### Modified Capabilities +- `recognition-contract`: «Файл результата» — v1.2 с `DISCLOSURE`, `statementDocument.checks`, границами периода ОСВ; + «Версионирование контракта» — без изменений (MINOR по правилу). + +## Impact + +- Контракт: MINOR, обратно совместимо; копии обновляются в `tabular-parser` (дочерний change), `arm-core` и `scan-gateway` + (bounded-PR: схема, примеры, pydantic-модели; логика не меняется — ядро по-прежнему маппит только `osvDocument`). + Порядок merge: docs → arm-core, scan-gateway → tabular-parser → fixtures. +- Таблицы DBML: нет (`DISCLOSURE` уже есть в `document_type`). Правила: §12 (решения владельца), §7 (пометки). + +## Non-goals + +- Маппинг `statementDocument` в ядре и экран проверки форм — Срез 3. +- Признак продолжения страницы в контракте, нормализация единиц, словарь имён строк — после появления потребителя/файла. diff --git a/openspec/changes/slice-2b-statement-parser/specs/recognition-contract/spec.md b/openspec/changes/slice-2b-statement-parser/specs/recognition-contract/spec.md new file mode 100644 index 0000000..63a5e58 --- /dev/null +++ b/openspec/changes/slice-2b-statement-parser/specs/recognition-contract/spec.md @@ -0,0 +1,39 @@ +# Spec Delta: recognition-contract + +## MODIFIED Requirements + +### Requirement: Файл результата +Файл результата SHALL соответствовать `docs/contracts/recognition-result.schema.json` v1.2: один файл (`stored_file`) +→ один `job_id` → массив `documents` из `osvDocument` и/или `statementDocument`; денежные значения — строки +`^-?\d+(\.\d{1,2})?$`; `confidence` — 0–100. Пустой массив `documents` допустим только при `status = error`. +У строки `osvDocument.rows[]` SHALL быть необязательное поле `extra` — объект «заголовок колонки → строка или null» для +колонок внутри таблицы, не сопоставленных с шестью суммами и ИНН (§1.2 «не терять»). У шапки `osvDocument.header` SHALL +быть необязательные `period_start`, `period_end` (дата или null — границы периода по шапке; для квартала и года — +первое и последнее число, для диапазона месяцев — его границы) и `is_from_year_start` (boolean или null; `is_cumulative` +не переопределяется). `statementDocument.document_type` SHALL допускать `DISCLOSURE` (расшифровка или иная форма +0710xxx: документ без `columns` и `lines`). У `statementDocument` SHALL быть необязательный объект `checks`: +`relations_failed` и `soft_relations_failed` — массивы `{column_index, relation, expected, actual}` (нарушенные +соотношения §7 по колонке; `expected`/`actual` — `money`), `duplicate_line_codes` — массив строк; отсутствие объекта +означает «не проверялось», пустые массивы — «проверено, сошлось». Потребители, не знающие новых полей и значений, +игнорируют их (MINOR). Производитель v1.2 пишет `schema_version = "1.2"`; файлы v1.0 и v1.1 остаются валидными. + +#### Scenario: Результат валидируется примерами +- **WHEN** CI сервиса запускает тесты контракта +- **THEN** все файлы `docs/contracts/examples/result-*.json` проходят схему файла результата, а все + `request-*.json` / `result-message-*.json` — схемы сообщений + +#### Scenario: Лишняя колонка сохранена +- **WHEN** в ОСВ между ИНН и суммами есть колонка «КПП» +- **THEN** у строк контрагентов `extra = {"КПП": "<значение>"}`, файл проходит схему v1.2 и схему v1.0 (поле + игнорируется) + +#### Scenario: Форма из Excel с проверками +- **WHEN** tabular-parser разобрал книгу с балансом на двух листах, ОФР и пояснениями (`examples/result-statement-excel.json`) +- **THEN** файл проходит схему v1.2: `BALANCE` с `locator` из двух имён листов и `checks` с нарушением в + `soft_relations_failed`, `INCOME` с пустыми массивами `checks`, `DISCLOSURE` без `columns`/`lines`; тот же файл + без `checks` и с `document_type = OTHER` вместо `DISCLOSURE` проходит схему v1.1 + +#### Scenario: Границы периода ОСВ +- **WHEN** в шапке ОСВ период «Октябрь 2024 г. – Сентябрь 2025 г.» +- **THEN** `period_start = "2024-10-01"`, `period_end = "2025-09-30"`, `is_from_year_start = false`; файл проходит схему + v1.2, а потребитель v1.1 читает его без ошибок diff --git a/openspec/changes/slice-2b-statement-parser/tasks.md b/openspec/changes/slice-2b-statement-parser/tasks.md new file mode 100644 index 0000000..cbdb4ab --- /dev/null +++ b/openspec/changes/slice-2b-statement-parser/tasks.md @@ -0,0 +1,17 @@ +# Tasks: Срез 2b (сквозное) — контракт v1.2, §12, OQ (arm-ka-docs) + +Задачи выполняются в ветке `feat/26-slice-2b-statement-parser`; детализация — в плане superpowers вместе с дочерним +change tabular-parser. + +- [ ] 1. Контракт v1.2: `docs/contracts/recognition-result.schema.json` — `DISCLOSURE`, `statementDocument.checks`, + `osvDocument.header.period_start/period_end/is_from_year_start`, заголовок и описание «1.2» (design D-1); проверка + схемы валидатором JSON Schema на всех примерах. +- [ ] 2. Примеры: `result-statement-minimal.json` (+`checks`, «1.2»), новый `result-statement-excel.json`, + `result-osv-minimal.json` (границы периода) (design D-2). +- [ ] 3. Спека `openspec/specs/recognition-contract` синхронизируется из дельты при архивации; до этого — дельта в change. +- [ ] 4. `05-domain-rules.md` §12 — решения владельца и уточнения (design D-3); §7 — пометка жёстких/мягких соотношений. +- [ ] 5. `06-test-data.md` — раздел `statements-xlsx/**`, манифест, golden `expected_statement_parse/`, синтетика. +- [ ] 6. `03-architecture.md` — контракт 1.2, `BookParser`, порядок раскатки MINOR; `09-delivery-plan.md` — Срез 2b. +- [ ] 7. `07-open-questions.md` — новые OQ (единицы против копеек; форма без колонки кодов; страница-продолжение в + отдельном файле; строка без кода с суммами в ОФР ФСБУ; образец ФСБУ «АКТИВ»+1110), статус OQ-131. +- [ ] 8. `docs/dev/qa-guide.md` §6.7 — прогон парсера на формах; README `docs/contracts/` (если есть) — v1.2. -- 2.47.2 From ca8615eab10dfef85b677a08c67fa26c6d88367c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 13:54:04 +0400 Subject: [PATCH 02/12] =?UTF-8?q?plan:=20=D0=A1=D1=80=D0=B5=D0=B7=202b=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D0=B0=D1=80=D1=81=D0=B5=D1=80=20=D0=91=D0=91?= =?UTF-8?q?/=D0=9E=D0=A4=D0=A0=20=D0=B8=D0=B7=20Excel,=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=20v1.2=20(17=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit План superpowers по спеке change slice-2b-statement-parser: контракт v1.2 в docs/парсере/копиях, словари форм, columns/detect/header/lines/checks/merge, BookParser, границы периода ОСВ, синтетика st01…st05, манифест и golden, интеграция, документы, завершение. Refs #26 Co-Authored-By: Claude Fable 5.1 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- .../2026-10-06-slice-2b-statement-parser.md | 3585 +++++++++++++++++ 1 file changed, 3585 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-06-slice-2b-statement-parser.md diff --git a/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser.md b/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser.md new file mode 100644 index 0000000..2878e1b --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser.md @@ -0,0 +1,3585 @@ +# Срез 2b — парсер ББ и ОФР из Excel, контракт v1.2 — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** tabular-parser разбирает ББ (0710001) и ОФР (0710002) из книг Excel в `statementDocument` контракта v1.2 на всех 15 реальных файлах `fixtures/statements-xlsx/**` без правил под файл; контракт v1.2 раскатан на docs, arm-core, scan-gateway. + +**Architecture:** `BookParser` (книга → классификация листов → раздача) заменяет `OsvParser.parse`; `OsvParser` остаётся конвейером листа ОСВ; новый пакет `src/parser/statements/` (columns → detect → header → lines → checks → merge/statement) со словарями `rules/statement_*.yaml`, где соотношения §7 — данные. Контракт растёт MINOR-ом: `DISCLOSURE`, `statementDocument.checks`, границы периода ОСВ. + +**Tech Stack:** Python 3.11, pydantic v2, openpyxl/xlrd/pyxlsb (уже есть), PyYAML, pytest; Decimal для денег. + +**Spec:** `tabular-parser/openspec/changes/slice-2b-statement-parser/` (proposal, design D-1…D-11, specs `statement-parsing`, дельта `tabular-recognition-service`) и `docs/openspec/changes/slice-2b-statement-parser/` (design D-1…D-4, дельта `recognition-contract`). Исполнитель читает оба. + +## Рабочее окружение (читать до первой задачи) + +- Workspace `/Users/andrey.epifancev/projects/awp-ca` — не git. Worktree среза: `.wt/slice-2b/` = `tabular-parser` (ветка `feat/14-slice-2b-statement-parser`), `docs` (ветка `feat/26-slice-2b-statement-parser`), симлинки `fixtures`, `.claude`, `CLAUDE.md`. Для задачи 3 добавляются worktree `.wt/slice-2b/arm-core` и `.wt/slice-2b/scan-gateway` (создаёт задача 3). +- Все пути ниже — от `/Users/andrey.epifancev/projects/awp-ca/.wt/slice-2b/tabular-parser`, если не сказано иное; `T` = этот каталог, `API` = `T/api`, `DOCS` = `.wt/slice-2b/docs`, `FX` = `.wt/slice-2b/fixtures` (симлинк на основной `fixtures/` — для golden и синтетики создаётся worktree `feat/14-statements-golden`, см. задачу 14). +- Никаких `cd` в основные checkout'ы (`tabular-parser/`, `docs/`, `arm-core/` на main): хук guard-main смотрит на cwd. Команды: `make -C T `, `git -C T …`, pytest — `cd API && .venv/bin/pytest …` в одном Bash (cwd в worktree на ветке — допустимо) либо `API/.venv/bin/pytest -c API/pytest.ini --rootdir API …`. +- Перед задачей 1 выполнить один раз: `git -C T fetch origin && git -C T rebase origin/main` (на main к этому моменту должны быть PR агента A: #13, #15, #17 — план написан по их состоянию; если ещё не слиты, спросить владельца), `make -C T venv`, `codegraph init T`. +- Коммиты: русская тема (`feat(statements): …`, `test(statements): …`, `feat(contract): …`), тело `Refs #14`, трейлеры `Co-Authored-By: Claude <модель> ` и `Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b`. + +## Global Constraints + +- Никаких литералов координат в `src/parser/**` (кроме `grid.py`, `readers/`): букв колонок, адресов ячеек, имён листов, сравнений номера строки с числом — линтер `tests/unit/parser/test_no_coordinates.py` (spec «Словари форм, координаты, golden»). +- Словари — данные: синонимы, метки шапки, соотношения §7 — только `api/rules/statement_*.yaml`; код не содержит ни одного слова из формы (spec, design D-10). +- Деньги — `Decimal` через `numbers.to_money`; `float` в домене запрещён (config.yaml rules.design). +- Единицы не нормализуются; первичка не мутируется: `amount` как в ячейке (design D-6). +- Контракт MINOR 1.2: только необязательные поля и новые значения enum; `CONTRACT_VERSION = "1.2"`; копия `api/contracts/` байт в байт равна `docs/contracts/` (spec `tabular-recognition-service` «Копия контракта идентична источнику»). +- Порог колонки кодов 60 %, диапазоны ББ 1100–1799, ОФР 2100–2999, расшифровки 5000–5999, пятизначные — по первым четырём цифрам (design D-4, решение №3). +- Допуск соотношений ±1 в единицах формы, отсутствующий код = 0, двойной расчёт с `abs_terms` (design D-7, решение №7). +- Документы книги: ОСВ в порядке листов, затем формы в порядке листов (design D-1, решение №11). +- Golden правятся только `make golden-update` с просмотром diff и отдельным коммитом в fixtures. +- Модули ОСВ (`header.py`, `classify.py`, `checks.py`, `hierarchy.py`, `periods.yaml`, `osv_headers.yaml`) не правятся, кроме задачи 6b (границы периода в `header.py`/`osv.py`). + +## Review Focus + +1. **Код как число с дробной частью** («1110.0» из xlrd-float, Decimal `1110`): колонка кодов должна найтись. Тест — задача 7 (`test_code_from_numeric_cell`). +2. **ИНН аудитора в шапке 1С** (второй «ИНН» ниже строки «аудиторской организации»): в `header.inn` должен попасть ИНН организации. Тест — задача 9 (`test_inn_skips_auditor_block`). +3. **Расходы ОФР со знаком минус в 1С против скобок в 66н**: `2100 = 2110 − 2120` сходится в обоих написаниях. Тест — задача 11 (`test_relation_holds_with_abs_terms`). +4. **Лист-продолжение между двумя формами разного типа** (книга НПЗ: ОФР идёт после пяти листов баланса): продолжение присоединяется к последнему открытому документу, а не к первому. Тест — задача 12 (`test_continuation_joins_last_open_document`). +5. **Книга только из листов DISCLOSURE** (пояснения без ББ/ОФР): результат `done` с документами без строк, не `INVALID_DOCUMENT`. Тест — задача 12 (`test_disclosure_only_book_is_done`). + +--- + +### Task 1: Контракт v1.2 в docs — схема и примеры + +**Files:** +- Modify: `DOCS/contracts/recognition-result.schema.json` +- Modify: `DOCS/contracts/examples/result-statement-minimal.json`, `DOCS/contracts/examples/result-osv-minimal.json` +- Create: `DOCS/contracts/examples/result-statement-excel.json` +- Test: проверка валидатором из venv tabular-parser (jsonschema уже в зависимостях парсера: `API/.venv/bin/python -c "import jsonschema"`) + +**Interfaces:** +- Produces: схема v1.2 с `$defs.statementDocument.properties.checks`, `document_type.enum` + `DISCLOSURE`, `$defs.osvDocument.properties.header.properties.period_start/period_end/is_from_year_start`. + +- [ ] **Step 1: Написать проверочный скрипт (падает на старой схеме)** + +Создать `DOCS/contracts/examples/.check_v12.py`? Нет — временные файлы только в scratchpad. Скрипт — в `/private/tmp/claude-501/-Users-andrey-epifancev-projects-awp-ca/cedd7915-2efe-41e5-988c-a9117033598b/scratchpad/check_v12.py`: + +```python +import json, sys +from pathlib import Path +import jsonschema + +DOCS = Path(sys.argv[1]) +schema = json.loads((DOCS / "contracts/recognition-result.schema.json").read_text(encoding="utf-8")) +defs = schema["$defs"] +assert "DISCLOSURE" in defs["statementDocument"]["properties"]["document_type"]["enum"] +checks = defs["statementDocument"]["properties"]["checks"] +assert checks["type"] == "object" +assert set(checks["properties"]) == {"relations_failed", "soft_relations_failed", "duplicate_line_codes"} +head = defs["osvDocument"]["properties"]["header"]["properties"] +assert head["period_start"]["format"] == "date" and head["is_from_year_start"]["type"] == ["boolean", "null"] +assert schema["properties"]["schema_version"]["description"].endswith('"1.2"') +validator = jsonschema.Draft202012Validator(schema) +for example in sorted((DOCS / "contracts/examples").glob("result-*.json")): + if "message" in example.name: + continue + validator.validate(json.loads(example.read_text(encoding="utf-8"))) + print("ok", example.name) +``` + +- [ ] **Step 2: Запустить, убедиться, что падает на `DISCLOSURE`** + +Run: `API/.venv/bin/python -I /check_v12.py DOCS` +Expected: `AssertionError` на первой проверке. + +- [ ] **Step 3: Изменить схему** + +В `$defs.statementDocument.properties.document_type`: `"enum": ["BALANCE", "INCOME", "DISCLOSURE", "OTHER"]`. После `lines` добавить: + +```json +"checks": { + "type": "object", + "description": "Результаты самопроверки формы по соотношениям §7 (tabular-parser). Отсутствие объекта — «не проверялось» (OCR-путь); пустые массивы — «проверено, сошлось».", + "properties": { + "relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения жёстких соотношений: документ needs_check" }, + "soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения мягких соотношений (OQ-12, подстроки «в том числе»): только предупреждение" }, + "duplicate_line_codes": { "type": "array", "items": { "type": "string" }, "description": "коды строк, встретившиеся в документе больше одного раза (осталась первая строка)" } + } +} +``` + +В `$defs` добавить: + +```json +"failedRelation": { + "type": "object", + "required": ["column_index", "relation", "expected", "actual"], + "properties": { + "column_index": { "type": "integer" }, + "relation": { "type": "string", "examples": ["1600 = 1100 + 1200"] }, + "expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения как в файле" }, + "actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам" } + } +} +``` + +В `$defs.osvDocument.properties.header.properties` после `is_cumulative`: + +```json +"period_start": { "type": ["string", "null"], "format": "date", "description": "начало периода по шапке: первое число квартала/года либо начало диапазона месяцев (OQ-131)" }, +"period_end": { "type": ["string", "null"], "format": "date", "description": "конец периода по шапке" }, +"is_from_year_start": { "type": ["boolean", "null"], "description": "period_start — 1 января; is_cumulative не переопределяет" } +``` + +`title`: заменить «v1.1» на «v1.2»; `properties.schema_version.description`: «…производитель контракта v1.2 пишет "1.2"». `$comment` не трогать. + +- [ ] **Step 4: Примеры** + +`result-statement-minimal.json`: `"schema_version": "1.2"`; в документ после `lines` — `"checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] }`. +`result-osv-minimal.json`: `"schema_version": "1.2"`; в `header` после `is_cumulative` — `"period_start": "2025-01-01", "period_end": "2025-03-31", "is_from_year_start": true` (подобрать под `period_text` примера). +Новый `result-statement-excel.json`: + +```json +{ + "schema_version": "1.2", + "job_id": "3f1c7a2e-5b64-4d0a-9c1e-8a7b6d5e4f30", + "file_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "service": { "name": "tabular-parser", "version": "0.3.0" }, + "status": "needs_check", + "metrics": { "duration_ms": 412, "rows": 7, "warnings": ["sheet=Бухгалтерский баланс. Лист 1 значений с дробной частью при единице тыс. руб.: 3"] }, + "documents": [ + { + "document_type": "BALANCE", + "locator": "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2", + "header": { "form_name": "Бухгалтерский баланс", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "inn": "7700000000", "inn_confidence": 100, "report_date_text": "на 30 сентября 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710001", "okpo": "12345678" }, + "columns": [ + { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, + { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + ], + "lines": [ + { "line_code": "1110", "line_code_confidence": 100, "line_name": "Нематериальные активы", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "11101", "line_code_confidence": 100, "line_name": "Нематериальные активы, готовые к использованию", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "900000.00", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "1100", "line_code_confidence": 100, "line_name": "Итого по разделу I", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": null, "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": null, "confidence": 100, "is_recognized": true } ] }, + { "line_code": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] } + ], + "checks": { "relations_failed": [], "soft_relations_failed": [ { "column_index": 1, "relation": "1110 = 11101", "expected": "905786.99", "actual": "900000.00" } ], "duplicate_line_codes": [] } + }, + { + "document_type": "INCOME", + "locator": "sheet:Отчет о финансовых результатах", + "header": { "form_name": "Отчет о финансовых результатах", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "report_date_text": "за январь - сентябрь 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710002" }, + "columns": [ { "column_index": 0, "label": "За январь - сентябрь 2025 г.", "period_date": "2025-09-30" } ], + "lines": [ + { "line_code": "2110", "line_code_confidence": 100, "line_name": "Выручка", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "1000.00", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "2120", "line_code_confidence": 100, "line_name": "Себестоимость продаж", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "-600.00", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "2100", "line_code_confidence": 100, "line_name": "Валовая прибыль (убыток)", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "400.00", "confidence": 100, "is_recognized": true } ] } + ], + "checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] } + }, + { + "document_type": "DISCLOSURE", + "locator": "sheet:Пояснения", + "header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах" }, + "columns": [], + "lines": [] + } + ] +} +``` + +- [ ] **Step 5: Проверить** + +Run: `API/.venv/bin/python -I /check_v12.py DOCS` +Expected: `ok result-error.json`, `ok result-osv-minimal.json`, `ok result-osv-needs-check.json`, `ok result-statement-excel.json`, `ok result-statement-minimal.json`. + +- [ ] **Step 6: Commit (docs)** + +```bash +git -C DOCS add contracts +git -C DOCS commit -m "feat(contract): v1.2 — DISCLOSURE, statementDocument.checks, границы периода ОСВ, примеры + +Refs #26" +``` + +--- + +### Task 2: Контракт v1.2 в tabular-parser — модели, копия схемы, версия + +**Files:** +- Modify: `API/src/contract/result.py` (StatementDocument, OsvHeader, новые модели), `API/src/contract/common.py:30` (`CONTRACT_VERSION`) +- Copy: `DOCS/contracts/*.json` и `examples/*.json` → `API/contracts/` (cp, не править руками) +- Modify: `API/tests/result_contract.py`? — нет; тест примеров уже перебирает каталог. Обновить ожидания `"1.1"` → `"1.2"`: `grep -rn '"1\.1"' API/tests` (test_worker, test_roundtrip, test_cli и т. п.) +- Test: `API/tests/unit/test_contract_v12.py` + +**Interfaces:** +- Produces: `FailedRelation(column_index, relation, expected, actual)`, `StatementChecks(relations_failed, soft_relations_failed, duplicate_line_codes)`, `StatementDocument.checks: StatementChecks | None`, `StatementDocument.document_type` с `DISCLOSURE`, `OsvHeader.period_start/period_end: date | None`, `OsvHeader.is_from_year_start: bool | None`, `CONTRACT_VERSION == "1.2"`. + +- [ ] **Step 1: Тест** + +```python +# API/tests/unit/test_contract_v12.py +"""Контракт v1.2: новые необязательные поля и значение DISCLOSURE (docs change slice-2b design D-1).""" + +import datetime +from decimal import Decimal + +from src.contract.common import CONTRACT_VERSION +from src.contract.result import FailedRelation, OsvHeader, StatementChecks, StatementDocument, StatementHeader + + +def test_contract_version_is_1_2() -> None: + assert CONTRACT_VERSION == "1.2" + + +def test_statement_document_accepts_disclosure_and_checks() -> None: + document = StatementDocument( + document_type="DISCLOSURE", + locator="sheet:Пояснения", + header=StatementHeader(form_name="Пояснения"), + columns=[], + lines=[], + ) + assert document.checks is None + checked = StatementDocument.model_validate( + { + "document_type": "BALANCE", + "locator": "sheet:ББ", + "header": {}, + "columns": [], + "lines": [], + "checks": { + "relations_failed": [{"column_index": 0, "relation": "1600 = 1100 + 1200", "expected": "10.00", "actual": "9.00"}], + "soft_relations_failed": [], + "duplicate_line_codes": ["1110"], + }, + } + ) + assert checked.checks == StatementChecks( + relations_failed=[FailedRelation(column_index=0, relation="1600 = 1100 + 1200", expected=Decimal("10.00"), actual=Decimal("9.00"))], + soft_relations_failed=[], + duplicate_line_codes=["1110"], + ) + wire = checked.model_dump(mode="json", by_alias=True, exclude_none=True) + assert wire["checks"]["relations_failed"][0]["expected"] == "10.00" + + +def test_osv_header_period_bounds_are_optional_dates() -> None: + header = OsvHeader(organization_name="ООО", account_code_raw="60", period_text="1 квартал 2025 г.") + assert header.period_start is None and header.is_from_year_start is None + filled = OsvHeader.model_validate( + {"organization_name": "ООО", "account_code_raw": "60", "period_text": "x", "period_start": "2024-10-01", "period_end": "2025-09-30", "is_from_year_start": False} + ) + assert filled.period_start == datetime.date(2024, 10, 1) + assert filled.period_end == datetime.date(2025, 9, 30) +``` + +- [ ] **Step 2: Запустить — падает (`ImportError: FailedRelation`)** + +Run: `cd API && .venv/bin/pytest tests/unit/test_contract_v12.py -q` + +- [ ] **Step 3: Реализация** + +`API/src/contract/common.py:30`: `CONTRACT_VERSION = "1.2"`. +`API/src/contract/result.py`: в `OsvHeader` после `is_cumulative`: + +```python + period_start: ContractDate | None = None # начало периода по шапке (v1.2, OQ-131) + period_end: ContractDate | None = None + is_from_year_start: bool | None = None # period_start — 1 января; is_cumulative не переопределяет +``` + +Перед `StatementDocument`: + +```python +class FailedRelation(ContractModel): + """Нарушенное соотношение §7 по одной колонке: `expected` — левая часть как в файле, `actual` — правая по строкам.""" + + column_index: IntLike + relation: str # «1600 = 1100 + 1200» — текст из словаря statement_checks.yaml + expected: Money + actual: Money + + +class StatementChecks(ContractModel): + """Самопроверка формы (v1.2): отсутствие объекта — «не проверялось», пустые списки — «сошлось».""" + + relations_failed: list[FailedRelation] = Field(default_factory=list) + soft_relations_failed: list[FailedRelation] = Field(default_factory=list) + duplicate_line_codes: list[str] = Field(default_factory=list) +``` + +В `StatementDocument`: `document_type: Literal["BALANCE", "INCOME", "DISCLOSURE", "OTHER"]` и после `lines`: `checks: OptionalNotNull[StatementChecks] = None`. В `__all__` добавить `FailedRelation`, `StatementChecks`. Docstring модуля: «v1.2». + +Скопировать схемы: `cp DOCS/contracts/*.schema.json API/contracts/ && cp DOCS/contracts/examples/*.json API/contracts/examples/`. Обновить таблицу примеров в тесте контракта парсера, если она есть (`grep -n "result-statement-minimal" API/tests -r`): добавить `result-statement-excel.json`. + +Заменить ожидания версии: `grep -rn '"1\.1"' API/tests API/src` → в `test_worker`, `tests/integration/test_roundtrip.py:301`, docstrings — `"1.2"`. + +- [ ] **Step 4: Проверить** + +Run: `make -C T check` +Expected: зелёный, включая тест идентичности копии схемы и все примеры. + +- [ ] **Step 5: Commit** + +```bash +git -C T add api/src/contract api/contracts api/tests +git -C T commit -m "feat(contract): модели v1.2 — DISCLOSURE, StatementChecks, границы периода ОСВ; копия схемы + +Refs #14" +``` + +--- + +### Task 3: Копии схемы v1.2 в arm-core и scan-gateway (два bounded-PR без изменения поведения) + +**Files (arm-core, worktree `.wt/slice-2b/arm-core`, ветка `chore/contract-v1-2`):** +- Copy: `DOCS/contracts/**` → `arm-core/api/contracts/` +- Modify: `arm-core/api/src/contract/result.py` (те же правки, что в задаче 2: `OsvHeader` + 3 поля, `FailedRelation`, `StatementChecks`, `StatementDocument.document_type` + `DISCLOSURE`, `checks`), `arm-core/api/tests/test_contract.py` (таблица примеров: добавить `result-statement-excel.json`) +- Не менять: `CONTRACT_VERSION` ядра (ядро — потребитель), `services/result_mapping.py`. + +**Files (scan-gateway, worktree `.wt/slice-2b/scan-gateway`, ветка `chore/contract-v1-2`):** +- Copy: `DOCS/contracts/**` → `scan-gateway/api/contracts/` +- Modify: `scan-gateway/api/src/contract/result.py` (то же), `scan-gateway/api/tests/unit/test_contract.py` (таблица примеров) +- Не менять: `CONTRACT_VERSION = "1.0"` scan-gateway (он не пишет новых полей). + +- [ ] **Step 1: Worktree и маркер bounded** + +```bash +W=/Users/andrey.epifancev/projects/awp-ca +git -C $W/arm-core fetch origin && git -C $W/arm-core worktree add -b chore/contract-v1-2 $W/.wt/slice-2b/arm-core origin/main +git -C $W/scan-gateway fetch origin && git -C $W/scan-gateway worktree add -b chore/contract-v1-2 $W/.wt/slice-2b/scan-gateway origin/main +touch $W/.wt/slice-2b/arm-core/.bounded $W/.wt/slice-2b/scan-gateway/.bounded +make -C $W/.wt/slice-2b/arm-core venv; make -C $W/.wt/slice-2b/scan-gateway venv +``` + +- [ ] **Step 2: Тест (в каждом репозитории) — копия и модель принимают пример v1.2** + +В `arm-core/api/tests/test_contract.py` и `scan-gateway/api/tests/unit/test_contract.py` таблица примеров получает строку для `result-statement-excel.json` (как у `result-statement-minimal.json`). Запустить тесты контракта — падают: файла нет в `api/contracts/examples`, `DISCLOSURE` не в Literal. + +Run (arm-core): `cd $W/.wt/slice-2b/arm-core/api && .venv/bin/pytest tests/test_contract.py -q` +Run (scan-gateway): `cd $W/.wt/slice-2b/scan-gateway/api && .venv/bin/pytest tests/unit/test_contract.py -q` + +- [ ] **Step 3: Скопировать схемы и примеры, внести правки моделей (идентичные задаче 2, шаг 3, кроме `CONTRACT_VERSION`)** + +```bash +cp $W/.wt/slice-2b/docs/contracts/*.schema.json $W/.wt/slice-2b/arm-core/api/contracts/ +cp $W/.wt/slice-2b/docs/contracts/examples/*.json $W/.wt/slice-2b/arm-core/api/contracts/examples/ +cp $W/.wt/slice-2b/docs/contracts/*.schema.json $W/.wt/slice-2b/scan-gateway/api/contracts/ +cp $W/.wt/slice-2b/docs/contracts/examples/*.json $W/.wt/slice-2b/scan-gateway/api/contracts/examples/ +``` + +- [ ] **Step 4: Проверить — `make check` в обоих репозиториях зелёный** + +Для arm-core дополнительно: `cd $W/.wt/slice-2b/arm-core/api && .venv/bin/pytest -m "integration and not e2e" -k "mapping or contract" -q` (маппинг ОСВ по-прежнему читает только `osvDocument`; новые поля шапки игнорируются). + +- [ ] **Step 5: Commit, push, PR в каждом репозитории** + +```bash +git -C $W/.wt/slice-2b/arm-core add api/contracts api/src/contract api/tests/test_contract.py +git -C $W/.wt/slice-2b/arm-core commit -m "chore(contract): копия схемы v1.2 и модели (DISCLOSURE, checks, границы периода), без изменения логики + +Refs RSHB/arm-tabular-parser#14" +git -C $W/.wt/slice-2b/arm-core push -u origin chore/contract-v1-2 +tea pulls create --login home --repo RSHB/arm-core --base main --head chore/contract-v1-2 --title "chore(contract): копия схемы v1.2 (потребитель, без логики)" --description "Копия docs/contracts v1.2 + модели. Мержить после RSHB/arm-ka-docs#27 и до PR парсера RSHB/arm-tabular-parser#16. Refs RSHB/arm-tabular-parser#14." +``` + +То же для scan-gateway (`--repo RSHB/arm-scan-gateway`). + +--- + +### Task 4: Словари форм `rules/statement_*.yaml` и их модели + +**Files:** +- Create: `API/rules/statement_headers.yaml`, `API/rules/statement_periods.yaml`, `API/rules/statement_checks.yaml` +- Modify: `API/src/parser/rules.py` (модели `StatementHeaderRules`, `StatementPeriodRules`, `StatementCheckRules`, `RelationRule`, `Rules` + 3 поля, `load_rules`, константы имён файлов), `API/rules/README.md` +- Test: `API/tests/unit/parser/test_statement_rules.py` + +**Interfaces:** +- Produces: `Rules.statement_headers: StatementHeaderRules`, `Rules.statement_periods: StatementPeriodRules`, `Rules.statement_checks: StatementCheckRules`; `FormType = Literal["BALANCE", "INCOME", "DISCLOSURE"]`; `StatementHeaderRules.form_type(text) -> FormType | None`, `.okud_type(text) -> FormType | None`, `.is_continuation(text) -> bool`, `.is_code_caption(text)`, `.is_name_caption(text)`, `.is_explanations_caption(text)`, `.label(key, text) -> re.Match | None`, `.is_auditor_label(text)`, `.has_legal_form(text)`, `.code_ranges: dict[FormType, tuple[int, int]]`, `.code_min_share: float`, `.min_codes: int`; `StatementPeriodRules.period_end(text) -> date | None`, `.date_triple(day, month, year) -> date | None`; `StatementCheckRules.relations_for(form) -> list[RelationRule]`, `RelationRule(form, relation, strict, abs_terms, target: str, terms: tuple[tuple[int, str], ...])`, `StatementCheckRules.tolerance: Decimal`. + +- [ ] **Step 1: Тест** + +```python +# API/tests/unit/parser/test_statement_rules.py +"""Словари форм (design D-10): загрузка, обязательные ключи, разбор соотношений, периоды форм.""" + +import datetime +from decimal import Decimal +from pathlib import Path + +import pytest +import yaml + +from src.parser.rules import DEFAULT_RULES_DIR, STATEMENT_CHECKS_FILE, Rules, load_rules + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +def test_form_type_by_title_and_okud(rules: Rules) -> None: + headers = rules.statement_headers + assert headers.form_type("Бухгалтерский баланс") == "BALANCE" + assert headers.form_type("ОТЧЕТ О ФИНАНСОВЫХ РЕЗУЛЬТАТАХ") == "INCOME" + assert headers.form_type("Отчет о прибылях и убытках") == "INCOME" + assert headers.form_type("Пояснения к бухгалтерскому балансу") == "DISCLOSURE" + assert headers.form_type("Бухгалтерская отчетность") is None # титул — не тип + assert headers.okud_type("Форма по ОКУД 0710001") == "BALANCE" + assert headers.okud_type("0710002") == "INCOME" + assert headers.okud_type("0710004") == "DISCLOSURE" + assert headers.okud_type("0710001") == "BALANCE" + + +def test_continuation_marker_and_captions(rules: Rules) -> None: + headers = rules.statement_headers + assert headers.is_continuation("Форма 0710001 с.2") + assert headers.is_continuation("Форма 0710002 с. 2") + assert not headers.is_continuation("Бухгалтерский баланс") + assert headers.is_code_caption("Код") and headers.is_code_caption("Коды") + assert not headers.is_code_caption("Код строки по ОКУД 0710001") + assert headers.is_name_caption("Наименование показателя") + assert headers.is_explanations_caption("Пояснения") + assert headers.code_ranges == {"BALANCE": (1100, 1799), "INCOME": (2100, 2999), "DISCLOSURE": (5000, 5999)} + assert headers.code_min_share == 0.6 and headers.min_codes == 3 + + +def test_header_labels(rules: Rules) -> None: + headers = rules.statement_headers + assert headers.label("organization", "Организация:") is not None + assert headers.label("organization", "Организация (орган исполнительной власти)") is not None + assert headers.label("inn", "Идентификационный номер налогоплательщика") is not None + assert headers.label("inn", "ИНН") is not None + assert headers.label("units", "Единица измерения:") is not None + assert headers.label("date", "Дата (число, месяц, год)") is not None + assert headers.label("date", "Отчетная дата\n(число, месяц, год)") is not None + assert headers.label("address", "Местонахождение (адрес)") is not None + assert headers.label("address", "Адрес в пределах места нахождения") is not None + assert headers.label("okopf_okfs", "по ОКОПФ / ОКФС") is not None + assert headers.is_auditor_label("Наименование аудиторской организации") + assert headers.has_legal_form('ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"') and not headers.has_legal_form("Коды") + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("На 31 декабря 2024 г.", datetime.date(2024, 12, 31)), + ("на 30 сентября 2025 г.", datetime.date(2025, 9, 30)), + ("На 31 декабря \n2023 года", datetime.date(2023, 12, 31)), + ("31.12.2024", datetime.date(2024, 12, 31)), + ("За 9 месяцев 2025 г.", datetime.date(2025, 9, 30)), + ("За 9 месяцев\n2025 года", datetime.date(2025, 9, 30)), + ("За Январь - Декабрь 2024 г.", datetime.date(2024, 12, 31)), + ("за январь - сентябрь 2025 г.", datetime.date(2025, 9, 30)), + ("За 2024 г.", datetime.date(2024, 12, 31)), + ("2024", datetime.date(2024, 12, 31)), + ("Изменение, %", None), + ("Пояснения", None), + ], +) +def test_period_end(rules: Rules, text: str, expected: datetime.date | None) -> None: + assert rules.statement_periods.period_end(text) == expected + + +def test_date_triple(rules: Rules) -> None: + assert rules.statement_periods.date_triple("31", "12", "2024") == datetime.date(2024, 12, 31) + assert rules.statement_periods.date_triple("30", "9", "2025") == datetime.date(2025, 9, 30) + assert rules.statement_periods.date_triple("", "12", "2024") is None + + +def test_relations_parsed_into_terms(rules: Rules) -> None: + checks = rules.statement_checks + balance = {rule.relation: rule for rule in checks.relations_for("BALANCE")} + rule = balance["1600 = 1100 + 1200"] + assert rule.strict and rule.target == "1600" and rule.terms == ((1, "1100"), (1, "1200")) + income = {rule.relation: rule for rule in checks.relations_for("INCOME")} + gross = income["2100 = 2110 - 2120"] + assert gross.terms == ((1, "2110"), (-1, "2120")) and gross.abs_terms == ("2120",) + assert not income["2400 = 2300 - 2410 + 2420"].strict + assert checks.tolerance == Decimal("1") + assert checks.relations_for("DISCLOSURE") == [] + + +def test_bad_relation_rejected(tmp_path: Path) -> None: + for name in DEFAULT_RULES_DIR.glob("*.yaml"): + (tmp_path / name.name).write_bytes(name.read_bytes()) + broken = yaml.safe_load((tmp_path / STATEMENT_CHECKS_FILE).read_text(encoding="utf-8")) + broken["relations"].append({"form": "BALANCE", "relation": "1600 = 1100 +", "strict": True}) + (tmp_path / STATEMENT_CHECKS_FILE).write_text(yaml.safe_dump(broken, allow_unicode=True), encoding="utf-8") + with pytest.raises(RuntimeError, match="statement_checks.yaml"): + load_rules(tmp_path) +``` + +- [ ] **Step 2: Запустить — падает (`ImportError: STATEMENT_CHECKS_FILE`)** + +Run: `cd API && .venv/bin/pytest tests/unit/parser/test_statement_rules.py -q` + +- [ ] **Step 3: Словари** + +`API/rules/statement_headers.yaml`: + +```yaml +# Словарь форм ББ/ОФР (05-domain-rules.md §12): заголовки форм и ОКУД, маркер продолжения, подписи колонок, метки шапки. +# Правила записи — как в osv_headers.yaml: паттерн ищется в НОРМАЛИЗОВАННОМ тексте (нижний регистр, «е» вместо «ё», +# дефисы/тире/переносы -> пробел), без учёта регистра; новый синоним = строка в YAML. + +# Заголовок формы -> тип документа. Титул «Бухгалтерская отчетность» (НПЗ) типом не является и здесь не перечислен. +form_titles: + BALANCE: + - 'бухгалтерский\s+баланс' + INCOME: + - 'отчет\s+о\s+финансовых\s+результатах' + - 'отчет\s+о\s+прибылях\s+и\s+убытках' + DISCLOSURE: + - 'пояснени\w*\s+к\s+бухгалтерскому\s+балансу' + - '^расшифровк' + - 'отчет\s+об\s+изменениях\s+капитала' + - 'отчет\s+о\s+движении\s+денежных\s+средств' + - 'отчет\s+о\s+целевом\s+использовании' + +# Код ОКУД -> тип (ячейка «Форма по ОКУД 0710001» или сам код). Прочие формы 0710xxx — расшифровки (design D-3, решение №2) +okud: + BALANCE: + - '\b0710001\b' + INCOME: + - '\b0710002\b' + DISCLOSURE: + - '\b07100(0[3-9]|[1-9]\d)\b' + +# Маркер страницы-продолжения в 1С-выгрузке: «Форма 0710001 с.2» +continuation: + - '\bформа\s+0710\d{3}\s+с\.?\s*\d+' + +# Подписи колонок заголовка таблицы: ячейка целиком +code_caption: + - '^код(ы)?$' +name_caption: + - 'наименование\s+показателя' +explanations_caption: + - '^пояснени' +# Строка нумерации колонок под шапкой («1 | 2 | 3 | 4 | 5»): каждая непустая ячейка — одно-двузначное число +column_numbering: '^\d{1,2}$' + +# Метки шапки документа (значение — правее в той же строке либо в следующей строке) +labels: + organization: + - '^организация\b' + inn: + - '^инн\b' + - 'идентификационный\s+номер\s+налогоплательщика' + okpo: + - 'по\s+окпо' + okved: + - 'по\s+оквэд' + okopf: + - 'по\s+окопф\b(?!\s*/)' + okfs: + - 'по\s+окфс' + okopf_okfs: + - 'по\s+окопф\s*/\s*окфс' + okei: + - 'по\s+океи' + units: + - 'единица\s+измерения' + address: + - 'местонахождение\s*\(адрес\)' + - 'адрес\s+в\s+пределах\s+места\s+нахождения' + - '^адрес\b' + date: + - '^(отчетная\s+)?дата\b' + okud_label: + - 'форма\s+по\s+окуд' + +# Строки блока аудитора: ИНН ниже этой метки — ИНН аудитора, не организации +auditor: + - 'аудитор' + +# Признак, что строка содержит название организации (для имени над меткой «Организация», НПЗ) +legal_form: + - '\b(ооо|ао|пао|зао|оао|нао|ип|гуп|муп|фгуп)\b' + - '"' + - '«' + +# Коды строк форм: диапазоны четырёхзначных кодов (пятизначные — по первым четырём цифрам), доля кодов в колонке, +# минимум кодов, чтобы колонка считалась колонкой кодов +line_codes: + ranges: + BALANCE: [1100, 1799] + INCOME: [2100, 2999] + DISCLOSURE: [5000, 5999] + min_share: 0.6 + min_codes: 3 +``` + +`API/rules/statement_periods.yaml`: + +```yaml +# Периоды форм ББ/ОФР: ячейка заголовка колонки или подзаголовок формы -> дата (конец периода). §12 п. 4. +# Именованные группы обязательны: d (день), m (месяц числом или названием), y (год), n (число месяцев). +months: '(?Pянвар)|(?Pфеврал)|(?Pмарт)|(?Pапрел)|(?Pма[йя])|(?Pиюн)|(?Pиюл)|(?Pавгуст)|(?Pсентябр)|(?Pоктябр)|(?Pноябр)|(?Pдекабр)' +# «На 31 декабря 2024 г.», «на 30 сентября 2025 г.», «31 декабря 2023 года» (без «на» — подзаголовок формы) +on_date: '(?P\d{1,2})\s+(?Pянвар\w*|феврал\w*|март\w*|апрел\w*|ма[йя]\w*|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)\s+(?P(19|20)\d{2})' +# «31.12.2024», «30.09.2025 г.» +numeric_date: '(?P\d{1,2})\.(?P\d{1,2})\.(?P(19|20)\d{2})' +# «За 9 месяцев 2025 г.», «за 6 мес. 2025» +for_months: '\b(?P\d{1,2})\s*мес\w*\.?\s*(?P(19|20)\d{2})' +# «За Январь - Декабрь 2024 г.», «за январь сентябрь 2025 г.» (дефис уже заменён пробелом) +for_range: '(?Pянвар\w*|феврал\w*|март\w*|апрел\w*|ма[йя]\w*|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)\s+(по\s+)?(?Pянвар\w*|феврал\w*|март\w*|апрел\w*|ма[йя]\w*|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)\s+(?P(19|20)\d{2})' +# «За 2024 г.», «2024 год», ячейка «2024» +for_year: '(^|\bза\s+)(?P(19|20)\d{2})(\s*(г\b|год)|\s*$)' +``` + +`API/rules/statement_checks.yaml`: + +```yaml +# Соотношения §7 (05-domain-rules.md), ББ и ОФР. Данные, не код: новое соотношение — строка здесь. +# relation: «<код> = <код> [+|-] <код> …», только четырёхзначные коды; strict: нарушение переводит документ в needs_check +# (false — только предупреждение, ASSUMPTION(OQ-12)); abs_terms: коды, которые 1С выгружает со знаком минус, а 66н — в +# скобках: правая часть считается и как записано, и с этими членами по модулю (design D-7). +tolerance: 1 # допуск в единицах формы (тыс. руб. округляются построчно) +subline_check: true # «сумма пятизначных подстрок = родитель» — мягкая проверка, генерируется по коду +relations: + - {form: BALANCE, relation: "1100 = 1105 + 1110 + 1120 + 1130 + 1140 + 1150 + 1160 + 1170 + 1180 + 1190", strict: true} + - {form: BALANCE, relation: "1200 = 1210 + 1215 + 1220 + 1230 + 1240 + 1250 + 1260", strict: true} + - {form: BALANCE, relation: "1300 = 1310 + 1320 + 1340 + 1350 + 1360 + 1370", strict: true} + - {form: BALANCE, relation: "1400 = 1410 + 1420 + 1430 + 1450", strict: true} + - {form: BALANCE, relation: "1500 = 1510 + 1520 + 1530 + 1540 + 1550", strict: true} + - {form: BALANCE, relation: "1600 = 1100 + 1200", strict: true} + - {form: BALANCE, relation: "1700 = 1300 + 1400 + 1500", strict: true} + - {form: BALANCE, relation: "1600 = 1700", strict: true} + - {form: INCOME, relation: "2100 = 2110 - 2120", strict: true, abs_terms: ["2120"]} + - {form: INCOME, relation: "2200 = 2100 - 2210 - 2220", strict: true, abs_terms: ["2210", "2220"]} + - {form: INCOME, relation: "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350", strict: true, abs_terms: ["2330", "2350"]} + - {form: INCOME, relation: "2400 = 2300 - 2410 + 2420", strict: false, abs_terms: ["2410"]} + - {form: INCOME, relation: "2400 = 2300 + 2410 + 2460", strict: false} + - {form: INCOME, relation: "2410 = 2411 + 2412", strict: false} + - {form: INCOME, relation: "2500 = 2400 + 2510 + 2520 + 2530", strict: false} +``` + +- [ ] **Step 4: Модели в `rules.py`** + +Добавить после `DataTypeRules`: + +```python +STATEMENT_HEADERS_FILE = "statement_headers.yaml" +STATEMENT_PERIODS_FILE = "statement_periods.yaml" +STATEMENT_CHECKS_FILE = "statement_checks.yaml" + +FormType = Literal["BALANCE", "INCOME", "DISCLOSURE"] +StatementLabel = Literal[ + "organization", "inn", "okpo", "okved", "okopf", "okfs", "okopf_okfs", "okei", "units", "address", "date", "okud_label" +] +_RELATION = re.compile(r"^\s*(?P\d{4})\s*=\s*(?P\d{4}(\s*[+\-]\s*\d{4})*)\s*$") +_TERM = re.compile(r"([+\-])?\s*(\d{4})") + + +class _LineCodeRules(_Model): + ranges: dict[FormType, tuple[int, int]] + min_share: float + min_codes: int + + @model_validator(mode="after") + def _check(self) -> "_LineCodeRules": + if set(self.ranges) != set(get_args(FormType)): + raise ValueError(f"line_codes.ranges: нужны все формы {get_args(FormType)}") + if not 0 < self.min_share <= 1 or self.min_codes < 1: + raise ValueError("line_codes: min_share в (0, 1], min_codes ≥ 1") + return self + + +class StatementHeaderRules(_Model): + form_titles: dict[FormType, list[Pattern]] + okud: dict[FormType, list[Pattern]] + continuation: list[Pattern] + code_caption: list[Pattern] + name_caption: list[Pattern] + explanations_caption: list[Pattern] + column_numbering: Pattern + labels: dict[StatementLabel, list[Pattern]] + auditor: list[Pattern] + legal_form: list[Pattern] + line_codes: _LineCodeRules + + @model_validator(mode="after") + def _check_required_keys(self) -> "StatementHeaderRules": + for field_name, expected in ( + ("form_titles", set(get_args(FormType))), + ("okud", set(get_args(FormType))), + ("labels", set(get_args(StatementLabel))), + ): + missing = expected - set(getattr(self, field_name)) + if missing: + raise ValueError(f"{field_name}: нет обязательных ключей {sorted(missing)}") + return self + + def form_type(self, text: str) -> FormType | None: + return first_match(((pattern, kind) for kind, patterns in self.form_titles.items() for pattern in patterns), text) + + def okud_type(self, text: str) -> FormType | None: + return first_match(((pattern, kind) for kind, patterns in self.okud.items() for pattern in patterns), text) + + def is_continuation(self, text: str) -> bool: + return any(pattern.search(text) for pattern in self.continuation) + + def is_code_caption(self, text: str) -> bool: + return any(pattern.search(text) for pattern in self.code_caption) + + def is_name_caption(self, text: str) -> bool: + return any(pattern.search(text) for pattern in self.name_caption) + + def is_explanations_caption(self, text: str) -> bool: + return any(pattern.search(text) for pattern in self.explanations_caption) + + def label(self, key: StatementLabel, text: str) -> re.Match[str] | None: + for pattern in self.labels[key]: + found = pattern.search(text) + if found is not None: + return found + return None + + def is_auditor_label(self, text: str) -> bool: + return any(pattern.search(text) for pattern in self.auditor) + + def has_legal_form(self, text: str) -> bool: + return any(pattern.search(text) for pattern in self.legal_form) + + @property + def code_ranges(self) -> dict[FormType, tuple[int, int]]: + return dict(self.line_codes.ranges) + + @property + def code_min_share(self) -> float: + return self.line_codes.min_share + + @property + def min_codes(self) -> int: + return self.line_codes.min_codes + + +class StatementPeriodRules(_Model): + months: Pattern + on_date: Pattern + numeric_date: Pattern + for_months: Pattern + for_range: Pattern + for_year: Pattern + + @model_validator(mode="after") + def _check_groups(self) -> "StatementPeriodRules": + if self.months.group_names != _MONTH_GROUPS: + raise ValueError("months: ожидаются именованные группы m01…m12") + for name, pattern, groups in ( + ("on_date", self.on_date, {"d", "m", "y"}), + ("numeric_date", self.numeric_date, {"d", "m", "y"}), + ("for_months", self.for_months, {"n", "y"}), + ("for_range", self.for_range, {"m", "y"}), + ("for_year", self.for_year, {"y"}), + ): + missing = groups - pattern.group_names + if missing: + raise ValueError(f"{name}: нет именованных групп {sorted(missing)}") + return self + + def _month(self, text: str) -> int | None: + if text.isdigit(): + number = int(text) + return number if 1 <= number <= _MONTHS_IN_YEAR else None + found = self.months.search(text) + return None if found is None or found.lastgroup is None else int(found.lastgroup.removeprefix("m")) + + def date_triple(self, day: str, month: str, year: str) -> datetime.date | None: + """Дата из трёх ячеек «число | месяц | год» (строки или числа как текст); невалидная — None.""" + month_number = self._month(month.strip()) if month.strip() else None + if not day.strip().isdigit() or month_number is None or not year.strip().isdigit(): + return None + try: + return datetime.date(int(year), month_number, int(day)) + except ValueError: + return None + + def period_end(self, text: str) -> datetime.date | None: + """Дата колонки/подзаголовка: «на <дата>» — она сама; «за N месяцев/за январь–март/за год» — конец периода.""" + found = self.on_date.search(text) or self.numeric_date.search(text) + if found is not None: + return self.date_triple(found.group("d"), found.group("m"), found.group("y")) + found = self.for_months.search(text) + if found is not None: + return _month_end(int(found.group("y")), int(found.group("n"))) + found = self.for_range.search(text) + if found is not None: + month = self._month(found.group("m")) + return None if month is None else _month_end(int(found.group("y")), month) + found = self.for_year.search(text) + if found is not None: + return datetime.date(int(found.group("y")), _MONTHS_IN_YEAR, 31) + return None + + +def _month_end(year: int, month: int) -> datetime.date | None: + if not 1 <= month <= _MONTHS_IN_YEAR: + return None + last = calendar.monthrange(year, month)[1] + return datetime.date(year, month, last) + + +class RelationRule(_Model): + form: FormType + relation: str + strict: bool = True + abs_terms: tuple[str, ...] = () + + @property + def target(self) -> str: + return _parse_relation(self.relation)[0] + + @property + def terms(self) -> tuple[tuple[int, str], ...]: + return _parse_relation(self.relation)[1] + + @model_validator(mode="after") + def _check(self) -> "RelationRule": + target, terms = _parse_relation(self.relation) + codes = {code for _, code in terms} + unknown = set(self.abs_terms) - codes + if unknown: + raise ValueError(f"abs_terms {sorted(unknown)} нет в правой части «{self.relation}»") + if target in codes: + raise ValueError(f"«{self.relation}»: код слева встречается справа") + return self + + +def _parse_relation(relation: str) -> tuple[str, tuple[tuple[int, str], ...]]: + found = _RELATION.match(relation) + if found is None: + raise ValueError(f"соотношение «{relation}»: ожидается «<код> = <код> [+|-] <код> …» из четырёхзначных кодов") + terms = tuple((-1 if sign == "-" else 1, code) for sign, code in _TERM.findall(found.group("rhs"))) + return found.group("target"), terms + + +class StatementCheckRules(_Model): + tolerance: Decimal + subline_check: bool = True + relations: list[RelationRule] + + def relations_for(self, form: str) -> list[RelationRule]: + return [rule for rule in self.relations if rule.form == form] +``` + +Импорты вверху: `import calendar`, `import datetime`, `from decimal import Decimal`. `_MONTHS_IN_YEAR` и `_MONTH_GROUPS` уже есть (ветка #11). В `Rules` добавить поля `statement_headers`, `statement_periods`, `statement_checks`; в `load_rules` — три `_load(...)`. `__all__` дополнить. + +`Pattern.group_names` — есть в ветке #11 (используется `PeriodRules`); если нет — добавить `@property def group_names(self) -> frozenset[str]: return frozenset(self._regex.groupindex)`. + +- [ ] **Step 5: Запустить тест и весь `make check` (старые словари и тесты не задеты)** + +Run: `cd API && .venv/bin/pytest tests/unit/parser/test_statement_rules.py tests/unit/parser/test_rules.py -q && cd .. && make check` +Expected: PASS. Внимание: `tests/fixtures/test_rules_coverage.py` теперь увидит новые словари через `iter_patterns`? Нет — `DICTIONARIES` в `tests/rules_coverage.py` перечисляет четыре файла, новые добавятся в задаче 14. `test_coverage_sees_every_dictionary_entry_and_all_files` сравнивает только перечисленные. + +- [ ] **Step 6: README словарей** + +В `API/rules/README.md` в таблицу добавить три строки: `statement_headers.yaml` (`form_titles`, `okud`, `continuation`, `code_caption`, `name_caption`, `explanations_caption`, `column_numbering`, `labels`, `auditor`, `legal_form`, `line_codes`), `statement_periods.yaml` (`months`, `on_date`, `numeric_date`, `for_months`, `for_range`, `for_year`), `statement_checks.yaml` (`tolerance`, `subline_check`, `relations[].form/relation/strict/abs_terms`) с описанием «что описывает» из комментариев YAML. + +- [ ] **Step 7: Commit** + +```bash +git -C T add api/rules api/src/parser/rules.py api/tests/unit/parser/test_statement_rules.py +git -C T commit -m "feat(statements): словари форм — заголовки и ОКУД, метки шапки, периоды, соотношения §7 как данные + +Refs #14" +``` + +--- + +### Task 5: `statements/columns.py` — колонка кодов, заголовок таблицы, колонки-периоды + +**Files:** +- Create: `API/src/parser/statements/__init__.py` (пустой docstring пакета), `API/src/parser/statements/columns.py` +- Test: `API/tests/unit/parser/statements/__init__.py` (пустой), `API/tests/unit/parser/statements/test_columns.py` + +**Interfaces:** +- Consumes: `Sheet`, `Cell` (`src.parser.grid`), `Rules.statement_headers`, `Rules.statement_periods` (задача 4). +- Produces: `line_code(cell, rules) -> str | None`; `find_code_column(sheet, rules, first_row, last_row) -> int | None`; `PeriodColumn(col, label, period_date)`; `TableLayout(header_row, header_last_row, code_col, name_col, periods, skipped_labels, first_data_row)`; `find_layout(sheet, rules, first_row, last_row) -> TableLayout`; `LayoutNotFoundError(reason)`; `is_numbering_row(sheet, row, rules) -> bool`; `cell_label(sheet, row, col) -> str` (текст с переносами, схлопнутыми в пробел). + +- [ ] **Step 1: Тест** + +```python +# API/tests/unit/parser/statements/test_columns.py +"""Заголовок таблицы формы, колонка кодов, наименований и периодов (design D-4; spec «Заголовок таблицы, колонка кодов +и колонки-периоды определяются по тексту»).""" + +import datetime +from decimal import Decimal + +import pytest +from gridbuilder import M, at, sheet_from_rows + +from src.parser.grid import Cell +from src.parser.rules import Rules, load_rules +from src.parser.statements.columns import LayoutNotFoundError, find_code_column, find_layout, line_code + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +def test_line_code_from_text_and_numeric_cells(rules: Rules) -> None: + assert line_code(Cell("1110", "text"), rules) == "1110" + assert line_code(Cell(Decimal("1110"), "number"), rules) == "1110" + assert line_code(Cell(Decimal("1110.0"), "number"), rules) == "1110" # Review Focus 1: xlrd даёт float + assert line_code(Cell("11101", "text"), rules) == "11101" + assert line_code(Cell("2110", "text"), rules) == "2110" + assert line_code(Cell("5200", "text"), rules) == "5200" + assert line_code(Cell("2023", "text"), rules) is None # год, не код + assert line_code(Cell("0710001", "text"), rules) is None + assert line_code(Cell("", "empty"), rules) is None + + +ONE_C_BALANCE = [ + ["Бухгалтерский баланс"], + ["на 31 декабря 2024 г."], + [], + ["Пояснения", "Наименование показателя", None, "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г.", "Изменение, %"], + [None, "АКТИВ"], + [None, "Нематериальные активы", None, "1110", 5971264, 27405634, 12], + [None, "Нематериальные активы в организации", None, "11101", 5852464, 27214614, 0], + [None, None, None, None, 0, 0, 0], + ["1", "Основные средства", None, "1150", 2039077663, 1809528568, 7], + [None, "Итого по разделу I", None, "1100", 2045048927, 1836934202, 7], +] + + +def test_code_column_by_share(rules: Rules) -> None: + sheet = sheet_from_rows(ONE_C_BALANCE) + + assert find_code_column(sheet, rules, 1, sheet.n_rows) == 4 + + +def test_code_column_needs_three_codes_and_sixty_percent(rules: Rules) -> None: + sheet = sheet_from_rows([["x", "1110"], ["y", "abc"], ["z", "def"], ["w", "ghi"], ["v", "1150"]]) + + assert find_code_column(sheet, rules, 1, 5) is None + + +def test_layout_one_c(rules: Rules) -> None: + sheet = sheet_from_rows(ONE_C_BALANCE) + + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + assert (layout.header_row, layout.header_last_row, layout.first_data_row) == (4, 4, 5) + assert layout.code_col == 4 and layout.name_col == 2 + assert [(column.col, column.label, column.period_date) for column in layout.periods] == [ + (5, "На 31 декабря 2024 г.", datetime.date(2024, 12, 31)), + (6, "На 31 декабря 2023 г.", datetime.date(2023, 12, 31)), + ] + assert layout.skipped_labels == ("Изменение, %",) + + +def test_layout_tdsheet_with_numbering_row_and_line_breaks(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + ["БУХГАЛТЕРСКИЙ БАЛАНС"], + ["Наименование показателя", "Коды", "За 9 месяцев\n2025 года", "На 31 декабря \n2024 года"], + ["1", "2", "3", "4"], + ["Нематериальные активы", "1110", 0, 0], + ["Основные средства", "1150", Decimal("898852.21795"), 966270], + ["ИТОГО по разделу I", "1100", Decimal("898852.21795"), 966270], + ] + ) + + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + assert (layout.header_row, layout.header_last_row, layout.first_data_row) == (2, 3, 4) + assert layout.name_col == 1 and layout.code_col == 2 + assert [column.label for column in layout.periods] == ["За 9 месяцев 2025 года", "На 31 декабря 2024 года"] + assert layout.periods[0].period_date == datetime.date(2025, 9, 30) + + +def test_layout_two_row_header(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + ["Наименование показателя", "Код", "На 31 декабря", "На 31 декабря"], + [None, None, "2024 г.", "2023 г."], + ["Запасы", "1210", 10, 20], + ["Дебиторская задолженность", "1230", 30, 40], + ["Итого по разделу II", "1200", 40, 60], + ] + ) + + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + assert (layout.header_row, layout.header_last_row, layout.first_data_row) == (1, 2, 3) + assert [column.period_date for column in layout.periods] == [datetime.date(2024, 12, 31), datetime.date(2023, 12, 31)] + + +def test_layout_without_code_caption(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + ["Показатель", None, "На 31.12.2024", "На 31.12.2023"], + ["Основные средства", "1150", 100, 90], + ["Запасы", "1210", 50, 40], + ["БАЛАНС", "1600", 150, 130], + ] + ) + + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + assert layout.header_row == 1 and layout.code_col == 2 and layout.name_col == 1 + assert [column.period_date for column in layout.periods] == [datetime.date(2024, 12, 31), datetime.date(2023, 12, 31)] + + +def test_layout_continuation_without_header(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + [None, None, None, None, "в том числе:"], + [None, None, None, None, "Биологические активы", "11901", 0, 0, 0], + [None, None, None, None, "Итого по разделу I", "1100", 11853698344, 11570097340, 9391157985], + [None, None, None, "II. Оборотные активы"], + [None, None, None, "Запасы", None, "1210", 1001705104, 1426756617, 3393013092], + ] + ) + + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + assert layout.header_row is None and layout.first_data_row == 1 + assert layout.code_col == 6 + assert [(column.col, column.label, column.period_date) for column in layout.periods] == [(7, "", None), (8, "", None), (9, "", None)] + + +def test_explanations_column_is_not_name(rules: Rules) -> None: + sheet = sheet_from_rows(ONE_C_BALANCE) + + assert find_layout(sheet, rules, 1, sheet.n_rows).name_col == 2 + + +def test_no_code_column_raises(rules: Rules) -> None: + sheet = sheet_from_rows([["Показатель", "Сумма"], ["Основные средства", 100], ["Запасы", 50]]) + + with pytest.raises(LayoutNotFoundError, match="колонка кодов не найдена"): + find_layout(sheet, rules, 1, 3) + + +def test_bare_year_in_title_block_is_not_a_header(rules: Rules) -> None: + """У НПЗ над таблицей стоит «Отчетная дата | 30 | 09 | 2025»: строка с подписью «Код» побеждает.""" + sheet = sheet_from_rows( + [ + [*at({6: "Отчетная дата", 22: "30", 24: "09", 25: "2025"})], + [*at({4: "Наименование показателя", 12: "Код", 15: "На 30 сентября 2025 г.", 20: "На 31 декабря 2024 г."})], + [*at({4: "Гудвил", 12: "1105", 15: 0, 20: 0})], + [*at({4: "Нематериальные активы", 12: "1110", 15: 586260, 20: 905786})], + [*at({4: "Итого по разделу I", 12: "1100", 15: 586260, 20: 905786})], + ] + ) + + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + assert layout.header_row == 2 and [column.col for column in layout.periods] == [15, 20] +``` + +- [ ] **Step 2: Запустить — падает (`ModuleNotFoundError: src.parser.statements`)** + +Run: `cd API && .venv/bin/pytest tests/unit/parser/statements/test_columns.py -q` + +- [ ] **Step 3: Реализация** + +```python +# API/src/parser/statements/__init__.py +"""Конвейер форм ББ/ОФР (Срез 2b, spec `statement-parsing`): columns → detect → header → lines → checks → merge/statement. + +Как и в ОСВ, структура ищется по содержимому: синонимы и соотношения §7 — `rules/statement_*.yaml`. +""" +``` + +```python +# API/src/parser/statements/columns.py +"""Заголовок таблицы формы, колонка кодов, колонка наименований, колонки-периоды (design D-4). + +Колонка кодов — та, где не меньше `line_codes.min_share` непустых значений блока — коды строк форм; строка заголовка +— с подписью «Код» и ячейками-периодами правее; заголовок в две строки склеивается; строка нумерации «1 2 3 4 5» +пропускается. Ячейки заголовка без периода («Изменение, %») в колонки не попадают. +""" + +import re +from dataclasses import dataclass +from datetime import date + +from src.parser.grid import Cell, Sheet +from src.parser.rules import Rules + +__all__ = [ + "LayoutNotFoundError", + "PeriodColumn", + "TableLayout", + "cell_label", + "find_code_column", + "find_layout", + "is_numbering_row", + "line_code", +] + +_CODE = re.compile(r"^(\d{4,5})(\.0+)?$") +_SHORT_TEXT = 3 # значения колонки «Пояснения»: 1–2 символа/цифры +_MIN_NUMBERING_CELLS = 3 +NO_CODE_COLUMN = "колонка кодов не найдена" + + +class LayoutNotFoundError(Exception): + """Таблица формы не найдена: `reason` — что именно (колонка кодов).""" + + def __init__(self, reason: str) -> None: + super().__init__(reason) + self.reason = reason + + +@dataclass(frozen=True) +class PeriodColumn: + col: int + label: str # как в заголовке (переносы → пробел); "" у продолжения без заголовка + period_date: date | None + + +@dataclass(frozen=True) +class TableLayout: + header_row: int | None # None — заголовка таблицы нет (лист-продолжение) + header_last_row: int | None # вторая строка заголовка или строка нумерации + code_col: int + name_col: int | None + periods: tuple[PeriodColumn, ...] + skipped_labels: tuple[str, ...] # заголовки правее кодов без периода + first_data_row: int + + +def cell_label(sheet: Sheet, row: int, col: int) -> str: + return " ".join(sheet.text_at(row, col).split()) + + +def line_code(cell: Cell, rules: Rules) -> str | None: + """Код строки формы: 4–5 цифр, первые четыре — в диапазонах словаря; число `1110.0` читается как «1110».""" + found = _CODE.match(cell.text.replace(" ", "")) + if found is None: + return None + code = found.group(1) + head = int(code[:4]) + ranges = rules.statement_headers.code_ranges.values() + return code if any(low <= head <= high for low, high in ranges) else None + + +def find_code_column(sheet: Sheet, rules: Rules, first_row: int, last_row: int) -> int | None: + """Колонка с наибольшим числом кодов, если кодов ≥ `min_codes` и их доля среди непустых ≥ `min_share`.""" + counts: dict[int, list[int]] = {} + for row in range(first_row, last_row + 1): + for col, cell in sheet.row_cells(row): + tally = counts.setdefault(col, [0, 0]) + tally[1] += 1 + if line_code(cell, rules) is not None: + tally[0] += 1 + headers = rules.statement_headers + best: int | None = None + for col in sorted(counts): + codes, nonempty = counts[col] + if codes < headers.min_codes or codes / nonempty < headers.code_min_share: + continue + if best is None or codes > counts[best][0]: + best = col + return best + + +def is_numbering_row(sheet: Sheet, row: int, rules: Rules) -> bool: + cells = sheet.row_cells(row) + pattern = rules.statement_headers.column_numbering + return len(cells) >= _MIN_NUMBERING_CELLS and all(pattern.search(cell.text) for _, cell in cells) + + +def _first_code_row(sheet: Sheet, rules: Rules, code_col: int, first_row: int, last_row: int) -> int: + for row in range(first_row, last_row + 1): + if line_code(sheet.cell(row, code_col), rules) is not None: + return row + return last_row + 1 + + +def _is_period_label(label: str, rules: Rules, *, allow_bare_year: bool) -> bool: + if rules.statement_periods.period_end(label) is None: + return False + return allow_bare_year or not rules.statement_periods.for_year.search(label) or any( + pattern.search(label) + for pattern in (rules.statement_periods.on_date, rules.statement_periods.numeric_date, rules.statement_periods.for_months, rules.statement_periods.for_range) + ) + + +def _find_header_row(sheet: Sheet, rules: Rules, code_col: int, first_row: int, first_code_row: int) -> int | None: + """Последняя над первым кодом строка с подписью «Код» в колонке кодов; без подписи — последняя строка с двумя и + более ячейками-периодами правее кодов либо с одной, но не голым годом (титульный блок НПЗ содержит «2025»).""" + captioned = [ + row for row in range(first_row, first_code_row) if rules.statement_headers.is_code_caption(sheet.text_at(row, code_col)) + ] + if captioned: + return captioned[-1] + for row in range(first_code_row - 1, first_row - 1, -1): + labels = [cell_label(sheet, row, col) for col, _ in sheet.row_cells(row) if col > code_col] + strong = [label for label in labels if _is_period_label(label, rules, allow_bare_year=False)] + weak = [label for label in labels if _is_period_label(label, rules, allow_bare_year=True)] + if strong or len(weak) >= 2: # noqa: PLR2004 - две даты подряд — заголовок, одна «2025» — титул + return row + return None + + +def _header_last_row(sheet: Sheet, rules: Rules, header_row: int, code_col: int, first_code_row: int) -> int: + """Вторая строка заголовка (продолжение подписей без кодов) и строка нумерации колонок.""" + last = header_row + following = header_row + 1 + if following < first_code_row and not is_numbering_row(sheet, following, rules) and not sheet.is_row_empty(following): + last = following + if last + 1 < first_code_row and is_numbering_row(sheet, last + 1, rules): + last += 1 + return last + + +def _joined_label(sheet: Sheet, header_row: int, last_row: int, col: int, rules: Rules) -> str: + parts = [cell_label(sheet, header_row, col)] + for row in range(header_row + 1, last_row + 1): + if is_numbering_row(sheet, row, rules): + continue + origin_row, _ = sheet.merged_origin(row, col) + if origin_row == header_row: # merged-продолжение той же ячейки + continue + parts.append(cell_label(sheet, row, col)) + return " ".join(part for part in parts if part) + + +def _period_columns( + sheet: Sheet, rules: Rules, header_row: int, header_last_row: int, code_col: int +) -> tuple[tuple[PeriodColumn, ...], tuple[str, ...]]: + periods: list[PeriodColumn] = [] + skipped: list[str] = [] + seen: set[int] = set() + for row in range(header_row, header_last_row + 1): + for col, _ in sheet.row_cells(row): + origin_row, origin_col = sheet.merged_origin(row, col) + if origin_col <= code_col or origin_col in seen or is_numbering_row(sheet, row, rules): + continue + seen.add(origin_col) + label = _joined_label(sheet, origin_row, header_last_row, origin_col, rules) + end = rules.statement_periods.period_end(label) + if end is None and not label: + continue + if end is None: + skipped.append(label) + else: + periods.append(PeriodColumn(origin_col, label, end)) + periods.sort(key=lambda column: column.col) + return tuple(periods), tuple(skipped) + + +def _value_columns(sheet: Sheet, rules: Rules, code_col: int, first_row: int, last_row: int) -> tuple[PeriodColumn, ...]: + """Продолжение без заголовка: колонки правее кодов, где в строках с кодами есть хотя бы одно число.""" + found: set[int] = set() + for row in range(first_row, last_row + 1): + if line_code(sheet.cell(row, code_col), rules) is None: + continue + found.update(col for col, cell in sheet.row_cells(row) if col > code_col and cell.kind == "number") + return tuple(PeriodColumn(col, "", None) for col in sorted(found)) + + +def _name_column(sheet: Sheet, rules: Rules, code_col: int, header_row: int | None, first_row: int, last_row: int) -> int | None: + """Колонка левее кодов с наибольшим числом текстовых значений в строках с кодами; «Пояснения» (по подписи или по + коротким значениям) исключается; при равенстве — ближайшая к кодам.""" + texts: dict[int, int] = {} + long_texts: dict[int, int] = {} + for row in range(first_row, last_row + 1): + if line_code(sheet.cell(row, code_col), rules) is None: + continue + for col, cell in sheet.row_cells(row): + if col >= code_col or cell.kind != "text": + continue + texts[col] = texts.get(col, 0) + 1 + if len(cell.text) > _SHORT_TEXT: + long_texts[col] = long_texts.get(col, 0) + 1 + candidates = [ + col + for col in texts + if long_texts.get(col, 0) > 0 + and not (header_row is not None and rules.statement_headers.is_explanations_caption(sheet.text_at(header_row, col))) + ] + if not candidates: + return None + return max(candidates, key=lambda col: (texts[col], col)) + + +def find_layout(sheet: Sheet, rules: Rules, first_row: int, last_row: int) -> TableLayout: + code_col = find_code_column(sheet, rules, first_row, last_row) + if code_col is None: + raise LayoutNotFoundError(NO_CODE_COLUMN) + first_code_row = _first_code_row(sheet, rules, code_col, first_row, last_row) + header_row = _find_header_row(sheet, rules, code_col, first_row, first_code_row) + if header_row is None: + periods = _value_columns(sheet, rules, code_col, first_row, last_row) + name_col = _name_column(sheet, rules, code_col, None, first_row, last_row) + return TableLayout(None, None, code_col, name_col, periods, (), first_row) + header_last_row = _header_last_row(sheet, rules, header_row, code_col, first_code_row) + periods, skipped = _period_columns(sheet, rules, header_row, header_last_row, code_col) + name_col = _name_column(sheet, rules, code_col, header_row, header_last_row + 1, last_row) + return TableLayout(header_row, header_last_row, code_col, name_col, periods, skipped, header_last_row + 1) +``` + +Проверить на `ONE_C_BALANCE`: вторая строка заголовка — `_header_last_row` берёт строку 5 («АКТИВ»)? Нет: условие «не пустая и не нумерация» слишком слабое. Усилить: следующая строка считается продолжением заголовка, только если в ней нет текста в колонке наименований **или** все её непустые ячейки правее кодов. Реализация: `following` принимается, когда `all(col > code_col for col, _ in sheet.row_cells(following))` либо `sheet.text_at(following, code_col) == "" and not any(cell.kind == "number" for _, cell in sheet.row_cells(following)) and any(col > code_col for col, _ in sheet.row_cells(following))`. «АКТИВ» стоит левее кодов и ничего правее — не продолжение; «2024 г. | 2023 г.» — правее — продолжение. + +- [ ] **Step 4: Запустить тесты — PASS; линтер координат и `make check`** + +Run: `cd API && .venv/bin/pytest tests/unit/parser/statements -q && .venv/bin/pytest tests/unit/parser/test_no_coordinates.py -q && cd .. && make lint` + +- [ ] **Step 5: Commit** + +```bash +git -C T add api/src/parser/statements api/tests/unit/parser/statements +git -C T commit -m "feat(statements): колонка кодов, заголовок таблицы и колонки-периоды по содержимому + +Refs #14" +``` + +--- + +### Task 6: `statements/detect.py` — блоки форм, тип, продолжения; расширение `classify_sheet` + +**Files:** +- Create: `API/src/parser/statements/detect.py` +- Modify: `API/src/parser/detect.py` (`SheetKind.STATEMENT_CONTINUATION`, `classify_sheet`), `API/src/parser/rules.py` (удалить `statement_markers` из `OsvHeaderRules`), `API/rules/osv_headers.yaml` (удалить `statement_markers`), `API/rules/README.md` +- Test: `API/tests/unit/parser/statements/test_detect.py`, правки `API/tests/unit/parser/test_detect.py`, `API/tests/fixtures/test_detect_real.py` (если проверяет `statement_markers`) + +**Interfaces:** +- Consumes: `find_code_column`, `line_code` (задача 5), `Rules.statement_headers`. +- Produces: `FormBlock(kind: FormType, form_name: str | None, okud: str | None, title_row: int | None, first_row: int, last_row: int)`; `find_forms(sheet, rules) -> tuple[list[FormBlock], list[str]]`; `StatementSheetKind = Literal["statement", "continuation", "other"]`; `classify_statement_sheet(sheet, rules) -> StatementSheetKind`; `continuation_form(sheet, rules) -> tuple[FormType | None, str | None]` (тип по ОКУД маркера и текст маркера); `SheetKind.STATEMENT_CONTINUATION`. + +- [ ] **Step 1: Тест** + +```python +# API/tests/unit/parser/statements/test_detect.py +"""Блоки форм на листе, тип формы, лист-продолжение (design D-2, D-3; spec «Листы форм и их продолжения находятся по +содержимому»).""" + +import pytest +from gridbuilder import at, sheet_from_rows + +from src.parser.detect import SheetKind, classify_sheet +from src.parser.rules import Rules, load_rules +from src.parser.statements.detect import classify_statement_sheet, continuation_form, find_forms + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +NPZ_FIRST_SHEET = [ + [None, None, "Бухгалтерская отчетность"], + [None, None, "за январь - сентябрь 2025 г."], + [*at({3: "Организация:", 7: 'ООО "НПЗ"', 22: "по ОКПО", 23: "16098811"})], + [*at({3: "Идентификационный номер налогоплательщика", 22: "ИНН", 23: "2315995868"})], + [], + [None, None, "Бухгалтерский баланс"], + [None, None, "на 30 сентября 2025 г."], + [*at({22: "Форма по ОКУД", 23: "0710001"})], + [*at({3: "Пояснения", 5: "Наименование показателя", 13: "Код", 16: "На 30 сентября 2025 г.", 21: "На 31 декабря 2024 г."})], + [*at({5: "Гудвил", 13: "1105", 16: 0, 21: 0})], + [*at({5: "Нематериальные активы", 13: "1110", 16: 586260, 21: 905786})], + [*at({5: "Итого по разделу I", 13: "1100", 16: 586260, 21: 905786})], +] + + +def test_title_after_report_header_gives_one_balance_block(rules: Rules) -> None: + sheet = sheet_from_rows(NPZ_FIRST_SHEET) + + blocks, warnings = find_forms(sheet, rules) + + assert len(blocks) == 1 and warnings == [] + block = blocks[0] + assert block.kind == "BALANCE" and block.form_name == "Бухгалтерский баланс" and block.okud == "0710001" + assert (block.title_row, block.first_row, block.last_row) == (6, 1, sheet.n_rows) + + +def test_two_forms_on_one_sheet_split_at_second_title(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + ["Бухгалтерский баланс"], + ["Наименование", "Код", "На 31.12.2024"], + ["Запасы", "1210", 10], + ["БАЛАНС", "1600", 10], + [], + ["Отчет о финансовых результатах"], + ["Наименование", "Код", "За 2024 г."], + ["Выручка", "2110", 100], + ] + ) + + blocks, _ = find_forms(sheet, rules) + + assert [(block.kind, block.first_row, block.last_row) for block in blocks] == [("BALANCE", 1, 5), ("INCOME", 6, 8)] + + +def test_okud_without_title_and_disclosure(rules: Rules) -> None: + sheet = sheet_from_rows([["Форма по ОКУД", "0710002"], ["Код", "За 2024 г."], ["2110", 1]]) + assert find_forms(sheet, rules)[0][0].kind == "INCOME" + + disclosure = sheet_from_rows([["Пояснения к бухгалтерскому балансу и отчету о финансовых результатах"], ["текст"]]) + assert find_forms(disclosure, rules)[0][0].kind == "DISCLOSURE" + odds = sheet_from_rows([["Отчет о движении денежных средств"], ["Код", "За 2024 г."], ["4110", 1]]) + assert find_forms(odds, rules)[0][0].kind == "DISCLOSURE" + + +def test_title_and_okud_conflict_prefers_okud_with_warning(rules: Rules) -> None: + sheet = sheet_from_rows([["Бухгалтерский баланс"], ["Форма по ОКУД", "0710002"]]) + + blocks, warnings = find_forms(sheet, rules) + + assert blocks[0].kind == "INCOME" and warnings and "0710002" in warnings[0] + + +def test_classify_statement_continuation_and_other(rules: Rules) -> None: + one_c_page_two = sheet_from_rows([[*at({17: "Форма 0710001 с.2"})], ["Пояснения", "Наименование показателя", "Код", "На 31 декабря 2024 г."], [None, "Уставный капитал", "1310", 250000]]) + npz_page = sheet_from_rows([["в том числе:"], ["Биологические активы", "11901", 0, 0], ["Итого по разделу I", "1100", 5, 5], ["Запасы", "1210", 1, 1]]) + header_only = sheet_from_rows([["Пояснения", "Наименование показателя", "Код", "На 31 декабря 2024 г."], ["Запасы", None, None, 5]]) + other = sheet_from_rows([["Справка"], ["Наименование", "Сумма"], ["Аренда", 100]]) + + assert classify_statement_sheet(sheet_from_rows(NPZ_FIRST_SHEET), rules) == "statement" + assert classify_statement_sheet(one_c_page_two, rules) == "continuation" + assert classify_statement_sheet(npz_page, rules) == "continuation" + assert classify_statement_sheet(header_only, rules) == "continuation" + assert classify_statement_sheet(other, rules) == "other" + assert continuation_form(one_c_page_two, rules) == ("BALANCE", "Форма 0710001 с.2") + assert continuation_form(npz_page, rules) == (None, None) + + +def test_sheet_kind_wiring(rules: Rules) -> None: + assert classify_sheet(sheet_from_rows(NPZ_FIRST_SHEET), rules) is SheetKind.STATEMENT + assert classify_sheet(sheet_from_rows([["Форма 0710002 с.2"], ["Код", "За 2024 г."], ["2510", 0]]), rules) is SheetKind.STATEMENT_CONTINUATION + assert classify_sheet(sheet_from_rows([["Оборотно-сальдовая ведомость по счету 60"], ["Бухгалтерский баланс"]]), rules) is SheetKind.OSV +``` + +- [ ] **Step 2: Запустить — падает (нет модуля)** + +- [ ] **Step 3: Реализация `statements/detect.py`** + +```python +"""Блоки форм на листе и вид листа форм (design D-2, D-3). + +Блок начинается с заголовка формы или кода ОКУД; новый блок открывается, только если между заголовками уже были +строки с кодами (иначе «Бухгалтерский баланс» и «0710001» на соседних строках — один блок). Титул «Бухгалтерская +отчетность» типа не задаёт. Лист без заголовка, но с маркером «Форма 0710001 с.2», подписью «Код» с периодами или +колонкой кодов — продолжение. +""" + +import re +from dataclasses import dataclass +from typing import Literal + +from src.parser.grid import Sheet +from src.parser.rules import FormType, Rules +from src.parser.statements.columns import cell_label, find_code_column, line_code + +__all__ = ["FormBlock", "StatementSheetKind", "classify_statement_sheet", "continuation_form", "find_forms"] + +StatementSheetKind = Literal["statement", "continuation", "other"] +_OKUD_CODE = re.compile(r"0710\d{3}") + + +@dataclass(frozen=True) +class FormBlock: + kind: FormType + form_name: str | None + okud: str | None + title_row: int | None + first_row: int + last_row: int + + +@dataclass(frozen=True) +class _Candidate: + row: int + title: str | None + title_kind: FormType | None + okud: str | None + okud_kind: FormType | None + + +def _candidates(sheet: Sheet, rules: Rules) -> list[_Candidate]: + headers = rules.statement_headers + found: list[_Candidate] = [] + for row in range(1, sheet.n_rows + 1): + title, title_kind, okud, okud_kind = None, None, None, None + for col, cell in sheet.row_cells(row): + label = cell_label(sheet, row, col) + kind = headers.form_type(label) + if kind is not None and title is None: + title, title_kind = label, kind + by_okud = headers.okud_type(label) + if by_okud is not None and okud is None: + code = _OKUD_CODE.search(label) + okud, okud_kind = (code.group(0) if code else label), by_okud + if title is not None or okud is not None: + found.append(_Candidate(row, title, title_kind, okud, okud_kind)) + return found + + +def _code_rows(sheet: Sheet, rules: Rules) -> list[int]: + return [row for row in range(1, sheet.n_rows + 1) if any(line_code(cell, rules) for _, cell in sheet.row_cells(row))] + + +def find_forms(sheet: Sheet, rules: Rules) -> tuple[list[FormBlock], list[str]]: + """Блоки форм сверху вниз и предупреждения (заголовок и ОКУД противоречат — тип по ОКУД).""" + candidates = _candidates(sheet, rules) + if not candidates: + return [], [] + code_rows = _code_rows(sheet, rules) + groups: list[list[_Candidate]] = [] + for candidate in candidates: + if groups and not any(groups[-1][0].row < row < candidate.row for row in code_rows): + groups[-1].append(candidate) + else: + groups.append([candidate]) + blocks: list[FormBlock] = [] + warnings: list[str] = [] + for index, group in enumerate(groups): + title = next((item for item in group if item.title is not None), None) + okud = next((item for item in group if item.okud is not None), None) + kind = okud.okud_kind if okud is not None else title.title_kind # type: ignore[union-attr] + if title is not None and okud is not None and title.title_kind != okud.okud_kind: + warnings.append( + f"лист «{sheet.name}»: заголовок «{title.title}» и ОКУД {okud.okud} противоречат, тип по ОКУД" + ) + first_row = 1 if index == 0 else group[0].row + last_row = sheet.n_rows if index == len(groups) - 1 else groups[index + 1][0].row - 1 + blocks.append( + FormBlock( + kind=kind, # type: ignore[arg-type] + form_name=title.title if title is not None else None, + okud=okud.okud if okud is not None else None, + title_row=group[0].row, + first_row=first_row, + last_row=last_row, + ) + ) + return blocks, warnings + + +def continuation_form(sheet: Sheet, rules: Rules) -> tuple[FormType | None, str | None]: + """Маркер «Форма 0710001 с.2»: тип формы по ОКУД в маркере и сам текст; без маркера — `(None, None)`.""" + headers = rules.statement_headers + for row in range(1, sheet.n_rows + 1): + for col, _ in sheet.row_cells(row): + label = cell_label(sheet, row, col) + if headers.is_continuation(label): + return headers.okud_type(label), label + return None, None + + +def _has_table_header(sheet: Sheet, rules: Rules) -> bool: + headers, periods = rules.statement_headers, rules.statement_periods + for row in range(1, sheet.n_rows + 1): + cells = sheet.row_cells(row) + caption = next((col for col, cell in cells if headers.is_code_caption(cell.text)), None) + if caption is None: + continue + if any(col > caption and periods.period_end(cell_label(sheet, row, col)) is not None for col, _ in cells): + return True + return False + + +def classify_statement_sheet(sheet: Sheet, rules: Rules) -> StatementSheetKind: + if find_forms(sheet, rules)[0]: + return "statement" + if continuation_form(sheet, rules)[1] is not None or _has_table_header(sheet, rules): + return "continuation" + if find_code_column(sheet, rules, 1, sheet.n_rows) is not None: + return "continuation" + return "other" +``` + +`src/parser/detect.py`: `SheetKind` + `STATEMENT_CONTINUATION = "statement_continuation"`; `classify_sheet`: + +```python +def classify_sheet(sheet: Sheet, rules: Rules) -> SheetKind: + """ОСВ — если в первых строках есть признак ведомости; иначе форма ББ/ОФР, её продолжение или прочее — по + `statements.detect` (признаки формы ищутся по всему листу до таблицы).""" + head = [text for row in range(1, min(sheet.n_rows, SHEET_MARKER_ROWS) + 1) for _, text in _texts(sheet, row)] + if any(_search(rules.headers.sheet_markers, text) for text in head): + return SheetKind.OSV + return _STATEMENT_KINDS[classify_statement_sheet(sheet, rules)] +``` + +с `_STATEMENT_KINDS = {"statement": SheetKind.STATEMENT, "continuation": SheetKind.STATEMENT_CONTINUATION, "other": SheetKind.OTHER}` и импортом `from src.parser.statements.detect import classify_statement_sheet` (цикла нет: `statements/*` не импортируют `src.parser.detect`). Удалить `statement_markers` из `OsvHeaderRules`, `osv_headers.yaml`, README словарей (иначе тест покрытия словарей сочтёт их мёртвыми). В `tests/unit/parser/test_detect.py` параметр `"Форма по ОКУД 0710001"` остаётся валидным. + +- [ ] **Step 4: Прогнать unit и fixtures-тесты детектора** + +Run: `cd API && .venv/bin/pytest tests/unit/parser/statements tests/unit/parser/test_detect.py tests/unit/parser/test_rules.py -q && .venv/bin/pytest tests/fixtures/test_detect_real.py -q` +Expected: PASS; реальные ОСВ по-прежнему `OSV`. + +- [ ] **Step 5: Commit** + +```bash +git -C T add api/src/parser api/rules api/tests +git -C T commit -m "feat(statements): блоки форм, тип по заголовку и ОКУД, листы-продолжения; classify_sheet без statement_markers + +Refs #14" +``` + +--- + +### Task 7: `book.py` — `BookParser`, `OsvParser` как конвейер листа, `get_parser` + +**Files:** +- Create: `API/src/parser/book.py` +- Modify: `API/src/parser/osv.py` (удалить `parse`, `_parse_book`, `_error`, `_elapsed_ms`, `max_bytes`/`timeout_seconds`, конструктор → `OsvParser(rules)`; сделать публичными `sheet_line`, `unique`, `data_rows`), `API/src/parser/__init__.py` (`get_parser`), `API/src/parser/cli.py` (не меняется, использует `get_parser`) +- Modify tests: `git -C T mv api/tests/unit/parser/test_osv_parser.py api/tests/unit/parser/test_book_parser.py` (замена `OsvParser(rules, max_bytes=…, timeout_seconds=…)` → `book(rules, …)` хелпер), `API/tests/fixtures/test_rules_coverage.py` (сборка `BookParser`), любые `isinstance(parser, OsvParser)` → `BookParser` +- Test: новые тесты в `test_book_parser.py` + +**Interfaces:** +- Produces: `BookParser(rules, *, osv: OsvParser, statements: StatementSheetParser, max_bytes, timeout_seconds, clock)` с `.parse(data, file_type) -> ParseOutcome`, `.max_bytes`, `.timeout_seconds`; `StatementSheetParser` (Protocol): `parse_sheets(sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline) -> tuple[list[StatementDocument], list[str], bool]`; `SkippingStatementParser` (временная заглушка до задачи 12: документов нет, предупреждение `sheet=<имя> skipped: statement (slice 2b)`, ok=True); `OsvParser(rules).parse_sheet(sheet, deadline)`; `get_parser()` → `BookParser`. + +- [ ] **Step 1: Тесты (добавить в `test_book_parser.py` после переименования)** + +```python +from src.parser.book import BookParser, SkippingStatementParser, StatementSheetParser +from src.parser.osv import OsvParser + + +def book(rules: Rules, *, statements: StatementSheetParser | None = None, **kwargs: Any) -> BookParser: + kwargs.setdefault("max_bytes", MAX_BYTES) + kwargs.setdefault("timeout_seconds", TIMEOUT_SECONDS) + return BookParser(rules, osv=OsvParser(rules), statements=statements or SkippingStatementParser(), **kwargs) + + +class _FakeStatements: + """Возвращает по одному документу DISCLOSURE на лист формы и запоминает, что получил.""" + + def __init__(self) -> None: + self.received: list[tuple[str, SheetKind]] = [] + + def parse_sheets(self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline) -> tuple[list[StatementDocument], list[str], bool]: + self.received = [(sheet.name, kind) for sheet, kind in sheets] + documents = [ + StatementDocument(document_type="DISCLOSURE", locator=f"sheet:{sheet.name}", header=StatementHeader(), columns=[], lines=[]) + for sheet, _ in sheets + ] + return documents, [f"sheet={sheet.name} statements-fake" for sheet, _ in sheets], True + + +def test_statement_sheets_go_to_statement_parser_in_sheet_order(rules: Rules) -> None: + fake = _FakeStatements() + data = xlsx({"ББ": [["Бухгалтерский баланс"], ["Код", "На 31.12.2024"], ["1600", 1]], "Лист_1": mini_osv_rows(), "с.2": [["Форма 0710001 с.2"], ["Код", "На 31.12.2024"], ["1700", 1]]}) + + outcome = book(rules, statements=fake).parse(data, "XLSX") + + assert fake.received == [("ББ", SheetKind.STATEMENT), ("с.2", SheetKind.STATEMENT_CONTINUATION)] + assert [type(document).__name__ for document in outcome.documents] == ["OsvDocument", "StatementDocument", "StatementDocument"] + assert outcome.status is ResultStatus.DONE + assert "sheet=ББ statements-fake" in (outcome.metrics.warnings or []) + + +def test_skipping_parser_keeps_slice_2a_behaviour(rules: Rules) -> None: + data = xlsx({"ББ": [["Бухгалтерский баланс"], ["Код", "На 31.12.2024"], ["1600", 1]], "Лист_1": mini_osv_rows()}) + + outcome = book(rules).parse(data, "XLSX") + + assert len(outcome.documents) == 1 and "sheet=ББ skipped: statement (slice 2b)" in (outcome.metrics.warnings or []) + + +def test_book_without_osv_and_forms_is_invalid_document(rules: Rules) -> None: + outcome = book(rules).parse(xlsx({"Справка": [["Наименование", "Сумма"], ["Аренда", 100]]}), "XLSX") + + assert outcome.status is ResultStatus.ERROR and outcome.error is not None + assert outcome.error.code is ErrorCode.INVALID_DOCUMENT and "нет листов ОСВ или форм" in outcome.error.message + + +def test_statement_not_ok_makes_book_needs_check(rules: Rules) -> None: + class _NotOk(_FakeStatements): + def parse_sheets(self, sheets, deadline): # type: ignore[no-untyped-def] + documents, warnings, _ = super().parse_sheets(sheets, deadline) + return documents, warnings, False + + outcome = book(rules, statements=_NotOk()).parse(xlsx({"ББ": [["Бухгалтерский баланс"], ["Код", "На 31.12.2024"], ["1600", 1]]}), "XLSX") + + assert outcome.status is ResultStatus.NEEDS_CHECK and len(outcome.documents) == 1 +``` + +`mini_osv_rows()` — вынести из существующих тестов `test_osv_parser.py` строки мини-ОСВ (там они есть как список `ROWS`/функция; переименовать по месту). Существующие тесты, проверяющие `skipped: statement (slice 2b)`, остаются зелёными при `SkippingStatementParser`. + +- [ ] **Step 2: Запустить — падает (нет `src.parser.book`)** + +- [ ] **Step 3: Реализация `book.py`** + +```python +"""Оркестратор книги (design D-1): лимит размера → чтение → дедлайн → вид листа → конвейеры ОСВ и форм → статус. + +Карта исходов та же, что была у `OsvParser.parse` (D-6 Среза 2a): нет документов ОСВ и форм → `INVALID_DOCUMENT`; +лист ОСВ без заголовка/колонок → `INVALID_FORMAT`; книга не открывается → `UNREADABLE`; сигнатура/лимиты → +`UNSUPPORTED_FORMAT`; дедлайн → `TIMEOUT`; прочее → `RECOGNITION_FAILED`. Нарушение контрольных сумм ОСВ или +соотношений формы хотя бы в одном документе → `needs_check`. Документы: ОСВ в порядке листов, затем формы. +""" + +import time +from collections.abc import Callable, Sequence +from typing import Protocol + +from loguru import logger + +from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus, StatementDocument +from src.parser import ParseOutcome +from src.parser.deadline import Deadline +from src.parser.detect import SheetKind, classify_sheet +from src.parser.errors import InvalidDocumentError, ParserError, UnsupportedFormatError +from src.parser.grid import Sheet +from src.parser.osv import OsvParser, data_rows, sheet_line, unique +from src.parser.readers import read_book +from src.parser.readers._common import one_line_reason +from src.parser.readers._limits import ZIP_UNCOMPRESSED_LIMIT_FACTOR +from src.parser.rules import Rules + +__all__ = ["BookParser", "SkippingStatementParser", "StatementSheetParser"] + +_MS_IN_SECOND = 1000 +STATEMENT_KINDS = frozenset({SheetKind.STATEMENT, SheetKind.STATEMENT_CONTINUATION}) + + +class StatementSheetParser(Protocol): + def parse_sheets( + self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline + ) -> tuple[list[StatementDocument], list[str], bool]: ... + + +class SkippingStatementParser: + """Поведение Среза 2a: листы форм пропускаются с предупреждением. Заменяется `StatementParser` (задача 12).""" + + def parse_sheets( + self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline + ) -> tuple[list[StatementDocument], list[str], bool]: + return [], [sheet_line(sheet, "skipped: statement (slice 2b)") for sheet, _ in sheets], True + + +class BookParser: + def __init__( # noqa: PLR0913 - все зависимости оркестратора именованные + self, + rules: Rules, + *, + osv: OsvParser, + statements: StatementSheetParser, + max_bytes: int, + timeout_seconds: float, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self._rules = rules + self._osv = osv + self._statements = statements + self._max_bytes = max_bytes + self._timeout_seconds = timeout_seconds + self._clock = clock + + @property + def max_bytes(self) -> int: + return self._max_bytes + + @property + def timeout_seconds(self) -> float: + return self._timeout_seconds + + def parse(self, data: bytes, file_type: FileType) -> ParseOutcome: + started = self._clock() + warnings: list[str] = [] + try: + documents, ok = self._parse_book(data, file_type, warnings) + except ParserError as exc: + if exc.code is ErrorCode.UNREADABLE: + logger.warning("Книга не читается: {reason}", reason=exc.message) + return self._error(exc.code, exc.message, warnings, started) + except Exception as exc: + reason = one_line_reason(exc) + logger.opt(exception=exc).error("Непредвиденная ошибка разбора: {reason}", reason=reason) + return self._error(ErrorCode.RECOGNITION_FAILED, reason, warnings, started) + rows = sum(data_rows(document) if isinstance(document, OsvDocument) else len(document.lines) for document in documents) + metrics = Metrics(duration_ms=self._elapsed_ms(started), rows=rows, warnings=warnings) + return ParseOutcome(status=ResultStatus.DONE if ok else ResultStatus.NEEDS_CHECK, documents=tuple(documents), metrics=metrics) + + def _parse_book( + self, data: bytes, file_type: FileType, warnings: list[str] + ) -> tuple[list[OsvDocument | StatementDocument], bool]: + if len(data) > self._max_bytes: + raise UnsupportedFormatError( + f"размер файла {len(data)} байт больше лимита PARSE_MAX_BYTES = {self._max_bytes} байт: книга не открывается" + ) + deadline = Deadline(self._timeout_seconds, self._clock) + sheets, read_warnings = read_book(data, file_type, max_uncompressed_bytes=ZIP_UNCOMPRESSED_LIMIT_FACTOR * self._max_bytes) + warnings.extend(unique(read_warnings)) + osv_documents: list[OsvDocument] = [] + statement_sheets: list[tuple[Sheet, SheetKind]] = [] + ok = True + for sheet in sheets: + deadline.check(f"лист «{sheet.name}»") + kind = classify_sheet(sheet, self._rules) + if kind in STATEMENT_KINDS: + statement_sheets.append((sheet, kind)) + continue + if kind is SheetKind.OTHER: + warnings.append(sheet_line(sheet, "skipped: нет признаков ОСВ или формы")) + continue + document, sheet_warnings, sheet_ok = self._osv.parse_sheet(sheet, deadline) + osv_documents.append(document) + warnings.extend(sheet_warnings) + ok = ok and sheet_ok + statement_documents, statement_warnings, statements_ok = self._statements.parse_sheets(statement_sheets, deadline) + warnings.extend(statement_warnings) + documents: list[OsvDocument | StatementDocument] = [*osv_documents, *statement_documents] + if not documents: + raise InvalidDocumentError(f"в книге нет листов ОСВ или форм: листов просмотрено {len(sheets)}") + return documents, ok and statements_ok + + def _elapsed_ms(self, started: float) -> int: + return max(0, round((self._clock() - started) * _MS_IN_SECOND)) + + def _error(self, code: ErrorCode, message: str, warnings: list[str], started: float) -> ParseOutcome: + fields: dict[str, object] = {"duration_ms": self._elapsed_ms(started)} + if warnings: + fields["warnings"] = warnings + return ParseOutcome(status=ResultStatus.ERROR, error=MessageError(code=code, message=" ".join(message.split())), metrics=Metrics.model_validate(fields)) +``` + +`osv.py`: удалить `parse`, `_parse_book`, `_error`, `_elapsed_ms`, свойства и параметры лимитов; `__init__(self, rules)`; переименовать `_sheet_line → sheet_line`, `_unique → unique`, `_data_rows → data_rows` (и в `__all__`); docstring модуля — «конвейер листа ОСВ; книгу разбирает `book.BookParser`». Предупреждение «skipped: нет признаков ОСВ» → «…ОСВ или формы» (golden синтетики s06 изменится — учесть в задаче 14). + +`__init__.py::get_parser`: + +```python + from src.parser.book import BookParser, SkippingStatementParser # noqa: PLC0415 + from src.parser.osv import OsvParser # noqa: PLC0415 + + rules = load_rules(rules_dir) + ... + return BookParser(rules, osv=OsvParser(rules), statements=SkippingStatementParser(), max_bytes=max_bytes, timeout_seconds=timeout_seconds) +``` + +`tests/fixtures/test_rules_coverage.py`: `base = get_parser(); assert isinstance(base, BookParser); parser = BookParser(rules, osv=OsvParser(rules), statements=SkippingStatementParser(), max_bytes=base.max_bytes, timeout_seconds=base.timeout_seconds)`. + +- [ ] **Step 4: `make check` и golden-check (снимки ОСВ не должны измениться, кроме текста предупреждения s06)** + +Run: `make -C T check && make -C T golden-check` +Expected: тесты зелёные; `golden-check` показывает diff только у `s06_two_osv_and_other_sheet` (текст предупреждения) — не обновлять сейчас, обновление снимков — задача 14 одним коммитом в fixtures. + +- [ ] **Step 5: Commit** + +```bash +git -C T add api/src/parser api/tests +git -C T commit -m "refactor(parser): BookParser — оркестратор книги; OsvParser — конвейер листа ОСВ; заглушка форм до конвейера + +Refs #14" +``` + +--- + +### Task 8: Границы периода в шапке ОСВ (`period_start`, `period_end`, `is_from_year_start`) + +**Files:** +- Modify: `API/src/parser/header.py` (`_Period` + `start`, `end`; `_month_range_period`, `_parse_period`; `DocHeader` + `period_start`, `period_end`; `parse_doc_header`), `API/src/parser/osv.py` (`_header`) +- Test: `API/tests/unit/parser/test_header.py` (добавить), `API/tests/unit/parser/test_book_parser.py` (один тест через книгу) + +**Interfaces:** +- Produces: `DocHeader.period_start: date | None`, `DocHeader.period_end: date | None`; `OsvHeader.period_start/period_end/is_from_year_start` заполнены парсером ОСВ. + +- [ ] **Step 1: Тест (в `test_header.py`, рядом с тестами периода ветки #11)** + +```python +@pytest.mark.parametrize( + ("period_text", "start", "end", "from_year_start"), + [ + ("1 квартал 2025 г.", datetime.date(2025, 1, 1), datetime.date(2025, 3, 31), True), + ("3 квартал 2025 г.", datetime.date(2025, 7, 1), datetime.date(2025, 9, 30), False), + ("9 месяцев 2025 г.", datetime.date(2025, 1, 1), datetime.date(2025, 9, 30), True), + ("2024 г.", datetime.date(2024, 1, 1), datetime.date(2024, 12, 31), True), + ("Октябрь 2024 г. - Сентябрь 2025 г.", datetime.date(2024, 10, 1), datetime.date(2025, 9, 30), False), + ("Апрель - Июнь 2025 г.", datetime.date(2025, 4, 1), datetime.date(2025, 6, 30), False), + ], +) +def test_period_bounds(rules: Rules, period_text: str, start: datetime.date, end: datetime.date, from_year_start: bool) -> None: + sheet = osv_sheet(period=period_text) # хелпер этого файла, строящий шапку варианта 1 с заданным периодом + header = find_table_header(sheet, rules) + + doc = parse_doc_header(sheet, header, rules) + + assert (doc.period_start, doc.period_end) == (start, end) + assert (doc.period_start == datetime.date(doc.period_start.year, 1, 1)) is from_year_start + + +def test_period_bounds_unknown_when_period_unrecognised(rules: Rules) -> None: + doc = parse_doc_header(osv_sheet(period="за неизвестно что"), find_table_header(osv_sheet(period="за неизвестно что"), rules), rules) + + assert doc.period_start is None and doc.period_end is None +``` + +Если хелпера `osv_sheet(period=…)` в файле нет — посмотреть, как тесты ветки #11 строят шапку (`sheet_from_rows` с `TITLE`), и собрать строки с «Оборотно-сальдовая ведомость по счету 60 за ». + +- [ ] **Step 2: Запустить — падает (`DocHeader` без `period_start`)** + +- [ ] **Step 3: Реализация** + +В `header.py`: + +```python +@dataclass(frozen=True) +class _Period: + year: int | None + quarter: Quarter | None + is_cumulative: bool | None + start: datetime.date | None = None + end: datetime.date | None = None + + +def _month_end(year: int, month: int) -> datetime.date: + return datetime.date(year, month, calendar.monthrange(year, month)[1]) + + +def _bounds(year: int | None, quarter: Quarter | None, is_cumulative: bool | None) -> tuple[datetime.date | None, datetime.date | None]: + """Границы квартального/годового периода: конец — конец квартала; начало — 1 января у нарастающего, иначе первый + месяц квартала; без года или квартала — неизвестны.""" + if year is None or quarter is None: + return None, None + end_month = QUARTERS.index(quarter) * _MONTHS_IN_QUARTER + _MONTHS_IN_QUARTER + start_month = 1 if is_cumulative else end_month - _MONTHS_IN_QUARTER + 1 + return datetime.date(year, start_month, 1), _month_end(year, end_month) +``` + +В `_month_range_period` каждое `return _Period(...)` дополняется `start=datetime.date(span.start_year, span.start_month, 1), end=_month_end(span.end_year, span.end_month)`. В `_parse_period` после вычисления `year`/`rule`: `start, end = _bounds(year_value, quarter, is_cumulative)` и в `_Period(..., start=start, end=end)`. `DocHeader` + `period_start: datetime.date | None`, `period_end: datetime.date | None`; `parse_doc_header` передаёт `period.start`, `period.end`. + +В `osv.py::_header`: + +```python + period_start=doc.period_start, + period_end=doc.period_end, + is_from_year_start=None if doc.period_start is None else (doc.period_start.month == 1 and doc.period_start.day == 1), +``` + +- [ ] **Step 4: `make check`; `golden-check` покажет новые поля во всех снимках ОСВ — ожидаемо, обновление в задаче 14** + +- [ ] **Step 5: Commit** + +```bash +git -C T add api/src/parser/header.py api/src/parser/osv.py api/tests +git -C T commit -m "feat(osv): границы периода period_start/period_end и is_from_year_start в шапке (контракт v1.2, OQ-131) + +Refs #14" +``` + +--- + +### Task 9: `statements/header.py` — шапка документа + +**Files:** +- Create: `API/src/parser/statements/header.py` +- Test: `API/tests/unit/parser/statements/test_header.py` + +**Interfaces:** +- Consumes: `Rules.statement_headers.label/is_auditor_label/has_legal_form`, `Rules.statement_periods.period_end/date_triple`, `cell_label`, `FormBlock` (задача 6), `TableLayout.header_row` (задача 5), `numbers.inn_digits` (`src.parser.numbers`: ИНН из ячейки — 10/12 цифр). +- Produces: `parse_header(sheet, rules, first_row, last_row, block: FormBlock | None) -> tuple[StatementHeader, list[str]]` — область `[first_row, last_row]` (до строки заголовка таблицы); `block=None` для продолжения (все поля `None`, `form_name` — текст маркера). + +- [ ] **Step 1: Тест** + +```python +# API/tests/unit/parser/statements/test_header.py +"""Шапка формы по меткам (design D-5; spec «Шапка документа»).""" + +import datetime + +import pytest +from gridbuilder import at, sheet_from_rows + +from src.parser.rules import Rules, load_rules +from src.parser.statements.detect import FormBlock, find_forms +from src.parser.statements.header import parse_header + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +ONE_C = [ + [*at({9: "Бухгалтерский баланс"})], + [*at({9: "на 31 декабря 2024 г."})], + [*at({34: "Коды"})], + [*at({33: "Форма по ОКУД", 34: "0710001"})], + [*at({33: "Дата (число, месяц, год)", 34: "31", 37: "12", 40: "2024"})], + [*at({2: "Организация", 4: 'Акционерное общество "Унагранде Компани"', 33: "по ОКПО", 34: "47888105"})], + [*at({2: "Идентификационный номер налогоплательщика", 33: "ИНН", 34: "3249004697"})], + [*at({2: "Вид экономической\nдеятельности", 7: "Производство сыра", 30: "по\nОКВЭД 2", 34: "10.51.3"})], + [*at({2: "Организационно-правовая форма / форма собственности", 34: "12267", 39: "16"})], + [*at({2: "Непубличные акционерные общества", 14: " /", 15: "Частная собственность", 33: "по ОКОПФ / ОКФС"})], + [*at({2: "Единица измерения:", 7: "в тыс. рублей", 33: "по ОКЕИ", 34: "384"})], + [*at({2: "Местонахождение (адрес)"})], + [*at({2: "242440, Брянская обл, г Севск, ул Ленина, д. 130"})], + [*at({2: "Наименование аудиторской организации"})], + [*at({2: 'АО "ЮНИКОН"'})], + [*at({2: "Идентификационный номер налогоплательщика аудиторской организации", 33: "ИНН", 34: "7716021332"})], + [*at({3: "Пояснения", 5: "Наименование показателя", 22: "Код", 25: "На 31 декабря 2024 г."})], + [*at({5: "Нематериальные активы", 22: "1110", 25: 1})], +] + + +def _block(sheet, rules): # type: ignore[no-untyped-def] + return find_forms(sheet, rules)[0][0] + + +def test_one_c_header(rules: Rules) -> None: + sheet = sheet_from_rows(ONE_C) + + header, warnings = parse_header(sheet, rules, 1, 16, _block(sheet, rules)) + + assert header.form_name == "Бухгалтерский баланс" and header.okud == "0710001" + assert header.organization_name == 'Акционерное общество "Унагранде Компани"' + assert header.inn == "3249004697" # Review Focus 2: не ИНН аудитора + assert header.okpo == "47888105" and header.okopf == "12267" and header.okfs == "16" + assert header.units == "в тыс. рублей" + assert header.report_date_text == "на 31 декабря 2024 г." and header.report_date == datetime.date(2024, 12, 31) + assert header.address == "242440, Брянская обл, г Севск, ул Ленина, д. 130" + assert header.organization_name_confidence == 100 and header.inn_confidence == 100 + assert warnings == [] + + +def test_organization_above_label_and_income_period(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + [*at({3: "Отчет о финансовых результатах"})], + [*at({3: "за январь - сентябрь 2025 г."})], + [*at({13: "Форма по ОКУД", 14: "0710002"})], + [*at({7: "Отчетная дата\n(число, месяц, год)", 14: "30", 15: "09", 16: "2025"})], + [*at({7: 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"'})], + [*at({3: "Организация:", 13: "по ОКПО", 14: "16098811"})], + [*at({3: "Единица измерения", 8: "тыс. руб.", 13: "по ОКЕИ", 14: "384"})], + [*at({3: "Пояснения", 5: "Наименование показателя", 10: "Код", 11: "За январь - сентябрь 2025 г."})], + ] + ) + + header, _ = parse_header(sheet, rules, 1, 7, _block(sheet, rules)) + + assert header.organization_name == 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"' + assert header.inn is None and header.okpo == "16098811" and header.units == "тыс. руб." + assert header.report_date == datetime.date(2025, 9, 30) and header.report_date_text == "за январь - сентябрь 2025 г." + + +def test_date_from_numeric_triple_and_name_below_label(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + [*at({2: "БУХГАЛТЕРСКИЙ БАЛАНС"})], + [*at({17: "Форма по ОКУД", 18: "0710001"})], + [*at({17: "Дата (число, месяц, год)", 18: 30, 19: 9, 20: 2025})], + [*at({2: "Организация (орган исполнительной власти)", 7: 'ООО "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"', 17: "по ОКПО", 18: "85262693"})], + [*at({2: "Идентификационный номер налогоплательщика", 17: "ИНН", 18: "7016006635"})], + [*at({2: "Организационно-правовая форма***", 7: "ООО", 15: "по ОКОПФ/\nОКФС", 18: "65", 19: "16"})], + [*at({2: "Единица измерения:", 7: "тыс. руб", 17: "по ОКЕИ", 18: "384"})], + [*at({2: "Местонахождение (адрес)", 7: "636141, Томская обл"})], + ] + ) + + header, _ = parse_header(sheet, rules, 1, 8, _block(sheet, rules)) + + assert header.report_date == datetime.date(2025, 9, 30) and header.report_date_text is None + assert header.inn == "7016006635" and header.okopf == "65" and header.okfs == "16" + assert header.address == "636141, Томская обл" + + +def test_continuation_header_is_empty(rules: Rules) -> None: + sheet = sheet_from_rows([[*at({17: "Форма 0710001 с.2"})], ["Пояснения", "Наименование показателя", "Код", "На 31 декабря 2024 г."]]) + + header, warnings = parse_header(sheet, rules, 1, 1, None) + + assert header.form_name == "Форма 0710001 с.2" and header.organization_name is None and header.inn is None + assert header.report_date is None and warnings == [] + + +def test_missing_units_and_organization_warn(rules: Rules) -> None: + sheet = sheet_from_rows([["Бухгалтерский баланс"], ["на 31 декабря 2024 г."]]) + + header, warnings = parse_header(sheet, rules, 1, 2, _block(sheet, rules)) + + assert header.units is None and header.organization_name is None + assert any("единица измерения" in warning for warning in warnings) and any("организация" in warning for warning in warnings) +``` + +- [ ] **Step 2: Запустить — падает** + +- [ ] **Step 3: Реализация** + +```python +# API/src/parser/statements/header.py +"""Шапка формы по меткам словаря (design D-5): значение — правее метки в той же строке, иначе в следующей строке. + +Особые правила: ИНН ниже метки «аудитор» не берётся; организация — после «Организация», а при пустом значении — +ближайшая строка выше с признаком ОПФ (НПЗ); дата — из подзаголовка формы («на <дата>», «за <период>») либо из тройки +«число | месяц | год»; единицы — текст как в файле. Найденные поля получают `*_confidence = 100`. +""" + +import re +from collections.abc import Iterator + +from src.contract.result import StatementHeader +from src.parser.grid import Sheet +from src.parser.numbers import inn_digits +from src.parser.rules import Rules, StatementLabel +from src.parser.statements.columns import cell_label +from src.parser.statements.detect import FormBlock + +__all__ = ["parse_header"] + +_FULL_CONFIDENCE = 100 +_DIGITS = re.compile(r"\d+") +NO_ORGANIZATION_WARNING = "в шапке не найдена организация" +NO_UNITS_WARNING = "в шапке не найдена единица измерения" + + +def _cells(sheet: Sheet, first_row: int, last_row: int) -> Iterator[tuple[int, int, str]]: + for row in range(first_row, last_row + 1): + for col, _ in sheet.row_cells(row): + yield row, col, cell_label(sheet, row, col) + + +def _right(sheet: Sheet, row: int, col: int) -> list[tuple[int, str]]: + return [(c, cell_label(sheet, row, c)) for c, _ in sheet.row_cells(row) if c > col] + + +def _value_right_or_below(sheet: Sheet, row: int, col: int, last_row: int) -> str | None: + right = _right(sheet, row, col) + if right: + return right[0][1] + if row + 1 <= last_row: + below = [(c, cell_label(sheet, row + 1, c)) for c, _ in sheet.row_cells(row + 1)] + if below: + return below[0][1] + return None + + +def _label_rows(sheet: Sheet, rules: Rules, key: StatementLabel, first_row: int, last_row: int) -> list[tuple[int, int, str]]: + return [(row, col, text) for row, col, text in _cells(sheet, first_row, last_row) if rules.statement_headers.label(key, text)] + + +def _auditor_from(sheet: Sheet, rules: Rules, first_row: int, last_row: int) -> int: + """Первая строка блока аудитора; ниже неё ИНН/названия — не организации.""" + rows = [row for row, _, text in _cells(sheet, first_row, last_row) if rules.statement_headers.is_auditor_label(text)] + return min(rows) if rows else last_row + 1 + + +def _organization(sheet: Sheet, rules: Rules, first_row: int, last_row: int, auditor_from: int) -> str | None: + for row, col, _ in _label_rows(sheet, rules, "organization", first_row, min(last_row, auditor_from - 1)): + value = _value_right_or_below(sheet, row, col, last_row) + if value and not rules.statement_headers.label("okpo", value) and not value.isdigit(): + return value + for above in range(row - 1, first_row - 1, -1): + texts = [text for _, _, text in _cells(sheet, above, above)] + if any(rules.statement_headers.has_legal_form(text) for text in texts): + return next(text for text in texts if rules.statement_headers.has_legal_form(text)) + return None + + +def _inn(sheet: Sheet, rules: Rules, first_row: int, last_row: int, auditor_from: int) -> str | None: + for row, col, _ in _label_rows(sheet, rules, "inn", first_row, min(last_row, auditor_from - 1)): + for c, _ in _right(sheet, row, col): + digits = inn_digits(sheet.cell(row, c)) + if digits is not None: + return digits + return None + + +def _labelled(sheet: Sheet, rules: Rules, key: StatementLabel, first_row: int, last_row: int) -> str | None: + for row, col, _ in _label_rows(sheet, rules, key, first_row, last_row): + value = _value_right_or_below(sheet, row, col, last_row) + if value: + return value + return None + + +def _okopf_okfs(sheet: Sheet, rules: Rules, first_row: int, last_row: int) -> tuple[str | None, str | None]: + """«по ОКОПФ / ОКФС» с двумя числами правее (в той же или предыдущей строке — у 1С коды стоят строкой выше).""" + for row, col, _ in _label_rows(sheet, rules, "okopf_okfs", first_row, last_row): + for candidate in (row, row - 1): + numbers = [text for _, text in _right(sheet, candidate, col) if _DIGITS.fullmatch(text)] + if len(numbers) >= 2: # noqa: PLR2004 - пара кодов + return numbers[0], numbers[1] + if len(numbers) == 1: + return numbers[0], None + return _labelled(sheet, rules, "okopf", first_row, last_row), _labelled(sheet, rules, "okfs", first_row, last_row) + + +def _units(sheet: Sheet, rules: Rules, first_row: int, last_row: int) -> str | None: + for row, col, _ in _label_rows(sheet, rules, "units", first_row, last_row): + for _, text in _right(sheet, row, col): + if not rules.statement_headers.label("okei", text) and not text.isdigit(): + return text + return None + + +def _report_date(sheet: Sheet, rules: Rules, block: FormBlock, first_row: int, last_row: int) -> tuple[str | None, "datetime.date | None"]: + periods = rules.statement_periods + if block.title_row is not None and block.title_row + 1 <= last_row: + # Подзаголовок — только следующая строка за заголовком формы («на 31 декабря 2024 г.», «за 9 месяцев 2025 г.»); + # глубже лежат «Форма по ОКУД» и тройка даты, где голый «2025» принялся бы за период + for _, _, text in _cells(sheet, block.title_row + 1, block.title_row + 1): + end = periods.period_end(text) + if end is not None: + return text, end + for row, col, _ in _label_rows(sheet, rules, "date", first_row, last_row): + parts = [text for _, text in _right(sheet, row, col)] + if len(parts) >= 3: # noqa: PLR2004 - число, месяц, год + found = periods.date_triple(parts[0], parts[1], parts[2]) + if found is not None: + return None, found + return None, None + + +def parse_header( + sheet: Sheet, rules: Rules, first_row: int, last_row: int, block: FormBlock | None +) -> tuple[StatementHeader, list[str]]: + if block is None: + marker = next((text for _, _, text in _cells(sheet, first_row, last_row) if rules.statement_headers.is_continuation(text)), None) + return StatementHeader(form_name=marker), [] + auditor_from = _auditor_from(sheet, rules, first_row, last_row) + organization = _organization(sheet, rules, first_row, last_row, auditor_from) + inn = _inn(sheet, rules, first_row, last_row, auditor_from) + okopf, okfs = _okopf_okfs(sheet, rules, first_row, last_row) + units = _units(sheet, rules, first_row, last_row) + date_text, report_date = _report_date(sheet, rules, block, first_row, last_row) + warnings: list[str] = [] + if organization is None: + warnings.append(NO_ORGANIZATION_WARNING) + if units is None: + warnings.append(NO_UNITS_WARNING) + header = StatementHeader( + form_name=block.form_name, + organization_name=organization, + organization_name_confidence=_FULL_CONFIDENCE if organization else None, + inn=inn, + inn_confidence=_FULL_CONFIDENCE if inn else None, + report_date_text=date_text, + report_date=report_date, + units=units, + okud=block.okud, + okpo=_labelled(sheet, rules, "okpo", first_row, last_row), + okopf=okopf, + okfs=okfs, + address=_labelled(sheet, rules, "address", first_row, min(last_row, auditor_from - 1)), + ) + return header, warnings +``` + +Уточнения при реализации: `import datetime` для аннотации; `_labelled("okpo")` берёт первое значение правее метки — у 1С «по ОКПО | 47888105» в той же строке. `_organization`: значение правее «Организация» у 1С — название (col 4), у НПЗ справа только «по ОКПО» → отбрасывается по метке → поиск выше. Для `inn_digits` проверить сигнатуру в `numbers.py` (принимает `Cell`). + +- [ ] **Step 4: Прогнать, `make lint`** + +- [ ] **Step 5: Commit** + +```bash +git -C T add api/src/parser/statements/header.py api/tests/unit/parser/statements/test_header.py +git -C T commit -m "feat(statements): шапка формы по меткам — организация, ИНН не аудитора, дата, единицы, ОКПО/ОКОПФ/ОКФС, адрес + +Refs #14" +``` + +--- + +### Task 10: `statements/lines.py` — строки с кодом и значения + +**Files:** +- Create: `API/src/parser/statements/lines.py` +- Test: `API/tests/unit/parser/statements/test_lines.py` + +**Interfaces:** +- Consumes: `TableLayout`, `line_code`, `cell_label` (задача 5), `numbers.to_money`. +- Produces: `LinesResult(lines: list[StatementLine], duplicates: list[str], skipped_with_sums: int, fractional_values: int, numeric_values: int, warnings: list[str])`; `collect_lines(sheet, rules, layout, first_row, last_row) -> LinesResult`. + +- [ ] **Step 1: Тест** + +```python +# API/tests/unit/parser/statements/test_lines.py +"""Строки с кодом и значения (design D-6; spec «Строки с кодом и значения»).""" + +from decimal import Decimal + +import pytest +from gridbuilder import sheet_from_rows + +from src.parser.rules import Rules, load_rules +from src.parser.statements.columns import find_layout +from src.parser.statements.lines import collect_lines + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +ROWS = [ + ["Пояснения", "Наименование показателя", "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."], + [None, "АКТИВ"], + [None, "Нематериальные активы", "1110", Decimal("5971264.14"), 27405634], + [None, "Нематериальные активы\nв организации", "11101", Decimal("5852464.14"), "27 214 614,65"], + [None, None, None, 0, 0], + [None, "в том числе:"], + [None, "Финансовые вложения", "1170", None, 10], + [None, "Прочие", "1190", "–", "(2 100)"], + [None, "Запасы", "1210", "н/д", 5], + [None, "Запасы", "1210", 1, 1], + [None, "Итого по разделу I", "1100", Decimal("11823728.28"), 27405644], +] + + +def test_lines_values_and_skips(rules: Rules) -> None: + sheet = sheet_from_rows(ROWS) + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows) + + codes = [line.line_code for line in result.lines] + assert codes == ["1110", "11101", "1170", "1190", "1210", "1100"] + by_code = {line.line_code: line for line in result.lines} + assert by_code["11101"].line_name == "Нематериальные активы в организации" + assert [value.amount for value in by_code["11101"].values] == [Decimal("5852464.14"), Decimal("27214614.65")] + assert by_code["1170"].values[0].amount is None and by_code["1170"].values[0].is_recognized is True + assert by_code["1190"].values[0].amount is None and by_code["1190"].values[1].amount == Decimal("-2100.00") + unrecognised = by_code["1210"].values[0] + assert unrecognised.amount is None and unrecognised.string_value == "н/д" and unrecognised.is_recognized is False + assert all(value.confidence == 100 for line in result.lines for value in line.values) + assert all(line.line_code_confidence == 100 and line.line_name_confidence == 100 for line in result.lines) + assert [value.column_index for value in by_code["1110"].values] == [0, 1] + assert result.duplicates == ["1210"] and by_code["1210"].values[1].amount == Decimal("5.00") + assert result.skipped_with_sums == 1 # строка нулей без кода + assert result.fractional_values == 4 and result.numeric_values == 9 + + +def test_fractional_warning_when_units_thousands(rules: Rules) -> None: + sheet = sheet_from_rows(ROWS) + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows, units="в тыс. рублей") + + assert any(warning.startswith("значений с дробной частью при единице тыс. руб.: 4 из 9") for warning in result.warnings) + assert any("строк без кода с суммами пропущено: 1" in warning for warning in result.warnings) + + +def test_no_fractional_warning_without_thousands(rules: Rules) -> None: + sheet = sheet_from_rows(ROWS) + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows, units="руб.") + + assert not any("дробной частью" in warning for warning in result.warnings) + + +def test_line_without_name_column(rules: Rules) -> None: + sheet = sheet_from_rows([["Код", "На 31.12.2024"], ["1150", 5], ["1210", 6], ["1600", 11]]) + layout = find_layout(sheet, rules, 1, 4) + + result = collect_lines(sheet, rules, layout, layout.first_data_row, 4) + + assert [line.line_name for line in result.lines] == ["", "", ""] +``` + +- [ ] **Step 2: Запустить — падает** + +- [ ] **Step 3: Реализация** + +```python +# API/src/parser/statements/lines.py +"""Строки формы с кодом и их значения (design D-6). + +Строка с кодом в колонке кодов → `StatementLine`; строки без кода не отдаются (ASSUMPTION(OQ-36)), но если среди них +есть суммы — одно предупреждение. Повтор кода — остаётся первая строка, код в `duplicates`. Числа — `numbers.to_money` +(Decimal, скобки → минус, прочерк → null); нечисловой текст — `string_value`, `is_recognized = false`. Единицы не +нормализуются: при «тыс.» в единицах и доле значений с дробной частью ≥ `FRACTIONAL_SHARE` — предупреждение. +""" + +from dataclasses import dataclass, field +from decimal import Decimal + +from src.contract.result import StatementLine, StatementValue +from src.parser.grid import Sheet +from src.parser.numbers import to_money +from src.parser.rules import Rules, normalize_text +from src.parser.statements.columns import TableLayout, cell_label, line_code + +__all__ = ["FRACTIONAL_SHARE", "LinesResult", "collect_lines"] + +_FULL_CONFIDENCE = 100 +FRACTIONAL_SHARE = 0.1 +_THOUSANDS = "тыс" + + +@dataclass +class LinesResult: + lines: list[StatementLine] = field(default_factory=list) + duplicates: list[str] = field(default_factory=list) + skipped_with_sums: int = 0 + fractional_values: int = 0 + numeric_values: int = 0 + warnings: list[str] = field(default_factory=list) + + +def _value(sheet: Sheet, row: int, col: int, column_index: int) -> tuple[StatementValue, bool]: + """Значение ячейки и признак «число с дробной частью».""" + cell = sheet.cell(*sheet.merged_origin(row, col)) + parsed = to_money(cell) + if parsed.value is not None: + return StatementValue(column_index=column_index, amount=parsed.value, confidence=_FULL_CONFIDENCE, is_recognized=True), parsed.value % 1 != 0 + if parsed.warning is None: # пусто или прочерк + return StatementValue(column_index=column_index, amount=None, confidence=_FULL_CONFIDENCE, is_recognized=True), False + return StatementValue(column_index=column_index, amount=None, string_value=cell.text, confidence=_FULL_CONFIDENCE, is_recognized=False), False + + +def collect_lines( + sheet: Sheet, rules: Rules, layout: TableLayout, first_row: int, last_row: int, *, units: str | None = None +) -> LinesResult: + result = LinesResult() + seen: set[str] = set() + for row in range(first_row, last_row + 1): + if sheet.is_row_empty(row): + continue + code = line_code(sheet.cell(row, layout.code_col), rules) + if code is None: + if any(to_money(sheet.cell(row, column.col)).value is not None for column in layout.periods): + result.skipped_with_sums += 1 + continue + if code in seen: + result.duplicates.append(code) + continue + seen.add(code) + values: list[StatementValue] = [] + for index, column in enumerate(layout.periods): + value, fractional = _value(sheet, row, column.col, index) + values.append(value) + if value.amount is not None: + result.numeric_values += 1 + result.fractional_values += int(fractional) + name = cell_label(sheet, row, layout.name_col) if layout.name_col is not None else "" + result.lines.append( + StatementLine(line_code=code, line_code_confidence=_FULL_CONFIDENCE, line_name=name, line_name_confidence=_FULL_CONFIDENCE, values=values) + ) + if result.skipped_with_sums: + result.warnings.append(f"строк без кода с суммами пропущено: {result.skipped_with_sums}") + if units and _THOUSANDS in normalize_text(units) and result.numeric_values and result.fractional_values / result.numeric_values >= FRACTIONAL_SHARE: + result.warnings.append(f"значений с дробной частью при единице тыс. руб.: {result.fractional_values} из {result.numeric_values}") + return result +``` + +Примечание к тесту: `to_money` возвращает квантованный Decimal, поэтому `% 1` корректно; дробных значений четыре (5971264.14, 5852464.14, 27214614.65, 11823728.28). Дубликат «1210»: первое вхождение (`["н/д", 5]`) остаётся, второе пропускается. + +- [ ] **Step 4: Прогнать, lint** + +- [ ] **Step 5: Commit** + +```bash +git -C T add api/src/parser/statements/lines.py api/tests/unit/parser/statements/test_lines.py +git -C T commit -m "feat(statements): строки с кодом, значения Decimal, дубликаты кодов, предупреждение о единицах + +Refs #14" +``` + +--- + +### Task 11: `statements/checks.py` — соотношения §7 + +**Files:** +- Create: `API/src/parser/statements/checks.py` +- Test: `API/tests/unit/parser/statements/test_checks.py` + +**Interfaces:** +- Consumes: `Rules.statement_checks` (`RelationRule.target/terms/abs_terms/strict`, `tolerance`, `subline_check`), `StatementLine`, `PeriodColumn`. +- Produces: `run_statement_checks(lines: Sequence[StatementLine], column_count: int, form: FormType, rules) -> tuple[StatementChecks, list[str]]` — `duplicate_line_codes` заполняет вызывающий (`statement.py`), здесь только соотношения. + +- [ ] **Step 1: Тест** + +```python +# API/tests/unit/parser/statements/test_checks.py +"""Соотношения §7 как данные (design D-7; spec «Соотношения §7 — данные, проверка по колонкам»).""" + +from decimal import Decimal + +import pytest + +from src.contract.result import StatementLine, StatementValue +from src.parser.rules import Rules, load_rules +from src.parser.statements.checks import run_statement_checks + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +def line(code: str, *amounts: str | None) -> StatementLine: + values = [StatementValue(column_index=index, amount=None if amount is None else Decimal(amount), confidence=100, is_recognized=True) for index, amount in enumerate(amounts)] + return StatementLine(line_code=code, line_code_confidence=100, line_name=code, line_name_confidence=100, values=values) + + +def test_balance_relations_hold_with_missing_codes_as_zero(rules: Rules) -> None: + lines = [line("1150", "100", "90"), line("1100", "100", "90"), line("1210", "50", "40"), line("1200", "50", "40"), line("1600", "150", "130"), line("1310", "150", "130"), line("1300", "150", "130"), line("1700", "150", "130")] + + checks, warnings = run_statement_checks(lines, 2, "BALANCE", rules) + + assert checks.relations_failed == [] and checks.soft_relations_failed == [] and warnings == [] + + +def test_broken_total_reported_per_column(rules: Rules) -> None: + lines = [line("1100", "100", "100"), line("1200", "50", "50"), line("1600", "155", "150")] + + checks, warnings = run_statement_checks(lines, 2, "BALANCE", rules) + + failed = [(item.column_index, item.relation, item.expected, item.actual) for item in checks.relations_failed] + assert (0, "1600 = 1100 + 1200", Decimal("155"), Decimal("150")) in failed + assert not any(item.column_index == 1 and item.relation == "1600 = 1100 + 1200" for item in checks.relations_failed) + assert any("1600 = 1100 + 1200" in warning and "колонка 0" in warning for warning in warnings) + + +def test_tolerance_one_unit(rules: Rules) -> None: + lines = [line("1100", "100"), line("1200", "50"), line("1600", "151"), line("1700", "151"), line("1300", "151")] + + checks, _ = run_statement_checks(lines, 1, "BALANCE", rules) + + assert checks.relations_failed == [] + + +def test_relation_holds_with_abs_terms(rules: Rules) -> None: + """Review Focus 3: 1С пишет расходы со знаком минус, 66н — в скобках; оба варианта сходятся.""" + one_c = [line("2110", "1000"), line("2120", "-600"), line("2100", "400")] + brackets = [line("2110", "1000"), line("2120", "600"), line("2100", "400")] + + assert run_statement_checks(one_c, 1, "INCOME", rules)[0].relations_failed == [] + assert run_statement_checks(brackets, 1, "INCOME", rules)[0].relations_failed == [] + + +def test_soft_relation_and_sublines(rules: Rules) -> None: + lines = [line("2400", "-100"), line("2410", "-100"), line("2411", "-80"), line("2412", "-30"), line("1110", "10"), line("11101", "6"), line("11102", "3")] + + checks, warnings = run_statement_checks(lines, 1, "INCOME", rules) + balance, _ = run_statement_checks(lines, 1, "BALANCE", rules) + + assert [item.relation for item in checks.soft_relations_failed] == ["2410 = 2411 + 2412"] + assert checks.relations_failed == [] + assert [item.relation for item in balance.soft_relations_failed] == ["1110 = 11101 + 11102"] + assert any("мягкое" in warning for warning in warnings) + + +def test_null_values_count_as_zero_and_empty_form_passes(rules: Rules) -> None: + lines = [line("1100", None), line("1200", None), line("1600", None)] + + checks, _ = run_statement_checks(lines, 1, "BALANCE", rules) + + assert checks.relations_failed == [] +``` + +- [ ] **Step 2: Запустить — падает** + +- [ ] **Step 3: Реализация** + +```python +# API/src/parser/statements/checks.py +"""Соотношения §7 по колонкам (design D-7): правила — `statement_checks.yaml`; отсутствующий код — 0; правая часть +считается как записано и с членами `abs_terms` по модулю; допуск `tolerance`. Жёсткие нарушения → `relations_failed`, +мягкие и «сумма подстрок = родитель» → `soft_relations_failed`; всё дублируется предупреждениями. +""" + +from collections.abc import Sequence +from decimal import Decimal + +from src.contract.result import FailedRelation, StatementChecks, StatementLine +from src.parser.rules import FormType, RelationRule, Rules + +__all__ = ["run_statement_checks"] + +_ZERO = Decimal("0") +_PARENT_LEN = 4 + + +def _amounts(lines: Sequence[StatementLine], column_count: int) -> list[dict[str, Decimal]]: + """Код → сумма по каждой колонке (null → 0).""" + columns: list[dict[str, Decimal]] = [{} for _ in range(column_count)] + for item in lines: + for value in item.values: + if 0 <= value.column_index < column_count: + columns[value.column_index][item.line_code] = value.amount if value.amount is not None else _ZERO + return columns + + +def _rhs(amounts: dict[str, Decimal], rule: RelationRule, *, absolute: bool) -> Decimal: + total = _ZERO + for sign, code in rule.terms: + value = amounts.get(code, _ZERO) + if absolute and code in rule.abs_terms: + value = abs(value) + total += sign * value + return total + + +def _holds(amounts: dict[str, Decimal], rule: RelationRule, tolerance: Decimal) -> tuple[bool, Decimal, Decimal]: + expected = amounts.get(rule.target, _ZERO) + plain = _rhs(amounts, rule, absolute=False) + if abs(expected - plain) <= tolerance: + return True, expected, plain + if rule.abs_terms: + with_abs = _rhs(amounts, rule, absolute=True) + if abs(expected - with_abs) <= tolerance: + return True, expected, with_abs + return False, expected, plain + + +def _subline_rules(lines: Sequence[StatementLine], form: FormType, rules: Rules) -> list[RelationRule]: + """«1110 = 11101 + 11102 + …» для каждого четырёхзначного кода формы (в диапазоне кодов этой формы), у которого + есть пятизначные подстроки.""" + low, high = rules.statement_headers.code_ranges[form] + codes = [item.line_code for item in lines] + rules_out: list[RelationRule] = [] + for parent in codes: + if len(parent) != _PARENT_LEN or not low <= int(parent) <= high: + continue + children = [code for code in codes if len(code) == _PARENT_LEN + 1 and code.startswith(parent)] + if children: + rules_out.append(RelationRule(form=form, relation=f"{parent} = {' + '.join(children)}", strict=False)) + return rules_out + + +def run_statement_checks( + lines: Sequence[StatementLine], column_count: int, form: FormType, rules: Rules +) -> tuple[StatementChecks, list[str]]: + config = rules.statement_checks + relations = list(config.relations_for(form)) + if config.subline_check: + relations.extend(_subline_rules(lines, form, rules)) + checks = StatementChecks() + warnings: list[str] = [] + for column_index, amounts in enumerate(_amounts(lines, column_count)): + for rule in relations: + holds, expected, actual = _holds(amounts, rule, config.tolerance) + if holds: + continue + failed = FailedRelation(column_index=column_index, relation=rule.relation, expected=expected, actual=actual) + if rule.strict: + checks.relations_failed.append(failed) + warnings.append(f"соотношение «{rule.relation}» нарушено (колонка {column_index}): {expected} ≠ {actual}") + else: + checks.soft_relations_failed.append(failed) + warnings.append(f"мягкое соотношение «{rule.relation}» нарушено (колонка {column_index}): {expected} ≠ {actual}") + return checks, warnings +``` + +`RelationRule` в `_subline_rules` требует четырёхзначные коды в `relation` по регулярке `_RELATION` — пятизначные подстроки не пройдут. Ослабить `_RELATION`/`_TERM` в `rules.py` до `\d{4,5}` (справа), цель — `\d{4}`. Обновить тест задачи 4, если нужно. + +- [ ] **Step 4: Прогнать, lint** + +- [ ] **Step 5: Commit** + +```bash +git -C T add api/src/parser/statements/checks.py api/src/parser/rules.py api/tests +git -C T commit -m "feat(statements): соотношения §7 из словаря — жёсткие/мягкие, abs_terms, допуск, подстроки + +Refs #14" +``` + +--- + +### Task 12: `statements/statement.py` + `merge.py` — `StatementParser`, склейка, статус; подключение в `get_parser` + +**Files:** +- Create: `API/src/parser/statements/statement.py`, `API/src/parser/statements/merge.py` +- Modify: `API/src/parser/__init__.py` (`get_parser` → `StatementParser(rules)`), `API/src/parser/book.py` (удалить `SkippingStatementParser`), `API/tests/unit/parser/test_book_parser.py` (тест заглушки → тест реального конвейера), `API/tests/fixtures/test_rules_coverage.py` +- Test: `API/tests/unit/parser/statements/test_statement.py` + +**Interfaces:** +- Consumes: всё из задач 5–11, `SheetKind`, `Deadline`. +- Produces: `StatementParser(rules)` с `parse_sheets(sheets: Sequence[tuple[Sheet, SheetKind]], deadline) -> tuple[list[StatementDocument], list[str], bool]`; `LOCATOR_PREFIX = "sheet:"`, `LOCATOR_SEPARATOR = ","`; предупреждения вида `sheet=<имя> …`; константы `CONTINUATION_WARNING = "страница-продолжение без шапки: загрузите форму одним файлом"`, `NO_CODE_COLUMN_WARNING`. + +- [ ] **Step 1: Тест** + +```python +# API/tests/unit/parser/statements/test_statement.py +"""Конвейер форм: блоки, склейка продолжений, статус документа (design D-8; spec «Склейка листов-продолжений и статус +документа»). Книги собираются `gridbuilder`, вид листа задаётся явно, как это делает `BookParser`.""" + +from decimal import Decimal + +import pytest +from gridbuilder import at, sheet_from_rows + +from src.parser.deadline import Deadline +from src.parser.detect import SheetKind +from src.parser.rules import Rules, load_rules +from src.parser.statements.statement import CONTINUATION_WARNING, StatementParser + + +@pytest.fixture(scope="module") +def parser() -> StatementParser: + return StatementParser(load_rules()) + + +def deadline() -> Deadline: + return Deadline(120) + + +BALANCE_PAGE_1 = [ + ["Бухгалтерский баланс"], + ["на 31 декабря 2024 г."], + ["Форма по ОКУД", "0710001"], + ["Организация", 'ООО "Пример"', "ИНН", "7700000000"], + ["Единица измерения:", "тыс. руб."], + ["Наименование показателя", "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."], + ["Основные средства", "1150", 100, 90], + ["Итого по разделу I", "1100", 100, 90], + ["Запасы", "1210", 50, 40], + ["Итого по разделу II", "1200", 50, 40], + ["БАЛАНС", "1600", 150, 130], +] +BALANCE_PAGE_2 = [ + [*at({6: "Форма 0710001 с.2"})], + ["Наименование показателя", "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."], + ["Уставный капитал", "1310", 150, 130], + ["Итого по разделу III", "1300", 150, 130], + ["БАЛАНС", "1700", 150, 130], +] +INCOME = [ + ["Отчет о финансовых результатах"], + ["за 2024 г."], + ["Организация", 'ООО "Пример"'], + ["Единица измерения:", "тыс. руб."], + ["Наименование показателя", "Код", "За 2024 г."], + ["Выручка", "2110", 1000], + ["Себестоимость продаж", "2120", -600], + ["Валовая прибыль (убыток)", "2100", 400], + ["Коммерческие расходы", "2210", -50], + ["Управленческие расходы", "2220", -50], + ["Прибыль (убыток) от продаж", "2200", 300], + ["Проценты к уплате", "2330", -100], + ["Прибыль (убыток) до налогообложения", "2300", 200], + ["Налог на прибыль", "2410", -40], + ["Чистая прибыль (убыток)", "2400", 160], +] +# Полный набор: 2200 = 2100 − 2210 − 2220 и 2300 = 2200 − 2330 сходятся через abs_terms (знаки 1С), 2400 — мягкое +NPZ_CONTINUATION = [ + ["в том числе:"], + ["Биологические активы", "11901", 0, 0], + ["Расходы будущих периодов", "11903", 0, 0], + ["Прочие внеоборотные активы", "1190", 0, 0], +] + + +def test_full_balance_over_two_sheets_is_done(parser: StatementParser) -> None: + sheets = [(sheet_from_rows(BALANCE_PAGE_1, "ББ1"), SheetKind.STATEMENT), (sheet_from_rows(BALANCE_PAGE_2, "ББ2"), SheetKind.STATEMENT_CONTINUATION)] + + documents, warnings, ok = parser.parse_sheets(sheets, deadline()) + + assert ok and len(documents) == 1 + document = documents[0] + assert document.document_type == "BALANCE" and document.locator == "sheet:ББ1,ББ2" + assert [line.line_code for line in document.lines] == ["1150", "1100", "1210", "1200", "1600", "1310", "1300", "1700"] + assert document.checks is not None and document.checks.relations_failed == [] + assert document.header.inn == "7700000000" and document.header.units == "тыс. руб." + assert len(document.columns) == 2 and document.columns[0].label == "На 31 декабря 2024 г." + + +def test_page_one_alone_fails_total_relation(parser: StatementParser) -> None: + documents, warnings, ok = parser.parse_sheets([(sheet_from_rows(BALANCE_PAGE_1), SheetKind.STATEMENT)], deadline()) + + assert not ok + assert [item.relation for item in documents[0].checks.relations_failed] == ["1600 = 1700", "1600 = 1700"] + assert any("1600 = 1700" in warning for warning in warnings) + + +def test_lonely_continuation_is_needs_check(parser: StatementParser) -> None: + documents, warnings, ok = parser.parse_sheets([(sheet_from_rows(BALANCE_PAGE_2, "с.2"), SheetKind.STATEMENT_CONTINUATION)], deadline()) + + assert not ok and documents[0].document_type == "BALANCE" + assert documents[0].header.organization_name is None and documents[0].header.form_name == "Форма 0710001 с.2" + assert [line.line_code for line in documents[0].lines] == ["1310", "1300", "1700"] + assert f"sheet=с.2 {CONTINUATION_WARNING}" in warnings + + +def test_continuation_joins_last_open_document(parser: StatementParser) -> None: + """Review Focus 4: продолжение после ОФР относится к ОФР, а не к балансу.""" + income_tail = [["Прочее", "2460", 0], ["Совокупный финансовый результат периода", "2500", 160]] + sheets = [ + (sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT), + (sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), + (sheet_from_rows(income_tail, "ОФР_2"), SheetKind.STATEMENT_CONTINUATION), + ] + + documents, _, _ = parser.parse_sheets(sheets, deadline()) + + assert [document.document_type for document in documents] == ["BALANCE", "INCOME"] + assert documents[1].locator == "sheet:ОФР,ОФР_2" and [line.line_code for line in documents[1].lines][-2:] == ["2460", "2500"] + + +def test_continuation_without_header_maps_value_columns_by_order(parser: StatementParser) -> None: + sheets = [(sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT), (sheet_from_rows(NPZ_CONTINUATION, "ББ_2"), SheetKind.STATEMENT_CONTINUATION)] + + documents, _, _ = parser.parse_sheets(sheets, deadline()) + + tail = documents[0].lines[-1] + assert tail.line_code == "1190" and [value.column_index for value in tail.values] == [0, 1] + assert [line.line_code for line in documents[0].lines][-3:] == ["11901", "11903", "1190"] + + +def test_continuation_with_other_column_count_opens_new_document(parser: StatementParser) -> None: + three_columns = [["Форма 0710001 с.2"], ["Наименование", "Код", "На 31.12.2024", "На 31.12.2023", "На 31.12.2022"], ["Уставный капитал", "1310", 1, 1, 1]] + sheets = [(sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT), (sheet_from_rows(three_columns, "с.2"), SheetKind.STATEMENT_CONTINUATION)] + + documents, warnings, ok = parser.parse_sheets(sheets, deadline()) + + assert len(documents) == 2 and not ok and any(CONTINUATION_WARNING in warning for warning in warnings) + + +def test_two_forms_on_one_sheet(parser: StatementParser) -> None: + sheet = sheet_from_rows([*BALANCE_PAGE_1, *BALANCE_PAGE_2[2:], [], *INCOME], "Отчетность") + + documents, _, ok = parser.parse_sheets([(sheet, SheetKind.STATEMENT)], deadline()) + + assert [document.document_type for document in documents] == ["BALANCE", "INCOME"] and ok + assert all(document.locator == "sheet:Отчетность" for document in documents) + assert "2110" not in [line.line_code for line in documents[0].lines] + + +def test_disclosure_only_book_is_done(parser: StatementParser) -> None: + """Review Focus 5.""" + sheet = sheet_from_rows([["Пояснения к бухгалтерскому балансу и отчету о финансовых результатах"], ["1. Нематериальные активы"], ["текст"]], "Пояснения") + + documents, warnings, ok = parser.parse_sheets([(sheet, SheetKind.STATEMENT)], deadline()) + + assert ok and documents[0].document_type == "DISCLOSURE" and documents[0].lines == [] and documents[0].checks is None + + +def test_no_code_column_is_needs_check_with_warning(parser: StatementParser) -> None: + sheet = sheet_from_rows([["Бухгалтерский баланс"], ["Организация", "ООО Х"], ["Показатель", "На 31.12.2024"], ["Основные средства", 100], ["Запасы", 50], ["БАЛАНС", 150]], "ББ") + + documents, warnings, ok = parser.parse_sheets([(sheet, SheetKind.STATEMENT)], deadline()) + + assert not ok and documents[0].lines == [] and documents[0].columns == [] + assert "sheet=ББ колонка кодов не найдена" in warnings + + +def test_duplicate_code_is_needs_check(parser: StatementParser) -> None: + rows = [*BALANCE_PAGE_1, ["Запасы", "1210", 1, 1]] + + documents, _, ok = parser.parse_sheets([(sheet_from_rows(rows), SheetKind.STATEMENT)], deadline()) + + assert not ok and documents[0].checks.duplicate_line_codes == ["1210"] + + +def test_template_without_sums_is_needs_check(parser: StatementParser) -> None: + rows = [["Бухгалтерский баланс"], ["Наименование показателя", "Код", "На 31 декабря 2024 г."], ["Основные средства", "1150"], ["Запасы", "1210"], ["БАЛАНС", "1600"]] + + documents, _, ok = parser.parse_sheets([(sheet_from_rows(rows), SheetKind.STATEMENT)], deadline()) + + assert not ok and documents[0].checks.relations_failed == [] and all(value.amount is None for line in documents[0].lines for value in line.values) +``` + +- [ ] **Step 2: Запустить — падает** + +- [ ] **Step 3: Реализация `merge.py` и `statement.py`** + +```python +# API/src/parser/statements/merge.py +"""Черновик документа формы и присоединение листов-продолжений (design D-8).""" + +from dataclasses import dataclass, field + +from src.contract.result import StatementHeader, StatementLine +from src.parser.rules import FormType +from src.parser.statements.columns import PeriodColumn, TableLayout + +__all__ = ["Draft", "can_join", "join"] + + +@dataclass +class Draft: + kind: FormType | None # None — продолжение без ОКУД в маркере → OTHER + header: StatementHeader + periods: tuple[PeriodColumn, ...] + sheet_names: list[str] + lines: list[StatementLine] = field(default_factory=list) + duplicates: list[str] = field(default_factory=list) + has_code_column: bool = True + lonely_continuation: bool = False + fractional_values: int = 0 + numeric_values: int = 0 + warnings: list[str] = field(default_factory=list) + + @property + def codes(self) -> set[str]: + return {line.line_code for line in self.lines} + + +def can_join(draft: Draft, layout: TableLayout) -> bool: + """Продолжение присоединяется, если число колонок-сумм совпало с открытым документом.""" + return draft.has_code_column and len(layout.periods) == len(draft.periods) + + +def join(draft: Draft, sheet_name: str, lines: list[StatementLine], duplicates: list[str]) -> None: + """Строки продолжения в конец документа; коды, уже бывшие в документе, — в дубликаты.""" + for line in lines: + if line.line_code in draft.codes: + draft.duplicates.append(line.line_code) + continue + draft.lines.append(line) + draft.duplicates.extend(duplicates) + draft.sheet_names.append(sheet_name) +``` + +```python +# API/src/parser/statements/statement.py +"""Конвейер форм (design D-3…D-8): листы книги по порядку → блоки форм → шапка, таблица, строки → продолжения → +соотношения §7 → `statementDocument`. `ok` документа: жёсткие соотношения сошлись, дубликатов нет, колонка кодов +найдена, есть колонка-период, в шапке есть организация или ИНН, документ не одинокое продолжение. DISCLOSURE на +статус не влияет. +""" + +from collections.abc import Sequence + +from src.contract.result import StatementColumn, StatementDocument, StatementHeader +from src.parser.deadline import Deadline +from src.parser.detect import SheetKind +from src.parser.grid import Sheet +from src.parser.rules import Rules +from src.parser.statements.checks import run_statement_checks +from src.parser.statements.columns import NO_CODE_COLUMN, LayoutNotFoundError, TableLayout, find_layout +from src.parser.statements.detect import FormBlock, continuation_form, find_forms +from src.parser.statements.header import parse_header +from src.parser.statements.lines import collect_lines +from src.parser.statements.merge import Draft, can_join, join + +__all__ = ["CONTINUATION_WARNING", "LOCATOR_PREFIX", "LOCATOR_SEPARATOR", "StatementParser"] + +LOCATOR_PREFIX = "sheet:" +LOCATOR_SEPARATOR = "," +CONTINUATION_WARNING = "страница-продолжение без шапки: загрузите форму одним файлом" +SKIPPED_COLUMN_WARNING = "колонка без периода пропущена" + + +def _line(sheet: Sheet, text: str) -> str: + return f"sheet={sheet.name} {text}" + + +class StatementParser: + def __init__(self, rules: Rules) -> None: + self._rules = rules + + def parse_sheets( + self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline + ) -> tuple[list[StatementDocument], list[str], bool]: + drafts: list[Draft] = [] + warnings: list[str] = [] + for sheet, kind in sheets: + deadline.check(f"лист «{sheet.name}»: форма") + if kind is SheetKind.STATEMENT: + blocks, block_warnings = find_forms(sheet, self._rules) + warnings.extend(_line(sheet, text) for text in block_warnings) + for block in blocks: + drafts.append(self._block_draft(sheet, block, warnings)) + else: + self._continuation(sheet, drafts, warnings) + documents = [self._document(draft) for draft in drafts] + for draft, document in zip(drafts, documents, strict=True): + warnings.extend(draft.warnings) + ok = all(self._is_ok(draft, document) for draft, document in zip(drafts, documents, strict=True)) + return documents, warnings, ok + + # --- блоки --- + + def _block_draft(self, sheet: Sheet, block: FormBlock, warnings: list[str]) -> Draft: + if block.kind == "DISCLOSURE": + header, _ = parse_header(sheet, self._rules, block.first_row, block.last_row, block) + return Draft(kind=block.kind, header=header, periods=(), sheet_names=[sheet.name]) + try: + layout = find_layout(sheet, self._rules, block.first_row, block.last_row) + except LayoutNotFoundError: + header, header_warnings = parse_header(sheet, self._rules, block.first_row, block.last_row, block) + warnings.extend(_line(sheet, text) for text in [NO_CODE_COLUMN, *header_warnings]) + return Draft(kind=block.kind, header=header, periods=(), sheet_names=[sheet.name], has_code_column=False) + header_last = (layout.header_row or layout.first_data_row) - 1 + header, header_warnings = parse_header(sheet, self._rules, block.first_row, header_last, block) + draft = Draft(kind=block.kind, header=header, periods=layout.periods, sheet_names=[sheet.name]) + self._fill(sheet, draft, layout, layout.first_data_row, block.last_row, header_warnings, warnings) + return draft + + def _continuation(self, sheet: Sheet, drafts: list[Draft], warnings: list[str]) -> None: + try: + layout = find_layout(sheet, self._rules, 1, sheet.n_rows) + except LayoutNotFoundError: + warnings.append(_line(sheet, NO_CODE_COLUMN)) + return + open_draft = next((draft for draft in reversed(drafts) if draft.kind != "DISCLOSURE"), None) + if open_draft is not None and can_join(open_draft, layout): + result = collect_lines(sheet, self._rules, layout, layout.first_data_row, sheet.n_rows, units=open_draft.header.units) + join(open_draft, sheet.name, result.lines, result.duplicates) + open_draft.fractional_values += result.fractional_values + open_draft.numeric_values += result.numeric_values + warnings.extend(_line(sheet, text) for text in result.warnings) + return + kind, marker = continuation_form(sheet, self._rules) + header, _ = parse_header(sheet, self._rules, 1, (layout.header_row or layout.first_data_row) - 1, None) + draft = Draft(kind=kind, header=header, periods=layout.periods, sheet_names=[sheet.name], lonely_continuation=True) + warnings.append(_line(sheet, CONTINUATION_WARNING)) + self._fill(sheet, draft, layout, layout.first_data_row, sheet.n_rows, [], warnings) + drafts.append(draft) + + def _fill( # noqa: PLR0913 - все части документа передаются явно + self, sheet: Sheet, draft: Draft, layout: TableLayout, first_row: int, last_row: int, header_warnings: list[str], warnings: list[str] + ) -> None: + result = collect_lines(sheet, self._rules, layout, first_row, last_row, units=draft.header.units) + draft.lines = result.lines + draft.duplicates = result.duplicates + draft.fractional_values, draft.numeric_values = result.fractional_values, result.numeric_values + texts = [*header_warnings, *result.warnings] + if layout.skipped_labels: + texts.append(f"{SKIPPED_COLUMN_WARNING}: {', '.join(layout.skipped_labels)}") + warnings.extend(_line(sheet, text) for text in texts) + + # --- документ --- + + def _document(self, draft: Draft) -> StatementDocument: + kind = draft.kind or "OTHER" + columns = [StatementColumn(column_index=index, label=column.label, period_date=column.period_date) for index, column in enumerate(draft.periods)] + locator = LOCATOR_PREFIX + LOCATOR_SEPARATOR.join(draft.sheet_names) + if kind == "DISCLOSURE" or not draft.has_code_column: + return StatementDocument(document_type=kind, locator=locator, header=draft.header, columns=columns if draft.has_code_column else [], lines=[]) + checks, check_warnings = run_statement_checks(draft.lines, len(columns), kind if kind != "OTHER" else "BALANCE", self._rules) + checks.duplicate_line_codes = list(dict.fromkeys(draft.duplicates)) + draft.warnings.extend(f"sheet={draft.sheet_names[0]} {text}" for text in check_warnings) + return StatementDocument(document_type=kind, locator=locator, header=draft.header, columns=columns, lines=draft.lines, checks=checks) + + @staticmethod + def _is_ok(draft: Draft, document: StatementDocument) -> bool: + if document.document_type == "DISCLOSURE": + return True + header: StatementHeader = document.header + return ( + draft.has_code_column + and not draft.lonely_continuation + and bool(document.columns) + and bool(header.organization_name or header.inn) + and document.checks is not None + and not document.checks.relations_failed + and not document.checks.duplicate_line_codes + ) +``` + +Для `kind == "OTHER"` (продолжение без ОКУД в маркере) соотношения считаются по правилам ББ — зафиксировать как решение без обсуждения в design (таких документов `ok` всё равно нет: одинокое продолжение). + +`get_parser`: заменить `SkippingStatementParser()` на `StatementParser(rules)` (`from src.parser.statements.statement import StatementParser`); удалить `SkippingStatementParser` из `book.py`, тест заглушки в `test_book_parser.py` переписать: книга с листом баланса даёт `StatementDocument` (как в `test_statement_sheets_go_to_statement_parser_in_sheet_order`, но без фейка). `tests/fixtures/test_rules_coverage.py`: `statements=StatementParser(rules)`. + +- [ ] **Step 4: `make check`; прогнать CLI на реальных файлах (ручная сверка, без golden)** + +```bash +cd API && for f in ../../fixtures/statements-xlsx/npz/2025-Q3/*.xlsx ../../fixtures/statements-xlsx/mezheninovskaya/2025-Q3/*.xlsx "../../fixtures/statements-xlsx/unagrande-co/2024-FY/Б.Б Лист 1.xls"; do ENV_FILE=.env.test .venv/bin/python -m src.parser.cli "$f" | .venv/bin/python -c "import json,sys; r=json.load(sys.stdin); print(r['status'], [(d['document_type'], d['locator'][:40], len(d.get('lines',[])), d.get('checks',{}).get('relations_failed')) for d in r['documents']]); print(*r['metrics']['warnings'], sep='\n')"; done +``` +Expected: НПЗ — `done`, BALANCE (5 листов) и INCOME (3 листа), `relations_failed = []`; Межениновская — `done`; Унагранде стр. 1 — `needs_check` с `1600 = 1700`. Расхождения разбирать здесь (systematic-debugging), а не подгонять ожидания. + +- [ ] **Step 5: Commit** + +```bash +git -C T add api/src/parser api/tests +git -C T commit -m "feat(statements): конвейер форм — блоки, склейка продолжений, статус документа; подключение в get_parser + +Refs #14" +``` + +--- + +### Task 13: Синтетика форм `st01…st05` + +**Files:** +- Create: `API/tests/synthetic/generate_statements.py` +- Modify: `API/tests/fixtures_helpers.py` (`statement_files`, `statement_synthetic_files`, `STATEMENTS_DIR = "statements-xlsx"`), `API/tests/conftest.py` (параметры `statement_file`, `statement_synthetic_file`) +- Test: `API/tests/synthetic/test_generate_statements.py` (детерминизм, имена), `API/tests/synthetic/test_statement_synthetic_files.py` (сценарии спеки на файлах, маркер `fixtures`) +- Fixtures (репозиторий arm-ka-fixtures, worktree `.wt/slice-2b/fixtures-wt`, ветка `feat/14-statements-golden`): `statements-xlsx/synthetic/st0*.xlsx`, `statements-xlsx/synthetic/README.md` + +**Interfaces:** +- Produces: `generate_statements.FILE_NAMES`, `generate_all(directory)`, `main(argv)`; `fixtures_helpers.statement_files(root) -> list[Path]` (все книги `statements-xlsx/**`, кроме `synthetic/`; `templates/` входят), `statement_synthetic_files(root)`. + +- [ ] **Step 1: Worktree fixtures** + +```bash +W=/Users/andrey.epifancev/projects/awp-ca +git -C $W/fixtures fetch origin && git -C $W/fixtures worktree add -b feat/14-statements-golden $W/.wt/slice-2b/fixtures-wt origin/main +rm $W/.wt/slice-2b/fixtures && ln -s fixtures-wt $W/.wt/slice-2b/fixtures +``` + +- [ ] **Step 2: Тест генератора** + +```python +# API/tests/synthetic/test_generate_statements.py +from pathlib import Path + +from tests.synthetic.generate_statements import FILE_NAMES, generate_all + +EXPECTED = ("st01_both_forms_one_sheet.xlsx", "st02_csv_like.xlsx", "st03_no_codes.xlsx", "st04_mixed_book.xlsx", "st05_continuation_only.xlsx") + + +def test_names_and_determinism(tmp_path: Path) -> None: + assert FILE_NAMES == EXPECTED + first = {path.name: path.read_bytes() for path in generate_all(tmp_path / "a")} + second = {path.name: path.read_bytes() for path in generate_all(tmp_path / "b")} + assert first == second and set(first) == set(EXPECTED) +``` + +- [ ] **Step 3: Генератор** + +```python +# API/tests/synthetic/generate_statements.py +"""Синтетические формы ББ/ОФР (Срез 2b): `python -m tests.synthetic.generate_statements <каталог>` из `api/`. + +st01 — ББ и ОФР на одном листе; st02 — «csv-подобный» лист: заголовок формы, коды без подписи «Код», заголовок в две +строки, колонка «Изменение, %», отрицательные в скобках; st03 — форма без колонки кодов; st04 — книга с листом ОСВ +и листом баланса; st05 — одинокая страница 2. Детерминирован (см. `generate.py`). +""" + +import argparse +import sys +from collections.abc import Callable, Sequence +from pathlib import Path + +from tests.synthetic.generate import ORG, SheetSpec, build_osv, OsvSpec, base_tree, to_xlsx + +__all__ = ["FILE_NAMES", "generate_all", "main"] + +BALANCE_LINES = [("Основные средства", "1150", 100, 90), ("Итого по разделу I", "1100", 100, 90), ("Запасы", "1210", 50, 40), ("Итого по разделу II", "1200", 50, 40), ("БАЛАНС", "1600", 150, 130), ("Уставный капитал", "1310", 150, 130), ("Итого по разделу III", "1300", 150, 130), ("БАЛАНС", "1700", 150, 130)] +INCOME_LINES = [ + ("Выручка", "2110", 1000, 900), ("Себестоимость продаж", "2120", "(600)", "(500)"), ("Валовая прибыль (убыток)", "2100", 400, 400), + ("Коммерческие расходы", "2210", "(50)", "(40)"), ("Управленческие расходы", "2220", "(50)", "(60)"), ("Прибыль (убыток) от продаж", "2200", 300, 300), + ("Проценты к уплате", "2330", "(100)", "(100)"), ("Прибыль (убыток) до налогообложения", "2300", 200, 200), + ("Налог на прибыль", "2410", "(40)", "(40)"), ("Чистая прибыль (убыток)", "2400", 160, 160), +] # полный набор: все жёсткие соотношения ОФР сходятся; скобки — запись 66н + + +def _head(sheet: SheetSpec, row: int, title: str, subtitle: str, okud: str) -> int: + sheet.put(row, 2, title) + sheet.put(row + 1, 2, subtitle) + sheet.put(row + 2, 6, "Форма по ОКУД") + sheet.put(row + 2, 7, okud) + sheet.put(row + 3, 1, "Организация") + sheet.put(row + 3, 3, ORG) + sheet.put(row + 3, 6, "ИНН") + sheet.put(row + 3, 7, "7700000000") + sheet.put(row + 4, 1, "Единица измерения:") + sheet.put(row + 4, 3, "тыс. руб.") + return row + 6 + + +def _table(sheet: SheetSpec, row: int, labels: Sequence[str], lines: Sequence[tuple], *, caption: str | None = "Код") -> int: + sheet.put(row, 2, "Наименование показателя") + if caption: + sheet.put(row, 4, caption) + for offset, label in enumerate(labels): + sheet.put(row, 5 + offset, label) + for index, (name, code, *values) in enumerate(lines, start=row + 1): + sheet.put(index, 2, name) + sheet.put(index, 4, code) + for offset, value in enumerate(values): + sheet.put(index, 5 + offset, value) + return row + 1 + len(lines) + + +def _st01() -> list[SheetSpec]: + sheet = SheetSpec("Отчетность") + row = _head(sheet, 1, "Бухгалтерский баланс", "на 31 декабря 2024 г.", "0710001") + row = _table(sheet, row, ["На 31 декабря 2024 г.", "На 31 декабря 2023 г."], BALANCE_LINES) + row = _head(sheet, row + 1, "Отчет о финансовых результатах", "за 2024 г.", "0710002") + _table(sheet, row, ["За 2024 г.", "За 2023 г."], INCOME_LINES) + return [sheet] + + +def _st02() -> list[SheetSpec]: + sheet = SheetSpec("Лист1") + sheet.put(1, 1, "Бухгалтерский баланс") + sheet.put(2, 1, "Организация") + sheet.put(2, 2, 'ООО "Синтетика"') + sheet.put(3, 1, "ИНН") + sheet.put(3, 2, "7700000001") + sheet.put(5, 1, "Показатель") + sheet.put(5, 3, "На 31 декабря") + sheet.put(5, 4, "На 31 декабря") + sheet.put(5, 5, "Изменение, %") + sheet.put(6, 3, "2024 г.") + sheet.put(6, 4, "2023 г.") + for index, (name, code, current, previous) in enumerate(BALANCE_LINES, start=7): + sheet.put(index, 1, name) + sheet.put(index, 2, code) + sheet.put(index, 3, current if code != "1210" else "(50)") + sheet.put(index, 4, previous) + sheet.put(index, 5, 5) + return [sheet] + + +def _st03() -> list[SheetSpec]: + sheet = SheetSpec("ББ") + row = _head(sheet, 1, "Бухгалтерский баланс", "на 31 декабря 2024 г.", "0710001") + sheet.put(row, 2, "Показатель") + sheet.put(row, 5, "На 31 декабря 2024 г.") + for index, (name, _, current, _) in enumerate(BALANCE_LINES, start=row + 1): + sheet.put(index, 2, name) + sheet.put(index, 5, current) + return [sheet] + + +def _st04() -> list[SheetSpec]: + balance = SheetSpec("Баланс") + row = _head(balance, 1, "Бухгалтерский баланс", "на 31 декабря 2024 г.", "0710001") + _table(balance, row, ["На 31 декабря 2024 г."], [(name, code, current) for name, code, current, _ in BALANCE_LINES]) + return [build_osv(OsvSpec(), base_tree()), balance] + + +def _st05() -> list[SheetSpec]: + sheet = SheetSpec("Лист_1") + sheet.put(1, 8, "Форма 0710001 с.2") + _table(sheet, 2, ["На 31 декабря 2024 г.", "На 31 декабря 2023 г."], BALANCE_LINES[5:]) + return [sheet] + + +FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSpec]], bytes]]] = { + "st01_both_forms_one_sheet.xlsx": (_st01, to_xlsx), + "st02_csv_like.xlsx": (_st02, to_xlsx), + "st03_no_codes.xlsx": (_st03, to_xlsx), + "st04_mixed_book.xlsx": (_st04, to_xlsx), + "st05_continuation_only.xlsx": (_st05, to_xlsx), +} +FILE_NAMES = tuple(FILES) + + +def generate_all(directory: Path) -> list[Path]: + directory.mkdir(parents=True, exist_ok=True) + written: list[Path] = [] + for name, (build, serialize) in FILES.items(): + path = directory / name + path.write_bytes(serialize(build())) + written.append(path) + return written + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="python -m tests.synthetic.generate_statements", description="Синтетические формы st01…st05.") + parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/statements-xlsx/synthetic") + args = parser.parse_args(argv) + for path in generate_all(args.directory): + print(path) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +Проверить имена экспортов `generate.py` (`build_osv`, `OsvSpec`, `base_tree`, `to_xlsx`, `ORG`, `SheetSpec`) — они есть в ветке #9/#11; при необходимости добавить в `__all__`. + +- [ ] **Step 4: Хелперы и conftest** + +`fixtures_helpers.py`: + +```python +STATEMENTS_DIR = "statements-xlsx" + + +def statement_files(root: Path) -> list[Path]: + """Книги форм `root/statements-xlsx/**` (включая `templates/`), кроме `synthetic/`; отсортированы.""" + directory = root / STATEMENTS_DIR + if not directory.is_dir(): + return [] + synthetic = directory / _SYNTHETIC + return sorted(path for path in directory.rglob("*") if _is_workbook(path, directory) and synthetic not in path.parents) + + +def statement_synthetic_files(root: Path) -> list[Path]: + directory = root / STATEMENTS_DIR / _SYNTHETIC + if not directory.is_dir(): + return [] + return sorted(path for path in directory.iterdir() if _is_workbook(path, directory)) +``` + +`conftest.py::pytest_generate_tests`: `"statement_file": (statement_files, fixtures_skip_reason)`, `"statement_synthetic_file": (statement_synthetic_files, lambda: fixtures_skip_reason() or statement_synthetic_skip_reason())` (добавить `statement_synthetic_skip_reason` по образцу `synthetic_skip_reason`). + +- [ ] **Step 5: Сгенерировать синтетику в fixtures, тест сценариев** + +```bash +cd API && .venv/bin/python -m tests.synthetic.generate_statements ../../fixtures/statements-xlsx/synthetic +``` + +`API/tests/synthetic/test_statement_synthetic_files.py` (маркер `fixtures`): для каждого файла по имени — ожидания из сценариев спеки: st01 → два документа BALANCE и INCOME с одним `locator`, строк ОФР нет в балансе; st02 → колонки `2024-12-31/2023-12-31`, предупреждение «колонка без периода пропущена: Изменение, %», `1210` = `-50.00`, организация и ИНН найдены, единицы `None` с предупреждением; статус книги `done`, потому что единицы в критерий `ok` не входят, а `1600 = 1700` сходится; st03 → `needs_check`, предупреждение «колонка кодов не найдена», `lines == []`; st04 → `OsvDocument` + `StatementDocument`, книга `done`; st05 → `needs_check`, `CONTINUATION_WARNING`, строки 1310/1300/1700. Писать ожидания из содержимого генератора, не из вывода парсера. + +- [ ] **Step 6: `make check`, commit в tabular-parser и fixtures** + +```bash +git -C T add api/tests +git -C T commit -m "test(statements): синтетика st01…st05 и сценарии спеки на ней; параметры statement_file + +Refs #14" +git -C $W/.wt/slice-2b/fixtures-wt add statements-xlsx/synthetic +git -C $W/.wt/slice-2b/fixtures-wt commit -m "test(statements): синтетика форм st01…st05 (arm-tabular-parser#14)" +``` + +`statements-xlsx/synthetic/README.md` — по образцу `osv/synthetic/README.md`: таблица «файл → что проверяет». + +--- + +### Task 14: Реальные файлы — манифест, property-тест, golden `expected_statement_parse/`, покрытие словарей + +**Files:** +- Create: `API/tests/statements_manifest.yaml`, `API/tests/statement_helpers.py`, `API/tests/fixtures/test_statements_property.py` +- Modify: `API/tests/golden_helpers.py` (снимки форм), `API/scripts/golden_update.py` (файлы форм), `API/tests/fixtures/test_golden.py` (+2 теста), `API/tests/rules_coverage.py` (`DICTIONARIES` + 3), `API/tests/fixtures/test_rules_coverage.py` (файлы форм в прогон), `API/tests/unit/test_golden_helpers.py` +- Fixtures: `golden/expected_statement_parse/**` (новые), `golden/expected_osv_parse/**` (поля `period_start/period_end/is_from_year_start`, текст предупреждения s06, s12/s14 — документы форм) + +**Interfaces:** +- Produces: `statement_helpers.load_manifest() -> dict[str, Expectation]` (ключ — путь относительно `statements-xlsx/`), `Expectation(organization: str | None, inn: str | None, documents: list[{type, status: done|needs_check, complete: bool}])`; `golden_helpers.golden_path` для файлов `statements-xlsx/**` → `golden/expected_statement_parse/<путь>.json`. + +- [ ] **Step 1: Манифест** + +```yaml +# API/tests/statements_manifest.yaml +# Ожидания по реальным формам fixtures/statements-xlsx/** (README там же). Ключ — путь относительно statements-xlsx. +# documents — по порядку документов результата: type, status (done | needs_check), complete (вся форма: §7 сходится). +# Причина needs_check — данные файла (страница без шапки, шаблон без сумм), не дефект разбора. +files: + "unagrande-co/2024-FY/Б.Б Лист 1.xls": + organization: 'Акционерное общество "Унагранде Компани"' + inn: "3249004697" + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив: 1600 = 1700 не сходится"}] + "unagrande-co/2024-FY/Б.Б. Лист 2.xls": + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки"}] + "unagrande-co/2024-FY/ОФР. Лист 1.xls": + organization: 'Акционерное общество "Унагранде Компани"' + inn: "3249004697" + documents: [{type: INCOME, status: done, complete: true}] + "unagrande-co/2024-FY/ОФР. Лист 2 .xls": + documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки"}] + "unagrande-group/2024-FY/ББ. Лист 1 .xls": + organization: 'Акционерное общество "Унагранде Групп"' + inn: "7728757141" + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив"}] + "unagrande-group/2024-FY/ББ. Лист 2 (2).xls": + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки"}] + "unagrande-group/2024-FY/ОФР. Лист 2.xls": + documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки, стр. 1 в пакете нет"}] + "unagrande-group/2025-Q3/ББ. Лист 1 3кв.25.xls": + organization: 'Акционерное общество "Унагранде Групп"' + inn: "7728757141" + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив"}] + "unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls": + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки"}] + "unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls": + organization: 'Акционерное общество "Унагранде Групп"' + inn: "7728757141" + documents: [{type: INCOME, status: done, complete: true}] + "unagrande-group/2025-Q3/ОФР. Лист 2 3кв.25.xls": + documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки"}] + "npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx": + organization: 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"' + inn: "2315995868" + documents: + - {type: BALANCE, status: done, complete: true, sheets: 5} + - {type: INCOME, status: done, complete: true, sheets: 3} + "mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx": + organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"' + inn: "7016006635" + documents: [{type: BALANCE, status: done, complete: true}] + "mezheninovskaya/2025-Q3/1_Форма2_бух_09.xlsx": + organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"' + inn: "7016006635" + documents: [{type: INCOME, status: done, complete: true}] + "templates/ББ до 01.01.2025.xlsx": + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "шаблон без сумм и шапки"}] + "templates/ББ после 01.01.2025.xlsx": + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "шаблон"}] + "templates/ОФР_до 01.01.2025.xlsx": + documents: [{type: INCOME, status: needs_check, complete: false, reason: "шаблон"}] + "templates/ОФР_после 01.01.2025.xlsx": + documents: [{type: INCOME, status: needs_check, complete: false, reason: "шаблон"}] +``` + +Значения `organization`/`inn` ОФР Унагранде и типы шаблонов проверить по файлам при первом прогоне: расхождение — повод разобраться (CLI, `systematic-debugging`), а не переписать манифест. У ОФР НПЗ ИНН в блоке нет: манифест задаёт `inn` на файл и применяется к документам, у которых ИНН найден; для `INCOME` НПЗ допускается `None` — поле `inn_optional_for: [INCOME]` в записи НПЗ. + +- [ ] **Step 2: Property-тест** + +```python +# API/tests/fixtures/test_statements_property.py +"""Инварианты форм на всех `../fixtures/statements-xlsx/**` по манифесту (spec «Словари форм, координаты, golden»).""" + +import time +from pathlib import Path +from typing import cast + +import pytest +from fixtures_helpers import statement_files +from result_contract import assert_valid_result +from statement_helpers import load_manifest, relative_key + +from src.contract import FileType, ResultStatus, StatementDocument +from src.parser import get_parser + +pytestmark = pytest.mark.fixtures +EXPECTED_FILES = 19 +MAX_PARSE_SECONDS = 5 + + +def test_manifest_covers_every_file(fixtures_dir: Path) -> None: + files = statement_files(fixtures_dir) + assert len(files) == EXPECTED_FILES + assert {relative_key(fixtures_dir, path) for path in files} == set(load_manifest()) + + +def test_statement_file_matches_manifest(fixtures_dir: Path, statement_file: Path) -> None: + expectation = load_manifest()[relative_key(fixtures_dir, statement_file)] + started = time.monotonic() + outcome = get_parser().parse(statement_file.read_bytes(), cast(FileType, statement_file.suffix.lstrip(".").upper())) + assert time.monotonic() - started < MAX_PARSE_SECONDS + assert outcome.error is None, outcome.error + documents = [document for document in outcome.documents if isinstance(document, StatementDocument)] + assert [document.document_type for document in documents] == [item.type for item in expectation.documents] + assert outcome.status is (ResultStatus.DONE if all(item.status == "done" for item in expectation.documents) else ResultStatus.NEEDS_CHECK) + for document, item in zip(documents, expectation.documents, strict=True): + codes = [line.line_code for line in document.lines] + assert len(codes) == len(set(codes)), "коды уникальны" + assert all(value.column_index < len(document.columns) for line in document.lines for value in line.values) + if item.sheets: + assert document.locator.count(",") == item.sheets - 1 + if item.complete: + assert document.checks is not None and document.checks.relations_failed == [], document.checks + assert document.columns and all(column.period_date is not None for column in document.columns) + if expectation.organization and document.header.organization_name is not None: + assert document.header.organization_name == expectation.organization + if expectation.inn and document.document_type not in expectation.inn_optional_for: + assert document.header.inn == expectation.inn + assert_valid_result(outcome) # проверить сигнатуру хелпера в tests/result_contract.py +``` + +`statement_helpers.py`: pydantic-модели `DocumentExpectation(type, status, complete, reason=None, sheets=None)`, `Expectation(organization=None, inn=None, inn_optional_for=(), documents)`, `load_manifest()` (кэш через `functools.cache`), `relative_key(fixtures_dir, path) -> str`. + +- [ ] **Step 3: Golden для форм** + +`golden_helpers.py`: константы `STATEMENT_SNAPSHOT_DIR = "expected_statement_parse"`, `STATEMENTS_DIR = "statements-xlsx"`; `golden_path`: если файл под `statements-xlsx` → `golden/expected_statement_parse/<путь относительно statements-xlsx без расширения>.json`; `_mirrored`/`_source_part`: части `statements` и `statements-synthetic`; `sync_snapshots` обходит оба каталога снимков (`expected_osv_parse`, `expected_statement_parse`) при поиске лишних. `scripts/golden_update.py`: `files = [..., *statement_files(root), *statement_synthetic_files(root)]`. `test_golden.py`: `test_statement_file_matches_golden_snapshot(statement_file)`, `test_statement_synthetic_matches_golden_snapshot(statement_synthetic_file)`, `test_every_snapshot_has_a_source_file` — учесть второй каталог. `tests/unit/test_golden_helpers.py`: путь снимка для файла форм. + +- [ ] **Step 4: Покрытие словарей и линтер** + +`tests/rules_coverage.py::DICTIONARIES` + `"statement_headers": "statement_headers.yaml"`, `"statement_periods": "statement_periods.yaml"`, `"statement_checks": "statement_checks.yaml"`. `test_rules_coverage.py`: в `files` добавить `statement_files` и `statement_synthetic_files`; `BookParser(rules, osv=OsvParser(rules), statements=StatementParser(rules), …)`. Мёртвые записи (например, `^расшифровк`, `отчет о целевом использовании`, `for_year` без «за») — удалить из YAML либо покрыть синтетикой (добавить лист в st01/st04); решение фиксировать в ledger. `test_no_coordinates.py` — убедиться, что обходит `statements/` (PARSER_ROOT рекурсивно): добавить тест «в списке проверенных файлов есть `statements/columns.py` и `book.py`». + +- [ ] **Step 5: Обновить golden одним прогоном и просмотреть diff** + +```bash +make -C T golden-update +git -C $W/.wt/slice-2b/fixtures-wt status --short | head -40 +git -C $W/.wt/slice-2b/fixtures-wt diff --stat | tail -3 +git -C $W/.wt/slice-2b/fixtures-wt diff -- 'golden/expected_osv_parse/rb/2024-FY/ОСВ_66 за 2024.json' | head -30 +``` + +Ожидаемый diff ОСВ: только три новых поля шапки на снимок (+ текст предупреждения у s06, документы форм у s12/s14). Любая другая строка в diff ОСВ — дефект, остановиться. Новые снимки `expected_statement_parse/**` — 19 реальных + 5 синтетики: просмотреть НПЗ и Межениновскую вручную по контрольным цифрам (1600/1700, 2400). + +- [ ] **Step 6: `make check` и `make test-fixtures`, замер времени** + +Run: `make -C T check && time make -C T test-fixtures` +Expected: зелёный; время `test-fixtures` — записать в ledger (порог — не более ×2 от 144 с). + +- [ ] **Step 7: Commit в обоих репозиториях** + +```bash +git -C T add api/tests api/scripts +git -C T commit -m "test(statements): манифест реальных форм, property-тест, golden expected_statement_parse, покрытие словарей форм + +Refs #14" +git -C $W/.wt/slice-2b/fixtures-wt add golden statements-xlsx +git -C $W/.wt/slice-2b/fixtures-wt commit -m "test(golden): снимки форм expected_statement_parse (19 + 5), границы периода в снимках ОСВ (arm-tabular-parser#14)" +``` + +--- + +### Task 15: Интеграция через воркер, CLI, README и CLAUDE.md сервиса + +**Files:** +- Modify: `API/tests/integration/test_roundtrip.py` (+ тест книги НПЗ), `T/README.md` (раздел «Парсер форм» после «Парсер ОСВ»; «Запуск на файле» — пример с формой; «Тесты на реальных данных» — манифест и `expected_statement_parse`), `T/CLAUDE.md` (раздел «Добавлено в Срезе 2b»), `API/rules/README.md` (если не дописан в задаче 4) +- Test: интеграционный + +- [ ] **Step 1: Интеграционный тест** + +```python +REAL_NPZ = Path("statements-xlsx") / "npz" / "2025-Q3" / "Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx" + + +async def test_real_statements_through_worker_give_done_with_two_documents(core: Core, uploads: Uploads, fixtures_dir: Path) -> None: + """«Реальные формы через воркер»: книга НПЗ → ack → `done`, `result_schema_version 1.2`, BALANCE и INCOME.""" + request = make_request(file_type="XLSX", name=REAL_NPZ.name) # по образцу теста ОСВ выше + await uploads.put(request, (fixtures_dir / REAL_NPZ).read_bytes(), XLSX_CONTENT_TYPE) + await core.publish(request) + ack, body = await core.wait_ack_and_result(request) + assert body["status"] == "done" and body["result_schema_version"] == "1.2" + stored = json.loads(await uploads.read(body["result_s3"]["bucket"], body["result_s3"]["key"])) + assert [document["document_type"] for document in stored["documents"]] == ["BALANCE", "INCOME"] + assert all(document["checks"]["relations_failed"] == [] for document in stored["documents"]) +``` + +Имена хелперов (`make_request`, `wait_ack_and_result`, `XLSX_CONTENT_TYPE`) взять из существующего теста `test_real_osv_through_worker_gives_done_and_valid_result_file`. + +Run: `make -C T test-integration` (нужен compose парсера `make -C T up`; compose `arm-ka` ядра не трогать). + +- [ ] **Step 2: README — раздел «Парсер форм (ББ/ОФР)»** + +Содержание: конвейер (`book.py` → `statements/`: columns, detect, header, lines, checks, merge, statement), что считается формой/продолжением, критерий `ok`, словари и как добавить синоним/соотношение, таблица «файл → статус» по манифесту, как запустить CLI на форме, где golden. В «Статусы и коды»: `INVALID_DOCUMENT` — «нет листов ОСВ или форм». CLAUDE.md: раздел «Добавлено в Срезе 2b» (модули, правила расширения форм, запрет координат, где манифест, golden 2 каталога), `get_parser` → `BookParser`. + +- [ ] **Step 3: Commit** + +```bash +git -C T add api/tests/integration README.md CLAUDE.md api/rules/README.md +git -C T commit -m "docs(statements): README и CLAUDE.md — парсер форм; интеграционный тест книги НПЗ через воркер + +Refs #14" +``` + +--- + +### Task 16: Документы docs — §12, §7, 06-test-data, 03-architecture, 09-delivery-plan, OQ, qa-guide + +**Files (DOCS):** +- Modify: `docs/context/05-domain-rules.md` (§12 целиком, пометки в §7), `docs/context/06-test-data.md` (новый подраздел «4б. Формы ББ/ОФР в Excel: манифест, golden, синтетика» после 4а и строка в §7 про `expected_statement_parse`), `docs/context/03-architecture.md` (контракт 1.2, `BookParser`), `docs/context/09-delivery-plan.md` (Срез 2b — статус и объём), `docs/context/07-open-questions.md` (OQ-137…OQ-141, статус OQ-131), `docs/dev/qa-guide.md` §6.7 (абзац про формы) + +- [ ] **Step 1: §12 — заменить текст пунктов 1–8 и добавить 9–11** + +Структура нового §12 (каждый пункт — решение из design D-2…D-8 и brainstorming 06.10.2026): +1. Классификация листов: ОСВ → форма (заголовок/ОКУД в любой строке до таблицы; титул «Бухгалтерская отчетность» не тип) → продолжение (маркер «с.2», подпись «Код» с периодами, колонка кодов) → прочее. Типы: 0710001 → BALANCE, 0710002 → INCOME, прочие 0710xxx и пояснения → DISCLOSURE. Две формы на листе — по второму заголовку. +2. Колонка кодов: ≥ 60 % непустых значений — коды (ББ 1100–1799, ОФР 2100–2999, расшифровки 5xxx, пятизначные по первым четырём цифрам), ≥ 3 кодов. Нет колонки — `needs_check`, «колонка кодов не найдена»; сопоставление по именам отложено (OQ-138). +3. Колонка наименований и «Пояснения» — как было. +4. Колонки-периоды: подпись «Код» и ячейки-периоды правее; двухстрочный заголовок склеивается; строка нумерации пропускается; `period_date` — дата или конец периода; колонки без периода не читаются (предупреждение; `extra` у форм нет). +5. Значения: Decimal, скобки → минус, прочерк → null; **единицы как в файле, не нормализуются**; при «тыс.» и ≥ 10 % значений с дробной частью — предупреждение (решение владельца 06.10.2026, OQ-137). +6. Шапка: метки, ИНН не аудитора, организация над меткой, дата из подзаголовка или тройки. +7. Самопроверка: соотношения §7 — данные `statement_checks.yaml`, жёсткие/мягкие, допуск ±1, двойной расчёт знаков (`abs_terms`), подстроки «в том числе» как мягкая проверка; результат — `statementDocument.checks` (v1.2) и предупреждения. +8. Расшифровки — DISCLOSURE без строк (OQ-23). +9. Склейка: листы-продолжения внутри книги присоединяются к последнему открытому документу при равном числе колонок; `locator` перечисляет листы; **страницы в разных файлах не склеиваются**: страница без шапки — документ с пустой шапкой и `needs_check` (решение владельца, OQ-139). +10. Статус: критерий `ok` (D-8); книга `done`/`needs_check`; `INVALID_DOCUMENT` без ОСВ и форм; порядок документов — ОСВ, затем формы. +11. Отложено: запасной путь по именам, нормализация единиц, признак продолжения в контракте, склейка в ядре (Срез 3). + +§7: после формул добавить абзац «Парсер tabular-parser (§12 п. 7) считает жёсткими … и мягкими … (`ASSUMPTION(OQ-12)`); ядро — Срез 3». + +- [ ] **Step 2: OQ** + +Добавить строки (формат таблицы как у OQ-136): +- OQ-137 — единицы «тыс. руб.» в шапке против значений в рублях с копейками в 1С-выгрузках (Унагранде, НПЗ) и тысяч с дробями (Межениновская): кто и где приводит; парсер отдаёт как есть + предупреждение; ядро/аналитик — Срез 3. Откуда: `statements-xlsx/README.md`, design 2b D-6. Влияет: Срез 3, §8 (КФО в тыс. руб.). Допущение: не нормализовать. +- OQ-138 — форма без колонки кодов: словарь имён строк из шаблонов 66н/ФСБУ (`statement_lines.yaml`), `line_code_confidence < 100`; сейчас `needs_check`. Нужен реальный файл. +- OQ-139 — страница-продолжение в отдельном файле (1С печатает ББ/ОФР постранично): склейка в ядре по ЮЛ+период+тип, признак `is_continuation`/`form_page` в контракте (MINOR). Сейчас `needs_check`. +- OQ-140 — строка без кода с суммами в ОФР ФСБУ 4/2023 (НПЗ «Реализация иного имущества…»): хранить ли (OQ-36 расширяется на суммы). +- OQ-141 — образец ФСБУ 4/2023 (шаблон): «АКТИВ» и код 1110 в одной строке из-за объединений; шаблоны не являются данными, парсер даёт `needs_check`; нужен ли их разбор вообще. +- OQ-131 — статус: «границы периода `period_start/period_end/is_from_year_start` в контракте v1.2 (docs change 2b); парсер заполняет (PR #13 + 2b); ядро — привязка скользящего документа к версии остаётся открытой». + +- [ ] **Step 3: 03-architecture, 09-delivery-plan, 06-test-data, qa-guide** + +03: в абзаце о `schema_version` — «1.2» вместо «1.1» в примере раскатки; в описании tabular-parser — «`BookParser`: ОСВ и формы, конвейер форм `statements/`». 09: Срез 2b — «реализован (ветки feat/14…, PR #16/#27), объём: парсер + контракт v1.2, маппинг форм — Срез 3». 06: подраздел 4б (раскладки, манифест `tests/statements_manifest.yaml`, golden `expected_statement_parse/`, синтетика st01–st05 с таблицей). qa-guide §6.7: «Формы ББ/ОФР: та же команда CLI; что смотреть — `document_type`, `columns[].period_date`, `checks.relations_failed`, предупреждение об единицах; постраничные xls дают `needs_check`». + +- [ ] **Step 4: Commit (docs)** + +```bash +git -C DOCS add docs/context docs/dev +git -C DOCS commit -m "docs(2b): §12 по решениям владельца, §7 жёсткие/мягкие, 06-test-data формы, 03-architecture v1.2, OQ-137…141 + +Refs #26" +``` + +--- + +### Task 17: Завершение — rebase, `check-full`, PR + +- [ ] **Step 1: Rebase на main (ветки агента A уже слиты), повторный прогон** + +```bash +git -C T fetch origin && git -C T rebase origin/main +make -C T check-full +make -C T golden-check +``` +Конфликты в `osv.py`/`test_book_parser.py`/`conftest.py`/`golden_helpers.py`: логика ОСВ — из main, оркестрация книги и формы — из ветки. + +- [ ] **Step 2: openspec** + +`cd T && openspec validate slice-2b-statement-parser --strict`; в `tasks.md` change отметить `[x]`; в `design.md` раздел «Decisions made without discussion» дополнить решениями исполнения (нумерация продолжается с №12: `OTHER`-продолжение считается по правилам ББ; `_RELATION` допускает пятизначные коды справа; мёртвые записи словарей удалены/покрыты; порог `min_codes = 3`; манифест в `tests/`, не в fixtures). + +- [ ] **Step 3: PR** + +PR #16 tabular-parser уже открыт на ветку спеки — дополнить описание: что сделано по задачам, «как проверить» (команды задачи 12 шаг 4, `make test-fixtures`), таблица «файл → статус» из манифеста, «Rulings I made», находки с маркерами и статусами, deferred; порядок мержа: docs #27 → arm-core/scan-gateway (копии) → fixtures `feat/14-statements-golden` → tabular-parser #16. PR fixtures: `tea pulls create --login home --repo RSHB/arm-ka-fixtures --base main --head feat/14-statements-golden --title "test(golden): снимки форм и границы периода ОСВ (arm-tabular-parser#14)" …`. PR docs #27 — дополнить описанием задач 1 и 16. -- 2.47.2 From 82ad2ab2511987d6adb0a355a00c072362b738e9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 14:13:44 +0400 Subject: [PATCH 03/12] =?UTF-8?q?feat(contract):=20v1.2=20=E2=80=94=20DISC?= =?UTF-8?q?LOSURE,=20statementDocument.checks,=20=D0=B3=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D1=8B=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=20=D0=9E=D0=A1=D0=92,=20=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D1=80?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #26 Co-Authored-By: Claude Haiku 4.5 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- contracts/examples/result-osv-minimal.json | 5 +- .../examples/result-statement-excel.json | 46 +++++++++++++++++++ .../examples/result-statement-minimal.json | 5 +- contracts/recognition-result.schema.json | 28 +++++++++-- 4 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 contracts/examples/result-statement-excel.json diff --git a/contracts/examples/result-osv-minimal.json b/contracts/examples/result-osv-minimal.json index f883a8d..b0a9e97 100644 --- a/contracts/examples/result-osv-minimal.json +++ b/contracts/examples/result-osv-minimal.json @@ -1,5 +1,5 @@ { - "schema_version": "1.1", + "schema_version": "1.2", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", "service": { "name": "tabular-parser", "version": "0.1.0" }, @@ -16,6 +16,9 @@ "period_year": 2025, "period_quarter": "Q1", "is_cumulative": false, + "period_start": "2025-01-01", + "period_end": "2025-03-31", + "is_from_year_start": true, "data_type": "BU", "currency": "RUB", "header_variant": 1 diff --git a/contracts/examples/result-statement-excel.json b/contracts/examples/result-statement-excel.json new file mode 100644 index 0000000..fcee529 --- /dev/null +++ b/contracts/examples/result-statement-excel.json @@ -0,0 +1,46 @@ +{ + "schema_version": "1.2", + "job_id": "3f1c7a2e-5b64-4d0a-9c1e-8a7b6d5e4f30", + "file_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "service": { "name": "tabular-parser", "version": "0.3.0" }, + "status": "needs_check", + "metrics": { "duration_ms": 412, "rows": 7, "warnings": ["sheet=Бухгалтерский баланс. Лист 1 значений с дробной частью при единице тыс. руб.: 3"] }, + "documents": [ + { + "document_type": "BALANCE", + "locator": "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2", + "header": { "form_name": "Бухгалтерский баланс", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "inn": "7700000000", "inn_confidence": 100, "report_date_text": "на 30 сентября 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710001", "okpo": "12345678" }, + "columns": [ + { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, + { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + ], + "lines": [ + { "line_code": "1110", "line_code_confidence": 100, "line_name": "Нематериальные активы", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "11101", "line_code_confidence": 100, "line_name": "Нематериальные активы, готовые к использованию", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "900000.00", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "1100", "line_code_confidence": 100, "line_name": "Итого по разделу I", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": null, "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": null, "confidence": 100, "is_recognized": true } ] }, + { "line_code": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] } + ], + "checks": { "relations_failed": [], "soft_relations_failed": [ { "column_index": 1, "relation": "1110 = 11101", "expected": "905786.99", "actual": "900000.00" } ], "duplicate_line_codes": [] } + }, + { + "document_type": "INCOME", + "locator": "sheet:Отчет о финансовых результатах", + "header": { "form_name": "Отчет о финансовых результатах", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "report_date_text": "за январь - сентябрь 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710002" }, + "columns": [ { "column_index": 0, "label": "За январь - сентябрь 2025 г.", "period_date": "2025-09-30" } ], + "lines": [ + { "line_code": "2110", "line_code_confidence": 100, "line_name": "Выручка", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "1000.00", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "2120", "line_code_confidence": 100, "line_name": "Себестоимость продаж", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "-600.00", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "2100", "line_code_confidence": 100, "line_name": "Валовая прибыль (убыток)", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "400.00", "confidence": 100, "is_recognized": true } ] } + ], + "checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] } + }, + { + "document_type": "DISCLOSURE", + "locator": "sheet:Пояснения", + "header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах" }, + "columns": [], + "lines": [] + } + ] +} diff --git a/contracts/examples/result-statement-minimal.json b/contracts/examples/result-statement-minimal.json index 484f222..51e3b17 100644 --- a/contracts/examples/result-statement-minimal.json +++ b/contracts/examples/result-statement-minimal.json @@ -1,5 +1,5 @@ { - "schema_version": "1.0", + "schema_version": "1.2", "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", "service": { "name": "scan-gateway", "version": "0.1.0" }, @@ -44,7 +44,8 @@ { "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true } ] } - ] + ], + "checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] } } ] } diff --git a/contracts/recognition-result.schema.json b/contracts/recognition-result.schema.json index 5d329a8..9a168cf 100644 --- a/contracts/recognition-result.schema.json +++ b/contracts/recognition-result.schema.json @@ -1,13 +1,13 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "arm-ka/recognition-result/v1", - "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.1", + "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.2", "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.", "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", "type": "object", "required": ["schema_version", "job_id", "file_id", "service", "status", "documents"], "properties": { - "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.1 пишет \"1.1\"" }, + "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.2 пишет \"1.2\"" }, "job_id": { "type": "string", "format": "uuid" }, "file_id": { "type": "string", "format": "uuid" }, "service": { @@ -53,6 +53,16 @@ }, "$defs": { "money": { "type": ["string", "null"], "pattern": "^-?\\d+(\\.\\d{1,2})?$", "description": "Decimal как строка, чтобы не терять копейки в JSON" }, + "failedRelation": { + "type": "object", + "required": ["column_index", "relation", "expected", "actual"], + "properties": { + "column_index": { "type": "integer" }, + "relation": { "type": "string", "examples": ["1600 = 1100 + 1200"] }, + "expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения как в файле" }, + "actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам" } + } + }, "osvDocument": { "type": "object", "required": ["document_type", "locator", "header", "group_schema", "rows"], @@ -70,6 +80,9 @@ "period_year": { "type": ["integer", "null"] }, "period_quarter": { "enum": ["Q1", "Q2", "Q3", "Q4", null] }, "is_cumulative": { "type": ["boolean", "null"], "description": "по тексту; ядро может переопределить по данным (OQ-02)" }, + "period_start": { "type": ["string", "null"], "format": "date", "description": "начало периода по шапке: первое число квартала/года либо начало диапазона месяцев (OQ-131)" }, + "period_end": { "type": ["string", "null"], "format": "date", "description": "конец периода по шапке" }, + "is_from_year_start": { "type": ["boolean", "null"], "description": "period_start — 1 января; is_cumulative не переопределяет" }, "data_type": { "enum": ["BU", "NU", "SUM", "CURRENCY", "QUANTITY", null] }, "currency": { "type": ["string", "null"], "examples": ["RUB", "руб."] }, "header_variant": { "enum": [1, 2] } @@ -133,7 +146,7 @@ "type": "object", "required": ["document_type", "locator", "header", "columns", "lines"], "properties": { - "document_type": { "enum": ["BALANCE", "INCOME", "OTHER"] }, + "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE", "OTHER"] }, "locator": { "type": "string", "examples": ["pages:1-2"] }, "header": { "type": "object", @@ -191,6 +204,15 @@ } } } + }, + "checks": { + "type": "object", + "description": "Результаты самопроверки формы по соотношениям §7 (tabular-parser). Отсутствие объекта — «не проверялось» (OCR-путь); пустые массивы — «проверено, сошлось».", + "properties": { + "relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения жёстких соотношений: документ needs_check" }, + "soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения мягких соотношений (OQ-12, подстроки «в том числе»): только предупреждение" }, + "duplicate_line_codes": { "type": "array", "items": { "type": "string" }, "description": "коды строк, встретившиеся в документе больше одного раза (осталась первая строка)" } + } } } } -- 2.47.2 From 909e7ff08470043e77dac10dd916527057fa3c39 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 19:40:29 +0400 Subject: [PATCH 04/12] =?UTF-8?q?plan:=20ledger=20SDD=20=D0=A1=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=D0=B0=202b=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2013=20(=D0=BF=D0=B0=D1=83=D0=B7?= =?UTF-8?q?=D0=B0=2006.10.2026)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Копия .superpowers/sdd/…/progress.md для продолжения на другой машине: задачи 1–13 complete, рулинги R-1…R-12, находки deferred, пауза перед задачей 14. Refs #26 Co-Authored-By: Claude Fable 5.1 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- ...-10-06-slice-2b-statement-parser-ledger.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-06-slice-2b-statement-parser-ledger.md diff --git a/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser-ledger.md b/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser-ledger.md new file mode 100644 index 0000000..342c73d --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser-ledger.md @@ -0,0 +1,134 @@ +# SDD ledger — plan: docs/superpowers/plans/2026-10-06-slice-2b-statement-parser.md + +Прогон 1: задачи 1–3. Задачи 4–17 — следующий прогон по этому же ledger. +Plan file (абс.): /Users/andrey.epifancev/projects/awp-ca/.wt/slice-2b/docs/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser.md +Spec: tabular-parser/openspec/changes/slice-2b-statement-parser/ и docs/openspec/changes/slice-2b-statement-parser/ (прочитаны реализаторами по брифу, контроллер — выборочно). +Setup: tabular-parser venv создан, codegraph init выполнен (worktree), .superpowers/ в .gitignore docs — есть. + +## Pre-flight scan (по всему плану, 17 задач) +| Пара задач / задача | Разделяют | Производит / потребляет | Найдено | +|---|---|---|---| +| T1 -> T2 | схема v1.2, примеры | T1 пишет DOCS/contracts; T2 cp в API/contracts, байт в байт | согласовано; T2 зависит от коммита T1 (порядок есть) | +| T1 -> T3 | схема, примеры | T3 копирует DOCS/contracts в arm-core/scan-gateway | согласовано | +| T2 <-> T3 | правки result.py (модели) | T3 повторяет модели T2 в двух репо; CONTRACT_VERSION не менять | согласовано; в T3 пути тестов существуют (arm-core/api/tests/test_contract.py, scan-gateway/api/tests/unit/test_contract.py) | +| T4 -> T5,T6,T9,T11 | rules.py, statement_*.yaml | T4 производит Rules.statement_*; потребляют T5/T6/T9/T11 | согласовано | +| T4 <-> T6 | rules.py, osv_headers.yaml | T6 удаляет statement_markers после T4 | порядок последовательный, конфликта нет | +| T5 -> T6,T9,T10 | TableLayout, line_code, cell_label | интерфейсы совпадают с Consumes | согласовано | +| T6 -> T9,T12 | FormBlock, classify_statement_sheet | согласовано | | +| T7 <-> T8 <-> T12 | osv.py, book.py, test_book_parser.py | T7 создаёт book.py и SkippingStatementParser, T8 правит osv.py::_header, T12 удаляет заглушку | порядок последовательный; согласовано | +| T10 -> T11 -> T12 | StatementLine, checks | согласовано | | +| T13 -> T14 -> T15 | fixtures_helpers, синтетика, golden | T13/T14 пишут в fixtures worktree feat/14-statements-golden | зависимость от fixtures PR; вне прогона 1 | +| T16, T17 | docs, PR | вне прогона 1 | | +| Каждая задача (само-согласованность) | | T1: тест-скрипт в scratchpad и шаги согласованы. T2: файла result_contract.py нет (указано «нет»), grep "1.1" в tests пока пуст — шаг допустим. T3: в плане сказано "Не менять CONTRACT_VERSION" — консистентно. Задачи 4–17 проверены по Files/Interfaces (grep), содержимое шагов будет сверено в прогоне 2. | | + +Конфликтов плана с Global Constraints и review rubric в Files/Interfaces не обнаружено. +Ruling: R-1 — план требует в Task 1 Step 1 временный скрипт в scratchpad; использую путь scratchpad контроллера — тот же что в плане — цена ошибки нулевая. +Замечания по окружению: T1 в плане использует `API/.venv/bin/python` tabular-parser (jsonschema есть); T3 — командой `make venv` в arm-core/scan-gateway. +BASE T1: docs ca8615e +Task 1: implementer haiku DONE, commit 82ad2ab (docs). Review dispatched (sonnet). +Task 1: minor T1.1 (deferred): $comment схемы упоминает «1.1» — plan-mandated не трогать; поправить в follow-up. +Task 1: minor T1.2 (deferred): format:date не проверяется без format_checker в check-скрипте; не влияет на контракт. +Task 1: minor T1.3 (rejected: косметика примера, число «3» произвольно). +Task 1: complete (commits ca8615e..82ad2ab, review clean) +Task 2: BASE tabular-parser 37b25e0; implementer haiku dispatched +Task 2: implementer DONE, commit 3a08b99; tests 2028 passed. +O-1: implementer запустил make golden-update, что переписало 113 golden-файлов (только schema_version 1.1->1.2) в ОСНОВНОМ checkout fixtures на main (через симлинк; не закоммичено). Откат (git restore) заблокирован классификатором — оставлено человеку. Статус: deferred (человек: git -C fixtures restore -- golden, либо оставить для Task 14, где golden пересоздаются в worktree feat/14-statements-golden). Правка вне плана (план: golden только в worktree-ветке + отдельный коммит). +Task 2: minor T2.1 (deferred): $comment схемы «1.1» (= T1.1, plan-mandated). +Task 2: minor T2.2 (deferred): пример result-statement-excel.json status needs_check при одном мягком нарушении — вводит в заблуждение; поправить в docs (plan-mandated пример). +Task 2: minor T2.3 (deferred): result-statement-minimal.json (OCR) получил checks с пустыми списками, что противоречит семантике «нет объекта = не проверялось»; plan-mandated. +Task 2: minor T2.4 (rejected: необязательные негативные тесты, вне брифа). +Task 2: complete (commits 37b25e0..3a08b99, review clean) +Task 3: implementer DONE: arm-core 46d17e6, scan-gateway e2f1bb5; make check: arm-core 1108 passed, scan-gateway 749 passed. +Ruling: R-2 — в scan-gateway копия схемы отставала на v1.1 (не было OsvRow.extra и примера result-osv-needs-check.json); принят догон v1.1 вместе с v1.2 (копия обязана совпадать с docs побайтно) — если неверно: лишний необязательный OsvRow.extra в scan-gateway, откатывается отдельным коммитом (design #13, см. design.md). +Task 3: minor T3.1 (fixed в описании PR: упомянут догон v1.1). +Task 3: minor T3.2 (fixed в описании PR: CONTRACT_VERSION остаётся 1.0). +Task 3: complete (arm-core origin/main..46d17e6, scan-gateway origin/main..e2f1bb5, review clean) +R-2 (design #12). PR: arm-core#27, arm-scan-gateway#5 созданы. design.md решения №12–13 закоммичены в tabular-parser. +Пауза после Task 3: ждём мержа PR копий схемы + +## Прогон 2 +Ruling: R-3 — golden schema_version 1.1->1.2 пересозданы в worktree fixtures (.wt/slice-2b/fixtures-wt, ветка feat/14-statements-golden, коммит bf8c88f, diff только schema_version, 113 файлов); симлинк fixtures переключён на fixtures-wt (шаг 1 T13 выполнен контроллером); основной checkout fixtures не тронут (O-1 остаётся человеку) — чтобы make check был зелёным на каждой задаче; цена ошибки: коммит в fixtures-ветке раньше плана, откат — reset ветки (design #14). +Task 4: BASE tabular-parser ed516d8; implementer haiku dispatched +Ruling: R-4 (от координатора, design #15) — docs/contracts/** после задачи 1 заморожены, в задаче 16 НЕ править; T1.1/T2.1/T2.2/T2.3 остаются deferred и идут в follow-up issue при закрытии — копии в arm-core#27 и scan-gateway#5 на ревью и должны остаться байт в байт; цена ошибки: три косметических замечания живут до следующего MINOR. +Task 4: implementer haiku DONE d21884e; 2046 passed. Review (sonnet): spec ❌ 1 Important: okud DISCLOSURE pattern statement_headers.yaml:103 `\b0710(?!001|002)` вместо брифа `\b07100(0[3-9]|[1-9]\d)\b` (T4.I1). +Task 4: minor T4.1 (deferred): RelationRule.target/.terms перепарсиваются на каждый доступ. +Task 4: minor T4.2 (deferred): шесть одинаковых any(...) методов — хелпер _any. +Task 4: minor T4.3 (deferred): нет проверки lo<=hi в _LineCodeRules._check. +Task 4: minor T4.4 (deferred): FormType не в __all__. +Task 4: minor T4.5 (deferred): нет негативных тестов (_check_required_keys, abs_terms, target in codes, группы периода, okopf vs okopf_okfs, границы okud). +Task 4: minor T4.6 (deferred): комментарий statement_periods.yaml:5 про обязательные группы неточен, m1 не валидируется. +Task 4: fix round 1/5 (1 addressed, 0 open — T4.I1 okud pattern; commits d21884e..6a3661c) +Task 4: complete (commits ed516d8..6a3661c, review clean) +Task 5: BASE 6a3661c; implementer haiku dispatched +Task 5: implementer haiku DONE c3df9ff; make check (контроллер) 2058 passed. Review sonnet: spec ✅, approved. +Task 5: minor T5.1 (deferred): мёртвые noqa columns.py:136,211. T5.2 (deferred): шумные константы в test_columns.py. T5.3 (deferred): ветка code_col_empty and no_numbers не покрыта тестом (plan-mandated). T5.4 (deferred): лишние вычисления до проверки following Date: Tue, 6 Oct 2026 21:17:12 +0400 Subject: [PATCH 05/12] =?UTF-8?q?docs(2b):=20=C2=A712=20=D0=91=D0=91/?= =?UTF-8?q?=D0=9E=D0=A4=D0=A0=20=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8,=20=C2=A77=20=D0=B6=D1=91?= =?UTF-8?q?=D1=81=D1=82=D0=BA=D0=B8=D0=B5/=D0=BC=D1=8F=D0=B3=D0=BA=D0=B8?= =?UTF-8?q?=D0=B5,=20=C2=A71.1/=C2=A71.3=E2=80=93=C2=A71.5=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D1=84=D0=B0=D0=BA=D1=82=D1=83=20PR=20tabular-parser=20#13/#?= =?UTF-8?q?15?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #26 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- context/05-domain-rules.md | 129 ++++++++++++++++++++++++++++--------- 1 file changed, 100 insertions(+), 29 deletions(-) diff --git a/context/05-domain-rules.md b/context/05-domain-rules.md index 03c3618..e965325 100644 --- a/context/05-domain-rules.md +++ b/context/05-domain-rules.md @@ -86,6 +86,13 @@ r11 Договоры ← "period_text":"1 квартал 2025 г.","data_type":"BU","groups":[{"type":"ACCOUNT"},{"type":"COUNTERPARTY","has_inn":true}, {"type":"CONTRACT"}]}` и → `document(document_type=OSV, osv_account_code, period_year, period_quarter, is_cumulative, locator)`. +**Шапка без меток и без строки организации (реализовано, PR tabular-parser #13, OQ-134; ОСВ 63 Новоросметалл).** Период +может стоять отдельной ячейкой («Бухгалтерский | 9 месяцев 2025 г.»: `periods.yaml` — `standalone`, `data_types.yaml` — +`standalone_header`), организация — хвостом названия ведомости, а колонки счёта — подписями «Счет: Код / Наименование» +(`osv_headers.yaml` — `column_captions`: «Код» и «Наименование» под группой «Счет» не группировки). Код счёта берётся из +первой строки-счёта таблицы, если «по счету …» в шапке нет, — с предупреждением «код счёта … из строки таблицы». Текст +строки может стоять правее колонки текста: уровень задаёт колонка, в которой он найден. + ### 1.2 Колонки сумм Заголовки сумм объединены (merged) и позиции колонок плавают между файлами даже одной организации. Определять по тексту @@ -99,8 +106,13 @@ r11 Договоры ← Для каждой строки табличной части: `row_number` (исходный номер), `level` (outline level Excel, в xls — `rowinfo_map`; без группировок — каскад §1.0 п. 5), `row_value` (текст первой колонки), шесть сумм, ИНН (**только из колонки ИНН**; в ячейке оставляются цифры, результат только при 10 или 12 цифрах — «ИНН 352819549855» → `352819549855`, иначе NULL; число из 9/11 цифр — ИНН региона 01–09 -с потерянным ведущим нулём — при извлечении из выбранной колонки дополняется нулём слева, R-24; ИНН внутри текста строки контрагента -(«Полиграф, 3528315710») парсер не извлекает: `counterparty_name` = весь текст строки, **[открыто]** OQ-100), +с потерянным ведущим нулём — при извлечении из выбранной колонки дополняется нулём слева, R-24; ИНН контрагента — в порядке +приоритета: колонка ИНН (в том числе склеенная «ИНН/КПП»), затем строка без сумм **прямо над** названием на том же уровне +оформления (в `rows` не попадает, предупреждение «ИНН … над строкой … отнесён к ней»), затем конец текста названия +(«Полиграф, 3528315710», «ООО Альфа (ИНН 7701234567)» — 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»). +Из текста ИНН отрезается всегда: `counterparty_name` без ИНН и разделителя, `row_value` — исходный текст; только для строк +`COUNTERPARTY`, договоры и прочее не трогаются; правила — `osv_headers.yaml` (`counterparty_inn_suffix`, `inn_only_row`, +`inn_kpp_cell`); **реализовано**, OQ-100, OQ-130 (tabular-parser #9, PR #15; синтетика `s17`–`s19`), `data_type` (строки «USD», «Вал.», «Кол.» под контрагентом → CURRENCY/QUANTITY; по умолчанию — из шапки). Строки «Вал.», «Кол.» и трёхбуквенный код валюты («USD») — не уровень группировки, а вид сумм родителя: @@ -159,7 +171,12 @@ r11 Договоры ← значения родителя и выше не затронуты); - родитель (0, 0) при детях (X Дт, X Кт) принимается (нетто 0) — осознанное поведение, закреплено тестом; - вне R-21: текст вида «12.ООО» принимается за счёт (§1.3, R-16). -- Строка «Итого» (TOTAL) == корневая строка ACCOUNT (первая строка уровня 0). Если корня нет (**пустая ОСВ**: заголовок и +- Строка «Итого» (TOTAL) == корневая строка ACCOUNT (первая строка уровня 0); если корней уровня 0 несколько (лист без + строки корневого счёта: верхний уровень — субсчета 58.01, 58.03, OQ-133, PR #13) — «Итого» == **сумме строк уровня 0** + (показатели без суммы и сама «Итого» не входят; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя + выше, R-21); при одном корне сверка прежняя. Вторая итоговая строка сразу за «Итого» («Итого развернутое») — тоже `TOTAL`, + в сверку не входит; строка сумм без подписи в конце таблицы принимается за «Итого» с предупреждением. + Если корня нет (**пустая ОСВ**: заголовок и сразу «Итого» — в фикстурах две, schastl 58 и 66 за 2 кв. 2025) — «Итого» должна быть пустой или нулевой; такая ОСВ штатна: `done`, `hierarchy=trivial`, `metrics.rows = 0`, предупреждение «нет строк данных». Если строка «Итого» не найдена вовсе — сверка невозможна: `total_row_matches_root = false` и предупреждение «строка «Итого» не найдена» @@ -179,6 +196,15 @@ r11 Договоры ← | 4 квартал, IV кв. | Q4 | **false** [открыто] | | год, за 2025 год, 12 месяцев, годовая | Q4 | true | +**Период-диапазон месяцев** («Январь - Май 2025 г.», «Октябрь 2024 г. – Сентябрь 2025 г.»; `periods.yaml`: `months`, `range`; +OQ-131, PR #13 и 2b): парсер заполняет границы `period_start`/`period_end`/`is_from_year_start` (контракт v1.2; для квартала +и года: конец — последний день квартала, начало — 1 января у нарастающего и первый день квартала у квартального; +`is_from_year_start` — `period_start` = 1 января). Диапазон «январь – конец квартала» — нарастающий +(`is_cumulative = true`, квартал по месяцу конца), ровно один квартал — квартальный, остальное (скользящие 12 месяцев, +полгода с апреля, конец не на границе квартала) — `period_year` = год конца, `period_quarter = null`, +`is_cumulative = null`, границы по тексту и предупреждение «не с начала года» / «конец диапазона не совпадает с концом +квартала»: ядро такой документ к версии не привязывает (OQ-131). + `ASSUMPTION(OQ-02)`: парсер определяет `period_quarter` и `is_cumulative` **только по тексту периода** (`rules/periods.yaml`, правила идут от специфичных к общим): квартальная ведомость Q2–Q4 считается не нарастающей; сверку по данным (`Q3.opening == Q1.opening`) выполняет ядро и может переопределить признак. `period_year` — четыре @@ -420,6 +446,14 @@ variance = |amount_a − amount_b|; variance_pct = variance / min(amount_a, amou Межформенная: `1370(отчётная дата) − 1370(начало года) = 2400(ОФР за период)` — при расхождении КА может акцептовать с комментарием (`financial_statement_meta.checks_accepted_*`). +**Парсер tabular-parser (§12 п. 7)** считает **жёсткими** (нарушение → `needs_check`) соотношения ББ — `1100`, `1200` +(+1215), `1300`, `1400`, `1500`, `1600 = 1100 + 1200`, `1700 = 1300 + 1400 + 1500`, `1600 = 1700` — и ОФР — `2100`, `2200`, +`2300`; **мягкими** (предупреждение) — `2400 = 2300 − 2410 + 2420` и варианты ФСБУ 4/2023 (`2400 = 2300 + 2410 + 2460`, +`2410 = 2411 + 2412`), `2500 = 2400 + 2510 + 2520 + 2530` и «сумма пятизначных подстрок = родитель» +(`ASSUMPTION(OQ-12)`). Допуск ±1 в единицах формы, расходы — двойной расчёт знаков (`abs_terms`). Межформенная проверка +`1370 − 1370(начало года) = 2400` парсером не выполняется. Это сигнал о структуре и данных файла, а не замена проверок +ядра: ядро (Срез 3) пересчитывает §7 и ведёт акцепт (`financial_statement_meta.checks_accepted_*`). + Когда: после распознавания; после «Сохранить» правок; при «Подтвердить данные». Непройдено → `document.status = NEEDS_CHECK`; пройдено/акцептовано + нажато «Подтвердить» → `CONFIRMED` + `audit_event(DOCUMENT_CONFIRM)`. Отрицательные значения в формах — в скобках `(2100)` → `-2100`. Значения с `confidence < 100` подсвечиваются. @@ -742,30 +776,67 @@ OQ-92 (пустые «Организация»/«Группа компаний» что и у `scan-gateway`, чтобы ядро маппило ББ/ОФР одинаково независимо от источника (`confidence = 100`, `is_recognized = true` для табличного пути). -1. **Классификация листа**: ячейки с «Бухгалтерский баланс» / «0710001» → BALANCE; «Отчет о финансовых результатах» / - «Отчет о прибылях и убытках» / «0710002» → INCOME. На одном листе могут быть обе формы подряд (ББ, затем ОФР) — - разрезать по второму заголовку; в одном файле несколько листов — несколько `document`. Листы ОСВ и форм в одном - файле — допустимо. -2. **Колонка кодов строк**: та, где ≥ 60 % непустых значений — четырёхзначные коды из допустимого набора - (1110–1700 для ББ, 2110–2500 для ОФР, плюс 5xxx для расшифровок). Заголовок «Код» необязателен. Если колонки кодов - нет — попытаться сопоставить по наименованиям показателей (словарь `rules/statement_lines.yaml`: «Основные средства» → - 1150) с пометкой `line_code_confidence < 100` и warning; агент-аналитик подтвердит. -3. **Колонка наименований**: ближайшая текстовая слева от кодов; «Пояснения» (1–2 символа/цифры) слева от неё — не - наименование. -4. **Колонки-периоды**: ячейки заголовка справа от кодов, матчащие дату («На 31 декабря 2024 г.», «31.12.2024», - «2024», «За 9 месяцев 2025 г.», «За январь–сентябрь 2025 г.») → `financial_statement_column` с `period_date` - (для ОФР — дата конца периода) и `is_current` = самая поздняя. Число колонок — от 1 до 3+. Колонки без даты - («Изменение», «%») — в `extra`. -5. **Значения**: Decimal по правилам §1.2; скобки → минус; «–»/«-»/пусто → NULL. Единица измерения — из шапки - («тыс. руб.», «млн руб.», ОКЕИ 384/385) → `header.units`; если не найдена — по порядку величины относительно - ОСВ той же организации **не угадывать**, оставить NULL (ядро спросит). -6. **Шапка**: организация, ИНН, дата, ОКПО/ОКОПФ/ОКФС/ОКВЭД — регулярками по области выше таблицы; как в §1.0 п. 6. -7. **Самопроверка**: формулы §7 по каждой колонке; расхождение > 0 (в единицах формы) → `needs_check` с перечнем - непройденных соотношений в `metrics.warnings`. Это не замена проверок ядра, а сигнал, что структура угадана неверно - (например, перепутаны колонки периодов). -8. Расшифровки к строкам (лист `К балансу`, рекомендуемые формы банка) — в первой очереди **не разбирать**; классифицировать - как `DISCLOSURE`/`OTHER` и сохранить `locator`. +**Статус: реализовано (Срез 2b, tabular-parser feat/14, контракт v1.2; решения владельца 06.10.2026).** Модули — +`api/src/parser/statements/`, словари — `api/rules/statement_headers.yaml`, `statement_periods.yaml`, +`statement_checks.yaml`; ожидания по реальным файлам — `api/tests/statements_manifest.yaml` (06-test-data §4б). -Что это даёт ядру: один маппинг `statementDocument → document + financial_statement_*` для PDF и Excel; экран -«Проверка распознавания» для Excel-источника показывает слева не страницы, а рендер листа (или просто таблицу) — -UI-решение вне парсера. +1. **Классификация листов**: лист ОСВ → форма → продолжение → прочее. Форма: заголовок («Бухгалтерский баланс», + «Отчет о финансовых результатах», «Отчет о прибылях и убытках») или ОКУД в **любой** строке до таблицы (у НПЗ он стоит + под титулом); титул «Бухгалтерская отчетность» — не тип, тип берёт ближайший следующий заголовок. Тип по ОКУД: + 0710001 → `BALANCE`, 0710002 → `INCOME`, прочие 0710xxx (0710003 капитал, 0710004 ДДС) и пояснения («Пояснения к + бухгалтерскому балансу», «Расшифровка») → `DISCLOSURE`; при противоречии ОКУД и заголовка — тип по ОКУД и + предупреждение. Две формы на одном листе — по второму заголовку, в книге несколько листов — несколько `document`. + Продолжение: лист без заголовка формы, но с маркером «Форма … с.2», либо с подписью «Код» и периодами правее, либо с + колонкой кодов. ОСВ и формы в одной книге допустимы. +2. **Колонка кодов**: в блоке ≥ 60 % непустых значений — коды (ББ 1100–1799, ОФР 2100–2999, расшифровки 5xxx, + пятизначные — по первым четырём цифрам), кодов не меньше `min_codes` = 3; заголовок «Код/Коды» необязателен, числа вида + `1110.0` читаются как текст «1110». Колонки нет — документ без строк, `needs_check`, предупреждение «колонка кодов не + найдена»; сопоставление по наименованиям строк **не делается** (решение владельца, OQ-138). +3. **Колонка наименований** — ближайшая левее кодов с преобладанием текста; «Пояснения» (пустые или ≤ 3 символов) не + наименование. +4. **Колонки-периоды**: ячейки заголовка правее кодов с распознанным периодом («На 31 декабря 2024 г.», «31.12.2024», + «За 9 месяцев 2025 г.», «За январь - сентябрь 2025 г.», «2024»), по порядку слева направо (`column_index` 0, 1, 2…); + `period_date` — дата или конец периода; `is_current` в контракте нет — ядро берёт самую позднюю. Заголовок в две строки + («На» + «20__ г.», перенос внутри ячейки) склеивается; строка нумерации «1 2 3 4 5» пропускается. Колонки без периода + («Изменение», «%») **не читаются** (одно предупреждение на документ): у `statementDocument` поля `extra` нет. +5. **Значения**: `Decimal`, скобки → минус, «–»/пусто → `null`, нечисловой текст → `string_value`, + `is_recognized = false`. **Единицы не нормализуются**: `amount` как в ячейке, `header.units` — текст шапки; при «тыс.» и + ≥ 10 % значений с дробной частью — предупреждение «N значений с дробной частью при единице тыс. руб.» (OQ-137, + решение владельца). Строки без кода (разделы, «в том числе:», «Реализация иного имущества…» у НПЗ) не отдаются + (`ASSUMPTION(OQ-36)`, OQ-140); повтор кода — остаётся первая строка, код в `checks.duplicate_line_codes`, `needs_check`. + Пятизначные подстроки (11101, 12301…) отдаются как обычные строки. +6. **Шапка**: организация (после метки «Организация», иначе текст с признаком ОПФ строкой выше метки — НПЗ), ИНН 10/12 + цифр у метки «ИНН» (**ИНН аудитора не берётся**: строки после метки «аудитор» исключены), ОКПО, ОКОПФ/ОКФС, ОКЕИ, адрес, + единицы, `report_date_text`/`report_date` — из подзаголовка формы или тройки «число | месяц | год» у метки «Дата» (для + ОФР — конец периода). Шапка другого листа не используется: у ОФР НПЗ нет метки «ИНН» → `inn = null`. Найденное поле — + `*_confidence = 100`, не найденное — `null`. +7. **Самопроверка**: соотношения §7 по каждой колонке — данные `statement_checks.yaml`. **Жёсткие** (нарушение → + `needs_check`): ББ `1100`, `1200` (+1215), `1300`, `1400`, `1500`, `1600 = 1100 + 1200`, `1700 = 1300 + 1400 + 1500`, + `1600 = 1700`; ОФР `2100`, `2200`, `2300`. **Мягкие** (только предупреждение, `ASSUMPTION(OQ-12)`): `2400 = 2300 − + 2410 + 2420` и варианты ФСБУ 4/2023, `2500`, «сумма пятизначных подстрок = родитель». Допуск ±1 **в единицах формы**, + отсутствующий код = 0, соотношение проверяется, только если присутствует его цель; правая часть считается дважды — как + записано и с членами `abs_terms` по модулю (1С выгружает расходы со знаком минус, 66н — в скобках). Результат — + `documents[].checks` (`relations_failed`, `soft_relations_failed`, `duplicate_line_codes`) и предупреждения. + **Фактически сходятся полные формы только у Межениновской**: книга НПЗ (`npz/2025-Q3`) — ББ `needs_check`: `1600 = 1700` + расходится на 1.94 / 9.02 / 2.48 руб., потому что значения в рублях с копейками при шапке «тыс. руб.»; допуск ±1 остаётся + (решение владельца 06.10.2026, вариант А, OQ-137). ОФР НПЗ и ОФР Унагранде (стр. 1) тоже `needs_check` по 2100/2200/2300 + по той же причине; ББ Унагранде (стр. 1) — только актив, `1600 = 1700` не сходится. Это сигнал о данных файла, а не о + неверно угаданной структуре; допуск под файл не подгоняется. +8. **Расшифровки** (0710003/0710004, пояснения, лист «Примечания» шаблона) — `DISCLOSURE` с `locator` и шапкой, если + нашлась, без `columns`/`lines`; на статус не влияет (OQ-23). Книга только из таких листов — `done`. +9. **Склейка**: листы-продолжения внутри книги присоединяются к последнему открытому документу формы, если число колонок + сумм совпадает; строки добавляются в конец, `locator` перечисляет листы через запятую. **Страницы в разных файлах не + склеиваются** (1С печатает форму постранично, один файл = одна страница): страница 2 без первой — документ с пустой шапкой + и `needs_check`, предупреждение «страница-продолжение без шапки: загрузите форму одним файлом» (решение владельца, OQ-139). + Продолжение без колонки кодов при непустом результате не теряется молча — `needs_check`. +10. **Статус**: документ `ok` ⇔ жёсткие соотношения сошлись по всем колонкам ∧ нет дубликатов кодов ∧ найдена колонка кодов + ∧ есть колонка-период ∧ в шапке есть организация или ИНН ∧ документ не одинокое продолжение. Книга — `done`, если `ok` + у всех документов, иначе `needs_check`; книга без листов ОСВ и форм — `INVALID_DOCUMENT`. Порядок документов: сначала + ОСВ в порядке листов, затем формы. Шаблоны (коды есть, сумм нет, шапки нет) — `needs_check`. +11. **Отложено**: запасной путь по именам строк (OQ-138), нормализация единиц (OQ-137), признак продолжения в контракте и + склейка страниц из разных файлов в ядре (OQ-139, Срез 3), строки без кода (OQ-140), образцы ФСБУ (OQ-141), лист + продолжения с единственной кодовой строкой (OQ-142: лист ОФР НПЗ с одной строкой 2400 не распознаётся как форма и + теряется, `min_codes` = 3). + +Что это даёт ядру: один маппинг `statementDocument → document + financial_statement_*` для PDF и Excel (маппинг форм — +Срез 3); экран «Проверка распознавания» для Excel-источника показывает не страницы, а таблицу — UI-решение вне парсера. -- 2.47.2 From 4272ba80614d60dacf91e0085983c90f241f38a1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 21:18:05 +0400 Subject: [PATCH 06/12] =?UTF-8?q?docs(2b):=20OQ-137=E2=80=A6OQ-142,=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D1=8B=20OQ-100/113/130/131?= =?UTF-8?q?/133/134=20=D0=BF=D0=BE=20=D1=84=D0=B0=D0=BA=D1=82=D1=83=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Закрывает A11-6, A9-5 (arm-tabular-parser#18), OQ-113. Refs #26 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- context/07-open-questions.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/context/07-open-questions.md b/context/07-open-questions.md index 714db22..628e06a 100644 --- a/context/07-open-questions.md +++ b/context/07-open-questions.md @@ -105,7 +105,7 @@ | OQ-97 | Право `DIRECTORY_DELETE` по матрице §10 только у ADMIN: удалять алиасы и пустые ГСК/ГСЗ могут только администраторы, а не КА. Расширить право на KA_RF/KA_GO/CURATOR_GO? | §10; arm-core O-6 | срез 1 (seed) | **закрыт решением владельца 05.10.2026: право `DIRECTORY_DELETE` на КА не расширяется**, удаляют алиасы и пустые ГСК/ГСЗ только ADMIN; матрица §10 без изменений | | OQ-98 | Единый список кодов валют для строк `data_type = CURRENCY` («USD», «EUR» под контрагентом): достаточно ли ISO 4217-списка из `rules/data_types.yaml` (`currency_codes`: RUB, USD, EUR, CNY, KZT, BYN, GBP, CHF, JPY, TRY, AED)? Нужны ли нестандартные написания и коды, которые используют конфигурации 1С | tabular-parser: design D-4, R-6, R-22 | срез 2c (маппинг CURRENCY-строк), словарь `data_types.yaml` | допущение принято: ISO-перечень, дополняется строкой в YAML вместе с синтетикой; коды не входят в тест покрытия словарей (R-22) | | OQ-99 | Несколько ОСВ **одного счёта** в одной книге (разные листы: два периода или две организации): парсер выдаёт по одному `document` на лист (`locator = sheet:<имя>`) и ничего не дедуплицирует. Как ядро выбирает/сопоставляет такие документы с периодом и организацией? | tabular-parser: spec `osv-parsing` («Листы ОСВ находятся по содержимому») | срез 2c (маппинг, `document.period_check`) | допущение принято: каждый лист — отдельный документ, выбор — за ядром и аналитиком при проверке | -| OQ-100 | ИНН внутри **текста** строки контрагента («Полиграф, 3528315710», 20 строк в ОСВ 76 Счастливочной) и склеенная ячейка «ИНН/КПП»: извлекать ли ИНН в парсере или в ядре при резолве контрагента? Сейчас `counterparty_inn` берётся только из колонки ИНН (§1.3), `counterparty_name` = весь текст строки | tabular-parser: R-15, R-17 (design №25, №27) | срез 2c (резолв `counterparty_id` по ИНН/имени) | **решено 07.10.2026 (владелец):** извлекать в парсере (ИНН → `counterparty_inn`, имя без ИНН) — RSHB/arm-tabular-parser#9; до реализации — как раньше | +| OQ-100 | ИНН внутри **текста** строки контрагента («Полиграф, 3528315710», 20 строк в ОСВ 76 Счастливочной) и склеенная ячейка «ИНН/КПП»: извлекать ли ИНН в парсере или в ядре при резолве контрагента? Сейчас `counterparty_inn` берётся только из колонки ИНН (§1.3), `counterparty_name` = весь текст строки | tabular-parser: R-15, R-17 (design №25, №27) | срез 2c (резолв `counterparty_id` по ИНН/имени) | **реализовано** (решение владельца 07.10.2026, RSHB/arm-tabular-parser#9, PR #15, слит с #13): ИНН из конца текста названия контрагента → `counterparty_inn`, `counterparty_name` без ИНН (`osv_headers.yaml`: `counterparty_inn_suffix`); тесты — `tests/unit/parser/test_classify.py` (`test_inn_is_extracted_from_counterparty_name`), синтетика `s17_inn_in_name`; описание — `05-domain-rules.md` §1.3 | | OQ-101 | Числа с ведущим «+», «.5», «5.» парсер отвергает как нечисловой текст (`null` + предупреждение с номером строки). Встречаются ли такие записи в выгрузках других систем; принимать ли их | tabular-parser: T7.3 | срез 2a+, словарь/правила `numbers.py` | допущение принято: отвергаются; в реальных файлах не встречаются | | OQ-102 | Нужен **реальный xlsb из Excel**: единственный `fixtures/osv/synthetic/s13_no_outline_indent.xlsb` собран тестовым писателем BIFF12 и не доказывает чтение файлов, сохранённых Excel (стили, другая раскладка записей). Получить xlsb у аналитиков или пересохранить `s02` в Excel и заменить `s13` по инструкции в README fixtures | tabular-parser: R-9, O-7 | срез 2a (достоверность xlsb) | допущение принято: читаем `pyxlsb` без outline level и форматирования; проверка — на первом настоящем xlsb | | OQ-103 | Счета с буквой в **первом** сегменте кода (забалансовые «МЦ.04», «ГТД», «НЕ.01.1») не распознаются как ACCOUNT: регулярка требует цифр в первом сегменте, строка станет контрагентом/группировкой по позиции. Нужны ли такие ОСВ, как их кодировать в `chart_of_account` | tabular-parser: R-16 (design №26), `classify.py` | срез 2c (план счетов), парсер | допущение принято: не распознаются; в 66 реальных файлах не встречаются | @@ -118,7 +118,7 @@ | OQ-110 | Удаление файла после консолидаций: у `document` появятся FK из `financial_statement_meta`, `consolidation_document`, `ex_snapshot_item`, а `DELETE /files/{id}` Среза 2c их не чистит (в 2c строк в этих таблицах нет). ANL-020 запрещает физическое удаление после INITIATED. Что делать: запретить удаление файла, документ которого входит в версию консолидации, и/или каскадно чистить служебные таблицы (`financial_statement_meta` — в Срезе 3) | arm-core Срез 2c: Task 10 (R-23, design №36, №48) | Срезы 3, 4 (расширить удаление или запрет) | **закрыт для Среза 4 (06.10.2026):** привязываются к версии только `CONFIRMED` документы незаменённых файлов, а файл с `CONFIRMED` документом удалить нельзя (`FILE_HAS_CONFIRMED_DOCUMENTS`) — удаление файла с документом в версии невозможно, расширять `DELETE /files/{id}` не нужно; автозамена снимает привязки документов заменённого файла (`FILE_REPLACE.unbound_version_ids`). Остаток: FK из `financial_statement_meta` и `ex_snapshot_item` — Срезы 3 и 7 | | OQ-111 | Ретеншн S3-объектов при удалении файла: сейчас `DELETE` лишь ставит `s3_object.expires_at = now()`, объекты `uploads`/`results` физически не стираются (дополняет OQ-22). Кто и когда их удаляет — lifecycle-правило бакета по метаданным, фоновая задача ядра, ручная очистка? Объект, записанный в S3 до сбоя БД, остаётся сиротой | arm-core Срез 2c: design D-10, №36 | платформа (lifecycle), Срез 2 → эксплуатация | допущение принято: бакет растёт; очистка — вне среза | | OQ-112 | Остаточные объекты e2e-прогона: каждый запуск `make e2e` оставляет в MinIO dev-стенда ~2 объекта (`uploads/…`, `results/…` — следствие OQ-111) | arm-core Срез 2c: Task 11 (O-5) | dev-стенд | допущение принято: чистить бакет `arm-ka` вручную (раздел «Сброс стенда» `dev/qa-guide.md`) | -| OQ-113 | Полнота плана счетов seed: 19 нормализованных кодов строк-счетов golden ОСВ отсутствуют в `chart_of_account` (`01.01`, `66.01`, `76.01`, `60.АВ`, `76.ОТ` и др.), поэтому у их строк `account_id = NULL`. Достаточно ли «администратор добавит» или seed должен включать полный набор субсчетов (включая буквенные) до Среза ВГО (§4–§5) | arm-core Срез 2c: Task 8 (O-3) | Срезы 4–5 (пары счетов элиминации) | **частично решён 06.10.2026:** план счетов 1С заказчика получен (`fixtures/directories/chart-of-accounts-1c.xlsx`, 488 строк, все 122 кода seed в нём есть); перенос в seed — arm-core#23; подтверждение владельца, что это полный план — ждём | +| OQ-113 | Полнота плана счетов seed: 19 нормализованных кодов строк-счетов golden ОСВ отсутствуют в `chart_of_account` (`01.01`, `66.01`, `76.01`, `60.АВ`, `76.ОТ` и др.), поэтому у их строк `account_id = NULL`. Достаточно ли «администратор добавит» или seed должен включать полный набор субсчетов (включая буквенные) до Среза ВГО (§4–§5) | arm-core Срез 2c: Task 8 (O-3) | Срезы 4–5 (пары счетов элиминации) | **закрыт: допущение принято — выгрузка плана счетов 1С заказчика (`fixtures/directories/chart-of-accounts-1c.xlsx`, 488 строк, все 122 кода seed в ней есть) полная (arm-core #25);** ранее: частично решён 06.10.2026, перенос в seed — arm-core#23 | | OQ-114 | `EntryOut` (API строк ОСВ) не отдаёт `is_modified`/`original_*`: панели правки подсреза 2d нужен признак правки аналитика (инвариант «первичка не мутируется»: правка — рядом, с аудитом). Добавить поля в схему и контракт API при проектировании 2d | arm-core Срез 2c: Task 9 (T9.7) | подсрез 2d | открыт: в 2c строк не правят, `is_modified = false` у всех | | OQ-115 | Автозамена: если новый файл содержит лишь часть документов прежнего (прежний — ОСВ 60 и 51, новый — только 60), прежний уходит в `SUPERSEDED` **целиком**, документ 51 пропадает из списка файлов; в `FILE_REPLACE.description` о нём ничего нет. Подтвердить «замена файлом целиком» (OQ-06) для этого случая или заменять по документам | arm-core Срез 2c: Task 8 (T8.4) | Срез 2d (список файлов), Срез 4 (комплект документов) | допущение принято: замена файлом целиком, документы заменённого файла сохраняются в БД | | OQ-116 | ОСВ Q2–Q4, признанная нарастающей по данным Q1 (сальдо на начало корня равно сальдо Q1, §1.5), получает предупреждение маппинга и поэтому остаётся `NEEDS_CHECK`, а не `RECOGNIZED` (буквально D-7/D-9 спеки: `RECOGNIZED` только без предупреждений). Верно ли, что аналитик подтверждает такие документы вручную, или нарастающая по данным — штатный случай без предупреждения (OQ-02, OQ-05) | arm-core Срез 2c: Task 7 (R-17, design №30) | Срез 2d (нагрузка на аналитика), §9.3 | допущение принято: `NEEDS_CHECK`; снять предупреждение — одна правка в `result_mapping.py` и правило статуса | @@ -135,13 +135,19 @@ | OQ-127 | Рекомендация кандидата (`recommended`) — только точное совпадение `normalize_alias` имени строки с нормализованным именем/алиасом ЮЛ. На реальных данных «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» (так записан контрагент в ОСВ РБ) рекомендации не даёт: аналитик находит ЮЛ поиском по `q`. Нужна ли нечёткая рекомендация (токены, порядок слов, префикс «Юг-Финанс /») или достаточно ручного выбора и накопления алиасов | arm-core Срез 4: Task 13 (O-3), design D-11 | `GET …/counterparty-candidates` | допущение принято: рекомендация точная; после первого ручного сопоставления алиас закрепляет имя | | OQ-128 | Неоднозначность дорезолва на лету при тёзке по имени: после ручного сопоставления создаётся алиас, но `CounterpartyIndex.resolve` на шаге «нормализованное имя/алиас» возвращает контрагента только при единственном кандидате — если ЮЛ-тёзка (по нормализованному `name`) есть рядом с владельцем нового алиаса, кандидатов двое, резолв неоднозначен и строки без сохранённого `counterparty_id` остаются несопоставленными. ТЗ DOC-221278: «алиас однозначен» — решает ли это, или алиас должен иметь приоритет над именем | arm-core Срез 4: Task 11 (T11.9) | дорезолв свода (D-3), `CounterpartyIndex` | открыт: допущение — резолв по Срезу 2c (неоднозначность → `NULL`); спасает автоприменение `PROPAGATED` (сохраняет `counterparty_id`) | | OQ-129 | Формулировка сценария «сопоставление и автоприменение» (spec consolidated-osv): «`unmapped_total` падает на 1 + propagated» неверна на реальных данных. `propagated` считает записи `osv_entry` (включая пары-пробелы и строки, не дающие строк свода), а свод склеивает кварталы, поэтому `unmapped_total` падает на число **склеенных** строк свода (R-15, design №80). Уточнить текст spec при архивации | arm-core Срез 4: Task 13 (T13.M1, R-15) | spec `consolidated-osv` при архивации change | допущение принято: тест сверяет обе величины отдельно; текст spec уточнён в change, `openspec/specs/` — при архивации | -| OQ-130 | ОСВ произвольного формата: ИНН в ячейке **на строку выше** названия контрагента (строка только с ИНН, без сумм). Сейчас такая строка уходит в OTHER, ИНН для резолва теряется. Решение владельца 07.10.2026: склеивать со следующей строкой контрагента; синтетические фикстуры на этот случай, на ИНН в тексте и на «ИНН/КПП» в одной ячейке | владелец, 07.10.2026 | tabular-parser §1.0 п. 4, §1.3; резолв контрагента в ядре | **решено:** RSHB/arm-tabular-parser#9 (bounded), ждёт реализации | -| OQ-131 | Скользящий период ОСВ «Октябрь 2024 г. – Сентябрь 2025 г.» (НПЗ 60/62/76, Агрокомплекс 76, `osv-dkb/`): парсер берёт первый месяц → `2024 Q4`, нарастающий. Нужен разбор диапазона дат и признак «не с начала года»; как ядру относиться к такому документу при привязке к версии (не нарастающий и не квартальный) | пакет ДКБ 06.10.2026, прогон парсера | tabular-parser `periods.yaml`/`header.py`; ядро `fits` (D-5 Среза 4) | открыт: arm-tabular-parser#11 п. 1; допущение — не привязывать, показывать в чек-листе как «период не соответствует» | +| OQ-130 | ОСВ произвольного формата: ИНН в ячейке **на строку выше** названия контрагента (строка только с ИНН, без сумм). Сейчас такая строка уходит в OTHER, ИНН для резолва теряется. Решение владельца 07.10.2026: склеивать со следующей строкой контрагента; синтетические фикстуры на этот случай, на ИНН в тексте и на «ИНН/КПП» в одной ячейке | владелец, 07.10.2026 | tabular-parser §1.0 п. 4, §1.3; резолв контрагента в ядре | **реализовано** (RSHB/arm-tabular-parser#9, PR #15): строка только с ИНН над названием склеивается со следующей строкой контрагента (`inn_only_row`, предупреждение «ИНН … над строкой … отнесён к ней»), «ИНН/КПП» в одной ячейке (`inn_kpp_cell`); тесты — `test_classify.py` (`test_inn_only_row_above_is_glued_to_next_counterparty`, `test_inn_with_kpp_in_one_column_cell_gives_inn`), синтетика `s18_inn_row_above`, `s19_inn_kpp_cell`; `05-domain-rules.md` §1.3 | +| OQ-131 | Скользящий период ОСВ «Октябрь 2024 г. – Сентябрь 2025 г.» (НПЗ 60/62/76, Агрокомплекс 76, `osv-dkb/`): парсер берёт первый месяц → `2024 Q4`, нарастающий. Нужен разбор диапазона дат и признак «не с начала года»; как ядру относиться к такому документу при привязке к версии (не нарастающий и не квартальный) | пакет ДКБ 06.10.2026, прогон парсера | tabular-parser `periods.yaml`/`header.py`; ядро `fits` (D-5 Среза 4) | **реализовано в парсере** (tabular-parser PR #13 + Срез 2b): диапазон месяцев разбирается (`periods.yaml`: `months`, `range`), границы `period_start`/`period_end`/`is_from_year_start` — в контракте v1.2 (docs change 2b), скользящий период → `period_quarter = null`, `is_cumulative = null` и предупреждение «не с начала года» (`05-domain-rules.md` §1.5). **Открыто для ядра:** привязка скользящего документа к версии (допущение — не привязывать, показывать в чек-листе как «период не соответствует») | | OQ-132 | Тождество сальдо по строкам (§1.4) на счёте 91 (и, вероятно, 90, 99): строки аналитики доходов/расходов несут только обороты, сальдо закрывается на 91.09 → `needs_check` на каждой ОСВ 91 (Цимлянский, Приманыческий). Исключать ли счета без сальдо по аналитике из проверки тождества строк | пакет ДКБ 06.10.2026 | tabular-parser `checks.py`, статус документа | открыт: допущение — оставить `needs_check`, аналитик подтверждает вручную; arm-tabular-parser#11 п. 2 | -| OQ-133 | ОСВ варианта 2 без строки корневого счёта: верхний уровень — субсчета (58.01, 58.03), «Итого» = их сумме, парсер сверяет «Итого» с первым корнем → `needs_check` на каждом листе (Новоросметалл, Агрокомплекс; уточняет OQ-106). Сверять «Итого» с суммой строк уровня 0 | пакет ДКБ 06.10.2026 | tabular-parser `checks.py` | открыт: arm-tabular-parser#11 п. 3 | -| OQ-134 | Шапка ОСВ без строки организации и с кодом/названием счёта в двух колонках («Счет: Код / Наименование», строка «Бухгалтерский \| 9 месяцев 2025 г.»; Новоросметалл ОСВ 63): парсер не находит организацию, счёт, период, тип данных. Новый вариант заголовка — словарь или код | пакет ДКБ 06.10.2026 | tabular-parser `detect.py`/`header.py`, `osv_headers.yaml` | открыт: arm-tabular-parser#11 п. 4 | +| OQ-133 | ОСВ варианта 2 без строки корневого счёта: верхний уровень — субсчета (58.01, 58.03), «Итого» = их сумме, парсер сверяет «Итого» с первым корнем → `needs_check` на каждом листе (Новоросметалл, Агрокомплекс; уточняет OQ-106). Сверять «Итого» с суммой строк уровня 0 | пакет ДКБ 06.10.2026 | tabular-parser `checks.py` | **реализовано** (tabular-parser PR #13, arm-tabular-parser#11 п. 3): при нескольких корнях уровня 0 «Итого» сверяется с суммой строк уровня 0 (сальдо по колонкам либо по нетто, как у родителя, R-21); `checks.py`, `05-domain-rules.md` §1.4 | +| OQ-134 | Шапка ОСВ без строки организации и с кодом/названием счёта в двух колонках («Счет: Код / Наименование», строка «Бухгалтерский \| 9 месяцев 2025 г.»; Новоросметалл ОСВ 63): парсер не находит организацию, счёт, период, тип данных. Новый вариант заголовка — словарь или код | пакет ДКБ 06.10.2026 | tabular-parser `detect.py`/`header.py`, `osv_headers.yaml` | **реализовано** (tabular-parser PR #13, arm-tabular-parser#11 п. 4): шапка без метки организации (организация — хвост названия ведомости), период и тип данных отдельными ячейками (`periods.yaml: standalone`, `data_types.yaml: standalone_header`), подписи «Код»/«Наименование» не группировки (`osv_headers.yaml: column_captions`), код счёта — из первой строки-счёта (с предупреждением); `05-domain-rules.md` §1.1; тест `test_header_without_labels_and_code_name_columns_are_found` | | OQ-135 | Отчёт 1С «Анализ счета» с отбором по контрагентам группы (`fixtures/analysis-reports/`, в именах файлов «ОСВ_…ПоКомпаниямГруппы»): не ОСВ, но содержит ВГО по корреспонденции счетов. Нужен ли отдельный тип документа для Среза 5, или ВГО считаются только из ОСВ | пакет ДКБ 06.10.2026 | `document_type`, Срез 5 | открыт: допущение — не разбирать; парсер отвечает `INVALID_DOCUMENT` | | OQ-136 | ИНН организаций пакета ДКБ (Цимлянский, НКЗ, Новоросметалл, Приманыческий, Агрокомплекс) в шапках ОСВ отсутствуют; для теста консолидации по этим группам нужны ИНН и состав ГК/ГСК (выгрузка ЕЦП или список от ДКБ) | пакет ДКБ 06.10.2026 | seed/фикстуры справочников, тесты Среза 4–5 на новых группах | открыт | +| OQ-137 | Единицы «тыс. руб.» в шапке против значений в рублях с копейками в выгрузках 1С (Унагранде Компани/Групп, НПЗ) и тысяч с дробями (Межениновская): кто и где приводит единицы. Примеры: книга НПЗ (`npz/2025-Q3`) — ББ `needs_check`, `1600 = 1700` расходится на 1.94 / 9.02 / 2.48 руб.; ОФР НПЗ и ОФР Унагранде (стр. 1) — `needs_check` по 2100/2200/2300 по той же причине; полные формы по факту сходятся только у Межениновской | `fixtures/statements-xlsx/README.md`, tabular-parser design 2b D-6, D-7, решения №22–24 (R-11, R-13, R-14) | Срез 3 (маппинг форм), §8 (КФО в тыс. руб.) | допущение принято (решение владельца 06.10.2026, вариант А): парсер отдаёт значения и `units` как в файле, при «тыс.» и ≥ 10 % значений с дробной частью — предупреждение; допуск ±1 не меняется, не нормализуем; приведение единиц — ядро/аналитик (Срез 3) | +| OQ-138 | Форма без колонки кодов: сопоставление по именам строк из шаблонов 66н/ФСБУ (словарь `statement_lines.yaml`, `line_code_confidence < 100`)? Сейчас документ без строк и `needs_check` («колонка кодов не найдена») | решение владельца 06.10.2026 (вопрос 6), tabular-parser design 2b D-4 | §12 п. 2, Срез 3 | открыт: нужен реальный файл без колонки кодов; допущение — не сопоставлять по именам | +| OQ-139 | Страница-продолжение в отдельном файле (1С печатает ББ/ОФР постранично, один файл = страница): склейка в ядре по ЮЛ + период + тип формы; признак `is_continuation` / `form_page` в контракте (MINOR, две необязательных поля) | решение владельца 06.10.2026 (вопрос 2), design 2b D-8 | §12 п. 9, Срез 3, контракт v1.x | допущение принято: страницы разных файлов не склеиваются; страница 2 без шапки — документ с пустой шапкой и `needs_check`, «загрузите форму одним файлом» | +| OQ-140 | Строка без кода, но с суммами в ОФР ФСБУ 4/2023 (НПЗ «Реализация иного имущества…»): хранить ли (OQ-36 расширяется с расшифровочных строк на строки с суммами) | `fixtures/statements-xlsx/npz`, design 2b D-6 | Срез 3, DBML `financial_statement_line` (хранит только строки с кодом) | допущение принято: строки без кода не отдаются, при наличии сумм — предупреждение «строк без кода пропущено: N» | +| OQ-141 | Образец ФСБУ 4/2023 (`templates/ББ после 01.01.2025.xlsx`): «АКТИВ» и код 1110 в одной строке из-за объединённых ячеек; шаблоны (коды есть, сумм и шапки нет) не данные. Нужен ли их разбор вообще | `fixtures/statements-xlsx/templates`, design 2b D-8 | §12 п. 10, тесты | допущение принято: шаблоны дают `needs_check` (шапка пустая, значения `null`); разбор шаблонов как источника данных не планируется, пока владелец не скажет иначе | +| OQ-142 | Лист продолжения с **единственной** кодовой строкой (книга НПЗ: лист ОФР с одной строкой 2400): `min_codes` = 3 → лист не распознаётся как форма и пропускается, строка 2400 теряется при `needs_check` документа. Понизить порог / принимать лист по близости к открытому документу ОФР / маркеру продолжения | tabular-parser design 2b: решение №25, O-9 | §12 п. 2, п. 9; полнота ОФР НПЗ | открыт: отложено на финальное ревью 2b; допущение — строка 2400 листа продолжения НПЗ в результате отсутствует | Служебные идентификаторы в DBML/аналитике: `ANL-nnn` — замечания аналитика к макету; `ARCH-nnn` — архитектурные решения; `DECISION-n` — решения по модели; `FIX-nn` — исправления дефектов v4. -- 2.47.2 From 00d171564ee6f7a2363cdd6426f6070434cd7ec4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 21:19:08 +0400 Subject: [PATCH 07/12] =?UTF-8?q?docs(2b):=2006-test-data=20(=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=204=D0=B1,=20=D1=81?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=B5=D1=82=D0=B8=D0=BA=D0=B0=20st01?= =?UTF-8?q?=E2=80=93st06,=20golden=20=D1=84=D0=BE=D1=80=D0=BC),=2003-archi?= =?UTF-8?q?tecture=20(BookParser,=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=20v1.2),=2009-delivery-plan=20(=D0=A1=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=202b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #26 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- context/03-architecture.md | 10 +++++--- context/06-test-data.md | 49 +++++++++++++++++++++++++++++++------ context/09-delivery-plan.md | 15 ++++++++++-- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/context/03-architecture.md b/context/03-architecture.md index f779d63..03d6e94 100644 --- a/context/03-architecture.md +++ b/context/03-architecture.md @@ -27,8 +27,12 @@ - **arm-core** — монолитное ядро: справочники, файлы/документы, маппинг результатов распознавания, консолидация (вычисления), согласование, ЕХ, аудит, права. Один Alembic, одна схема. - **tabular-parser** — читает файл из S3 `uploads/`, находит на листах ОСВ (§1) и формы ББ/ОФР (§12) **по содержимому**, - пишет нормализованный JSON в S3 `results/`, публикует `recognition.result`. Контракт результата — - `docs/contracts/recognition-result.schema.json` (контракт v1.0). Правила распознавания + пишет нормализованный JSON в S3 `results/`, публикует `recognition.result`. Внутри — `BookParser` (оркестратор книги: + лимиты, чтение, дедлайн, вид каждого листа): листы ОСВ идут в конвейер `osv.py`, листы форм — в конвейер форм + `statements/` (блоки форм, колонки, шапка, строки, соотношения §7, склейка продолжений); документы результата — сначала + ОСВ, затем формы. Контракт результата — + `docs/contracts/recognition-result.schema.json` (контракт v1.2: `DISCLOSURE` в `document_type`, `statementDocument.checks`, + границы периода ОСВ `period_start/period_end/is_from_year_start`). Правила распознавания заголовков/группировок — `rules/*.yaml`. - **scan-gateway** — адаптер ИС Сканирование: переименовать файл `UUID_имя.pdf`, положить в «Входящие», опросить «Исходящие/UUID/UUID.json», сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), нормализовать @@ -82,7 +86,7 @@ работе или обработанного, он не публикуется (его `Nats-Msg-Id` совпал бы с настоящим результатом). Нечитаемый запрос (не JSON, нет `job_id`) остаётся без ответа: сервис пишет лог и отбрасывает его, ядро не получает ack, повторяет публикацию `OUTBOX_MAX_REPUBLISH` раз и закрывает задание в `dlq` с `error_code = NO_ACK`. Новый MINOR раскатывается сначала на потребителей - (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают + (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR, например 1.2), потому что закрытые enum отвергают незнакомые значения. - Сервисы не знают про БД и про бизнес-смысл: они возвращают структуру файла; нормализация счетов, резолв контрагентов, проверки — в ядре. diff --git a/context/06-test-data.md b/context/06-test-data.md index c7b3d39..381046c 100644 --- a/context/06-test-data.md +++ b/context/06-test-data.md @@ -139,7 +139,7 @@ JSON `aep_*.json` — реальные ответы Content AI: структур |---|---|---| | ОСВ шести организаций (29 файлов, 34 МБ) | `osv-dkb//<период>/` | АО «Цимлянский» (7 ОСВ за 9 мес. 2025), ООО «Новороссийский Кислородный завод» (2 книги по 3 листа), ООО «Новоросметалл» (2 книги по 3 листа + ОСВ 63; вариант 2, «сумма»), ООО «Новороссийский прокатный завод» (2 книги; одна за окт. 2024 – сент. 2025), ООО «Приманыческий» (7 ОСВ), АО фирма «Агрокомплекс» им. Ткачева (4 ОСВ по 50 тыс. строк, 9–14 МБ) | | «Анализ счета» (3 книги) | `analysis-reports//2025-Q3/` | отчёт 1С по корреспонденции счетов с отбором по контрагентам группы; не ОСВ, парсер отвечает `INVALID_DOCUMENT` | -| ББ/ОФР в Excel (15 файлов) | `statements-xlsx//<период>/` | АО «Унагранде Ко» и «Унагранде Групп» (постраничные xls из 1С, 2024 г. и 9 мес. 2025), ООО НПЗ (одна книга ФСБУ 4/2023 с подстроками «в том числе»), ООО «Агрофирма Межениновская» (TDSheet, 66н); `templates/` — пустые формы 66н и ФСБУ 4/2023 | +| ББ/ОФР в Excel (18 файлов: 14 форм и 4 шаблона) | `statements-xlsx//<период>/` | АО «Унагранде Ко» и «Унагранде Групп» (постраничные xls из 1С, 2024 г. и 9 мес. 2025), ООО НПЗ (одна книга ФСБУ 4/2023 с подстроками «в том числе»), ООО «Агрофирма Межениновская» (TDSheet, 66н); `templates/` — пустые формы 66н и ФСБУ 4/2023 | | План счетов 1С заказчика | `directories/chart-of-accounts-1c.xlsx` | 488 строк: код, наименование, субконто 1–3, вид; в seed 122 кода, все есть в выгрузке; 366 кодов выгрузки в seed нет (OQ-113, arm-core#23) | `osv-dkb/` намеренно вне `osv/`: fixtures-тесты парсера и ядра требуют один лист `Лист_1`, `done`, период = каталогу и @@ -149,6 +149,40 @@ golden-снимок на каждый файл, а здесь многолист «Итого развернутое», листы с именем кода счёта, нет валюты в шапке, объём) — `fixtures/osv-dkb/README.md`, arm-tabular-parser#11, OQ-131…OQ-135. ИНН организаций в шапках ОСВ нет (OQ-136). +## 4б. Формы ББ/ОФР в Excel: манифест, golden, синтетика (Срез 2b) + +**Раскладки реальных файлов** (`fixtures/statements-xlsx/`, README там же; 18 файлов: 14 форм и 4 шаблона): + +| Раскладка | Где | Особенности | +|---|---|---| +| 1С-xls постранично | `unagrande-co/2024-FY/`, `unagrande-group/{2024-FY,2025-Q3}/` | один файл = одна страница формы; на странице 1 титул (организация, ИНН, ОКПО, единица «в тыс. рублей», ОКЕИ, аудитор со **своим** ИНН), на странице 2 только «Форма 0710001 с.2»; пятизначные подстроки (11101…), значения в рублях с копейками при «тыс. руб.»; страниц 1 ББ в пакете нет (только актив) и ОФР Групп 2024 — только стр. 2 | +| Многолистовая xlsx ФСБУ 4/2023 | `npz/2025-Q3/` | ББ на 5 листах, ОФР на 3; заголовок формы после титула; листы 2–5 без заголовка и маркера; строка 1105 «Гудвил», подстроки 12301…, строка без кода с суммами; лист ОФР с одной строкой 2400 (OQ-142) | +| `TDSheet` 66н | `mezheninovskaya/2025-Q3/` | вся форма на одном листе, дата тройкой «30 \| 9 \| 2025» числами, двухстрочные заголовки, строка нумерации колонок, значения — тысячи с дробной частью | +| Шаблоны | `templates/` | 66н и ФСБУ 4/2023: коды есть, сумм и шапки нет; заголовок на две строки; в шаблоне ФСБУ «АКТИВ» и код 1110 в одной строке (OQ-141) | + +**Манифест** `tabular-parser/api/tests/statements_manifest.yaml`: для каждого из 18 файлов — организация, ИНН, документы по +порядку (`type`, `status`, `complete`, причина `needs_check`, `failed_relations`). Статусы сверены прогоном: `done` только +у Межениновской (ББ и ОФР); остальные — `needs_check` по данным файла (рубли с копейками при шапке «тыс. руб.», только +актив, страница 2 без шапки, шаблон без сумм), НПЗ — `1600 = 1700` расходится на 1.94 / 9.02 / 2.48 руб. (OQ-137). +Таблица «файл → статус» — README `tabular-parser`, «Парсер форм». + +**Golden** `fixtures/golden/expected_statement_parse/<тот же путь без расширения>.json` — 24 снимка (18 реальных файлов + +6 синтетических); регрессия, не оракул; обновление — `make golden-update` (один для ОСВ и форм), просмотр diff, отдельный +коммит в `fixtures`. Оракулы корректности — манифест и property-тест `tests/fixtures/test_statements_property.py` (манифест покрывает +каждый файл; тип, статус и видимая причина `needs_check` каждого документа совпадают с ожиданиями). + +**Синтетика** `fixtures/statements-xlsx/synthetic/st01…st06` (генератор `tabular-parser/api/tests/synthetic/generate_statements.py`, +ожидания — `tests/synthetic/test_statement_synthetic_files.py`, описание — README каталога): + +| Файл | Что проверяет | +|---|---| +| `st01_both_forms_one_sheet` | ББ и ОФР на одном листе, два документа с одним `locator` (§12 п. 1) | +| `st02_csv_like` | csv-подобный лист: двухстрочный заголовок, коды без подписи «Код», отрицательное значение, единиц нет | +| `st03_no_codes` | форма без колонки кодов → `needs_check`, «колонка кодов не найдена» (OQ-138) | +| `st04_mixed_book` | книга: лист ОСВ + лист баланса, порядок документов ОСВ → форма | +| `st05_continuation_only` | одинокая страница 2 → `needs_check`, предупреждение о продолжении (OQ-139) | +| `st06_header_variants` | варианты, которых нет в реальных файлах: старое название ОФР, расшифровки/ОДДС/ОИК, форма только с ОКУД 0710xxx, организация в «», «Адрес», «Пояснения», числовая дата (покрытие словарей `statement_*.yaml`) | + ## 5. Прочее - `Форма расшифровки к отчетности 01 10 24 / 01 04 25 / 01 07 25.xlsx` — расшифровки АО РБ (кредиты и займы, дебиторы и @@ -224,8 +258,8 @@ fixtures/ ## 7. Синтетика, golden-снимки и xlsb парсера ОСВ (Срез 2a) Реальные файлы покрывают один вариант шапки и один формат файла (xls/xlsx из 1С 8.3). Остальное — синтетика -`fixtures/osv/synthetic/` (вымышленные организации и ИНН; суммы согласованы арифметически, поэтому все файлы, кроме -`s10`, дают `done`). Файлы собирает детерминированный генератор `tabular-parser/api/tests/synthetic/generate.py` +`fixtures/osv/synthetic/` (вымышленные организации и ИНН; суммы согласованы арифметически, поэтому листы ОСВ, кроме +`s10`, дают `done`; книги `s12`/`s14` с формами — `needs_check` по формам). Файлы собирает детерминированный генератор `tabular-parser/api/tests/synthetic/generate.py` (повторный запуск даёт те же байты), ожидания по каждому файлу — в `tests/synthetic/test_synthetic_files.py`, описание — в `fixtures/osv/synthetic/README.md`. @@ -242,13 +276,14 @@ fixtures/ | `s09_subaccount_names` | названия субсчетов в строке счёта, буквенный субсчёт | | `s10_broken_sum` | испорченная сумма → `needs_check` с номерами строк | | `s11_header_repeat` | заголовок таблицы повторяется внутри данных (печать по страницам) | -| `s12_statement_and_osv` | лист ББ и лист ОСВ в одной книге: форма пропускается с предупреждением | +| `s12_statement_and_osv` | лист ББ и лист ОСВ в одной книге: с Среза 2b — два документа (ОСВ, затем ББ без колонки кодов → `needs_check`) | | `s13_no_outline_indent` (`.xlsb`) | та же ведомость, что `s02`, в xlsb | -| `s14_statement_forms` | три формы ББ/ОФР, каждая опознаётся своим признаком | +| `s14_statement_forms` | три формы ББ/ОФР, каждая опознаётся своим признаком (с 2b — документы `BALANCE`/`INCOME` без колонки кодов, `needs_check`) | | `s15_group_title_synonyms` | названия групп сумм «Входящее/Начальное/Конечное/Исходящее сальдо» | | `s16_header_synonyms` | шапка без организации, период «2024», группировки `OTHER`, «Детализация: …» | -`s14`–`s16` добавлены тестом покрытия словарей: каждая запись `rules/*.yaml` обязана сработать на реальных или +`s17`–`s19` — извлечение ИНН (из текста названия, из строки над названием, «ИНН/КПП» в одной ячейке; OQ-100, OQ-130). +Формы ББ/ОФР из Excel — подраздел 4б. `s14`–`s16` добавлены тестом покрытия словарей: каждая запись `rules/*.yaml` обязана сработать на реальных или синтетических файлах. **xlsb рукописный.** `s13` собран **не Excel и не LibreOffice** (их нет на машине разработки), а минимальным писателем @@ -257,7 +292,7 @@ BIFF12 `tabular-parser/api/tests/synthetic/xlsb_writer.py`: он пишет то файл из Excel (OQ-102). Инструкция замены — в README `fixtures/osv/synthetic/`. **Golden-снимки.** Для каждого файла `fixtures/osv/**` (включая синтетику) в `fixtures/golden/expected_osv_parse/` лежит -результат разбора (88 файлов = 72 реальных + 16 синтетических): файл результата контракта v1.1 без `job_id`, `file_id`, +результат разбора (113 файлов, из них 19 синтетических `s01`–`s19`): файл результата контракта v1.2 (v1.1 — до Среза 2b) без `job_id`, `file_id`, `service.version`, `metrics.duration_ms`. Снимок — **регрессия, не оракул**: он фиксирует, что парсер выдаёт сейчас. Обновление — только `make golden-update` в `tabular-parser` с просмотром diff и отдельным коммитом в `fixtures`; `make golden-check` (код возврата 1 при расхождении) — без записи. Оракулы корректности — property-тест diff --git a/context/09-delivery-plan.md b/context/09-delivery-plan.md index 72f318a..a492254 100644 --- a/context/09-delivery-plan.md +++ b/context/09-delivery-plan.md @@ -104,15 +104,26 @@ Change'и: `tabular-parser/openspec/changes/slice-2a-osv-parser` (issue arm-tabu - **Документы:** `05-domain-rules.md` §1 приведён к факту (§1.6 — результат парсера), `06-test-data.md` §7 (синтетика, golden, рукописный xlsb), OQ-98…OQ-105 открыты (финальное ревью добавило OQ-106…OQ-109), OQ-81 частично закрыт (в `tabular-parser` `ARM_KA_FIXTURES` поддержан; `scan-gateway` — follow-up). - **Не сделано / риски:** xlsb проверен только рукописным синтетическим файлом (OQ-102); ИНН из текста строки не - извлекается (OQ-100); счета с буквой в первом сегменте не распознаются (OQ-103). + извлекался (OQ-100; реализовано позже в tabular-parser PR #15); счета с буквой в первом сегменте не распознаются (OQ-103). -### Срез 2b — ББ/ОФР из Excel (`tabular-parser`) +### Срез 2b — ББ/ОФР из Excel (`tabular-parser`) — реализован, ожидает ревью и merge - `tabular-parser`: ББ/ОФР из Excel по §12 в тот же `statementDocument`; поиск листов с формами по содержимому (в 2a такие листы пропускаются с предупреждением `skipped: statement (slice 2b)`); словари синонимов `rules/*.yaml`; контрольные формулы §7 для форм. - Тесты: property-тесты инвариантов на **всех** файлах `fixtures/statements-xlsx/**` (формулы §7) + синтетика (ББ+ОФР на одном листе, csv-подобный лист); снимки результата — рядом с golden ОСВ. +- **Реализовано (ветка `feat/14-slice-2b-statement-parser`, tabular-parser PR #16; docs — PR #27, ветка + `feat/26-slice-2b-statement-parser`):** `BookParser` + конвейер форм `statements/`, словари `statement_*.yaml`, контракт + v1.2 (`DISCLOSURE`, `checks`, границы периода ОСВ; копии схемы в arm-core PR #27 и scan-gateway PR #5), `§12`/`§7` по + решениям владельца. Тесты: манифест `api/tests/statements_manifest.yaml` (18 реальных файлов), синтетика `st01`–`st06`, + golden `expected_statement_parse/` (18 + 6 снимков), интеграционный тест воркера на книге НПЗ. +- **Объём:** парсер + контракт v1.2; **маппинг форм в ядре (`statementDocument → document + financial_statement_*`) — + Срез 3**. По факту `done` только у форм Межениновской; остальные `needs_check` по данным файлов (рубли с копейками при + «тыс. руб.», страницы без шапки, шаблоны) — OQ-137, OQ-139. +- **Не сделано / риски:** единицы не нормализуются (OQ-137); форма без колонки кодов не разбирается (OQ-138); страницы + в разных файлах не склеиваются (OQ-139); строки без кода не хранятся (OQ-140); лист ОФР НПЗ с единственной строкой 2400 + теряется (OQ-142); после контракта v1.2 `docs/contracts/**` заморожены (копии схемы в arm-core и scan-gateway равны им байт в байт); косметика T1.1/T2.x уходит в следующий MINOR. ### Срез 2c — Загрузка файлов и маппинг результата в ядре (`arm-core`) — реализован, ожидает ревью и merge -- 2.47.2 From a5adcaccaa3628aba1f9299a732d23705112c0c3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 21:19:34 +0400 Subject: [PATCH 08/12] =?UTF-8?q?docs(2b):=20qa-guide=20=C2=A76.7=20?= =?UTF-8?q?=E2=80=94=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20=D0=91=D0=91/?= =?UTF-8?q?=D0=9E=D0=A4=D0=A0,=20=D0=98=D0=9D=D0=9D=20=D0=B2=20=D1=82?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=D1=82=D0=B5=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #26 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- dev/qa-guide.md | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/dev/qa-guide.md b/dev/qa-guide.md index 28ece19..5b0f70f 100644 --- a/dev/qa-guide.md +++ b/dev/qa-guide.md @@ -17,7 +17,7 @@ Node 24, uv 0.10): от пустого каталога до экрана адм - Журнал аудита в БД (`dkb.audit_event`). - Загрузка файлов ОСВ через API (`POST /legal-entities/{id}/files`), распознавание реальным `tabular-parser`, разбор результата в документы и строки ОСВ, статусы файла и документа, автозамена версий, подтверждение документа, удаление - файла (раздел 6.6); проверка парсера на произвольных форматах ОСВ — раздел 6.7. Всё — через Swagger/curl, UI «Первичные документы» — подсрез 2d. + файла (раздел 6.6); проверка парсера на произвольных форматах ОСВ и формах ББ/ОФР — раздел 6.7 (маппинг форм в ядре — Срез 3). Всё — через Swagger/curl, UI «Первичные документы» — подсрез 2d. - Консолидация через API (Срез 4): версии за период, периметр, блокировка «взять в работу», статусы, привязка подтверждённых документов, чек-лист, сводная ОСВ, ручное сопоставление контрагента, экспорт xlsx (раздел 7; полный прогон на данных РБ и контрольные цифры — 7.1–7.3). UI консолидации — другой исполнитель. @@ -335,7 +335,7 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli "/путь/к/файлу.x | № | Что | Ожидание | |---|---|---| -| P1 | Лист | найден каждый лист с текстом «Оборотно-сальдовая ведомость» (один `documents[]` на лист, `locator = sheet:<имя>`); листы без ОСВ (справки, формы ББ/ОФР) пропущены с `warnings` `skipped: …`, а не ошибка | +| P1 | Лист | найден каждый лист с текстом «Оборотно-сальдовая ведомость» (один `documents[]` на лист, `locator = sheet:<имя>`); листы без ОСВ (справки) пропущены с `warnings` `skipped: …`, а не ошибка; листы форм ББ/ОФР — отдельные документы, см. «Формы ББ/ОФР» ниже | | P2 | Шапка | `organization_name`, `organization_inn` (если есть в шапке), `account_code_raw`, `period_text` → `period_year`, `period_quarter`, `is_cumulative` соответствуют тексту над таблицей. Поле не найдено → `null` + предупреждение, не ошибка | | P3 | Период | «1 квартал» → `Q1`, нарастающий; «2 квартал» → `Q2`, квартальный; «полугодие», «6 месяцев», «январь – июнь» → `Q2`, нарастающий; «9 месяцев», «3 квартал» → `Q3`; «2024 г.», «12 месяцев» → `Q4`, нарастающий; римские кварталы («I кв.») тоже | | P4 | Колонки сумм | шесть сумм разнесены верно: сальдо на начало Дт/Кт, обороты Дт/Кт, сальдо на конец Дт/Кт. Проверьте 2–3 строки с ненулевыми значениями во всех шести колонках и строку «Итого» | @@ -348,12 +348,23 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli "/путь/к/файлу.x **Реальные нестандартные файлы** — `../fixtures/osv-dkb/` (пакет ДКБ 06.10.2026: шесть организаций, многолистовые книги, вариант 2 «сумма», скользящий период, 50 тыс. строк; ожидаемые статусы и находки — `osv-dkb/README.md`) и -`../fixtures/analysis-reports/` («Анализ счета», ожидается `INVALID_DOCUMENT`). **Готовые кейсы синтетики** — синтетика `../fixtures/osv/synthetic/s01…s16` с описанием каждого файла и +`../fixtures/analysis-reports/` («Анализ счета», ожидается `INVALID_DOCUMENT`). **Готовые кейсы синтетики** — синтетика `../fixtures/osv/synthetic/s01…s19` с описанием каждого файла и ожиданием в `../fixtures/osv/synthetic/README.md`: шапка варианта 2 («Период: …», «Субконто»), уровни без outline (только отступами, только по кодам счетов), строки валюты и количества, числа текстом, два листа ОСВ и посторонний лист, нет сальдо на начало и свёрнутое сальдо на конец, колонка КПП, названия субсчетов, сломанная сумма (`needs_check`), повтор заголовка внутри таблицы, лист ББ рядом с ОСВ, xlsb, синонимы заголовков. Прогоните их способом Б и сверьте с README. +**Формы ББ/ОФР (Срез 2b).** Та же команда CLI (способ Б) разбирает и формы: в `documents[]` лист формы — отдельный документ +(`document_type` = `BALANCE` / `INCOME` / `DISCLOSURE`, ОСВ идут первыми), с `header`, `columns[]`, `lines[]`, `checks`. +Что смотреть: `document_type` соответствует форме на листе; `columns[].period_date` — даты колонок слева направо (для ОФР — +конец периода); `checks.relations_failed` (жёсткие соотношения §7 с `expected`/`actual` по колонкам — причина `needs_check`), +`checks.soft_relations_failed` и `duplicate_line_codes`; в `metrics.warnings` — предупреждение об единицах («N значений с +дробной частью при единице тыс. руб.»: значения как в файле, не нормализованы, OQ-137), «колонка кодов не найдена», +«страница-продолжение без шапки». Ожидания по реальным файлам — `../tabular-parser/api/tests/statements_manifest.yaml` и +таблица «файл → статус» в README `tabular-parser`; файлы — `../fixtures/statements-xlsx/` (синтетика `st01…st06`). +Постраничные xls из 1С (страница 2 отдельным файлом, ББ только актив) и формы в рублях при шапке «тыс. руб.» дают +`needs_check` — это штатно (OQ-137, OQ-139), не баг; `done` ожидается только у форм, где §7 сходится (Межениновская). + **Как делать свои файлы.** Возьмите любую реальную ОСВ и меняйте по одному признаку, после каждого — прогон и чек-лист: - сдвинуть таблицу: вставить 5–10 пустых строк и 2–3 колонки слева/сверху; перенести шапку организации ниже или выше; @@ -373,8 +384,8 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli "/путь/к/файлу.x только «Сальдо на начало». - Заголовок с незнакомым синонимом («Нач. остаток») даёт `needs_check`, суммы уходят в `extra`. Это не ошибка кода, а словарь: присылайте файл, синоним добавляется строкой в `rules/osv_headers.yaml`. -- ИНН внутри текста контрагента («Полиграф, 3528315710») и ИНН в ячейке над названием сейчас не извлекаются — задача - RSHB/arm-tabular-parser#9 (OQ-100, OQ-130). До неё строка уходит в ядро с именем целиком, `counterparty_inn` пуст. +- ИНН внутри текста контрагента («Полиграф, 3528315710»), в ячейке над названием и склеенный «ИНН/КПП» парсер извлекает + (OQ-100, OQ-130, arm-tabular-parser#9): `counterparty_inn` заполнен, `counterparty_name` без ИНН, исходный текст — в `row_value`. - xlsb читается без outline level и форматирования: уровни только по отступам пробелами и кодам счетов. Настоящего xlsb из Excel в фикстурах нет (OQ-102) — если у вас есть Excel, сохраните `s02_no_outline_indent.xlsx` как xlsb и прогоните. - Строка «Итого» нужна: без неё `needs_check`. -- 2.47.2 From 4729e401775a28e6d2e9341616bc2a3a3f4782d8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 21:22:30 +0400 Subject: [PATCH 09/12] =?UTF-8?q?docs(2b):=20fix=20round=201=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B5=2016=20=E2=80=94?= =?UTF-8?q?=20=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=D0=B8=20golden,?= =?UTF-8?q?=20=D1=84=D0=BE=D1=80=D0=BC=D1=83=D0=BB=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B9,=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #26 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- context/05-domain-rules.md | 2 +- context/06-test-data.md | 7 ++++--- context/07-open-questions.md | 2 +- context/09-delivery-plan.md | 2 +- dev/qa-guide.md | 4 ++-- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/context/05-domain-rules.md b/context/05-domain-rules.md index e965325..7e4526c 100644 --- a/context/05-domain-rules.md +++ b/context/05-domain-rules.md @@ -801,7 +801,7 @@ OQ-92 (пустые «Организация»/«Группа компаний» («Изменение», «%») **не читаются** (одно предупреждение на документ): у `statementDocument` поля `extra` нет. 5. **Значения**: `Decimal`, скобки → минус, «–»/пусто → `null`, нечисловой текст → `string_value`, `is_recognized = false`. **Единицы не нормализуются**: `amount` как в ячейке, `header.units` — текст шапки; при «тыс.» и - ≥ 10 % значений с дробной частью — предупреждение «N значений с дробной частью при единице тыс. руб.» (OQ-137, + ≥ 10 % значений с дробной частью — предупреждение «значений с дробной частью при единице тыс. руб.: N из M» (OQ-137, решение владельца). Строки без кода (разделы, «в том числе:», «Реализация иного имущества…» у НПЗ) не отдаются (`ASSUMPTION(OQ-36)`, OQ-140); повтор кода — остаётся первая строка, код в `checks.duplicate_line_codes`, `needs_check`. Пятизначные подстроки (11101, 12301…) отдаются как обычные строки. diff --git a/context/06-test-data.md b/context/06-test-data.md index 381046c..f8967b3 100644 --- a/context/06-test-data.md +++ b/context/06-test-data.md @@ -203,7 +203,7 @@ fixtures/ osv/ rb/ 2024-Q1/ 2024-Q2/ 2024-Q3/ 2024-FY/ 2025-Q1/ 2025-Q2/ 2025-Q3/ # файлы с исходными именами schastl/ ... - synthetic/ README.md + s01…s16 (генератор в tabular-parser, §7) + synthetic/ README.md + s01…s19 (генератор в tabular-parser, §7) statements/ rb/ schastl/ gir-bo/ # pdf statements-xlsx//<период>/ # ББ/ОФР в Excel (§12, §4а): unagrande-co, unagrande-group, npz, @@ -219,7 +219,8 @@ fixtures/ # буквально) golden/ Консолидация_Группа РБ.xlsx # эталон КФО по датам - expected_osv_parse/<зеркало osv/ без расширения>.json # golden-снимки парсера ОСВ: 88 файлов (72 реальных + 16 синтетических) + expected_osv_parse/<зеркало osv/ без расширения>.json # golden-снимки парсера ОСВ: 113 файлов (94 реальных: 72 в osv/ + 22 в osv-dkb/; + 19 синтетических) + expected_statement_parse/<зеркало statements-xlsx/ без расширения>.json # golden-снимки парсера форм ББ/ОФР: 24 файла (18 реальных + 6 синтетических), §4б directories/ ecp-export-russkiy-biskvit.xlsx # срез выгрузки ЕЦП CRM по ГК «Русский Бисквит» (Срез 1) chart-of-accounts-1c.xlsx # план счетов 1С заказчика, 488 строк (пакет ДКБ, OQ-113) @@ -292,7 +293,7 @@ BIFF12 `tabular-parser/api/tests/synthetic/xlsb_writer.py`: он пишет то файл из Excel (OQ-102). Инструкция замены — в README `fixtures/osv/synthetic/`. **Golden-снимки.** Для каждого файла `fixtures/osv/**` (включая синтетику) в `fixtures/golden/expected_osv_parse/` лежит -результат разбора (113 файлов, из них 19 синтетических `s01`–`s19`): файл результата контракта v1.2 (v1.1 — до Среза 2b) без `job_id`, `file_id`, +результат разбора (113 файлов: 94 реальных и 19 синтетических `s01`–`s19`): файл результата контракта v1.2 (v1.1 — до Среза 2b) без `job_id`, `file_id`, `service.version`, `metrics.duration_ms`. Снимок — **регрессия, не оракул**: он фиксирует, что парсер выдаёт сейчас. Обновление — только `make golden-update` в `tabular-parser` с просмотром diff и отдельным коммитом в `fixtures`; `make golden-check` (код возврата 1 при расхождении) — без записи. Оракулы корректности — property-тест diff --git a/context/07-open-questions.md b/context/07-open-questions.md index 628e06a..e0c910d 100644 --- a/context/07-open-questions.md +++ b/context/07-open-questions.md @@ -145,7 +145,7 @@ | OQ-137 | Единицы «тыс. руб.» в шапке против значений в рублях с копейками в выгрузках 1С (Унагранде Компани/Групп, НПЗ) и тысяч с дробями (Межениновская): кто и где приводит единицы. Примеры: книга НПЗ (`npz/2025-Q3`) — ББ `needs_check`, `1600 = 1700` расходится на 1.94 / 9.02 / 2.48 руб.; ОФР НПЗ и ОФР Унагранде (стр. 1) — `needs_check` по 2100/2200/2300 по той же причине; полные формы по факту сходятся только у Межениновской | `fixtures/statements-xlsx/README.md`, tabular-parser design 2b D-6, D-7, решения №22–24 (R-11, R-13, R-14) | Срез 3 (маппинг форм), §8 (КФО в тыс. руб.) | допущение принято (решение владельца 06.10.2026, вариант А): парсер отдаёт значения и `units` как в файле, при «тыс.» и ≥ 10 % значений с дробной частью — предупреждение; допуск ±1 не меняется, не нормализуем; приведение единиц — ядро/аналитик (Срез 3) | | OQ-138 | Форма без колонки кодов: сопоставление по именам строк из шаблонов 66н/ФСБУ (словарь `statement_lines.yaml`, `line_code_confidence < 100`)? Сейчас документ без строк и `needs_check` («колонка кодов не найдена») | решение владельца 06.10.2026 (вопрос 6), tabular-parser design 2b D-4 | §12 п. 2, Срез 3 | открыт: нужен реальный файл без колонки кодов; допущение — не сопоставлять по именам | | OQ-139 | Страница-продолжение в отдельном файле (1С печатает ББ/ОФР постранично, один файл = страница): склейка в ядре по ЮЛ + период + тип формы; признак `is_continuation` / `form_page` в контракте (MINOR, две необязательных поля) | решение владельца 06.10.2026 (вопрос 2), design 2b D-8 | §12 п. 9, Срез 3, контракт v1.x | допущение принято: страницы разных файлов не склеиваются; страница 2 без шапки — документ с пустой шапкой и `needs_check`, «загрузите форму одним файлом» | -| OQ-140 | Строка без кода, но с суммами в ОФР ФСБУ 4/2023 (НПЗ «Реализация иного имущества…»): хранить ли (OQ-36 расширяется с расшифровочных строк на строки с суммами) | `fixtures/statements-xlsx/npz`, design 2b D-6 | Срез 3, DBML `financial_statement_line` (хранит только строки с кодом) | допущение принято: строки без кода не отдаются, при наличии сумм — предупреждение «строк без кода пропущено: N» | +| OQ-140 | Строка без кода, но с суммами в ОФР ФСБУ 4/2023 (НПЗ «Реализация иного имущества…»): хранить ли (OQ-36 расширяется с расшифровочных строк на строки с суммами) | `fixtures/statements-xlsx/npz`, design 2b D-6 | Срез 3, DBML `financial_statement_line` (хранит только строки с кодом) | допущение принято: строки без кода не отдаются, при наличии сумм — предупреждение «строк без кода с суммами пропущено: N» | | OQ-141 | Образец ФСБУ 4/2023 (`templates/ББ после 01.01.2025.xlsx`): «АКТИВ» и код 1110 в одной строке из-за объединённых ячеек; шаблоны (коды есть, сумм и шапки нет) не данные. Нужен ли их разбор вообще | `fixtures/statements-xlsx/templates`, design 2b D-8 | §12 п. 10, тесты | допущение принято: шаблоны дают `needs_check` (шапка пустая, значения `null`); разбор шаблонов как источника данных не планируется, пока владелец не скажет иначе | | OQ-142 | Лист продолжения с **единственной** кодовой строкой (книга НПЗ: лист ОФР с одной строкой 2400): `min_codes` = 3 → лист не распознаётся как форма и пропускается, строка 2400 теряется при `needs_check` документа. Понизить порог / принимать лист по близости к открытому документу ОФР / маркеру продолжения | tabular-parser design 2b: решение №25, O-9 | §12 п. 2, п. 9; полнота ОФР НПЗ | открыт: отложено на финальное ревью 2b; допущение — строка 2400 листа продолжения НПЗ в результате отсутствует | diff --git a/context/09-delivery-plan.md b/context/09-delivery-plan.md index a492254..fc0267c 100644 --- a/context/09-delivery-plan.md +++ b/context/09-delivery-plan.md @@ -123,7 +123,7 @@ Change'и: `tabular-parser/openspec/changes/slice-2a-osv-parser` (issue arm-tabu «тыс. руб.», страницы без шапки, шаблоны) — OQ-137, OQ-139. - **Не сделано / риски:** единицы не нормализуются (OQ-137); форма без колонки кодов не разбирается (OQ-138); страницы в разных файлах не склеиваются (OQ-139); строки без кода не хранятся (OQ-140); лист ОФР НПЗ с единственной строкой 2400 - теряется (OQ-142); после контракта v1.2 `docs/contracts/**` заморожены (копии схемы в arm-core и scan-gateway равны им байт в байт); косметика T1.1/T2.x уходит в следующий MINOR. + теряется (OQ-142); после контракта v1.2 `docs/contracts/**` заморожены (копии схемы в arm-core и scan-gateway должны совпадать с ними; в tabular-parser версия закреплена тестом `api/tests/unit/test_contract_v12.py`, побайтное сравнение копий — вручную через `diff` при раскатке); косметика T1.1/T2.x уходит в следующий MINOR. ### Срез 2c — Загрузка файлов и маппинг результата в ядре (`arm-core`) — реализован, ожидает ревью и merge diff --git a/dev/qa-guide.md b/dev/qa-guide.md index 5b0f70f..9f5841e 100644 --- a/dev/qa-guide.md +++ b/dev/qa-guide.md @@ -358,8 +358,8 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli "/путь/к/файлу.x (`document_type` = `BALANCE` / `INCOME` / `DISCLOSURE`, ОСВ идут первыми), с `header`, `columns[]`, `lines[]`, `checks`. Что смотреть: `document_type` соответствует форме на листе; `columns[].period_date` — даты колонок слева направо (для ОФР — конец периода); `checks.relations_failed` (жёсткие соотношения §7 с `expected`/`actual` по колонкам — причина `needs_check`), -`checks.soft_relations_failed` и `duplicate_line_codes`; в `metrics.warnings` — предупреждение об единицах («N значений с -дробной частью при единице тыс. руб.»: значения как в файле, не нормализованы, OQ-137), «колонка кодов не найдена», +`checks.soft_relations_failed` и `duplicate_line_codes`; в `metrics.warnings` — предупреждение об единицах («значений с +дробной частью при единице тыс. руб.: N из M»: значения как в файле, не нормализованы, OQ-137), «колонка кодов не найдена», «страница-продолжение без шапки». Ожидания по реальным файлам — `../tabular-parser/api/tests/statements_manifest.yaml` и таблица «файл → статус» в README `tabular-parser`; файлы — `../fixtures/statements-xlsx/` (синтетика `st01…st06`). Постраничные xls из 1С (страница 2 отдельным файлом, ББ только актив) и формы в рублях при шапке «тыс. руб.» дают -- 2.47.2 From 9235e6ad796a4bbc216e443c664fbbaebf97b3d1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 21:25:40 +0400 Subject: [PATCH 10/12] =?UTF-8?q?docs:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20OQ-137?= =?UTF-8?q?=E2=80=A6142=20->=20OQ-138=E2=80=A6143=20(R-15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Номер OQ-137 в main занят вопросом о раскладке горячих папок ИС Сканирование. Refs #26 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- context/05-domain-rules.md | 16 +++++++-------- context/06-test-data.md | 10 +++++----- context/07-open-questions.md | 12 +++++------ context/09-delivery-plan.md | 8 ++++---- dev/qa-guide.md | 4 ++-- ...-10-06-slice-2b-statement-parser-ledger.md | 2 +- .../2026-10-06-slice-2b-statement-parser.md | 20 +++++++++---------- 7 files changed, 36 insertions(+), 36 deletions(-) diff --git a/context/05-domain-rules.md b/context/05-domain-rules.md index 4ec5bf7..f825e68 100644 --- a/context/05-domain-rules.md +++ b/context/05-domain-rules.md @@ -849,7 +849,7 @@ OQ-92 (пустые «Организация»/«Группа компаний» 2. **Колонка кодов**: в блоке ≥ 60 % непустых значений — коды (ББ 1100–1799, ОФР 2100–2999, расшифровки 5xxx, пятизначные — по первым четырём цифрам), кодов не меньше `min_codes` = 3; заголовок «Код/Коды» необязателен, числа вида `1110.0` читаются как текст «1110». Колонки нет — документ без строк, `needs_check`, предупреждение «колонка кодов не - найдена»; сопоставление по наименованиям строк **не делается** (решение владельца, OQ-138). + найдена»; сопоставление по наименованиям строк **не делается** (решение владельца, OQ-139). 3. **Колонка наименований** — ближайшая левее кодов с преобладанием текста; «Пояснения» (пустые или ≤ 3 символов) не наименование. 4. **Колонки-периоды**: ячейки заголовка правее кодов с распознанным периодом («На 31 декабря 2024 г.», «31.12.2024», @@ -859,9 +859,9 @@ OQ-92 (пустые «Организация»/«Группа компаний» («Изменение», «%») **не читаются** (одно предупреждение на документ): у `statementDocument` поля `extra` нет. 5. **Значения**: `Decimal`, скобки → минус, «–»/пусто → `null`, нечисловой текст → `string_value`, `is_recognized = false`. **Единицы не нормализуются**: `amount` как в ячейке, `header.units` — текст шапки; при «тыс.» и - ≥ 10 % значений с дробной частью — предупреждение «значений с дробной частью при единице тыс. руб.: N из M» (OQ-137, + ≥ 10 % значений с дробной частью — предупреждение «значений с дробной частью при единице тыс. руб.: N из M» (OQ-138, решение владельца). Строки без кода (разделы, «в том числе:», «Реализация иного имущества…» у НПЗ) не отдаются - (`ASSUMPTION(OQ-36)`, OQ-140); повтор кода — остаётся первая строка, код в `checks.duplicate_line_codes`, `needs_check`. + (`ASSUMPTION(OQ-36)`, OQ-141); повтор кода — остаётся первая строка, код в `checks.duplicate_line_codes`, `needs_check`. Пятизначные подстроки (11101, 12301…) отдаются как обычные строки. 6. **Шапка**: организация (после метки «Организация», иначе текст с признаком ОПФ строкой выше метки — НПЗ), ИНН 10/12 цифр у метки «ИНН» (**ИНН аудитора не берётся**: строки после метки «аудитор» исключены), ОКПО, ОКОПФ/ОКФС, ОКЕИ, адрес, @@ -877,7 +877,7 @@ OQ-92 (пустые «Организация»/«Группа компаний» `documents[].checks` (`relations_failed`, `soft_relations_failed`, `duplicate_line_codes`) и предупреждения. **Фактически сходятся полные формы только у Межениновской**: книга НПЗ (`npz/2025-Q3`) — ББ `needs_check`: `1600 = 1700` расходится на 1.94 / 9.02 / 2.48 руб., потому что значения в рублях с копейками при шапке «тыс. руб.»; допуск ±1 остаётся - (решение владельца 06.10.2026, вариант А, OQ-137). ОФР НПЗ и ОФР Унагранде (стр. 1) тоже `needs_check` по 2100/2200/2300 + (решение владельца 06.10.2026, вариант А, OQ-138). ОФР НПЗ и ОФР Унагранде (стр. 1) тоже `needs_check` по 2100/2200/2300 по той же причине; ББ Унагранде (стр. 1) — только актив, `1600 = 1700` не сходится. Это сигнал о данных файла, а не о неверно угаданной структуре; допуск под файл не подгоняется. 8. **Расшифровки** (0710003/0710004, пояснения, лист «Примечания» шаблона) — `DISCLOSURE` с `locator` и шапкой, если @@ -885,15 +885,15 @@ OQ-92 (пустые «Организация»/«Группа компаний» 9. **Склейка**: листы-продолжения внутри книги присоединяются к последнему открытому документу формы, если число колонок сумм совпадает; строки добавляются в конец, `locator` перечисляет листы через запятую. **Страницы в разных файлах не склеиваются** (1С печатает форму постранично, один файл = одна страница): страница 2 без первой — документ с пустой шапкой - и `needs_check`, предупреждение «страница-продолжение без шапки: загрузите форму одним файлом» (решение владельца, OQ-139). + и `needs_check`, предупреждение «страница-продолжение без шапки: загрузите форму одним файлом» (решение владельца, OQ-140). Продолжение без колонки кодов при непустом результате не теряется молча — `needs_check`. 10. **Статус**: документ `ok` ⇔ жёсткие соотношения сошлись по всем колонкам ∧ нет дубликатов кодов ∧ найдена колонка кодов ∧ есть колонка-период ∧ в шапке есть организация или ИНН ∧ документ не одинокое продолжение. Книга — `done`, если `ok` у всех документов, иначе `needs_check`; книга без листов ОСВ и форм — `INVALID_DOCUMENT`. Порядок документов: сначала ОСВ в порядке листов, затем формы. Шаблоны (коды есть, сумм нет, шапки нет) — `needs_check`. -11. **Отложено**: запасной путь по именам строк (OQ-138), нормализация единиц (OQ-137), признак продолжения в контракте и - склейка страниц из разных файлов в ядре (OQ-139, Срез 3), строки без кода (OQ-140), образцы ФСБУ (OQ-141), лист - продолжения с единственной кодовой строкой (OQ-142: лист ОФР НПЗ с одной строкой 2400 не распознаётся как форма и +11. **Отложено**: запасной путь по именам строк (OQ-139), нормализация единиц (OQ-138), признак продолжения в контракте и + склейка страниц из разных файлов в ядре (OQ-140, Срез 3), строки без кода (OQ-141), образцы ФСБУ (OQ-142), лист + продолжения с единственной кодовой строкой (OQ-143: лист ОФР НПЗ с одной строкой 2400 не распознаётся как форма и теряется, `min_codes` = 3). Что это даёт ядру: один маппинг `statementDocument → document + financial_statement_*` для PDF и Excel (маппинг форм — diff --git a/context/06-test-data.md b/context/06-test-data.md index f8967b3..2226cf2 100644 --- a/context/06-test-data.md +++ b/context/06-test-data.md @@ -156,14 +156,14 @@ arm-tabular-parser#11, OQ-131…OQ-135. ИНН организаций в шап | Раскладка | Где | Особенности | |---|---|---| | 1С-xls постранично | `unagrande-co/2024-FY/`, `unagrande-group/{2024-FY,2025-Q3}/` | один файл = одна страница формы; на странице 1 титул (организация, ИНН, ОКПО, единица «в тыс. рублей», ОКЕИ, аудитор со **своим** ИНН), на странице 2 только «Форма 0710001 с.2»; пятизначные подстроки (11101…), значения в рублях с копейками при «тыс. руб.»; страниц 1 ББ в пакете нет (только актив) и ОФР Групп 2024 — только стр. 2 | -| Многолистовая xlsx ФСБУ 4/2023 | `npz/2025-Q3/` | ББ на 5 листах, ОФР на 3; заголовок формы после титула; листы 2–5 без заголовка и маркера; строка 1105 «Гудвил», подстроки 12301…, строка без кода с суммами; лист ОФР с одной строкой 2400 (OQ-142) | +| Многолистовая xlsx ФСБУ 4/2023 | `npz/2025-Q3/` | ББ на 5 листах, ОФР на 3; заголовок формы после титула; листы 2–5 без заголовка и маркера; строка 1105 «Гудвил», подстроки 12301…, строка без кода с суммами; лист ОФР с одной строкой 2400 (OQ-143) | | `TDSheet` 66н | `mezheninovskaya/2025-Q3/` | вся форма на одном листе, дата тройкой «30 \| 9 \| 2025» числами, двухстрочные заголовки, строка нумерации колонок, значения — тысячи с дробной частью | -| Шаблоны | `templates/` | 66н и ФСБУ 4/2023: коды есть, сумм и шапки нет; заголовок на две строки; в шаблоне ФСБУ «АКТИВ» и код 1110 в одной строке (OQ-141) | +| Шаблоны | `templates/` | 66н и ФСБУ 4/2023: коды есть, сумм и шапки нет; заголовок на две строки; в шаблоне ФСБУ «АКТИВ» и код 1110 в одной строке (OQ-142) | **Манифест** `tabular-parser/api/tests/statements_manifest.yaml`: для каждого из 18 файлов — организация, ИНН, документы по порядку (`type`, `status`, `complete`, причина `needs_check`, `failed_relations`). Статусы сверены прогоном: `done` только у Межениновской (ББ и ОФР); остальные — `needs_check` по данным файла (рубли с копейками при шапке «тыс. руб.», только -актив, страница 2 без шапки, шаблон без сумм), НПЗ — `1600 = 1700` расходится на 1.94 / 9.02 / 2.48 руб. (OQ-137). +актив, страница 2 без шапки, шаблон без сумм), НПЗ — `1600 = 1700` расходится на 1.94 / 9.02 / 2.48 руб. (OQ-138). Таблица «файл → статус» — README `tabular-parser`, «Парсер форм». **Golden** `fixtures/golden/expected_statement_parse/<тот же путь без расширения>.json` — 24 снимка (18 реальных файлов + @@ -178,9 +178,9 @@ arm-tabular-parser#11, OQ-131…OQ-135. ИНН организаций в шап |---|---| | `st01_both_forms_one_sheet` | ББ и ОФР на одном листе, два документа с одним `locator` (§12 п. 1) | | `st02_csv_like` | csv-подобный лист: двухстрочный заголовок, коды без подписи «Код», отрицательное значение, единиц нет | -| `st03_no_codes` | форма без колонки кодов → `needs_check`, «колонка кодов не найдена» (OQ-138) | +| `st03_no_codes` | форма без колонки кодов → `needs_check`, «колонка кодов не найдена» (OQ-139) | | `st04_mixed_book` | книга: лист ОСВ + лист баланса, порядок документов ОСВ → форма | -| `st05_continuation_only` | одинокая страница 2 → `needs_check`, предупреждение о продолжении (OQ-139) | +| `st05_continuation_only` | одинокая страница 2 → `needs_check`, предупреждение о продолжении (OQ-140) | | `st06_header_variants` | варианты, которых нет в реальных файлах: старое название ОФР, расшифровки/ОДДС/ОИК, форма только с ОКУД 0710xxx, организация в «», «Адрес», «Пояснения», числовая дата (покрытие словарей `statement_*.yaml`) | ## 5. Прочее diff --git a/context/07-open-questions.md b/context/07-open-questions.md index 32515cd..c8aec64 100644 --- a/context/07-open-questions.md +++ b/context/07-open-questions.md @@ -143,12 +143,12 @@ | OQ-135 | Отчёт 1С «Анализ счета» с отбором по контрагентам группы (`fixtures/analysis-reports/`, в именах файлов «ОСВ_…ПоКомпаниямГруппы»): не ОСВ, но содержит ВГО по корреспонденции счетов. Нужен ли отдельный тип документа для Среза 5, или ВГО считаются только из ОСВ | пакет ДКБ 06.10.2026 | `document_type`, Срез 5 | открыт: допущение — не разбирать; парсер отвечает `INVALID_DOCUMENT` | | OQ-136 | ИНН организаций пакета ДКБ (Цимлянский, НКЗ, Новоросметалл, Приманыческий, Агрокомплекс) в шапках ОСВ отсутствуют; для теста консолидации по этим группам нужны ИНН и состав ГК/ГСК (выгрузка ЕЦП или список от ДКБ) | пакет ДКБ 06.10.2026 | seed/фикстуры справочников, тесты Среза 4–5 на новых группах | открыт | | OQ-137 | Раскладка горячих папок ИС Сканирование: scan-gateway кладёт `Input/_` через временный `Tmp/.<имя>.part`, ничего не удаляет; PDFWorker старого АРМ (`RSHB/armka_file_worker`, проверен в контуре) кладёт `Input/.` через временный `<назначение>..tmp` рядом (ИС игнорирует `*.tmp`), читает батч `Output//*.json` и удаляет `Output//` и входящий файл после чтения. Какая раскладка актуальна? | сравнение `armka_file_worker` (README «Файлы», `clients.py`) и `scan-gateway/gateway/hotfolder.py`, `smb.py`; OQ-78, OQ-79 | scan-gateway (спека `image-recognition-gateway`, эмулятор, тесты) | **закрыт владельцем 06.10.2026: «правильно как в FileWorker»** — имя `.`, временный `*.tmp` рядом с назначением (каталог `Tmp` из OQ-79 не нужен), очистка `Output//` и входящего файла после чтения; `Output//*.json` батчем и стабилизация набора (OQ-78) остаются. Реализация — arm-scan-gateway #6 | -| OQ-137 | Единицы «тыс. руб.» в шапке против значений в рублях с копейками в выгрузках 1С (Унагранде Компани/Групп, НПЗ) и тысяч с дробями (Межениновская): кто и где приводит единицы. Примеры: книга НПЗ (`npz/2025-Q3`) — ББ `needs_check`, `1600 = 1700` расходится на 1.94 / 9.02 / 2.48 руб.; ОФР НПЗ и ОФР Унагранде (стр. 1) — `needs_check` по 2100/2200/2300 по той же причине; полные формы по факту сходятся только у Межениновской | `fixtures/statements-xlsx/README.md`, tabular-parser design 2b D-6, D-7, решения №22–24 (R-11, R-13, R-14) | Срез 3 (маппинг форм), §8 (КФО в тыс. руб.) | допущение принято (решение владельца 06.10.2026, вариант А): парсер отдаёт значения и `units` как в файле, при «тыс.» и ≥ 10 % значений с дробной частью — предупреждение; допуск ±1 не меняется, не нормализуем; приведение единиц — ядро/аналитик (Срез 3) | -| OQ-138 | Форма без колонки кодов: сопоставление по именам строк из шаблонов 66н/ФСБУ (словарь `statement_lines.yaml`, `line_code_confidence < 100`)? Сейчас документ без строк и `needs_check` («колонка кодов не найдена») | решение владельца 06.10.2026 (вопрос 6), tabular-parser design 2b D-4 | §12 п. 2, Срез 3 | открыт: нужен реальный файл без колонки кодов; допущение — не сопоставлять по именам | -| OQ-139 | Страница-продолжение в отдельном файле (1С печатает ББ/ОФР постранично, один файл = страница): склейка в ядре по ЮЛ + период + тип формы; признак `is_continuation` / `form_page` в контракте (MINOR, две необязательных поля) | решение владельца 06.10.2026 (вопрос 2), design 2b D-8 | §12 п. 9, Срез 3, контракт v1.x | допущение принято: страницы разных файлов не склеиваются; страница 2 без шапки — документ с пустой шапкой и `needs_check`, «загрузите форму одним файлом» | -| OQ-140 | Строка без кода, но с суммами в ОФР ФСБУ 4/2023 (НПЗ «Реализация иного имущества…»): хранить ли (OQ-36 расширяется с расшифровочных строк на строки с суммами) | `fixtures/statements-xlsx/npz`, design 2b D-6 | Срез 3, DBML `financial_statement_line` (хранит только строки с кодом) | допущение принято: строки без кода не отдаются, при наличии сумм — предупреждение «строк без кода с суммами пропущено: N» | -| OQ-141 | Образец ФСБУ 4/2023 (`templates/ББ после 01.01.2025.xlsx`): «АКТИВ» и код 1110 в одной строке из-за объединённых ячеек; шаблоны (коды есть, сумм и шапки нет) не данные. Нужен ли их разбор вообще | `fixtures/statements-xlsx/templates`, design 2b D-8 | §12 п. 10, тесты | допущение принято: шаблоны дают `needs_check` (шапка пустая, значения `null`); разбор шаблонов как источника данных не планируется, пока владелец не скажет иначе | -| OQ-142 | Лист продолжения с **единственной** кодовой строкой (книга НПЗ: лист ОФР с одной строкой 2400): `min_codes` = 3 → лист не распознаётся как форма и пропускается, строка 2400 теряется при `needs_check` документа. Понизить порог / принимать лист по близости к открытому документу ОФР / маркеру продолжения | tabular-parser design 2b: решение №25, O-9 | §12 п. 2, п. 9; полнота ОФР НПЗ | открыт: отложено на финальное ревью 2b; допущение — строка 2400 листа продолжения НПЗ в результате отсутствует | +| OQ-138 | Единицы «тыс. руб.» в шапке против значений в рублях с копейками в выгрузках 1С (Унагранде Компани/Групп, НПЗ) и тысяч с дробями (Межениновская): кто и где приводит единицы. Примеры: книга НПЗ (`npz/2025-Q3`) — ББ `needs_check`, `1600 = 1700` расходится на 1.94 / 9.02 / 2.48 руб.; ОФР НПЗ и ОФР Унагранде (стр. 1) — `needs_check` по 2100/2200/2300 по той же причине; полные формы по факту сходятся только у Межениновской | `fixtures/statements-xlsx/README.md`, tabular-parser design 2b D-6, D-7, решения №22–24 (R-11, R-13, R-14) | Срез 3 (маппинг форм), §8 (КФО в тыс. руб.) | допущение принято (решение владельца 06.10.2026, вариант А): парсер отдаёт значения и `units` как в файле, при «тыс.» и ≥ 10 % значений с дробной частью — предупреждение; допуск ±1 не меняется, не нормализуем; приведение единиц — ядро/аналитик (Срез 3) | +| OQ-139 | Форма без колонки кодов: сопоставление по именам строк из шаблонов 66н/ФСБУ (словарь `statement_lines.yaml`, `line_code_confidence < 100`)? Сейчас документ без строк и `needs_check` («колонка кодов не найдена») | решение владельца 06.10.2026 (вопрос 6), tabular-parser design 2b D-4 | §12 п. 2, Срез 3 | открыт: нужен реальный файл без колонки кодов; допущение — не сопоставлять по именам | +| OQ-140 | Страница-продолжение в отдельном файле (1С печатает ББ/ОФР постранично, один файл = страница): склейка в ядре по ЮЛ + период + тип формы; признак `is_continuation` / `form_page` в контракте (MINOR, две необязательных поля) | решение владельца 06.10.2026 (вопрос 2), design 2b D-8 | §12 п. 9, Срез 3, контракт v1.x | допущение принято: страницы разных файлов не склеиваются; страница 2 без шапки — документ с пустой шапкой и `needs_check`, «загрузите форму одним файлом» | +| OQ-141 | Строка без кода, но с суммами в ОФР ФСБУ 4/2023 (НПЗ «Реализация иного имущества…»): хранить ли (OQ-36 расширяется с расшифровочных строк на строки с суммами) | `fixtures/statements-xlsx/npz`, design 2b D-6 | Срез 3, DBML `financial_statement_line` (хранит только строки с кодом) | допущение принято: строки без кода не отдаются, при наличии сумм — предупреждение «строк без кода с суммами пропущено: N» | +| OQ-142 | Образец ФСБУ 4/2023 (`templates/ББ после 01.01.2025.xlsx`): «АКТИВ» и код 1110 в одной строке из-за объединённых ячеек; шаблоны (коды есть, сумм и шапки нет) не данные. Нужен ли их разбор вообще | `fixtures/statements-xlsx/templates`, design 2b D-8 | §12 п. 10, тесты | допущение принято: шаблоны дают `needs_check` (шапка пустая, значения `null`); разбор шаблонов как источника данных не планируется, пока владелец не скажет иначе | +| OQ-143 | Лист продолжения с **единственной** кодовой строкой (книга НПЗ: лист ОФР с одной строкой 2400): `min_codes` = 3 → лист не распознаётся как форма и пропускается, строка 2400 теряется при `needs_check` документа. Понизить порог / принимать лист по близости к открытому документу ОФР / маркеру продолжения | tabular-parser design 2b: решение №25, O-9 | §12 п. 2, п. 9; полнота ОФР НПЗ | открыт: отложено на финальное ревью 2b; допущение — строка 2400 листа продолжения НПЗ в результате отсутствует | Служебные идентификаторы в DBML/аналитике: `ANL-nnn` — замечания аналитика к макету; `ARCH-nnn` — архитектурные решения; `DECISION-n` — решения по модели; `FIX-nn` — исправления дефектов v4. diff --git a/context/09-delivery-plan.md b/context/09-delivery-plan.md index e266480..45d42a2 100644 --- a/context/09-delivery-plan.md +++ b/context/09-delivery-plan.md @@ -120,10 +120,10 @@ Change'и: `tabular-parser/openspec/changes/slice-2a-osv-parser` (issue arm-tabu golden `expected_statement_parse/` (18 + 6 снимков), интеграционный тест воркера на книге НПЗ. - **Объём:** парсер + контракт v1.2; **маппинг форм в ядре (`statementDocument → document + financial_statement_*`) — Срез 3**. По факту `done` только у форм Межениновской; остальные `needs_check` по данным файлов (рубли с копейками при - «тыс. руб.», страницы без шапки, шаблоны) — OQ-137, OQ-139. -- **Не сделано / риски:** единицы не нормализуются (OQ-137); форма без колонки кодов не разбирается (OQ-138); страницы - в разных файлах не склеиваются (OQ-139); строки без кода не хранятся (OQ-140); лист ОФР НПЗ с единственной строкой 2400 - теряется (OQ-142); после контракта v1.2 `docs/contracts/**` заморожены (копии схемы в arm-core и scan-gateway должны совпадать с ними; в tabular-parser версия закреплена тестом `api/tests/unit/test_contract_v12.py`, побайтное сравнение копий — вручную через `diff` при раскатке); косметика T1.1/T2.x уходит в следующий MINOR. + «тыс. руб.», страницы без шапки, шаблоны) — OQ-138, OQ-140. +- **Не сделано / риски:** единицы не нормализуются (OQ-138); форма без колонки кодов не разбирается (OQ-139); страницы + в разных файлах не склеиваются (OQ-140); строки без кода не хранятся (OQ-141); лист ОФР НПЗ с единственной строкой 2400 + теряется (OQ-143); после контракта v1.2 `docs/contracts/**` заморожены (копии схемы в arm-core и scan-gateway должны совпадать с ними; в tabular-parser версия закреплена тестом `api/tests/unit/test_contract_v12.py`, побайтное сравнение копий — вручную через `diff` при раскатке); косметика T1.1/T2.x уходит в следующий MINOR. ### Срез 2c — Загрузка файлов и маппинг результата в ядре (`arm-core`) — реализован, ожидает ревью и merge diff --git a/dev/qa-guide.md b/dev/qa-guide.md index d508065..8e3fdaa 100644 --- a/dev/qa-guide.md +++ b/dev/qa-guide.md @@ -386,11 +386,11 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli "/путь/к/файлу.x Что смотреть: `document_type` соответствует форме на листе; `columns[].period_date` — даты колонок слева направо (для ОФР — конец периода); `checks.relations_failed` (жёсткие соотношения §7 с `expected`/`actual` по колонкам — причина `needs_check`), `checks.soft_relations_failed` и `duplicate_line_codes`; в `metrics.warnings` — предупреждение об единицах («значений с -дробной частью при единице тыс. руб.: N из M»: значения как в файле, не нормализованы, OQ-137), «колонка кодов не найдена», +дробной частью при единице тыс. руб.: N из M»: значения как в файле, не нормализованы, OQ-138), «колонка кодов не найдена», «страница-продолжение без шапки». Ожидания по реальным файлам — `../tabular-parser/api/tests/statements_manifest.yaml` и таблица «файл → статус» в README `tabular-parser`; файлы — `../fixtures/statements-xlsx/` (синтетика `st01…st06`). Постраничные xls из 1С (страница 2 отдельным файлом, ББ только актив) и формы в рублях при шапке «тыс. руб.» дают -`needs_check` — это штатно (OQ-137, OQ-139), не баг; `done` ожидается только у форм, где §7 сходится (Межениновская). +`needs_check` — это штатно (OQ-138, OQ-140), не баг; `done` ожидается только у форм, где §7 сходится (Межениновская). **Как делать свои файлы.** Возьмите любую реальную ОСВ и меняйте по одному признаку, после каждого — прогон и чек-лист: diff --git a/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser-ledger.md b/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser-ledger.md index 342c73d..ee552ae 100644 --- a/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser-ledger.md +++ b/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser-ledger.md @@ -116,7 +116,7 @@ Task 12: implementer sonnet DONE_WITH_CONCERNS 10b5bda; make check: 2 failed (go Task 12: review dispatched (sonnet) Task 12: review sonnet: spec ✅, Approved (Critical/Important нет). Review Focus 4, 5 покрыты тестами (мутации M1–M5 падают). Ruling: R-10 — отклонения Task 12 принимаются (флаг dropped: продолжение без колонки кодов → needs_check книги; OTHER-продолжение по правилам ББ; form_name не передаётся) и вносятся в design (#21) — цена ошибки: лишний needs_check на редком листе. -Ruling: R-11 — НПЗ даёт needs_check (1600=1700 расходится на 1.94/9.02/2.48 ₽ в источнике; значения в рублях с копейками при «тыс. руб.»), допуск ±1 по спеке НЕ меняется; ожидание манифеста НПЗ в задаче 14 — needs_check с причиной; выносится владельцу (OQ-137) — цена ошибки: если владелец хочет done — допуск/нормировка в словаре (design #22). +Ruling: R-11 — НПЗ даёт needs_check (1600=1700 расходится на 1.94/9.02/2.48 ₽ в источнике; значения в рублях с копейками при «тыс. руб.»), допуск ±1 по спеке НЕ меняется; ожидание манифеста НПЗ в задаче 14 — needs_check с причиной; выносится владельцу (OQ-138) — цена ошибки: если владелец хочет done — допуск/нормировка в словаре (design #22). Task 12: golden s12/s14 (синтетика, документы форм вместо заглушки) пересозданы контроллером (make golden-update, diff просмотрен: только s12/s14, статусы done→needs_check и документы форм без колонки кодов), коммит fixtures-wt "test(golden): s12/s14". Task 12: minor T12.1 (deferred): мёртвые счётчики fractional_values/numeric_values в Draft; предупреждение о дробной части считается по листу, не по документу (спека — «по документу»? уточнить в финале). T12.2 (deferred): ветка lonely_continuation в _is_ok избыточна — комментарий/тест. T12.3 (deferred): can_join игнорирует тип формы при маркере продолжения другого типа. T12.4 (deferred): join O(n·m). T12.5 (deferred): побочный эффект _document дописывает draft.warnings. T12.6 (deferred): test_template_without_sums_is_needs_check вакуумен при пустых lines. T12.7 (deferred): docstring после assert в synthetic documents(). T12.8 (deferred): повторяющийся приём form_name в header.py. T12.9 (deferred): нет теста дубликата внутри продолжения. O-7: D-8 «лист ОСВ между страницами → одиночка» не реализуем интерфейсом (формы получают только листы форм). Task 12: complete (commits b9e6e31..10b5bda + fixtures golden s12/s14, review clean) diff --git a/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser.md b/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser.md index 2878e1b..dc538ff 100644 --- a/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser.md +++ b/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser.md @@ -3521,20 +3521,20 @@ Refs #14" ### Task 16: Документы docs — §12, §7, 06-test-data, 03-architecture, 09-delivery-plan, OQ, qa-guide **Files (DOCS):** -- Modify: `docs/context/05-domain-rules.md` (§12 целиком, пометки в §7), `docs/context/06-test-data.md` (новый подраздел «4б. Формы ББ/ОФР в Excel: манифест, golden, синтетика» после 4а и строка в §7 про `expected_statement_parse`), `docs/context/03-architecture.md` (контракт 1.2, `BookParser`), `docs/context/09-delivery-plan.md` (Срез 2b — статус и объём), `docs/context/07-open-questions.md` (OQ-137…OQ-141, статус OQ-131), `docs/dev/qa-guide.md` §6.7 (абзац про формы) +- Modify: `docs/context/05-domain-rules.md` (§12 целиком, пометки в §7), `docs/context/06-test-data.md` (новый подраздел «4б. Формы ББ/ОФР в Excel: манифест, golden, синтетика» после 4а и строка в §7 про `expected_statement_parse`), `docs/context/03-architecture.md` (контракт 1.2, `BookParser`), `docs/context/09-delivery-plan.md` (Срез 2b — статус и объём), `docs/context/07-open-questions.md` (OQ-138…OQ-142, статус OQ-131), `docs/dev/qa-guide.md` §6.7 (абзац про формы) - [ ] **Step 1: §12 — заменить текст пунктов 1–8 и добавить 9–11** Структура нового §12 (каждый пункт — решение из design D-2…D-8 и brainstorming 06.10.2026): 1. Классификация листов: ОСВ → форма (заголовок/ОКУД в любой строке до таблицы; титул «Бухгалтерская отчетность» не тип) → продолжение (маркер «с.2», подпись «Код» с периодами, колонка кодов) → прочее. Типы: 0710001 → BALANCE, 0710002 → INCOME, прочие 0710xxx и пояснения → DISCLOSURE. Две формы на листе — по второму заголовку. -2. Колонка кодов: ≥ 60 % непустых значений — коды (ББ 1100–1799, ОФР 2100–2999, расшифровки 5xxx, пятизначные по первым четырём цифрам), ≥ 3 кодов. Нет колонки — `needs_check`, «колонка кодов не найдена»; сопоставление по именам отложено (OQ-138). +2. Колонка кодов: ≥ 60 % непустых значений — коды (ББ 1100–1799, ОФР 2100–2999, расшифровки 5xxx, пятизначные по первым четырём цифрам), ≥ 3 кодов. Нет колонки — `needs_check`, «колонка кодов не найдена»; сопоставление по именам отложено (OQ-139). 3. Колонка наименований и «Пояснения» — как было. 4. Колонки-периоды: подпись «Код» и ячейки-периоды правее; двухстрочный заголовок склеивается; строка нумерации пропускается; `period_date` — дата или конец периода; колонки без периода не читаются (предупреждение; `extra` у форм нет). -5. Значения: Decimal, скобки → минус, прочерк → null; **единицы как в файле, не нормализуются**; при «тыс.» и ≥ 10 % значений с дробной частью — предупреждение (решение владельца 06.10.2026, OQ-137). +5. Значения: Decimal, скобки → минус, прочерк → null; **единицы как в файле, не нормализуются**; при «тыс.» и ≥ 10 % значений с дробной частью — предупреждение (решение владельца 06.10.2026, OQ-138). 6. Шапка: метки, ИНН не аудитора, организация над меткой, дата из подзаголовка или тройки. 7. Самопроверка: соотношения §7 — данные `statement_checks.yaml`, жёсткие/мягкие, допуск ±1, двойной расчёт знаков (`abs_terms`), подстроки «в том числе» как мягкая проверка; результат — `statementDocument.checks` (v1.2) и предупреждения. 8. Расшифровки — DISCLOSURE без строк (OQ-23). -9. Склейка: листы-продолжения внутри книги присоединяются к последнему открытому документу при равном числе колонок; `locator` перечисляет листы; **страницы в разных файлах не склеиваются**: страница без шапки — документ с пустой шапкой и `needs_check` (решение владельца, OQ-139). +9. Склейка: листы-продолжения внутри книги присоединяются к последнему открытому документу при равном числе колонок; `locator` перечисляет листы; **страницы в разных файлах не склеиваются**: страница без шапки — документ с пустой шапкой и `needs_check` (решение владельца, OQ-140). 10. Статус: критерий `ok` (D-8); книга `done`/`needs_check`; `INVALID_DOCUMENT` без ОСВ и форм; порядок документов — ОСВ, затем формы. 11. Отложено: запасной путь по именам, нормализация единиц, признак продолжения в контракте, склейка в ядре (Срез 3). @@ -3543,11 +3543,11 @@ Refs #14" - [ ] **Step 2: OQ** Добавить строки (формат таблицы как у OQ-136): -- OQ-137 — единицы «тыс. руб.» в шапке против значений в рублях с копейками в 1С-выгрузках (Унагранде, НПЗ) и тысяч с дробями (Межениновская): кто и где приводит; парсер отдаёт как есть + предупреждение; ядро/аналитик — Срез 3. Откуда: `statements-xlsx/README.md`, design 2b D-6. Влияет: Срез 3, §8 (КФО в тыс. руб.). Допущение: не нормализовать. -- OQ-138 — форма без колонки кодов: словарь имён строк из шаблонов 66н/ФСБУ (`statement_lines.yaml`), `line_code_confidence < 100`; сейчас `needs_check`. Нужен реальный файл. -- OQ-139 — страница-продолжение в отдельном файле (1С печатает ББ/ОФР постранично): склейка в ядре по ЮЛ+период+тип, признак `is_continuation`/`form_page` в контракте (MINOR). Сейчас `needs_check`. -- OQ-140 — строка без кода с суммами в ОФР ФСБУ 4/2023 (НПЗ «Реализация иного имущества…»): хранить ли (OQ-36 расширяется на суммы). -- OQ-141 — образец ФСБУ 4/2023 (шаблон): «АКТИВ» и код 1110 в одной строке из-за объединений; шаблоны не являются данными, парсер даёт `needs_check`; нужен ли их разбор вообще. +- OQ-138 — единицы «тыс. руб.» в шапке против значений в рублях с копейками в 1С-выгрузках (Унагранде, НПЗ) и тысяч с дробями (Межениновская): кто и где приводит; парсер отдаёт как есть + предупреждение; ядро/аналитик — Срез 3. Откуда: `statements-xlsx/README.md`, design 2b D-6. Влияет: Срез 3, §8 (КФО в тыс. руб.). Допущение: не нормализовать. +- OQ-139 — форма без колонки кодов: словарь имён строк из шаблонов 66н/ФСБУ (`statement_lines.yaml`), `line_code_confidence < 100`; сейчас `needs_check`. Нужен реальный файл. +- OQ-140 — страница-продолжение в отдельном файле (1С печатает ББ/ОФР постранично): склейка в ядре по ЮЛ+период+тип, признак `is_continuation`/`form_page` в контракте (MINOR). Сейчас `needs_check`. +- OQ-141 — строка без кода с суммами в ОФР ФСБУ 4/2023 (НПЗ «Реализация иного имущества…»): хранить ли (OQ-36 расширяется на суммы). +- OQ-142 — образец ФСБУ 4/2023 (шаблон): «АКТИВ» и код 1110 в одной строке из-за объединений; шаблоны не являются данными, парсер даёт `needs_check`; нужен ли их разбор вообще. - OQ-131 — статус: «границы периода `period_start/period_end/is_from_year_start` в контракте v1.2 (docs change 2b); парсер заполняет (PR #13 + 2b); ядро — привязка скользящего документа к версии остаётся открытой». - [ ] **Step 3: 03-architecture, 09-delivery-plan, 06-test-data, qa-guide** @@ -3558,7 +3558,7 @@ Refs #14" ```bash git -C DOCS add docs/context docs/dev -git -C DOCS commit -m "docs(2b): §12 по решениям владельца, §7 жёсткие/мягкие, 06-test-data формы, 03-architecture v1.2, OQ-137…141 +git -C DOCS commit -m "docs(2b): §12 по решениям владельца, §7 жёсткие/мягкие, 06-test-data формы, 03-architecture v1.2, OQ-138…143 Refs #26" ``` -- 2.47.2 From 5b3486878433947285c55bbc3552ce7c807bf804 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 21:58:24 +0400 Subject: [PATCH 11/12] =?UTF-8?q?docs(2b):=20OQ-143=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=BE,=20st07,=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=81=D1=82=20=D0=BF=D1=80=D0=BE=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=20=D0=BB=D0=B8=D1=81=D1=82=D0=BE=D0=B2,=20ta?= =?UTF-8?q?sks.md=20=D0=BF=D0=BE=20=D1=84=D0=B0=D0=BA=D1=82=D1=83=20(O-9,?= =?UTF-8?q?=20M-2,=20M-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #26 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3 --- context/05-domain-rules.md | 8 ++++---- context/06-test-data.md | 5 +++-- context/07-open-questions.md | 2 +- context/09-delivery-plan.md | 7 +++---- dev/qa-guide.md | 2 +- .../changes/slice-2b-statement-parser/tasks.md | 14 +++++++------- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/context/05-domain-rules.md b/context/05-domain-rules.md index f825e68..a7941d0 100644 --- a/context/05-domain-rules.md +++ b/context/05-domain-rules.md @@ -241,8 +241,8 @@ result-osv-needs-check.json`. | `error` / `RECOGNITION_FAILED` | непредвиденное исключение; одна строка `Тип: сообщение`, стек — в лог ERROR | Формат определяется по сигнатуре байтов (zip → XLSX, либо XLSB при наличии `xl/workbook.bin`; OLE2 → XLS), а не по -`file_type` запроса; несовпадение — предупреждение. Листы форм ББ/ОФР (§12 п. 1) и листы без признаков ОСВ пропускаются с -предупреждением (`sheet=<имя> skipped: statement (slice 2b)` / `skipped: нет признаков ОСВ`). Другие предупреждения: +`file_type` запроса; несовпадение — предупреждение. Листы без признаков ОСВ и форм пропускаются с +предупреждением (`sheet=<имя> skipped: нет признаков ОСВ или формы`; листы форм теперь разбираются, §12). Другие предупреждения: `sheet=<имя> hierarchy=<стратегия>` (всегда), свёрнутое сальдо, нераспознанное число (с номером строки), не найденное поле шапки, «нет строк данных». xlsb не содержит outline level — каскад начинается с отступа (предупреждение «xlsb: outline level недоступен»). @@ -893,8 +893,8 @@ OQ-92 (пустые «Организация»/«Группа компаний» ОСВ в порядке листов, затем формы. Шаблоны (коды есть, сумм нет, шапки нет) — `needs_check`. 11. **Отложено**: запасной путь по именам строк (OQ-139), нормализация единиц (OQ-138), признак продолжения в контракте и склейка страниц из разных файлов в ядре (OQ-140, Срез 3), строки без кода (OQ-141), образцы ФСБУ (OQ-142), лист - продолжения с единственной кодовой строкой (OQ-143: лист ОФР НПЗ с одной строкой 2400 не распознаётся как форма и - теряется, `min_codes` = 3). + продолжения с единственной кодовой строкой (OQ-143 реализовано: после листа формы хватает одного кода, + `min_codes_continuation` = 1; первая страница по-прежнему требует `min_codes` = 3). Что это даёт ядру: один маппинг `statementDocument → document + financial_statement_*` для PDF и Excel (маппинг форм — Срез 3); экран «Проверка распознавания» для Excel-источника показывает не страницы, а таблицу — UI-решение вне парсера. diff --git a/context/06-test-data.md b/context/06-test-data.md index 2226cf2..04fce8e 100644 --- a/context/06-test-data.md +++ b/context/06-test-data.md @@ -156,7 +156,7 @@ arm-tabular-parser#11, OQ-131…OQ-135. ИНН организаций в шап | Раскладка | Где | Особенности | |---|---|---| | 1С-xls постранично | `unagrande-co/2024-FY/`, `unagrande-group/{2024-FY,2025-Q3}/` | один файл = одна страница формы; на странице 1 титул (организация, ИНН, ОКПО, единица «в тыс. рублей», ОКЕИ, аудитор со **своим** ИНН), на странице 2 только «Форма 0710001 с.2»; пятизначные подстроки (11101…), значения в рублях с копейками при «тыс. руб.»; страниц 1 ББ в пакете нет (только актив) и ОФР Групп 2024 — только стр. 2 | -| Многолистовая xlsx ФСБУ 4/2023 | `npz/2025-Q3/` | ББ на 5 листах, ОФР на 3; заголовок формы после титула; листы 2–5 без заголовка и маркера; строка 1105 «Гудвил», подстроки 12301…, строка без кода с суммами; лист ОФР с одной строкой 2400 (OQ-143) | +| Многолистовая xlsx ФСБУ 4/2023 | `npz/2025-Q3/` | ББ на 5 листах, ОФР на 3 (третий — одна строка 2400, присоединяется как продолжение); заголовок формы после титула; листы 2–5 без заголовка и маркера; строка 1105 «Гудвил», подстроки 12301…, строка без кода с суммами; лист ОФР с одной строкой 2400 (OQ-143) | | `TDSheet` 66н | `mezheninovskaya/2025-Q3/` | вся форма на одном листе, дата тройкой «30 \| 9 \| 2025» числами, двухстрочные заголовки, строка нумерации колонок, значения — тысячи с дробной частью | | Шаблоны | `templates/` | 66н и ФСБУ 4/2023: коды есть, сумм и шапки нет; заголовок на две строки; в шаблоне ФСБУ «АКТИВ» и код 1110 в одной строке (OQ-142) | @@ -171,7 +171,7 @@ arm-tabular-parser#11, OQ-131…OQ-135. ИНН организаций в шап коммит в `fixtures`. Оракулы корректности — манифест и property-тест `tests/fixtures/test_statements_property.py` (манифест покрывает каждый файл; тип, статус и видимая причина `needs_check` каждого документа совпадают с ожиданиями). -**Синтетика** `fixtures/statements-xlsx/synthetic/st01…st06` (генератор `tabular-parser/api/tests/synthetic/generate_statements.py`, +**Синтетика** `fixtures/statements-xlsx/synthetic/st01…st07` (генератор `tabular-parser/api/tests/synthetic/generate_statements.py`, ожидания — `tests/synthetic/test_statement_synthetic_files.py`, описание — README каталога): | Файл | Что проверяет | @@ -181,6 +181,7 @@ arm-tabular-parser#11, OQ-131…OQ-135. ИНН организаций в шап | `st03_no_codes` | форма без колонки кодов → `needs_check`, «колонка кодов не найдена» (OQ-139) | | `st04_mixed_book` | книга: лист ОСВ + лист баланса, порядок документов ОСВ → форма | | `st05_continuation_only` | одинокая страница 2 → `needs_check`, предупреждение о продолжении (OQ-140) | +| `st07_single_code_continuation` | ОФР без 2400 на первом листе и лист-продолжение с единственной кодовой строкой 2400 присоединяется (OQ-143) | | `st06_header_variants` | варианты, которых нет в реальных файлах: старое название ОФР, расшифровки/ОДДС/ОИК, форма только с ОКУД 0710xxx, организация в «», «Адрес», «Пояснения», числовая дата (покрытие словарей `statement_*.yaml`) | ## 5. Прочее diff --git a/context/07-open-questions.md b/context/07-open-questions.md index c8aec64..7f26256 100644 --- a/context/07-open-questions.md +++ b/context/07-open-questions.md @@ -148,7 +148,7 @@ | OQ-140 | Страница-продолжение в отдельном файле (1С печатает ББ/ОФР постранично, один файл = страница): склейка в ядре по ЮЛ + период + тип формы; признак `is_continuation` / `form_page` в контракте (MINOR, две необязательных поля) | решение владельца 06.10.2026 (вопрос 2), design 2b D-8 | §12 п. 9, Срез 3, контракт v1.x | допущение принято: страницы разных файлов не склеиваются; страница 2 без шапки — документ с пустой шапкой и `needs_check`, «загрузите форму одним файлом» | | OQ-141 | Строка без кода, но с суммами в ОФР ФСБУ 4/2023 (НПЗ «Реализация иного имущества…»): хранить ли (OQ-36 расширяется с расшифровочных строк на строки с суммами) | `fixtures/statements-xlsx/npz`, design 2b D-6 | Срез 3, DBML `financial_statement_line` (хранит только строки с кодом) | допущение принято: строки без кода не отдаются, при наличии сумм — предупреждение «строк без кода с суммами пропущено: N» | | OQ-142 | Образец ФСБУ 4/2023 (`templates/ББ после 01.01.2025.xlsx`): «АКТИВ» и код 1110 в одной строке из-за объединённых ячеек; шаблоны (коды есть, сумм и шапки нет) не данные. Нужен ли их разбор вообще | `fixtures/statements-xlsx/templates`, design 2b D-8 | §12 п. 10, тесты | допущение принято: шаблоны дают `needs_check` (шапка пустая, значения `null`); разбор шаблонов как источника данных не планируется, пока владелец не скажет иначе | -| OQ-143 | Лист продолжения с **единственной** кодовой строкой (книга НПЗ: лист ОФР с одной строкой 2400): `min_codes` = 3 → лист не распознаётся как форма и пропускается, строка 2400 теряется при `needs_check` документа. Понизить порог / принимать лист по близости к открытому документу ОФР / маркеру продолжения | tabular-parser design 2b: решение №25, O-9 | §12 п. 2, п. 9; полнота ОФР НПЗ | открыт: отложено на финальное ревью 2b; допущение — строка 2400 листа продолжения НПЗ в результате отсутствует | +| OQ-143 | Лист продолжения с **единственной** кодовой строкой (книга НПЗ: лист ОФР с одной строкой 2400): `min_codes` = 3 → лист не распознаётся как форма и пропускается, строка 2400 теряется при `needs_check` документа. Понизить порог / принимать лист по близости к открытому документу ОФР / маркеру продолжения | tabular-parser design 2b: решение №25, O-9 | §12 п. 2, п. 9; полнота ОФР НПЗ | **реализовано в 2b (финальная волна, I-1):** лист с 1–2 кодами форм после листа формы берётся как продолжение (`min_codes_continuation = 1` в словаре) и присоединяется при совпадении числа колонок сумм и типа формы; не присоединён — `needs_check` и предупреждение; первая страница с 1–2 кодами формой не считается. Строка 2400 НПЗ в результате есть | Служебные идентификаторы в DBML/аналитике: `ANL-nnn` — замечания аналитика к макету; `ARCH-nnn` — архитектурные решения; `DECISION-n` — решения по модели; `FIX-nn` — исправления дефектов v4. diff --git a/context/09-delivery-plan.md b/context/09-delivery-plan.md index 45d42a2..a9688ea 100644 --- a/context/09-delivery-plan.md +++ b/context/09-delivery-plan.md @@ -116,14 +116,13 @@ Change'и: `tabular-parser/openspec/changes/slice-2a-osv-parser` (issue arm-tabu - **Реализовано (ветка `feat/14-slice-2b-statement-parser`, tabular-parser PR #16; docs — PR #27, ветка `feat/26-slice-2b-statement-parser`):** `BookParser` + конвейер форм `statements/`, словари `statement_*.yaml`, контракт v1.2 (`DISCLOSURE`, `checks`, границы периода ОСВ; копии схемы в arm-core PR #27 и scan-gateway PR #5), `§12`/`§7` по - решениям владельца. Тесты: манифест `api/tests/statements_manifest.yaml` (18 реальных файлов), синтетика `st01`–`st06`, - golden `expected_statement_parse/` (18 + 6 снимков), интеграционный тест воркера на книге НПЗ. + решениям владельца. Тесты: манифест `api/tests/statements_manifest.yaml` (18 реальных файлов), синтетика `st01`–`st07`, + golden `expected_statement_parse/` (18 + 7 снимков), интеграционный тест воркера на книге НПЗ. - **Объём:** парсер + контракт v1.2; **маппинг форм в ядре (`statementDocument → document + financial_statement_*`) — Срез 3**. По факту `done` только у форм Межениновской; остальные `needs_check` по данным файлов (рубли с копейками при «тыс. руб.», страницы без шапки, шаблоны) — OQ-138, OQ-140. - **Не сделано / риски:** единицы не нормализуются (OQ-138); форма без колонки кодов не разбирается (OQ-139); страницы - в разных файлах не склеиваются (OQ-140); строки без кода не хранятся (OQ-141); лист ОФР НПЗ с единственной строкой 2400 - теряется (OQ-143); после контракта v1.2 `docs/contracts/**` заморожены (копии схемы в arm-core и scan-gateway должны совпадать с ними; в tabular-parser версия закреплена тестом `api/tests/unit/test_contract_v12.py`, побайтное сравнение копий — вручную через `diff` при раскатке); косметика T1.1/T2.x уходит в следующий MINOR. + в разных файлах не склеиваются (OQ-140); строки без кода не хранятся (OQ-141); после контракта v1.2 `docs/contracts/**` заморожены (копии схемы в arm-core и scan-gateway должны совпадать с ними; в tabular-parser версия закреплена тестом `api/tests/unit/test_contract_v12.py`, побайтное сравнение копий — вручную через `diff` при раскатке); косметика T1.1/T2.x уходит в следующий MINOR. ### Срез 2c — Загрузка файлов и маппинг результата в ядре (`arm-core`) — реализован, ожидает ревью и merge diff --git a/dev/qa-guide.md b/dev/qa-guide.md index 8e3fdaa..6e6a0a0 100644 --- a/dev/qa-guide.md +++ b/dev/qa-guide.md @@ -388,7 +388,7 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli "/путь/к/файлу.x `checks.soft_relations_failed` и `duplicate_line_codes`; в `metrics.warnings` — предупреждение об единицах («значений с дробной частью при единице тыс. руб.: N из M»: значения как в файле, не нормализованы, OQ-138), «колонка кодов не найдена», «страница-продолжение без шапки». Ожидания по реальным файлам — `../tabular-parser/api/tests/statements_manifest.yaml` и -таблица «файл → статус» в README `tabular-parser`; файлы — `../fixtures/statements-xlsx/` (синтетика `st01…st06`). +таблица «файл → статус» в README `tabular-parser`; файлы — `../fixtures/statements-xlsx/` (синтетика `st01…st07`). Постраничные xls из 1С (страница 2 отдельным файлом, ББ только актив) и формы в рублях при шапке «тыс. руб.» дают `needs_check` — это штатно (OQ-138, OQ-140), не баг; `done` ожидается только у форм, где §7 сходится (Межениновская). diff --git a/openspec/changes/slice-2b-statement-parser/tasks.md b/openspec/changes/slice-2b-statement-parser/tasks.md index cbdb4ab..29a6e34 100644 --- a/openspec/changes/slice-2b-statement-parser/tasks.md +++ b/openspec/changes/slice-2b-statement-parser/tasks.md @@ -3,15 +3,15 @@ Задачи выполняются в ветке `feat/26-slice-2b-statement-parser`; детализация — в плане superpowers вместе с дочерним change tabular-parser. -- [ ] 1. Контракт v1.2: `docs/contracts/recognition-result.schema.json` — `DISCLOSURE`, `statementDocument.checks`, +- [x] 1. Контракт v1.2: `docs/contracts/recognition-result.schema.json` — `DISCLOSURE`, `statementDocument.checks`, `osvDocument.header.period_start/period_end/is_from_year_start`, заголовок и описание «1.2» (design D-1); проверка схемы валидатором JSON Schema на всех примерах. -- [ ] 2. Примеры: `result-statement-minimal.json` (+`checks`, «1.2»), новый `result-statement-excel.json`, +- [x] 2. Примеры: `result-statement-minimal.json` (+`checks`, «1.2»), новый `result-statement-excel.json`, `result-osv-minimal.json` (границы периода) (design D-2). - [ ] 3. Спека `openspec/specs/recognition-contract` синхронизируется из дельты при архивации; до этого — дельта в change. -- [ ] 4. `05-domain-rules.md` §12 — решения владельца и уточнения (design D-3); §7 — пометка жёстких/мягких соотношений. -- [ ] 5. `06-test-data.md` — раздел `statements-xlsx/**`, манифест, golden `expected_statement_parse/`, синтетика. -- [ ] 6. `03-architecture.md` — контракт 1.2, `BookParser`, порядок раскатки MINOR; `09-delivery-plan.md` — Срез 2b. -- [ ] 7. `07-open-questions.md` — новые OQ (единицы против копеек; форма без колонки кодов; страница-продолжение в +- [x] 4. `05-domain-rules.md` §12 — решения владельца и уточнения (design D-3); §7 — пометка жёстких/мягких соотношений. +- [x] 5. `06-test-data.md` — раздел `statements-xlsx/**`, манифест, golden `expected_statement_parse/`, синтетика. +- [x] 6. `03-architecture.md` — контракт 1.2, `BookParser`, порядок раскатки MINOR; `09-delivery-plan.md` — Срез 2b. +- [x] 7. `07-open-questions.md` — новые OQ (единицы против копеек; форма без колонки кодов; страница-продолжение в отдельном файле; строка без кода с суммами в ОФР ФСБУ; образец ФСБУ «АКТИВ»+1110), статус OQ-131. -- [ ] 8. `docs/dev/qa-guide.md` §6.7 — прогон парсера на формах; README `docs/contracts/` (если есть) — v1.2. +- [x] 8. `docs/dev/qa-guide.md` §6.7 — прогон парсера на формах; README `docs/contracts/` (если есть) — v1.2. -- 2.47.2 From 7de9fd4fd69c1d3402cdaf123871dfbd9d8c94ca Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 22:13:12 +0400 Subject: [PATCH 12/12] =?UTF-8?q?plan:=20=D0=B8=D1=82=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=B9=20ledger=20SDD=20=D0=A1=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D0=B0=202b?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #26 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- ...-10-06-slice-2b-statement-parser-ledger.md | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser-ledger.md b/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser-ledger.md index ee552ae..7cd99e1 100644 --- a/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser-ledger.md +++ b/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser-ledger.md @@ -116,7 +116,7 @@ Task 12: implementer sonnet DONE_WITH_CONCERNS 10b5bda; make check: 2 failed (go Task 12: review dispatched (sonnet) Task 12: review sonnet: spec ✅, Approved (Critical/Important нет). Review Focus 4, 5 покрыты тестами (мутации M1–M5 падают). Ruling: R-10 — отклонения Task 12 принимаются (флаг dropped: продолжение без колонки кодов → needs_check книги; OTHER-продолжение по правилам ББ; form_name не передаётся) и вносятся в design (#21) — цена ошибки: лишний needs_check на редком листе. -Ruling: R-11 — НПЗ даёт needs_check (1600=1700 расходится на 1.94/9.02/2.48 ₽ в источнике; значения в рублях с копейками при «тыс. руб.»), допуск ±1 по спеке НЕ меняется; ожидание манифеста НПЗ в задаче 14 — needs_check с причиной; выносится владельцу (OQ-138) — цена ошибки: если владелец хочет done — допуск/нормировка в словаре (design #22). +Ruling: R-11 — НПЗ даёт needs_check (1600=1700 расходится на 1.94/9.02/2.48 ₽ в источнике; значения в рублях с копейками при «тыс. руб.»), допуск ±1 по спеке НЕ меняется; ожидание манифеста НПЗ в задаче 14 — needs_check с причиной; выносится владельцу (OQ-137) — цена ошибки: если владелец хочет done — допуск/нормировка в словаре (design #22). Task 12: golden s12/s14 (синтетика, документы форм вместо заглушки) пересозданы контроллером (make golden-update, diff просмотрен: только s12/s14, статусы done→needs_check и документы форм без колонки кодов), коммит fixtures-wt "test(golden): s12/s14". Task 12: minor T12.1 (deferred): мёртвые счётчики fractional_values/numeric_values в Draft; предупреждение о дробной части считается по листу, не по документу (спека — «по документу»? уточнить в финале). T12.2 (deferred): ветка lonely_continuation в _is_ok избыточна — комментарий/тест. T12.3 (deferred): can_join игнорирует тип формы при маркере продолжения другого типа. T12.4 (deferred): join O(n·m). T12.5 (deferred): побочный эффект _document дописывает draft.warnings. T12.6 (deferred): test_template_without_sums_is_needs_check вакуумен при пустых lines. T12.7 (deferred): docstring после assert в synthetic documents(). T12.8 (deferred): повторяющийся приём form_name в header.py. T12.9 (deferred): нет теста дубликата внутри продолжения. O-7: D-8 «лист ОСВ между страницами → одиночка» не реализуем интерфейсом (формы получают только листы форм). Task 12: complete (commits b9e6e31..10b5bda + fixtures golden s12/s14, review clean) @@ -132,3 +132,48 @@ Task 13: fix round 3/5 (3 addressed, 0 open — T13.I1, T13.I2(часть), ми Task 13: minor T13.5 (deferred → финальная волна): `_upper_header_row` — если в двухстрочном заголовке есть колонка с заполненными обеими ячейками, не склеиваемыми в период («Изменение» над «тыс. руб.»), функция возвращает нижнюю строку и теряет «На 31 декабря» для остальных (предложено: continue вместо return). T13.6 (deferred → финальная волна, до PR): в коммит 4907935 попал случайный артефакт api/uv.lock — убрать. Task 13: complete (tabular-parser 6f0f05f..4907935; fixtures ff39ad2, f938255, b63c0a5; review clean after round 3) Пауза после Task 13 (владелец, 06.10.2026) + +## Прогон 3 (06.10.2026, контроллер sonnet; продолжение с задачи 14) +Ruling: R-13 (design #23) — решение владельца по R-11, вариант А: допуск ±1 остаётся; баланс НПЗ ожидаемо needs_check (значения в рублях с копейками при шапке «тыс. руб.», 1600 = 1700 расходятся на 1.94/9.02/2.48, OQ-137); INCOME НПЗ — по факту; статус книги по худшему документу. Следствия: манифест T14, спека statement-parsing («Баланс на пяти листах», «Полные формы сходятся»), дельта tabular-recognition-service («Реальные формы через воркер» → needs_check), T15 интеграционный тест ждёт needs_check, T16 §12 п.7 и OQ-137 с примером НПЗ. Цена ошибки: если владелец передумает — допуск/нормировка в словаре. +Модели прогона 3: implementer sonnet, task reviewer sonnet, fix-раунды 4–5 opus, финальное ревью opus. +Task 14: BASE tabular-parser 4907935, fixtures b63c0a5; implementer sonnet dispatched (a2c2bf3000efe1f94) +Task 14: implementer sonnet DONE_WITH_CONCERNS: tabular-parser b6d2089 (openspec R-13/№23), 07d57cb (манифест, property, golden-хелперы, словари); fixtures-wt f017d96 (18+6 снимков). make check 2185 passed; test-fixtures 997 passed за 151 с (порог 288), 1 error test_roundtrip (нет MinIO/NATS — окружение); golden-check 137/137. Файлов форм 18, не 19 (EXPECTED_FILES=18); добавлена синтетика st06. +O-8: ДЕФЕКТ (пропущен ревью T11/T12): checks.py делает relations_failed.append на StatementChecks(), to_wire=exclude_unset → relations_failed/soft_relations_failed никогда не попадают в wire/golden. Фикс — в раунде задачи 14. +Ruling: R-14 (design #24) — INCOME НПЗ и ОФР Унагранде (co 2024-FY, group 2025-Q3 стр.1) по факту needs_check (рубли с копейками при тыс. руб., итоги округлены; 2100 расходится на 17.50/9.43/1416) — та же причина, что R-13/OQ-137; допуск не меняется; сценарий спеки «Знаки 1С» переписывается: abs_terms доказывается юнит-тестом/реальным файлом, где 2100 сходится по факту. Цена ошибки: если владелец хочет done на реальных ОФР — допуск/нормировка единиц (OQ-137). +O-9: лист ОФР НПЗ «…_2» (одна строка 2400, min_codes=3) классифицируется OTHER и пропускается — строка 2400 теряется молча при needs_check документа. Статус: deferred → финальное ревью (решить: предупреждение/правило min_codes для продолжения после открытого документа). +Task 14: fix round 1/5 dispatch (resume implementer): O-8 + спека (Знаки 1С, число листов) + design №24. +Task 14: review sonnet: spec ✅, Needs fixes (Important only): T14.I1 FailedRelation.actual для INCOME считается по варианту без abs_terms (14754604344.76 вместо ≈−71140867.82) — вводит в заблуждение; T14.I2 property-тест вакуумен для complete:false (нет проверки состава relations_failed), per-document status не проверяется, шапка пропускается при None. +Task 14: minor T14.1 (deferred): нет теста уровня документа для checks=None vs checks с пустыми списками. T14.2 (deferred): sheets через locator.count(",") ломается на запятой в имени листа. T14.3 (deferred → финальная волна): design.md шапка и 05-domain-rules.md:762 упоминают ОКВЭД, метка удалена. T14.4 (deferred → финал): шаблон «ББ до» даёт пустой DISCLOSURE done из блока «Примечания» — поведение парсера, закреплено манифестом. +Task 14: fix round 2/5 dispatch (resume implementer): T14.I1, T14.I2 +Task 14: minor T14.5 (deferred): document_ok в тесте не покрывает has_code_column/lonely_continuation из _is_ok. T14.6 (deferred): explained_needs_check слабый критерий (checks is None). +Task 14: fix round 1/5 (wire relations_failed O-8, спека, design №24–25; commits 07d57cb..1b17f2e, fixtures 33de51a); fix round 2/5 (2 addressed, 0 open — T14.I1, T14.I2; commits 1b17f2e..6cd2ea8, fixtures ba600fc). make check 2188 passed; test-fixtures 997 passed за 150 с (порог 288), 1 error test_roundtrip (нет compose) — закрыть в T15 через make up; golden-check 137/137. +Task 14: complete (tabular-parser 4907935..6cd2ea8; fixtures b63c0a5..ba600fc; review clean after round 2) +Task 15: BASE tabular-parser 6cd2ea8; implementer sonnet dispatched +Task 15: implementer sonnet BLOCKED (docker daemon off) → контроллер запустил Docker Desktop (open -a Docker; побочный эффект на машине владельца, нужен для make up по заданию) → продолжил: DONE 3a370d4; test-integration 10 passed (НПЗ needs_check, BALANCE relations_failed == {1600 = 1700}); make check 2188 passed. compose парсера остался поднятым (цели down в Makefile нет). +Task 15: review sonnet: spec ✅, Approved. minor T15.1 (deferred → финальная волна): README «Ограничения» п.1 неточен: предупреждение о дробных ≥ порога не влияет на статус; needs_check задают нарушенные соотношения. T15.2 (deferred → финальная волна): тест воркера INCOME проверяет только relations_failed != [] — сравнить множество 2100/2200/2300. T15.3 (deferred): README про golden-update: «лишние снимки» для форм (проверить sync_snapshots). T15.4 (deferred): README: остановка compose — docker compose -f ../docs/dev/docker-compose.yml down. +Task 15: complete (tabular-parser 6cd2ea8..3a370d4, review clean) +Task 16: BASE docs 909e7ff; implementer sonnet dispatched +Task 16: implementer sonnet DONE_WITH_CONCERNS (docs 6d39800, 4272ba8, 00d1715, a5adcac; make check в docs не запускается — нет jsonschema/venv; openspec validate не запускал). Review sonnet: Needs fixes — T16.I1 06-test-data.md:222 «88 файлов (72+16)» противоречит стр.295 (113 = 94+19); добавить expected_statement_parse в дерево. T16.2-4 minor: тексты предупреждений OQ-140 / §12 п.5 / qa-guide неточны («строк без кода с суммами пропущено: N»; «значений с дробной частью при единице тыс. руб.: N из M»); 09-delivery-plan «байт в байт» без ссылки на проверку; статус PR устареет после merge. +O-10: design.md D-11 (tabular-parser change) всё ещё обещает НПЗ done; design шапка/05-domain-rules:762 упоминают ОКВЭД — править в финальной волне (T14.3). +Task 16: fix round 1/5 dispatch (resume): T16.I1 + T16.2/T16.3 +Task 16: fix round 1/5 (3 addressed, 0 open — T16.I1, T16.2, T16.3; docs 4729e40). Re-review: All findings addressed. +Task 16: complete (docs 909e7ff..4729e40, review clean after round 1). Minor T16.4 (deferred): статус PR в 09-delivery-plan устареет после merge. +Task 17 (часть 1: merge origin/main, check-full, golden-check, openspec, tasks.md) — исполняет контроллер-диспатч; PR-описания — после финального ревью и волны фиксов. +O-11: origin/main docs ушёл вперёд (PR #35…#43): в main уже занят OQ-137 (раскладка горячих папок ИС Сканирование); merge origin/main в docs даёт конфликты в 07-open-questions.md и dev/qa-guide.md. +Ruling: R-15 (design #26) — наши новые OQ-137…OQ-142 перенумерованы в OQ-138…OQ-143 (номер OQ-137 в main занят другим вопросом, перенумерация нашего дешевле, чем чужого); правки во всех ссылках docs (context, plan, ledger-копия) и tabular-parser (README, CLAUDE.md, спека, design, манифест, тест воркера). Цена ошибки: ссылки на OQ-13x в уже открытых PR-описаниях/комментариях — в описаниях пишем новые номера. +Task 17 ч.1: docs merge origin/main e93b28e + перенумерация 9235e6a (R-15), tabular-parser be47e82; make check-full EXIT 0; openspec validate valid. Финальное ревью (opus) dispatched. +## Финальное ревью (opus): Ready to merge — With fixes +I-1 (=O-9): лист с единственной кодовой строкой 2400 теряется, предупреждение «skipped: нет признаков ОСВ или формы» неверно; противоречит §12 п.9. I-2 (=T8.5): bounds_span «одиночный месяц» даёт неверные period_start/is_from_year_start («на 31 декабря 2024 г.» → 12-01). I-3 (новая): пустой line_name при confidence=100 у итогов/«в том числе» (ОФР 2200/2300/2411/2412/2400, ББ 15401/15402): имя в соседней колонке. M-1 (=T13.6) uv.lock. M-2 tasks.md обоих change не [x]. M-3 (=O-10) D-11 НПЗ done. M-4 устаревший §1.1 05-domain-rules.md:244 и README:150 про «skipped: statement (slice 2b)». M-5 слово «тыс» (lines.py:20) и _OKUD_CODE (detect.py:20, T6.2) в коде — нарушение Global Constraint. M-6 (=T6.1). M-7 (=T10.7). M-8 строка нумерации на разрыве страницы и нули считаются «строкой без кода с суммами». M-9 подстроки с 10-го номера (11510, 15110…) не в _subline_rules. M-10 (=T12.3) can_join без типа формы. M-11 (=T12.1). M-12 примеры контракта (T2.2/T2.3 + result-statement-excel без 1700 при relations_failed=[]) — по R-4 follow-up. T13.5: простой continue опасен (сломает защиту «Единица измерения» над датами); правильно — возвращать верхнюю строку, если хотя бы одна колонка склеилась в период. +Ruling: R-16 (design #27) — единая волна фиксов: I-1 (присоединение листа как продолжения с ослабленным min_codes=1 после листа формы, запасной вариант dropped+точное предупреждение) + M-10, I-2, I-3, M-1, M-2, M-3, M-4, M-5(+T6.2), M-6, M-7, M-8, M-11, T13.5 (по рекомендации ревьюера), T15.1, T15.2. Follow-up (deferred в issue): M-9, M-12, остальные minor из ledger. Цена ошибки: I-1 расширяет парсер после ревью задач (риск регрессии) — ловит один scoped re-review и golden-check. +Final wave (R-16) DONE: tabular-parser 336f4d6 (M-1), 1abe212 (I-1/O-9, M-10, I-2/T8.5, I-3, M-5/T6.2, M-8, M-11/T12.1, T13.5, M-6/T6.1, M-7/T10.7, T15.1, T15.2), 15fb561 (design №25/D-11/R-16 №27, tasks.md, спека); docs 5b34868 (OQ-143, M-4, tasks.md); fixtures 9586207 (golden + st07). make check 2205; golden-check 138/138; test-fixtures 1003 за 149 с; test-integration 10; openspec valid; golden ОСВ без изменений. tasks.md: оставлены [ ] — T п.5.1 (README/check-full/PR — до PR), docs п.3 (синхронизация при архивации). +O-12: в трейлере Claude-Session коммитов волны — URL сессии из системной подсказки вместо UUID (разночтение системных инструкций); историю не переписываем; цена: единообразие трейлера. +Re-review волны (opus): All findings addressed (I-1, M-10, I-2, I-3, M-5, M-8, M-11, T13.5, M-1, M-6, M-7, T15.1, T15.2, M-3, M-4, M-2). Новые находки в fix-diff (Minor, адьюдикация, второй волны нет): +N-1 (deferred, follow-up; значимая): ослабленный порог (min_codes_continuation=1) допускает ложные присоединения: лист «Справка»/«Сведения» с единственным числом в диапазоне кодов (или 5-значным ОКОПФ) после формы → при совпадении числа колонок строка молча присоединяется к последнему документу и может остаться done; при несовпадении книга needs_check вместо done+skipped. Реальные фикстуры не затронуты. Меры (follow-up): охрана «сразу после листа формы», числа во всех колонках периодов, негативные BookParser-тесты. +N-2 (deferred): book.py:109 проверяет «была какая-то форма раньше», а не «сразу после листа формы» (спека/README говорят иначе). +N-3 (deferred): нет негативных BookParser-тестов шлюза (форма+посторонний лист; одиночный лист с одной кодовой строкой; ОСВ+лист с одним кодом). +N-4 (deferred): note_cols заполняются только при шапке таблицы; на продолжении без шапки значения «Пояснения» («5.1») могут стать line_name. +N-5 (deferred): design.md D-10 говорит «periods.yaml не правится», I-2 правит periods.yaml (not_single_month): противоречие текста и конфликт при rebase на #11 — править текст при follow-up/слиянии. +O-13: интеграционный прогон волны не был показан в отчёте → контроллер прогоняет make check-full сам перед PR. +Финал: fix волна закрыта; финальные проверки контроллером; PR-описания. +Финальные проверки контроллера: make check-full EXIT 0 (интеграция 10 passed); golden-check 138/138; openspec validate --strict valid в обоих репо. +Следующий шаг: push веток (без force), обновление PR #16 / #27, PR fixtures, удаление workspace. -- 2.47.2