openspec: change unified-normalization-tp — адаптер Excel, общий код, контракт v2.0 (#54)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 19:55:36 +04:00
parent 0b7b23eda0
commit 8b9a5a07c4
5 changed files with 458 additions and 0 deletions

View File

@ -0,0 +1,40 @@
## Why
Формы ББ/ОФР из Excel разбираются собственным конвейером tabular-parser (раскладка, отбор строк, значения, проверки §7,
статус), отличным от конвейера scan-gateway; ядро не доверяет ни одному и пересчитывает результат по косвенным признакам
(строки без кода молча теряются, повтор кода теряет сумму, предупреждения — плоские строки). Change `unified-statement-normalization`
в arm-ka-docs (issue RSHB/arm-ka-docs#116, дизайн с решениями Р-1…Р-24) приводит оба сервиса к одной цепочке шагов 1–9:
шаг 1 (кандидат в форму) пишет адаптер источника, шаги 2–8 выполняет общий пакет `arm_statements`, результат и конверт —
контракт v2.0. Этот change переносит требования в tabular-parser; реализация — план 4
(`plans/2026-10-18-plan-4-tabular-parser.md` в docs), issue RSHB/arm-tabular-parser#54.
## What Changes
- **statement-parsing:**
- **MODIFIED** «Листы форм и их продолжения находятся по содержимому» — классификация ОСВ → форма → продолжение →
прочее, слабое продолжение только сразу после листа формы, лист «прочее» даёт `SHEET_OTHER`;
- **MODIFIED** «Шапка документа» — поля шапки блока как `Cell` с `confidence = 100` в границах блока, разбор текста — общим кодом;
- **MODIFIED** «Склейка листов-продолжений и статус документа» — склейка до раскладки, `segment_breaks`,
`CONTINUATION_TYPE_CONFLICT`, `SHEET_NOT_JOINED`; статус документа считает функция пакета;
- **MODIFIED** «Словари форм, координаты, golden» — словари форм живут в пакете, координат в коде нет, golden и манифест;
- **REMOVED** «Заголовок таблицы, колонка кодов и колонки-периоды определяются по тексту», «Строки с кодом и значения»,
«Соотношения §7 — данные, проверка по колонкам» — шаги 2–8 выполняет копия `arm_statements`;
- **ADDED** «Адаптер Excel шага 1», «Ячейка Excel», «Строки без кода и неизвестные колонки в Excel»,
«Манифест ожиданий на реальных формах», «Статус книги».
- **tabular-recognition-service:** **MODIFIED** «Результат по контракту» — файл результата и конверт v2.0, структурные
`metrics.warnings`, `service.statements_version`, ack `processing` — `schema_version = "2.0"`, запрос остаётся 1.0.
- **osv-parsing:** **MODIFIED** «Контрольные суммы определяют статус» (`status` листа и `warnings[]` с кодами `OSV_*` и
`severity = info` в документе, строк `sheet=<имя> …` в `metrics.warnings` нет) и «Форматы, лимиты, время»
(несоответствие `file_type` — предупреждение файла `FILE_TYPE_MISMATCH` `info`). Остальной разбор ОСВ не меняется.
- **BREAKING** для потребителей: файл результата и конверт — MAJOR 2.0 (`line_code` nullable, структурные
предупреждения, нет `checks.duplicate_line_codes`); слияние PR — только после выкладки ядра, принимающего 2.0.
## Impact
- Код: `api/src/parser/statements/{cells,forms,header_cells,adapter,pipeline}.py` (новые), `grid.py`, `detect.py`,
`book.py`, `readers/`, `osv.py`, `checks.py`, `osv_warnings.py`; `api/src/contract/**` (модели v2.0), `api/contracts/**`
(копия схем v2.0), `api/src/core/{worker,config}.py`, `api/src/app.py`; удаляются `statements/{checks,columns,detect,header,lines,merge,statement}.py`
и `api/rules/statement_{headers,periods,checks}.yaml`.
- Копия пакета `api/arm_statements/` (раскладывает `sync-statements`) — не правится.
- Тесты: манифест `api/tests/statements_manifest.yaml`; golden `fixtures/golden/expected_{osv,statement}_parse/**` (отдельный PR fixtures, arm-tabular-parser#54).
- Спеки: `statement-parsing`, `tabular-recognition-service`, `osv-parsing`. Контракт — `docs/contracts/` v2.0.

View File

@ -0,0 +1,132 @@
## MODIFIED Requirements
### Requirement: Контрольные суммы определяют статус
Для каждого документа парсер SHALL проверить: по каждой строке `opening_debit − opening_credit + turnover_debit −
turnover_credit = closing_debit − closing_credit` (±0.01, `null` = 0); по каждому родителю суммы = Σ детей с тем же
`data_type` (±0.01), причём **обороты** сверяются по колонкам, а **сальдо** (начало, конец) — по колонкам либо, если у
родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто «дебет − кредит» (R-21: 1С показывает счёт нетто,
а субсчета и контрагентов — развёрнуто; остаточные риски перечислены ниже); строки CURRENCY/QUANTITY в суммирование не входят и сами
родителями не проверяются; строка TOTAL = корневая строка ACCOUNT (первая строка уровня 0). Если корня нет (пустая ОСВ,
R-13), «Итого» SHALL быть пустой или нулевой. Если строка «Итого» не найдена, `total_row_matches_root = false` и в
`warnings` — «строка «Итого» не найдена» (R-20). Результат — `checks{total_row_matches_root,
balance_identity_violations, parent_sum_violations}`. Результат проверки SHALL записываться в документ листа (контракт v2.0): `status` листа — `done`, когда контрольные суммы
§1.4 сошлись (и строки ИНН без нарушений), иначе `needs_check` (прежние условия §1.6, применённые к листу); любое
нарушение → `status = needs_check` документа и запись причины в `warnings[]` документа. Предупреждения листа SHALL быть
структурами `{code, severity, message}` с `severity = info` и кодом с префиксом `OSV_` (`OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`,
`OSV_HEADER`, `OSV_ROWS`, `OSV_INN_ROWS`, `OSV_NO_DATA_ROWS`, `OSV_BALANCE_IDENTITY`, `OSV_PARENT_SUM`, `OSV_TOTAL`,
`OSV_XLSB_NO_OUTLINE`); строк вида `sheet=<имя> <текст>` в `metrics.warnings` больше нет. Парсер MUST NOT подгонять
структуру под сходимость.
Исключение — **счета без сальдо по аналитике** (решение владельца 07.10.2026, OQ-132): у 91 и 99 строки аналитики несут
только обороты, сальдо закрыто на субсчёт закрытия (91.09). Список счетов — данные `rules/osv_checks.yaml`
(`accounts_without_analytic_balance`, начальный состав `91`, `99`; 90 не входит). Строка относится к такому счёту, если
код ближайшего счёта её ветки (`account_code_raw`) совпадает с записью списка либо является её субсчётом
(`91`, `91.01`, но не `910`). Для таких строк тождество строки SHALL NOT проверяться; сверка «сумма детей = родитель»
сохраняется: обороты — всегда по колонкам, сальдо — только у родителя с дочерними субсчетами (корень → субсчета), а у
родителя с одной лишь аналитикой сальдо не сверяется (сальдо аналитики пусто по определению); «Итого» сверяется как обычно.
Остаточные риски (известные слепые зоны, не ловятся проверками §1.4):
- RR-1 — у свёрнутого родителя сверяется только нетто сальдо: распределение сальдо детей между дебетом и кредитом
(перестановка сторон при совпавшем нетто) не проверяется.
- RR-2 — «свёрнутость» определяется по самому родителю: развёрнутое сальдо родителя, у которого в файле потеряна одна
сторона, неотличимо от нетто-показа 1С и проходит, если нетто совпало.
- RR-3 — нетто допускается на любом уровне дерева (счёт, субсчёт, контрагент, договор), а не только на уровне «счёт →
субсчета», где его показывает 1С: ошибка распределения сальдо между договорами под свёрнутым контрагентом не ловится.
- RR-4 — тождество строки сверяет только нетто строки, «Итого = корень» — только корень: ошибка, сохраняющая нетто
строки, ловится лишь сверкой с родителем по колонкам, а под свёрнутым родителем — ничем (см. слепую зону ниже).
- Слепая зона: обе стороны сальдо (на начало или на конец) развёрнутого ребёнка увеличены на одно и то же X, а его
непосредственный родитель свёрнут — не ловится ничем: достаточно свёрнутого прямого родителя (сверка с ним — по нетто,
а нетто ребёнка не изменилось; значения родителя и выше не затронуты; тождество строки и «Итого = корень» сохраняются).
- Родитель с сальдо (0, 0) при детях (X Дт, X Кт) принимается: нулевое сальдо — свёрнутое, нетто детей X − X = 0
(закреплено тестом как осознанное поведение R-21).
- Вне R-21: текст вида «12.ООО» (2–3 цифры, точка, до трёх заглавных букв) принимается за счёт (R-16) и меняет
`group_type` строк ветки; суммы и проверки это не затрагивает.
#### Scenario: Все реальные файлы сходятся
- **WHEN** разбирается каждый файл `fixtures/osv/**`
- **THEN** `status = done`, `checks.total_row_matches_root = true`, оба списка нарушений пусты
#### Scenario: Испорченная сумма
- **WHEN** в синтетической ОСВ у одного контрагента `closing_debit` увеличен на 1
- **THEN** `status = needs_check` документа, `balance_identity_violations` содержит его `row_number`, `parent_sum_violations` —
`row_number` родителя, в `warnings[]` документа — `OSV_BALANCE_IDENTITY` / `OSV_PARENT_SUM` (`info`) с номерами строк
#### Scenario: Сальдо родителя свёрнуто нетто
- **WHEN** у родителя сальдо показано одной стороной (нетто), а у детей — обеими сторонами, и нетто детей равно нетто родителя
- **THEN** нарушения «сумма детей = родитель» по сальдо нет; обороты при этом сверяются по колонкам, а неверное нетто
или расхождение оборотов — нарушение
#### Scenario: Счёт 91 — аналитика только с оборотами
- **WHEN** разбирается ОСВ по счёту 91: строки аналитики 91.01, 91.02, 91.09 несут только обороты, сальдо закрыто на
субсчёт закрытия (`fixtures/osv-dkb/tsimlyansky/2025-Q3/91_ 9мес.2025.xls`)
- **THEN** `status = done`, оба списка нарушений пусты; обороты аналитики при этом сходятся с оборотами субсчёта, а
расхождение оборотов, сальдо субсчетов с корнем или «Итого» осталось бы нарушением
#### Scenario: «Итого» не найдена
- **WHEN** в таблице нет строки с текстом из словаря «Итого»
- **THEN** `total_row_matches_root = false`, `status = needs_check`, в `warnings[]` — `OSV_TOTAL` (`info`) «строка «Итого» не найдена»
#### Scenario: Пустая ОСВ
- **WHEN** разбираются реальные пустые ОСВ (в фикстурах две: `schastl/2025-Q2/…58 за 2 квартал 2025 г..xlsx` и `…66 за 2
квартал 2025 г..xlsx`: заголовок и сразу «Итого») либо любой файл, где кроме «Итого» строк данных нет
- **THEN** `status = done` документа, `rows` содержит только строку TOTAL, `metrics.rows = 0` (строки данных без TOTAL),
`hierarchy=trivial`, `checks.total_row_matches_root = true` (корня нет, суммы TOTAL пусты), предупреждение
`OSV_NO_DATA_ROWS` «нет строк данных»
#### Scenario: Предупреждение ОСВ
- **WHEN** на листе ОСВ есть строка без родителя
- **THEN** у документа ОСВ предупреждение `{code: "OSV_…", severity: "info", message}`, статус документа по §1.6;
в `metrics.warnings` конверта строки `sheet=…` нет
### Requirement: Форматы, лимиты, время
Парсер SHALL читать XLSX (openpyxl, полное чтение), XLS (xlrd, `formatting_info`), XLSB (pyxlsb; outline level недоступен —
каскад начинается с отступа). Несоответствие `file_type` сигнатуре файла → попытка по сигнатуре и предупреждение файла `FILE_TYPE_MISMATCH` (`severity = info`) в `metrics.warnings`.
Файл больше `PARSE_MAX_BYTES` (по умолчанию 50 МиБ) → `error UNSUPPORTED_FORMAT` с текстом про лимит. До вызова
библиотеки чтения SHALL действовать лимиты чтения (R-23) → `error UNSUPPORTED_FORMAT` с текстом про лимит: сумма
объявленных несжатых размеров записей zip (xlsx/xlsb) не больше `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` (10 ×);
суммарная площадь объединённых ячеек книги не больше `MERGED_AREA_LIMIT` (200 000 ячеек). Для xlsx — до openpyxl
потоковым разбором `xml.parsers.expat` всех частей архива (R-25, I-1б: каждая некаталожная запись в любом каталоге,
без отбора по имени или началу содержимого; ошибка разбора части не `*.xml` лишь заканчивает её скан; элемент с локальным именем `mergeCell` в любом пространстве имён; только атрибут `ref` без
префикса; ссылки на символы раскрываются; площадь — по строке `ref`, без перебора ячеек; отказ сразу при превышении);
для любого формата — после чтения, по диапазонам, которые индекс листа раскладывает по ячейкам (площадь не больше
`grid.MAX_INDEXED_AREA`; merged на весь столбец не считается); число остальных (крупных) диапазонов листа — не больше
`LARGE_MERGED_COUNT_LIMIT` (1000). Книга не открывается (в том числе битый XML части
`*.xml`, `ref` объединения, который openpyxl не разберёт) → `UNREADABLE` (лог WARNING);
превышение `PARSE_TIMEOUT_SECONDS` (по умолчанию 120, проверка между листами, в циклах по всему листу — поиск заголовка
таблицы, дополнительные колонки, строки, стратегии иерархии; чтение книги библиотекой таймаут не прерывает) → `TIMEOUT`;
непредвиденное исключение → `RECOGNITION_FAILED` с одной строкой причины, стек — в лог уровня ERROR. Не ограничены (остаточный
риск, решение платформы — лимит памяти контейнера, `defusedxml`): объём самих ячеек, `sharedStrings` и стилей в
пределах лимита распакованного zip, XML-бомбы entity expansion (кроме защиты самого expat ≥ 2.4). Любой файл
`fixtures/osv/**` SHALL разбираться быстрее 5 с.
#### Scenario: XLSB
- **WHEN** разбирается синтетический xlsb той же ОСВ, что `s02` (`s13`, собран тестовым писателем BIFF12, а не Excel — O-7)
- **THEN** результат совпадает с xlsx-оригиналом во всём, кроме `locator` и предупреждения документа `OSV_XLSB_NO_OUTLINE`
(«xlsb: outline level недоступен», `info`); `hierarchy=indent`
#### Scenario: Расширение не совпадает с содержимым
- **WHEN** запрос говорит `XLSX`, а байты — BIFF8
- **THEN** файл прочитан как xls, в `metrics.warnings` — `FILE_TYPE_MISMATCH` (`info`), статус по содержимому
#### Scenario: Превышен лимит
- **WHEN** передан файл в 51 МиБ
- **THEN** `error UNSUPPORTED_FORMAT`, книга не открывается
#### Scenario: Бомба объединённых ячеек
- **WHEN** передан xlsx в несколько килобайт с `<mergeCell ref="A1:Z200000"/>` (5,2 млн ячеек)
- **THEN** `error UNSUPPORTED_FORMAT` («лимит объединённых ячеек») быстрее 1 с, openpyxl не вызывается
#### Scenario: Обходы скана объединённых ячеек
- **WHEN** тот же диапазон спрятан: лист вне `xl/worksheets/` или с расширением не `.xml` (цель связи в
`workbook.xml.rels`), `ref` записан ссылками на символы (`A1:Z&#50;00000`, `&#x41;1:…`), перед `ref` стоит атрибут
с префиксом (`a:ref="A1"`), в значении другого атрибута есть `>`, элемент записан с префиксом (`x:mergeCell`)
- **THEN** `error UNSUPPORTED_FORMAT` («лимит объединённых ячеек») быстрее 1 с, openpyxl не вызывается; тот же вариант с
малым диапазоном читается, и объединение видно в листе
#### Scenario: Объединение на весь столбец в xls
- **WHEN** в книге xls есть законный merged-диапазон на весь столбец (больше `grid.MAX_INDEXED_AREA`)
- **THEN** книга читается: после-чтения проверка его не считает (индекс листа его по ячейкам не раскладывает)
#### Scenario: Распакованный zip больше лимита
- **WHEN** zip книги распаковывается больше чем в 10 × `PARSE_MAX_BYTES`
- **THEN** `error UNSUPPORTED_FORMAT` («распакованный размер … больше лимита»), книга не открывается

View File

@ -0,0 +1,228 @@
## MODIFIED Requirements
### Requirement: Листы форм и их продолжения находятся по содержимому
Классификация листа SHALL различать `OSV`, `STATEMENT`, `STATEMENT_CONTINUATION` и `OTHER` и выполняться в порядке:
признак ОСВ → форма → продолжение → прочее. Признак ОСВ SHALL проверяться первым. Лист SHALL считаться `STATEMENT`, если
в любой строке до первой строки заголовка таблицы формы или первой строки с кодом формы есть заголовок формы или код
ОКУД из словаря заголовков пакета `arm_statements`. Лист без заголовка формы SHALL считаться `STATEMENT_CONTINUATION`,
если на нём есть маркер продолжения («Форма 0710001 с.2»), либо строка заголовка таблицы формы (ячейка «Код» и правее
ячейки-периоды), либо колонка, в которой не меньше 60 % непустых значений — коды строк форм; слабое продолжение (столбец
кодов найден только ослабленным поиском пакета, не меньше `min_codes_continuation` кодов) SHALL учитываться только сразу
после листа формы. Лист без признаков ОСВ, формы и продолжения, без `type_hint` и кодов SHALL считаться `OTHER`: документа
нет, предупреждение файла `SHEET_OTHER` (`info`, `locator = sheet:<имя>`). Тип формы (`type_hint`) SHALL определяться
правилом пакета: ОКУД по словарю (0710001 → `BALANCE`, 0710002 → `INCOME`, прочие 0710xxx → `DISCLOSURE`), иначе заголовок
по словарю («Пояснения», «Расшифровка» → `DISCLOSURE`), иначе `null`. Титул «Бухгалтерская отчетность» MUST NOT задавать
тип: тип берётся из ближайшего следующего заголовка. Два заголовка форм на одном листе SHALL давать два блока и два
документа, второй — со строки своего заголовка.
#### Scenario: Заголовок формы после титульного блока
- **WHEN** разбирается `statements-xlsx/npz/2025-Q3/Бухгалтерская отчетность … .xlsx`, лист «Бухгалтерский баланс. Лист 1»,
где заголовок «Бухгалтерский баланс» стоит ниже титула «Бухгалтерская отчетность за январь - сентябрь 2025 г.»
- **THEN** лист классифицирован как `STATEMENT`, документ `BALANCE`, `header.okud = "0710001"`
#### Scenario: Лист-продолжение без заголовка
- **WHEN** разбирается лист «Бухгалтерский баланс. Лист 2» той же книги (первая строка — «в том числе:», далее 11901, 1100…)
- **THEN** лист классифицирован как `STATEMENT_CONTINUATION` и не даёт отдельного документа
#### Scenario: Маркер «с.2» в 1С-выгрузке
- **WHEN** разбирается `statements-xlsx/unagrande-co/2024-FY/Б.Б. Лист 2.xls` (первая строка «Форма 0710001 с.2»)
- **THEN** лист классифицирован как `STATEMENT_CONTINUATION`, тип по ОКУД маркера — `BALANCE`
#### Scenario: Две формы на одном листе
- **WHEN** разбирается синтетический лист `st01_both_forms_one_sheet` (баланс, затем ОФР)
- **THEN** результат содержит два документа — `BALANCE` и `INCOME` с одинаковым `locator` листа, строки ОФР не попали в баланс
#### Scenario: Расшифровка
- **WHEN** разбирается лист с заголовком «Пояснения к бухгалтерскому балансу и отчёту о финансовых результатах»
- **THEN** документ `DISCLOSURE` с `locator`, без `columns` и `lines`, со статусом `done`; статус книги от него не зависит
#### Scenario: Лист «прочее»
- **WHEN** в книге лист «Примечания» без заголовка формы, ОКУД и кодов
- **THEN** документа нет; в `metrics.warnings` — `SHEET_OTHER` уровня `info` с `locator = "sheet:Примечания"`
### Requirement: Шапка документа
Адаптер SHALL собирать шапку каждого блока формы в границах этого блока: поля `organization_name`, `inn`, `report_date`,
`units`, `okud`, `okpo`, `okopf`, `okfs`, `okei`, `address` — каждое `Cell` или `null`, найденные поля с `confidence =
100`. Метки поля SHALL искаться по словарю заголовков пакета в области от начала блока до строки заголовка таблицы
(у блока без кодов — во всём блоке); значение — ближайшая непустая ячейка правее метки, иначе первая непустая ячейка
следующей строки. ИНН SHALL браться у метки «ИНН», причём строки после метки аудитора MUST NOT использоваться.
`organization_name` SHALL браться после метки «Организация», а при пустом значении — из ближайшей строки выше с признаком
ОПФ. Разбор текста `Cell` (ИНН — только цифры длиной 10 или 12, дата отчёта по словарю периодов, единицы по словарю
единиц или коду ОКЕИ) SHALL выполнять общий код пакета; единицы MUST NOT нормализоваться адаптером. Шапка другого блока
или другого листа MUST NOT использоваться. У документа без шапки все поля SHALL быть `null`, кроме `form_name`, который
ставит пакет по заголовку.
#### Scenario: Полная шапка 1С
- **WHEN** разбирается `unagrande-co/2024-FY/Б.Б Лист 1.xls`
- **THEN** `organization_name = 'Акционерное общество "Унагранде Компани"'`, `inn = "3249004697"` (не ИНН аудитора
7716021332), `okpo = "47888105"`, `okud = "0710001"`, `units = "в тыс. рублей"`, `report_date = 2024-12-31`,
`report_date_text = "на 31 декабря 2024 г."`, `address` начинается с «242440»; `*_confidence = 100`
#### Scenario: Организация над меткой, ОФР за период
- **WHEN** разбирается лист «Отчет о финансовых результата» книги НПЗ
- **THEN** `organization_name = 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"'`, `inn = null` (на листе ОФР метки «ИНН» нет, а
шапка другого листа не используется), `okpo = "16098811"`, `report_date = 2025-09-30`, `units = "тыс. руб."`; у
документа `BALANCE` той же книги `inn = "2315995868"` из титульного блока над заголовком формы
#### Scenario: Дата из тройки чисел
- **WHEN** разбирается `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` (ячейки 30 | 9 | 2025 как числа)
- **THEN** `report_date = 2025-09-30`, `inn = "7016006635"`, `okpo = "85262693"`
### Requirement: Склейка листов-продолжений и статус документа
Листы книги SHALL обрабатываться по порядку; лист-продолжение SHALL дописываться в сетку открытой формы адаптером до
раскладки, граница листа — в `segment_breaks`. Открытая форма — последний кандидат с `type_hint` `BALANCE` или `INCOME`.
Лист с маркером «Форма … с.N» присоединяется по типу маркера; расхождение типа маркера с `type_hint` формы SHALL давать
отдельный кандидат и предупреждение `CONTINUATION_TYPE_CONFLICT` (`error`). Лист без маркера и шапки SHALL
присоединяться, только если его коды в диапазоне открытой формы и, при трёх и более кодах, число колонок сумм совпадает с
формой; иначе — отдельный кандидат и предупреждение `SHEET_NOT_JOINED` (`error`). `locator` SHALL перечислять имена листов
через запятую. Продолжение без открытой формы SHALL давать отдельный кандидат без предупреждения адаптера (пакет выдаёт
`NO_ORGANIZATION_NO_INN`), документ — `needs_check`. Статус документа SHALL вычислять функция пакета (одна для сервисов и
ядра); `checks` и условие `done` — по спеке `statement-normalization`. `metrics.rows` книги SHALL быть суммой строк данных
ОСВ (без `TOTAL`) и строк `ok` документов форм (строки `detail` и без кода — отдельно). Если книга завершилась ошибкой
листа ОСВ, ответ — ошибка этого листа; список неразобранных листов форм не добавляется.
#### Scenario: Баланс на пяти листах
- **WHEN** разбирается книга НПЗ
- **THEN** два документа: `BALANCE` с `locator = "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2,…,Лист 5"`,
одной сеткой и `segment_breaks` по границам листов, строки от 1105 до 1700 включительно, `INCOME` на трёх листах (лист
отчёта с единственной строкой 2400 присоединяется как продолжение), строка 2400 присутствует; `1600 = 1700` и
`2100`/`2200`/`2300` — в `soft_relations_failed` как округление (OQ-138); статус книги `done`
#### Scenario: Лист-продолжение с одной кодовой строкой
- **WHEN** **сразу** после листа формы идёт лист без шапки с единственной строкой 2400 (код в диапазоне формы)
- **THEN** лист присоединяется к открытой форме (кодов меньше трёх — число колонок не сверяется), строка присутствует,
граница листа — в `segment_breaks`
#### Scenario: Лист другой ширины с тремя кодами
- **WHEN** лист без маркера и шапки содержит три кода ББ и четыре колонки сумм при трёх у открытой формы
- **THEN** лист — отдельный кандидат, предупреждение `SHEET_NOT_JOINED` уровня `error`
#### Scenario: Тип маркера против формы
- **WHEN** после листа ОФР идёт лист с маркером «Форма 0710001 с.2»
- **THEN** лист — отдельный кандидат `BALANCE`, предупреждение `CONTINUATION_TYPE_CONFLICT` уровня `error`
#### Scenario: Одинокая страница 2
- **WHEN** разбирается `unagrande-group/2024-FY/ОФР. Лист 2.xls` (стр. 1 в пакете нет)
- **THEN** один документ `INCOME` со строками 2510–2910, пустой шапкой, статус `needs_check`
#### Scenario: Страница 1 без продолжения
- **WHEN** разбирается `unagrande-co/2024-FY/Б.Б Лист 1.xls` (только актив)
- **THEN** документ `BALANCE` со строками 1110–1600, шапка полная, `1600 = 1100 + 1200` выполнено, `1600 = 1700`
нарушено как «непустая цель при пустых членах» → `needs_check`
#### Scenario: Смесь ОСВ и форм в одной книге
- **WHEN** разбирается синтетическая книга `st04_mixed_book` (лист ОСВ 60 и лист баланса)
- **THEN** результат содержит `osvDocument` и `statementDocument`, статус книги по обоим
### Requirement: Словари форм, координаты, golden
Словари форм (заголовки и ОКУД, периоды, соотношения §7, единицы, перечни кодов) SHALL жить в пакете `arm_statements` и в
tabular-parser MUST NOT дублироваться: файлы `api/rules/statement_*.yaml` удалены. Код `src/parser/statements/**` и
`src/parser/book.py` MUST NOT содержать литералов координат (линтер `test_no_coordinates`); `ref` ячейки собирается из
индексов функцией `grid.cell_ref`. Снимки `../fixtures/golden/expected_statement_parse/**` SHALL покрывать все файлы
`statements-xlsx/**` и обновляться только `make golden-update`; `service.statements_version` — волатильное поле снимка.
Property-тест по манифесту `tests/statements_manifest.yaml` SHALL проверять тип, ИНН, ожидаемый статус, сходимость
жёстких соотношений для полных форм и, где задано, множество кодов предупреждений `error` документа.
#### Scenario: Новый синоним без релиза кода
- **WHEN** в словарь заголовков пакета `arm-statements` добавлен синоним заголовка «Баланс предприятия» и копия пакета
обновлена скриптом `sync-statements`
- **THEN** лист классифицирован как `BALANCE` без изменения кода tabular-parser; собственного словаря форм в сервисе нет
#### Scenario: Мёртвая запись словаря
- **WHEN** запись словаря форм не срабатывает ни на одном файле
- **THEN** это проверяет тест покрытия словарей пакета `arm-statements` (внутри пакета, на его данных); тест покрытия
tabular-parser словари форм не охватывает
#### Scenario: Регрессия по снимкам
- **WHEN** результат разбора любого файла `statements-xlsx/**` отличается от снимка
- **THEN** golden-тест падает с diff; обновление — только `make golden-update` и отдельный коммит в fixtures
## REMOVED Requirements
### Requirement: Заголовок таблицы, колонка кодов и колонки-периоды определяются по тексту
**Reason**: Шаг 2 (раскладка: столбец кодов, строка заголовка, колонки-периоды) выполняет копия `arm_statements`.
**Migration**: Требования — спека `statement-normalization` (docs): «Шаг 2 — столбец кодов», «Шаг 2 — строка заголовка,
нумерация и роли строк», «Шаг 2 — подпись и дата колонки».
### Requirement: Строки с кодом и значения
**Reason**: Шаги 3–5 (строки данных, значение ячейки, строка формы, единицы) выполняет копия `arm_statements`.
**Migration**: Требования — спека `statement-normalization` (docs): «Шаг 3 — строки данных и их статусы», «Шаг 4 —
значение ячейки», «Шаг 4 — единицы и дробные значения», «Шаг 5 — строка формы».
### Requirement: Соотношения §7 — данные, проверка по колонкам
**Reason**: Шаг 7 (самопроверки §7, «посчитанное» соотношение, округление) выполняет копия `arm_statements`, словарь
соотношений — в пакете.
**Migration**: Требование — спека `statement-normalization` (docs): «Шаг 7 — самопроверки §7».
## ADDED Requirements
### Requirement: Адаптер Excel шага 1
tabular-parser SHALL оставить у себя чтение книги (лимиты `PARSE_MAX_BYTES`, `PARSE_TIMEOUT_SECONDS`, формат по
сигнатуре, лимиты zip и объединённых ячеек, `UNREADABLE`), классификацию листов (ОСВ — отдельный путь; форма;
продолжение; прочее) и адаптер «блок формы на листе → `FormCandidate`» общего кода; шаги 2–8 по формам SHALL
выполняться копией пакета `arm_statements`. Адаптер SHALL: выделять блоки форм на листе по заголовку или ОКУД (без
кодов между ними блоки сливаются, заголовок после кодов — новая форма); собирать шапку каждого блока в его границах
(организация, ИНН без ИНН аудитора, ОКПО, ОКОПФ, ОКФС, единицы, дата, адрес) как `Cell` с `confidence = 100`;
заполнять у каждой ячейки `text` по числовому формату (tp#42) и `value`, `ref = <лист>!<A1>` (имя листа в одинарных
кавычках по правилу Excel, если в нём есть что-то кроме букв, цифр и `_`); дописывать
листы-продолжения в сетку открытой формы до раскладки (маркер «Форма … с.2» или позиция листа; проверки типа,
диапазона кодов и, при трёх и более кодах, числа колонок — как `join_blocker`), записывая границы листов в
`segment_breaks`. Несовпадение формата с `file_type` — предупреждение файла `FILE_TYPE_MISMATCH` (`info`). Лист без
признаков ОСВ и формы, без `type_hint` и кодов — предупреждение файла `SHEET_OTHER` (`info`, `locator = sheet:<имя>`),
документа нет.
#### Scenario: Книга ББ + ОФР
- **WHEN** книга содержит лист «Баланс» (ОКУД 0710001), лист «Баланс (2)» с маркером «Форма 0710001 с.2» и лист «ОФР»
- **THEN** адаптер выдаёт два кандидата: `BALANCE` с `locator = sheet:Баланс,Баланс (2)` и одной сеткой из двух листов
(`segment_breaks` — первая строка второго листа) и `INCOME` с `locator = sheet:ОФР`
#### Scenario: Ссылки ячеек
- **WHEN** код строки стоит в ячейке C27 листа «Баланс»
- **THEN** `ref` строки — `Баланс!C27`; у листа «Бухгалтерский баланс. Лист 1» — `'Бухгалтерский баланс. Лист 1'!C27`
### Requirement: Ячейка Excel
Адаптер SHALL превращать ячейку Excel в `Cell`: `text` — как показывает числовой формат ячейки (tp#42), `value` — число
или дата, если источник их знает, `confidence = 100`, `ref = <лист>!<A1>` по правилу Excel (имя листа в одинарных
кавычках, если в нём есть что-то кроме букв, цифр и `_` или оно начинается с цифры; апостроф внутри имени удваивается).
Сетка кандидата — плотная: строки блока целиком, пустая позиция — `null`; объединённая ячейка — только в позиции левой
верхней. Значения ББ/ОФР из Excel SHALL приниматься пакетом по `value`; ОСВ числовой формат не применяет.
#### Scenario: Excel-ячейка с числовым форматом
- **WHEN** в Excel ячейка хранит 106311116.46 с форматом `#,##0,`
- **THEN** у `Cell` `text = "106 311"`, `value = 106311`, `confidence = 100`, `ref = "Баланс!D27"` (лист и адрес ячейки)
#### Scenario: Имя листа с пробелом
- **WHEN** код строки стоит в ячейке C14 листа «Бухгалтерский баланс. Лист 1»
- **THEN** `ref = "'Бухгалтерский баланс. Лист 1'!C14"`
### Requirement: Строки без кода и неизвестные колонки в Excel
tabular-parser SHALL отдавать строки без кода с содержимым со статусами `detail` / `missing` / `unreadable` (Р-8; ранее
не отдавались, OQ-36 / OQ-141), числовые колонки с нераспознанной подписью — колонкой без даты и `error` (ранее
пропускались), повторы кодов — всеми строками без `duplicate_line_codes`.
#### Scenario: НПЗ «Реализация иного имущества»
- **WHEN** в ОФР ФСБУ 4/2023 строка «Реализация иного имущества…» без кода с суммами стоит под строкой 2340
- **THEN** в результате строка `detail` с `parent_ref` строки 2340; ранее она отбрасывалась со счётчиком
### Requirement: Манифест ожиданий на реальных формах
Манифест `tabular-parser/api/tests/statements_manifest.yaml` SHALL быть обновлён в PR перехода на 2.0 по правилу
«посчитанного» соотношения §7 (пустая цель при непустых членах — нарушение): `failed_relations` страниц 2 баланса без
1600 получают `["1600 = 1700"]` — `unagrande-co/2024-FY/Б.Б. Лист 2.xls`, `unagrande-group/2024-FY/ББ. Лист 2 (2).xls`,
`unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` (было `[]`, причина «страница 2 без шапки» остаётся); страницы 1 «только
актив» (`Б.Б Лист 1.xls`, `ББ. Лист 1 .xls`, `ББ. Лист 1 3кв.25.xls`) сохраняют `["1600 = 1700"]` (теперь как «непустая
цель при пустых членах»). Статусы `done` (Межениновская ББ и ОФР, НПЗ ББ и ОФР, ОФР «Лист 1» Унагранде ×2) MUST NOT
меняться: их строки без кода уходят в `detail`, строки шапки выше заголовка — в `title`. Остальные записи манифеста
пересчитываются прогоном и правятся только с объяснением в описании PR.
#### Scenario: Страница 2 баланса
- **WHEN** разбирается `unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` (есть 1700, нет 1600)
- **THEN** документ `needs_check`, в `checks.relations_failed` — `1600 = 1700` с пустой целью
### Requirement: Статус книги
Статус конверта tabular-parser SHALL вычисляться по общему правилу: `done` ⇔ все документы `done` ∧ нет предупреждений
файла уровня `error`; книга без листов ОСВ и форм — `error INVALID_DOCUMENT`. Порядок документов — сначала ОСВ в
порядке листов, затем формы.
#### Scenario: Книга только из пояснений
- **WHEN** книга содержит только листы пояснений (0710003, «Пояснения»)
- **THEN** документы `DISCLOSURE` со статусом `done`, конверт `done`

View File

@ -0,0 +1,37 @@
## MODIFIED Requirements
### Requirement: Результат по контракту
После подтверждения сервис SHALL прочитать объект `s3.key` из бакета `s3.bucket`, вызвать парсер, записать файл результата
(по `recognition-result.schema.json` v2.0, `schema_version = "2.0"`) в `results/{file_id}/{job_id}/result.json` и
опубликовать конверт результата (по `recognition-result-message.schema.json` v2.0, `result_schema_version = "2.0"`) с
заголовком `Nats-Msg-Id = result:<job_id>:<attempt>`. Ack `processing` SHALL нести `schema_version = "2.0"`; запрос
`recognition-request` остаётся v1.0. `service.statements_version` файла результата SHALL быть коммитом копии пакета
`arm_statements` (файл `VERSION` копии; сервис без него не стартует) и записываться при наличии `statementDocument`.
`metrics.warnings` конверта SHALL содержать предупреждения уровня файла структурами `{code, severity, message, locator?}`,
не строками. Статус конверта — по общему правилу: `done` ⇔ все документы `done` ∧ нет предупреждений файла уровня
`error`. При неудаче публикации сервис SHALL повторить её до трёх раз с паузой, затем залогировать ошибку.
#### Scenario: Запрос с заглушкой парсера (Срез 0)
- **WHEN** воркеру явно передана заглушка `NullParser` (только тесты воркера; реализация по умолчанию — реальный парсер),
запрос подтверждён и объект существует в `uploads/`
- **THEN** в `files.tabular_parsed` появляется конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `job_id`/`file_id`
из запроса, `metrics.duration_ms ≥ 0`; файл результата может отсутствовать
#### Scenario: Запрос с реальным парсером
- **WHEN** запрос подтверждён и объект в `uploads/` существует, но не является книгой Excel (сигнатура не XLS/XLSX/XLSB)
- **THEN** в `files.tabular_parsed` появляется конверт `status = error`, `error.code = UNSUPPORTED_FORMAT` (повреждённая
книга — `UNREADABLE`), `job_id`/`file_id` из запроса, `metrics.duration_ms ≥ 0`; файл результата может отсутствовать;
корректная ОСВ в том же потоке даёт `done` (см. «Парсер — заменяемый интерфейс»)
#### Scenario: Результат и ack версии 2.0
- **WHEN** обработан запрос `schema_version = "1.0"` с книгой, содержащей баланс
- **THEN** ack `processing` и конверт результата несут `schema_version = "2.0"`, `result_schema_version = "2.0"`, файл
результата проходит схему v2.0, `service.statements_version` равен коммиту копии `arm_statements`
#### Scenario: Предупреждение файла структурой
- **WHEN** в книге лист «Примечания» без признаков ОСВ и формы
- **THEN** `metrics.warnings` конверта содержит `{code: "SHEET_OTHER", severity: "info", message, locator: "sheet:Примечания"}`
#### Scenario: Повтор публикации
- **WHEN** первая публикация конверта результата завершается ошибкой соединения, вторая — успешно
- **THEN** конверт опубликован один раз с тем же `Nats-Msg-Id`, обработка не повторяется

View File

@ -0,0 +1,21 @@
## 1. Change и подготовка
- [ ] 1.1 OpenSpec change `unified-normalization-tp`: proposal, tasks, дельты `statement-parsing`, `tabular-recognition-service`, `osv-parsing` (задача 1)
- [ ] 1.2 Подготовка хоста: сверка копии `arm_statements` с планом 2 и её свежесть (`make statements-check`) (задача 2)
## 2. Адаптер Excel шага 1
- [ ] 2.1 Ячейка Excel → `Cell`: `ref = <лист>!<A1>`, текст по числовому формату, плотная сетка (задача 3)
- [ ] 2.2 Блоки форм на листе, вид листа, шапка блока на словарях пакета (задача 4)
- [ ] 2.3 Адаптер книги → `FormCandidate`: классификация листов, склейка продолжений, `segment_breaks`, предупреждения (задача 5)
## 3. ОСВ и переход на контракт v2.0
- [ ] 3.1 Предупреждения ОСВ с кодами `OSV_*` (вывод пока прежний) (задача 6)
- [ ] 3.2 Переключение на общий код и контракт v2.0: модели, копия схем, сборка книги, путь ОСВ, воркер, `statements_version` (задача 7)
## 4. Регрессия и очистка
- [ ] 4.1 Golden и манифест реальных форм (задача 8)
- [ ] 4.2 Удаление собственного кода шагов 2–8 и словарей форм (задача 9)
- [ ] 4.3 Полная проверка `make check-full`, PR tabular-parser и PR fixtures (задача 10)