From 8b9a5a07c45c47b9b8beef6920438c0c52a03b7d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 19:55:36 +0400 Subject: [PATCH] =?UTF-8?q?openspec:=20change=20unified-normalization-tp?= =?UTF-8?q?=20=E2=80=94=20=D0=B0=D0=B4=D0=B0=D0=BF=D1=82=D0=B5=D1=80=20Exc?= =?UTF-8?q?el,=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20=D0=BA=D0=BE=D0=B4,=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=20v2.0=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- .../unified-normalization-tp/proposal.md | 40 +++ .../specs/osv-parsing/spec.md | 132 ++++++++++ .../specs/statement-parsing/spec.md | 228 ++++++++++++++++++ .../specs/tabular-recognition-service/spec.md | 37 +++ .../changes/unified-normalization-tp/tasks.md | 21 ++ 5 files changed, 458 insertions(+) create mode 100644 openspec/changes/unified-normalization-tp/proposal.md create mode 100644 openspec/changes/unified-normalization-tp/specs/osv-parsing/spec.md create mode 100644 openspec/changes/unified-normalization-tp/specs/statement-parsing/spec.md create mode 100644 openspec/changes/unified-normalization-tp/specs/tabular-recognition-service/spec.md create mode 100644 openspec/changes/unified-normalization-tp/tasks.md diff --git a/openspec/changes/unified-normalization-tp/proposal.md b/openspec/changes/unified-normalization-tp/proposal.md new file mode 100644 index 0000000..208e49e --- /dev/null +++ b/openspec/changes/unified-normalization-tp/proposal.md @@ -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. diff --git a/openspec/changes/unified-normalization-tp/specs/osv-parsing/spec.md b/openspec/changes/unified-normalization-tp/specs/osv-parsing/spec.md new file mode 100644 index 0000000..2f88dde --- /dev/null +++ b/openspec/changes/unified-normalization-tp/specs/osv-parsing/spec.md @@ -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 в несколько килобайт с `` (5,2 млн ячеек) +- **THEN** `error UNSUPPORTED_FORMAT` («лимит объединённых ячеек») быстрее 1 с, openpyxl не вызывается + +#### Scenario: Обходы скана объединённых ячеек +- **WHEN** тот же диапазон спрятан: лист вне `xl/worksheets/` или с расширением не `.xml` (цель связи в + `workbook.xml.rels`), `ref` записан ссылками на символы (`A1:Z200000`, `A1:…`), перед `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` («распакованный размер … больше лимита»), книга не открывается diff --git a/openspec/changes/unified-normalization-tp/specs/statement-parsing/spec.md b/openspec/changes/unified-normalization-tp/specs/statement-parsing/spec.md new file mode 100644 index 0000000..43436c7 --- /dev/null +++ b/openspec/changes/unified-normalization-tp/specs/statement-parsing/spec.md @@ -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 = <лист>!` (имя листа в одинарных +кавычках по правилу 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 = <лист>!` по правилу 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` diff --git a/openspec/changes/unified-normalization-tp/specs/tabular-recognition-service/spec.md b/openspec/changes/unified-normalization-tp/specs/tabular-recognition-service/spec.md new file mode 100644 index 0000000..95dd741 --- /dev/null +++ b/openspec/changes/unified-normalization-tp/specs/tabular-recognition-service/spec.md @@ -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::`. 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`, обработка не повторяется diff --git a/openspec/changes/unified-normalization-tp/tasks.md b/openspec/changes/unified-normalization-tp/tasks.md new file mode 100644 index 0000000..1588dd9 --- /dev/null +++ b/openspec/changes/unified-normalization-tp/tasks.md @@ -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 = <лист>!`, текст по числовому формату, плотная сетка (задача 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)