diff --git a/CLAUDE.md b/CLAUDE.md index 0f62ea1..5d880ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote **Добавлено** (в скобках — задача `openspec/changes/slice-0-skeleton/tasks.md`): - `api/src/parser/` — интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 — Срез 2a (см. ниже), форм §12 — Срез 2b; -- `api/src/contract/` — pydantic-модели контракта v1.1 (запрос, результат, конверт); `api/contracts/` — копии схем (идентичность по правилу MAJOR (S-53), +- `api/src/contract/` — pydantic-модели контракта v2.0 (запрос, результат, конверт); `api/contracts/` — копии схем (идентичность по правилу MAJOR (S-53), `tests/contract_identity.py`, с `../docs/contracts` проверяется в `make check`) — 3.1; - `api/rules/` — README о назначении; сами словари `*.yaml` (в документах `rules/*.yaml` относительно `api/`) появились в Срезе 2a — 4.1; @@ -93,8 +93,8 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote кооперативный, → `TIMEOUT`) — `core/config.py`. Фазу чтения таймаут не прерывает: до библиотеки чтения действуют лимиты `readers/_limits.py` (распакованный zip ≤ 10 × `PARSE_MAX_BYTES`, площадь объединённых ячеек ≤ 200 000; R-23). -**Контракт v1.1:** `rows[].extra` (нераспознанные колонки таблицы по заголовку); `CONTRACT_VERSION = "1.1"`; копия схемы -в `api/contracts/` идентична `../docs/contracts/` по правилу MAJOR (S-53, `tests/contract_identity.py`; проверяется в `make check`). +**Контракт:** `rows[].extra` (нераспознанные колонки таблицы по заголовку; появилось в v1.1); текущая версия — 2.0 +(`CONTRACT_VERSION`, раздел «Формы через arm_statements»); копия схемы в `api/contracts/` идентична `../docs/contracts/` по правилу MAJOR (S-53, `tests/contract_identity.py`; проверяется в `make check`). **Тесты:** `make check` ≈ 95 с, потому что `make test` (`-m "not integration"`) включает тесты маркера `fixtures` (golden, property, покрытие словарей); без каталога `../fixtures` они пропускаются. Полный набор — `make check-full` (+ интеграция @@ -106,7 +106,7 @@ property, покрытие словарей); без каталога `../fixtur Конвейер форм ББ/ОФР (шаги 2–8, словари форм, перевод в контракт) — в пакете `api/arm_statements/` (копия `arm-statements`, версия — `arm_statements/VERSION`). Собственного конвейера форм и словарей форм в сервисе нет: удалены -`api/src/parser/statements/{checks,columns,detect,header,lines,merge,statement,legacy_models}.py` и +`api/src/parser/statements/{checks,columns,detect,header,lines,merge,statement}.py` и `api/rules/statement_*.yaml`. **Модули адаптера** (`api/src/parser/statements/`): `forms.py` (блоки форм и вид листа по словарям пакета), `cells.py` @@ -136,7 +136,6 @@ property, покрытие словарей); без каталога `../fixtur в `../fixtures/golden/`: `expected_osv_parse` и `expected_statement_parse`; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. -**Контракт v1.2:** `CONTRACT_VERSION = "1.2"`; `documents[]` получает `BALANCE`/`INCOME`/`DISCLOSURE` (`statementDocument`: -`header`, `lines`, `checks`); копия `api/contracts/` идентична `../docs/contracts/`. Значения ББ/ОФР из Excel — как их показывает -числовой формат ячейки (#42, `statements/number_format.py`; xlsb — как в файле + предупреждение); ОСВ формат не применяет. +**Значения форм:** значения ББ/ОФР из Excel — как их показывает числовой формат ячейки (#42, `statements/number_format.py`; +xlsb — как в файле + предупреждение `XLSB_NO_FORMATS`); ОСВ формат не применяет. Книга НПЗ в манифесте — `done` (итоги расходятся в пределах округления, OQ-138). diff --git a/README.md b/README.md index d9c7f54..1482637 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Stateless-сервис распознавания табличных файло возвращает структуру файла и ничего не знает про организации, периметры и план счетов — нормализацией занимается `arm-core`. -Обмен с `arm-core` идёт по контракту **v1.1**: схемы — [`../docs/contracts/`](../docs/contracts/), копия — в +Обмен с `arm-core` идёт по контракту **v2.0**: схемы — [`../docs/contracts/`](../docs/contracts/), копия — в `api/contracts/` (идентичность с источником проверяется в `make check`). **Срез 0 — скелет.** Транспорт работает полностью (подписка, ack, чтение S3, публикация результата, ошибки транспорта); @@ -58,7 +58,7 @@ api/ бэкенд (все команды разработки распознавания), logger.py, exception_handlers.py src/parser/ парсер ОСВ (Срез 2a): интерфейс Parser, OsvParser, читатели, детекторы, иерархия, классификация, контрольные суммы, CLI; заглушка NullParser — для тестов воркера (модули — ниже) - src/contract/ pydantic-модели контракта v1.1 + src/contract/ pydantic-модели контракта v2.0 contracts/ копии JSON-схем контракта и примеры rules/ словари синонимов *.yaml (osv_headers, osv_groups, periods, data_types) и README scripts/ golden_update.py — обновление golden-снимков @@ -117,7 +117,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо | `rules.py` | загрузчик словарей `rules/*.yaml` в frozen-модели (pydantic), `normalize_text`, `Pattern`; ошибка словаря — `RuntimeError` с файлом и ключом при старте | | `detect.py` | вид листа (`classify_sheet`: ОСВ / форма ББ-ОФР / прочее), блок заголовка таблицы (`find_table_header`: 1–4 строки, шесть колонок сумм, колонка текста, ИНН, дополнительные колонки, правая граница, повтор заголовка внутри данных) | | `header.py` | шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок `group_schema` (варианты шапки 1 и 2) | -| `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `sums`; `trivial` — меньше двух строк данных; выбранная стратегия пишется в `metrics.warnings` как `hierarchy=<имя>` | +| `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `sums`; `trivial` — меньше двух строк данных; выбранная стратегия пишется в `warnings[]` документа листа как `OSV_HIERARCHY_BY_<СТРАТЕГИЯ>` (`info`) | | `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет | | `inn_rows.py` | строки ИНН рядом с контрагентом (восемь раскладок, контрольная сумма без подсказки шапки, противоречие подсказке, OQ-163); выполняется над деревом до классификации | | `classify.py` | сбор строк таблицы (`collect_body_rows`: «Итого», свёрнутое сальдо) и тип группировки, счёт, контрагент, `data_type`, `extra` (`classify_rows`) | @@ -139,7 +139,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо | Исход | Когда | |---|---| | `done` | контрольные суммы §1.4 сошлись на всех листах | -| `needs_check` | нарушены контрольные суммы хотя бы на одном листе (`checks`: `total_row_matches_root`, `balance_identity_violations`, `parent_sum_violations`; причины и номера строк — в `metrics.warnings`); структуру под сходимость парсер не подгоняет. Строка «Итого» не найдена — тоже `needs_check` | +| `needs_check` | нарушены контрольные суммы хотя бы на одном листе (`checks`: `total_row_matches_root`, `balance_identity_violations`, `parent_sum_violations`; причины и номера строк — в `warnings[]` документа листа, коды `OSV_*`); структуру под сходимость парсер не подгоняет. Строка «Итого» не найдена — тоже `needs_check` | | `error` / `INVALID_DOCUMENT` | в книге нет листов ОСВ и форм (в сообщении — число просмотренных листов) | | `error` / `INVALID_FORMAT` | лист ОСВ найден, но нет заголовка таблицы, обязательной группы сумм («обороты», «сальдо на конец» — отсутствовать может только «начало») или не удалось определить иерархию; в сообщении — лист и шаг | | `error` / `UNREADABLE` | книга не открывается (повреждённый zip/BIFF); пишется в лог уровня WARNING | @@ -147,9 +147,11 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо | `error` / `TIMEOUT` | превышен `PARSE_TIMEOUT_SECONDS` | | `error` / `RECOGNITION_FAILED` | непредвиденное исключение, одной строкой `Тип: сообщение`; стек — в лог уровня ERROR («Непредвиденная ошибка разбора: …») | -В `metrics.warnings` — диагностика, не влияющая на статус: `sheet=<имя> hierarchy=<стратегия>`, пропущенные листы -(`skipped: нет признаков ОСВ или формы`), несовпадение `file_type` с содержимым, свёрнутое -сальдо, нераспознанные числа с номером строки, непрочитанные поля шапки, «нет строк данных» (пустая ОСВ). +Диагностика, не влияющая на статус, — структурные предупреждения `{code, severity, message, locator?}`. Предупреждения +файла в `metrics.warnings`: пропущенный лист — `SHEET_OTHER` (`info`, `locator = sheet:<имя>`), несовпадение `file_type` +с содержимым — `FILE_TYPE_MISMATCH`, xls без кодовой страницы — `XLS_NO_CODEPAGE`. Предупреждения листа ОСВ — в +`warnings[]` его документа (`OSV_*`, `info`): стратегия иерархии (`OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`), свёрнутое сальдо, +нераспознанные числа с номером строки, непрочитанные поля шапки, «нет строк данных» (пустая ОСВ). ### Контрольные суммы (§1.4) @@ -167,7 +169,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо **ИНН контрагента** (OQ-100, OQ-130): `counterparty_inn` берётся из колонки ИНН (в том числе склеенного «ИНН/КПП»), затем из строки ИНН рядом с наименованием (любая из восьми раскладок, `inn_rows.py`, OQ-163: такая строка в `rows` не попадает, в -`metrics.warnings` — «строка N: ИНН … отнесён к контрагенту в строке M»; правило OQ-130 «строка без сумм над названием» +`warnings[]` документа (`OSV_INN_ROWS`) — «строка N: ИНН … отнесён к контрагенту в строке M»; правило OQ-130 «строка без сумм над названием» заменено), затем из конца текста названия («Полиграф, 3528315710», «ООО Альфа (ИНН 7701234567)»; 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»). Из текста ИНН отрезается всегда: `counterparty_name` без ИНН и разделителя, `row_value` — исходный текст. Только для строк @@ -308,15 +310,19 @@ detail`). Статус книги и критерий `ok` документа в (`[=0]"-"`, `[=-646532]"(647)"`) дают прочерк или текст-литерал («(647)» -> -647). Формат `General` — сырое значение. Формат, который не удалось разобрать (`%`, даты, дроби, экспонента), — сырое значение и одно предупреждение на документ: `формат чисел не разобран, значения как в ячейках: «…»`. В xlsb числовые форматы не читаются (pyxlsb): значения - как в файле и одно предупреждение на документ `формат чисел xlsb не читается: значения как в файле`. Разбор ОСВ + как в файле и одно предупреждение на документ `формат чисел xlsb не читается: значения как в ячейках` (`XLSB_NO_FORMATS`). Разбор ОСВ формат ячейки не применяет. При «тыс. руб.» и доле значений (по показываемым) с дробной частью не меньше - `fractional_share_percent` выдаётся одно предупреждение на документ; само оно на статус не влияет — `needs_check` - задают нарушенные соотношения (расхождение до `rounding_tolerance_percent` — мягкое нарушение, OQ-138). + `fractional_share_percent` выдаётся одно предупреждение на документ: `FRACTIONAL_IN_THOUSANDS` в xlsx/xls — на статус + не влияет, `XLSB_SCALE_SUSPECT` в xlsb (формат чисел не читается) — уровня `error`, документ `needs_check`; кроме того, + `needs_check` задают нарушенные соотношения (расхождение до `rounding_tolerance_percent` — мягкое нарушение, OQ-138). - Страницы одной формы в разных файлах не склеиваются: каждая страница-продолжение без первой страницы остаётся одиноким продолжением и даёт `needs_check`. - Лист с 1–2 кодами форм (меньше `min_codes`) формой не считается, если идёт первым; после листа формы он берётся - как продолжение (`min_codes_continuation` словаря) и присоединяется, если совпало число колонок сумм и тип формы; - иначе не присоединён, книга `needs_check`, предупреждение «лист с кодами форм (N < min_codes) не присоединён: …». + как продолжение (`min_codes_continuation` словаря) и присоединяется, если все его коды принадлежат типу открытой формы + (число колонок сумм при этом не сверяется); лист с `min_codes` и более кодами без маркера присоединяется, только если + совпало и число колонок сумм (`sheet_joins` пакета). Иначе лист остаётся отдельным кандидатом с предупреждением + `SHEET_NOT_JOINED` (`error`): «лист «X» не присоединён к форме «Y»: коды или число колонок сумм не подходят»; книга + `needs_check`. ### Реальные формы: файл → статус @@ -344,7 +350,7 @@ detail`). Статус книги и критерий `ok` документа в | `templates/ОФР_после 01.01.2025.xlsx` | INCOME `needs_check` | шаблон | Запуск на форме: `ENV_FILE=.env.test .venv/bin/python -m src.parser.cli "<путь к файлу>"` из `api/` (см. «Запуск на файле»); -в stdout — файл результата v1.2, у форм в `documents[]` — `header`, `lines[]`, `checks`. +в stdout — файл результата v2.0, у форм в `documents[]` — `header`, `lines[]`, `checks`. ## Тесты на реальных данных и golden-снимки @@ -419,7 +425,7 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli <файл> [--file-type XL читает настройки сервиса (`OPENBAO__…`); для разбора они не используются, подойдёт и `api/.env` (см. выше). Симптом без окружения — `ValidationError: OPENBAO … Field required` при запуске: задайте `ENV_FILE=.env.test`. -В stdout печатается JSON файла результата контракта v1.2 — тот же, что воркер кладёт в S3 под +В stdout печатается JSON файла результата контракта v2.0 — тот же, что воркер кладёт в S3 под `results/{file_id}/{job_id}/result.json` (`job_id` и `file_id` — нулевые заглушки). Формат вывода: UTF-8 без экранирования кириллицы, отступ 2 пробела, порядок полей — как в модели контракта, суммы — строки. `duration_ms` — фактическая, поэтому меняется от запуска к запуску. При `status = error` печатается `{"status": "error", "error": {...}}`, @@ -436,25 +442,32 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli <файл> [--file-type XL ```json { - "schema_version": "1.1", + "schema_version": "2.0", "job_id": "00000000-0000-0000-0000-000000000000", "file_id": "00000000-0000-0000-0000-000000000000", "service": { "name": "tabular-parser", - "version": "1.0.0" + "version": "1.0.0", + "statements_version": "3390f92daddbfd4919d7f799ad2959cc35bfbd92" }, "status": "done", "metrics": { - "duration_ms": 2, + "duration_ms": 3, "rows": 9, - "warnings": [ - "sheet=Лист_1 hierarchy=outline" - ] + "warnings": [] }, "documents": [ { "document_type": "OSV", "locator": "sheet:Лист_1", + "status": "done", + "warnings": [ + { + "code": "OSV_HIERARCHY_BY_OUTLINE", + "severity": "info", + "message": "иерархия строк — по группировке листа (outline)" + } + ], "header": { "organization_name": "АО \"Русский бисквит\"", "organization_inn": null, @@ -463,6 +476,9 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli <файл> [--file-type XL "period_year": 2024, "period_quarter": "Q4", "is_cumulative": true, + "period_start": "2024-01-01", + "period_end": "2024-12-31", + "is_from_year_start": true, "data_type": "BU", "currency": "RUB", "header_variant": 1 diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index e5866c1..96a7185 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -2,7 +2,7 @@ Стратегии — чистые функции над `BodyRow` (строки данных без листа): `outline` → `indent` → `account_pattern` → `sums`. Каждая возвращает уровни `{row: level}` или `None`, если неприменима; первая, давшая непротиворечивое дерево -(`is_consistent`), побеждает, её имя попадает в `Levels.strategy` (`hierarchy=<имя>` в `metrics.warnings`). +(`is_consistent`), побеждает, её имя попадает в `Levels.strategy` (`OSV_HIERARCHY_BY_<ИМЯ>` в `warnings[]` документа). Уровни и родители считаются по порядку строк и уровням, а не по номерам и текстам: два одноимённых контрагента под разными субсчетами получают разных родителей (Review Focus 3). Строка «Итого» в стратегии не передаётся: она — diff --git a/openspec/changes/unified-normalization-tp/design.md b/openspec/changes/unified-normalization-tp/design.md new file mode 100644 index 0000000..c7a5ec9 --- /dev/null +++ b/openspec/changes/unified-normalization-tp/design.md @@ -0,0 +1,122 @@ +# Design: unified-normalization-tp + +## Контекст + +Change переносит в tabular-parser требования общей нормализации ББ/ОФР: шаг 1 (кандидат в форму из книги Excel) +пишет адаптер сервиса, шаги 2–8 выполняет копия пакета `arm_statements`, результат и конверт — контракт v2.0. +Дизайн и решения Р-1…Р-24 — change `unified-statement-normalization` в arm-ka-docs (issue RSHB/arm-ka-docs#116); +реализация — план 4 (`plans/2026-10-18-plan-4-tabular-parser.md` в docs), issue RSHB/arm-tabular-parser#54, ветка +`feat/54-statements-v2`. + +Номера P4-n — решения автора плана, R-n — рулинги контроллера при исполнении (нумерация сквозная, R-3 не присваивался). +Дата всех решений ниже — 18.10.2026. + +## Decisions made without discussion + +### Решения автора плана (P4-n) + +- **P4-1.** Переход на общий код и на контракт v2.0 — одна задача (7): модели, копия схем, сборка книги, путь ОСВ и + воркер меняются вместе; «контракт v2.0 в копии» отдельной последней задачей не вынесен — модели 2.0 без копии 2.0 (или + наоборот) валят `result_contract` и тест идентичности. Golden и манифест — сразу за переключением (задача 8), до + удаления старого кода; между задачами 7 и 8 проверка идёт с `ARM_KA_FIXTURES=/nonexistent`. Цена: одна крупная задача и + окно в одну задачу без проверки на реальных файлах. +- **P4-2.** Адаптер пишется новыми модулями рядом со старыми (`cells`, `forms`, `header_cells`, `adapter`, `contract`, + `pipeline`); старые `statements/*` не правятся и удаляются целиком задачей 9. Цена: временное дублирование логики + поиска блоков и шапки. +- **P4-3.** `Cell.text` числа Excel — как в русской локали: группы по три через пробел, если в формате есть разделитель + групп; десятичная запятая; литералы секции (скобки) сохраняются; «-» добавляется только у односекционного формата; + `General`/неразобранный формат/xlsb — число без групп и экспоненты, целое без дробной части; дата — `dd.mm.yyyy`; + логическое — «ИСТИНА»/«ЛОЖЬ». Цена: у экзотических форматов текст может отличаться от экрана Excel; `value` и статус + от этого не зависят (у Excel значение берётся из `value`). Совпадает с примером docs + `result-statement-excel.json` (`"586 260,26"`). +- **P4-4.** Показываемое число вне `numeric(18,2)` больше не делает формат «неразобранным»: адаптер отдаёт `Decimal`, а + `VALUE_OUT_OF_RANGE` и `amount = null` выдаёт пакет (спека «Шаг 4»). Цена: правка поведения #42 для чисел больше 16 + целых цифр (на реальных файлах их нет). +- **P4-5.** Сетка кандидата: строки блока целиком, включая пустые; объединённая ячейка — только в позиции левой верхней, + прочие позиции `None`; ширина — наибольшая среди листов кандидата. Цена: подпись, объединённая на два столбца, видна + пакету только в левом. +- **P4-6.** Шапка ищется в строках блока выше первой строки с кодом его столбца кодов (столбец — `code_column` пакета: + то же правило (а)/(б) шага 2 с защитой от блока «Коды», своего правила у адаптера нет), без кодов — во всём блоке: так титульный блок «Коды» (ОКОПФ 12267) не мешает, а подписи под формой (ИНН и ФИО в блоке + подписей) не попадают в шапку. Цена: метка шапки ниже начала таблицы не найдётся. +- **P4-7.** Шаблоны словаря пакета адаптер применяет к тексту, нормализованному функцией пакета по таблице «Словари» + раздела API плана 1: метки шапки, заголовки, ОКУД, `auditor`, `legal_form` — `normalize_label` (= прежний + `normalize_text` tabular-parser, P1-22); подпись «Код» и относительные периоды — `normalize_caption`; маркер + продолжения — `is_continuation`. Цена: нет — смена нормализации в пакете меняет и `type_hint`, и адаптер. +- **P4-8.** Склейка: открытая форма — последний кандидат с `type_hint` `BALANCE`/`INCOME`; лист с маркером сверяется только + по типу; лист без маркера (сильное продолжение — столбец кодов по правилу шага 2 или своя строка заголовка; слабое — + столбец кодов ослабленным поиском `code_column(…, relaxed=<тип формы>)`, минимум `min_codes_continuation` кодов, + сразу после листа формы) — `sheet_joins` с числом колонок сумм `len(amount_columns(...))` пакета («столбцы правее + столбца кодов, где в строках с кодами есть число»); открытой формы нет — отдельный кандидат без предупреждения (пакет даст `NO_ORGANIZATION_NO_INN`). + Цена: «Изменение, %» с числами считается колонкой сумм с обеих сторон сравнения; лист с иной шапкой, но тем же числом + колонок, присоединится и будет сопоставлен пакетом по датам. +- **P4-9.** `SHEET_OTHER` для листов «прочее» выдаёт книга сама (документа и кандидата нет), а не пакет по кандидату без + `type_hint`. Цена: два места порождения одного кода (у пакета — для кандидатов, не нашедших ни типа, ни кодов). +- **P4-10.** `XLSB_NO_FORMATS` — у каждого кандидата книги xlsb (P1-5); `NUMBER_FORMAT_UNPARSED` — одно на кандидата с + перечнем форматов всех его листов. Цена: у пояснений xlsb тоже есть `XLSB_NO_FORMATS` (`info`). +- **P4-11.** Книга вызывает `normalize_candidate` по одному кандидату, а не `normalize`: так проверяется дедлайн между + кандидатами (`header.form_name` пакет ставит сам из `evidence.title`, пара «кандидат → документ» не нужна). + Исключение пакета на кандидате — `RECOGNITION_FAILED` всей книги, как сейчас (`TABLE_PARSE_ERROR` — код таблицы ИС). + Цена: одна сломанная форма роняет книгу с ОСВ. +- **P4-12.** Коды ОСВ — по источнику предупреждения, не по тексту: `OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`, `OSV_HEADER`, + `OSV_ROWS`, `OSV_INN_ROWS`, `OSV_NO_DATA_ROWS`, `OSV_BALANCE_IDENTITY`, `OSV_PARENT_SUM`, `OSV_TOTAL`, + `OSV_XLSB_NO_OUTLINE`; тексты сообщений прежние, кроме иерархии. Цена: внутри `OSV_HEADER` и + `OSV_ROWS` разные случаи различаются только текстом. +- **P4-13.** Заметки читателя: несовпадение формата — `FILE_TYPE_MISMATCH` файла; «xls без кодовой страницы» — + `XLS_NO_CODEPAGE` файла (`info`, код пакета, P1-23) в любой книге xls — касается и форм, и ОСВ; «xlsb без outline» — в + каждый документ ОСВ книги (`OSV_XLSB_NO_OUTLINE`), в книге без ОСВ только логируется (иерархия строк — только у ОСВ). + Цена: предупреждение о кодировке видно в карточке файла, а не у листа ОСВ. +- **P4-14.** Предупреждение «форма не разобрана: книга завершилась ошибкой листа ОСВ» снято: в v2.0 для него нет кода, а + текст ошибки и так называет лист ОСВ. Цена: аналитик не видит списка листов форм, оставшихся неразобранными. +- **P4-15.** Статус документа ОСВ — прежние условия листа: контрольные суммы §1.4 сошлись и строки ИНН без нарушений + (`glue.ok`, #35). Цена: формулировка спеки «когда контрольные суммы сошлись» читается шире. +- **P4-16.** Нет `api/arm_statements/VERSION` — сервис не стартует (`RuntimeError`), как при ошибке словаря; в golden + `service.statements_version` — volatile-поле. Цена: копия без `VERSION` не запустится даже локально. +- **P4-17.** Новые синтетические случаи (форма xlsb, книга из пояснений, конфликты продолжений) — в памяти в юнит-тестах + (`sheet_from_rows`, `write_xlsb`), новых файлов в `fixtures/statements-xlsx/synthetic` нет; PR fixtures — только golden. +- **P4-18.** Манифест получает необязательное поле `error_warnings` (точное множество кодов `error` документа); + проверка уникальности кодов строк снята (повторы законны, Р-4). Цена: при правке пакета, меняющей набор + предупреждений, манифест правится вместе с golden. +- **P4-19.** Тест покрытия словарей tabular-parser больше не охватывает словари форм — они в пакете. Цена: «мёртвый» + синоним заголовка или метки шапки на реальных файлах не ловится (O-8). +- **P4-20.** В tabular-parser — свой change `unified-normalization-tp` с дельтами `statement-parsing`, + `tabular-recognition-service`, `osv-parsing` (по образцу P0-5); требование «Копия контракта идентична источнику» правит + план 0, здесь не трогается. +- **P4-21.** Golden — отдельные issue, ветка и PR в RSHB/arm-ka-fixtures (один PR на репозиторий); сливается сразу за PR + tabular-parser. Цена: короткое окно, когда `make check` `main` одного из них красный на golden. +- **P4-22.** Две формы на одном листе дают два документа с одинаковым `locator` (спека уникальности `locator` не требует; + `ref` строк различаются). + +### Рулинги контроллера при исполнении (R-n) + +- **R-1.** docs-worktree отсоединён на origin/main (docs #117 слит), а не на feat/116 (П-1/П-5 плана). Цена ошибки: нет. +- **R-2.** Предупреждение tp #47 «xls без кодовой страницы…» — `XLS_NO_CODEPAGE` пакета (info, файл, без locator); нового + `OSV_*`-кода нет; текст сообщения reader'а tp #47 сохранён; передаётся флагом `BookRead.no_codepage`. Цена: правка + `book.py` и golden. +- **R-4.** Задача 1 (markdown-дельты) без отдельного task-ревью — проверена `openspec validate --strict` и финальным + ревью ветки. Цена: неточность формулировки дельты. +- **R-5.** Проверка диапазона numeric(18,2) в `display_amount` не возвращается (P4-4); диапазон и `VALUE_OUT_OF_RANGE` + выдаёт пакет; старый конвейер удалён задачей 9. Цена: нет после удаления. +- **R-6.** Для |raw| ≥ 1e15 текст/значение числа без округления до 15 значащих цифр (иначе число из 17 цифр теряет + цифры); пробел групп — обычный U+0020 как в примере docs `result-statement-excel.json`. Цена: расхождение с NBSP + русского Excel, на value/статус не влияет. +- **R-7.** Ячейки из одних цифр не считаются подзаголовком даты `report_date` (иначе год из тройки «число|месяц|год» + принимается за подзаголовок), как в прежнем `header.py`. Цена: подзаголовок из голого числа «2025» не распознаётся. +- **R-8.** Ячейка в строке-шапке таблицы (в строке есть подпись «Код» по словарю пакета `code_caption`) не заголовок + формы: подпись колонки «Пояснения» (`'^пояснени'` в `form_titles` пакета) делала лист-продолжение НПЗ ещё одной формой + DISCLOSURE. Дефект адаптера (спека называет «Пояснения» подписью колонки), пакет не правится. Решение владельца: + оставить, посмотреть позже. Цена: компактная шапка, где заголовок формы и «Код» в одной строке, потеряет заголовок. +- **R-9.** Предупреждения адаптера при кандидате без документа — предупреждения файла со своим `locator`; порядок + `metrics.warnings` — сначала reader, затем по номеру первого листа; `XLS_NO_CODEPAGE` и `OSV_XLSB_NO_OUTLINE` — флаги + `BookRead` (`no_codepage`, `no_outline`). Цена: правка `book.py` и golden. +- **R-10.** НПЗ, ОФР, строки «Реализация иного имущества, прав требования, прочих работ и услуг» (F34, F44): значения 0 + с форматом `[=0]"-"` — только прочерки, суммы нет; по спеке «строка без кода и без содержимого — не строка данных», + пакет её не выдаёт; значение прочерка не превращаем в 0 (сломало бы различие null/0 и соотношения §7). + `test_npz_documents` не требует detail/parent_ref 2340. Вопрос владельцу O-8.2: сценарий спеки «Реализация иного + имущества… с суммами» не соответствует файлу. Цена: строки из прочерков не видны аналитику. +- **R-11.** Задача 9 (удаление по списку) без отдельного task-ревью — покрыта финальным ревью ветки. +- **R-12.** Остановка Р-31 по Межениновской (посторонние формулы аналитика в столбцах U–W) снята владельцем 18.10.2026: + правило в пакете (docs #123, Р-44; arm-statements #6, 3390f92) — столбец без подписи правее последней подписанной + колонки с числами не колонка сумм, предупреждение `NUMBERS_OUTSIDE_TABLE` (info). Копия пакета обновлена коммитом + f5d071f. Цена: нет. +- **R-13.** Ложная склейка/дедлайн: `plan_sheets` получает `deadline` (финальное ревью I-1, регрессия таймаута против + main). Цена: нет. diff --git a/openspec/changes/unified-normalization-tp/proposal.md b/openspec/changes/unified-normalization-tp/proposal.md index 208e49e..d915274 100644 --- a/openspec/changes/unified-normalization-tp/proposal.md +++ b/openspec/changes/unified-normalization-tp/proposal.md @@ -33,7 +33,7 @@ - Код: `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` + (копия схем v2.0), `api/src/core/worker.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). diff --git a/openspec/changes/unified-normalization-tp/specs/osv-parsing/spec.md b/openspec/changes/unified-normalization-tp/specs/osv-parsing/spec.md index 2f88dde..faba9a1 100644 --- a/openspec/changes/unified-normalization-tp/specs/osv-parsing/spec.md +++ b/openspec/changes/unified-normalization-tp/specs/osv-parsing/spec.md @@ -108,6 +108,11 @@ balance_identity_violations, parent_sum_violations}`. Результат про - **WHEN** запрос говорит `XLSX`, а байты — BIFF8 - **THEN** файл прочитан как xls, в `metrics.warnings` — `FILE_TYPE_MISMATCH` (`info`), статус по содержимому +#### Scenario: xls без кодовой страницы +- **WHEN** разбирается xls без записи CODEPAGE (синтетика, `codepage=None`) +- **THEN** строки прочитаны как cp1251, в `metrics.warnings` — предупреждение файла `XLS_NO_CODEPAGE` (`info`, без + `locator`; код пакета, `OSV_`-кода для него нет); статус по содержимому + #### Scenario: Превышен лимит - **WHEN** передан файл в 51 МиБ - **THEN** `error UNSUPPORTED_FORMAT`, книга не открывается @@ -130,3 +135,66 @@ balance_identity_violations, parent_sum_violations}`. Результат про #### Scenario: Распакованный zip больше лимита - **WHEN** zip книги распаковывается больше чем в 10 × `PARSE_MAX_BYTES` - **THEN** `error UNSUPPORTED_FORMAT` («распакованный размер … больше лимита»), книга не открывается + +### Requirement: Листы ОСВ находятся по содержимому +Парсер SHALL считать листом ОСВ любой лист, где ячейка в первых 30 строках листа (`SHEET_MARKER_ROWS`: шапка ведомости +всегда над таблицей) матчит признак ОСВ из `rules/osv_headers.yaml` (без учёта регистра, «ё/е», переносов строк и +дефисов). Лист с признаками формы ББ/ОФР или её продолжения уходит в конвейер форм (спека `statement-parsing`); прочие листы +SHALL пропускаться с предупреждением файла `SHEET_OTHER` (`severity = info`, `locator = sheet:<имя>`) в +`metrics.warnings`. Книга без единого листа ОСВ и без форм → `status = error`, `error.code = INVALID_DOCUMENT` +(«в книге нет листов ОСВ или форм»). Несколько листов ОСВ → несколько `documents` с `locator = sheet:<имя>`. +Имя листа MUST NOT использоваться для поиска. + +#### Scenario: Один лист — одна ОСВ (все реальные фикстуры) +- **WHEN** разбирается любой файл из `fixtures/osv/**` +- **THEN** `documents` содержит ровно один `osvDocument` с `locator = "sheet:Лист_1"`, статус `done` + +#### Scenario: Два листа ОСВ и посторонний лист +- **WHEN** разбирается синтетический файл с листами «ОСВ 60», «Справка», «ОСВ 62» +- **THEN** `documents` содержит два документа в порядке листов, в `metrics.warnings` есть `SHEET_OTHER` с `locator = "sheet:Справка"` + +#### Scenario: Нет ОСВ +- **WHEN** разбирается книга с одним листом произвольной таблицы +- **THEN** `status = error`, `error.code = INVALID_DOCUMENT`, сообщение называет число просмотренных листов + +### Requirement: Иерархия строк — каскад стратегий +Парсер SHALL определять `level` строк данных первой подходящей стратегией из списка: outline level Excel; отступ ячейки +или ведущие пробелы; шаблон кода счёта и жирность; равенство «сумма детей = родитель». Стратегия подходит, если даёт дерево, +где у каждой строки есть предок с меньшим уровнем (кроме уровня 0). Строка «Итого» стратегиям MUST NOT передаваться: +она всегда имеет уровень 0 и не имеет родителя (R-14). Если строк данных (кроме «Итого») меньше двух (пустая ОСВ или одна +строка) — стратегия `trivial`: дерево из корней (R-13). Выбранная стратегия SHALL быть записана в `warnings[]` документа листа предупреждением +`OSV_HIERARCHY_BY_<СТРАТЕГИЯ>` (`severity = info`); в `metrics.warnings` строк `sheet=<имя> hierarchy=…` нет. `parent_row_number` SHALL указывать на ближайшую +предыдущую строку с меньшим уровнем. + +#### Scenario: Outline level (реальные файлы) +- **WHEN** разбирается `rb/2024-FY/ОСВ_66 за 2024.xls` (и любой другой реальный файл со строками данных) +- **THEN** предупреждение документа `OSV_HIERARCHY_BY_OUTLINE`, строки `66` (0) → `66.01` (1) → контрагент (2); у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» + `parent_row_number` — строка `66.03` + +#### Scenario: Без outline level +- **WHEN** разбирается синтетическая ОСВ без группировок Excel, с отступами ячеек (`s02`) +- **THEN** `OSV_HIERARCHY_BY_INDENT`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по + кодам счетов и жирности (`s03`) + +#### Scenario: Без outline и отступов +- **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`) +- **THEN** `OSV_HIERARCHY_BY_ACCOUNT_PATTERN`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт + +#### Scenario: Пустая ОСВ — тривиальное дерево +- **WHEN** в ОСВ нет строк данных кроме «Итого» +- **THEN** `OSV_HIERARCHY_BY_TRIVIAL`, «Итого» — уровень 0 без родителя + +### Requirement: Золотые снимки и property-тесты +Для каждого файла `fixtures/osv/**` SHALL существовать снимок `fixtures/golden/expected_osv_parse/<зеркальный путь>.json` +(= файл результата без `job_id`, `file_id`, `service.version`, `service.statements_version`, `metrics.duration_ms`); тест сравнивает разбор со снимком, +обновление — только явной командой (`make golden-update`) с просмотром diff. Property-тест по всем файлам SHALL проверять +инварианты независимо от снимков (статус ≠ `error`, контрольные суммы, корень = счёт шапки, период распознан). Без +каталога fixtures оба теста пропускаются с причиной (`ARM_KA_FIXTURES`). + +#### Scenario: Регрессия +- **WHEN** правка парсера меняет `level` одной строки в любом реальном файле +- **THEN** golden-тест падает с diff по этому файлу + +#### Scenario: Новый файл без снимка +- **WHEN** в `fixtures/osv/` добавлен файл без снимка +- **THEN** property-тест его разбирает, golden-тест сообщает «нет снимка: выполните make golden-update» 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 index 148e0a1..1a5bef6 100644 --- a/openspec/changes/unified-normalization-tp/specs/tabular-recognition-service/spec.md +++ b/openspec/changes/unified-normalization-tp/specs/tabular-recognition-service/spec.md @@ -46,7 +46,7 @@ NATS и S3; воркер MUST NOT зависеть от конкретной р (`error NOT_IMPLEMENTED`) SHALL использоваться только в тестах воркера и MUST NOT быть реализацией по умолчанию. Реализация по умолчанию SHALL создаваться один раз при старте приложения (словари ОСВ читаются и проверяются там же, словари форм — `default_rules()` пакета). Каталог `api/rules/` SHALL содержать словари ОСВ (`osv_headers`, `osv_groups`, -`periods`, `data_types`) и README; словарей форм ББ/ОФР в сервисе нет — они часть копии `arm_statements`. +`periods`, `data_types`, `osv_checks`) и README; словарей форм ББ/ОФР в сервисе нет — они часть копии `arm_statements`. #### Scenario: Подмена реализации в тесте - **WHEN** в тесте воркеру передан парсер, возвращающий один `osvDocument` diff --git a/openspec/changes/unified-normalization-tp/tasks.md b/openspec/changes/unified-normalization-tp/tasks.md index 1588dd9..cd5e2c3 100644 --- a/openspec/changes/unified-normalization-tp/tasks.md +++ b/openspec/changes/unified-normalization-tp/tasks.md @@ -1,21 +1,27 @@ ## 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) +- [x] 1.1 OpenSpec change `unified-normalization-tp`: proposal, tasks, дельты `statement-parsing`, `tabular-recognition-service`, `osv-parsing` (задача 1) +- [x] 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) +- [x] 2.1 Ячейка Excel → `Cell`: `ref = <лист>!`, текст по числовому формату, плотная сетка (задача 3) +- [x] 2.2 Блоки форм на листе, вид листа, шапка блока на словарях пакета (задача 4) +- [x] 2.3 Адаптер книги → `FormCandidate`: классификация листов, склейка продолжений, `segment_breaks`, предупреждения (задача 5) ## 3. ОСВ и переход на контракт v2.0 -- [ ] 3.1 Предупреждения ОСВ с кодами `OSV_*` (вывод пока прежний) (задача 6) -- [ ] 3.2 Переключение на общий код и контракт v2.0: модели, копия схем, сборка книги, путь ОСВ, воркер, `statements_version` (задача 7) +- [x] 3.1 Предупреждения ОСВ с кодами `OSV_*` (вывод пока прежний) (задача 6) +- [x] 3.2 Переключение на общий код и контракт v2.0: модели, копия схем, сборка книги, путь ОСВ, воркер, `statements_version` (задача 7) ## 4. Регрессия и очистка -- [ ] 4.1 Golden и манифест реальных форм (задача 8) -- [ ] 4.2 Удаление собственного кода шагов 2–8 и словарей форм (задача 9) +- [x] 4.1 Golden и манифест реальных форм (задача 8) +- [x] 4.2 Удаление собственного кода шагов 2–8 и словарей форм (задача 9) - [ ] 4.3 Полная проверка `make check-full`, PR tabular-parser и PR fixtures (задача 10) + +## 5. При архивации + +- [ ] 5.1 После `openspec archive` вручную поправить Purpose главных спек: `statement-parsing` — «контракт v1.2, + `rules/statement_*.yaml`» → контракт v2.0, словари форм в пакете `arm_statements`; `osv-parsing` — «`osvDocument` + контракта v1.1» → v2.0