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:
parent
0b7b23eda0
commit
8b9a5a07c4
40
openspec/changes/unified-normalization-tp/proposal.md
Normal file
40
openspec/changes/unified-normalization-tp/proposal.md
Normal 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.
|
||||
@ -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: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` («распакованный размер … больше лимита»), книга не открывается
|
||||
@ -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`
|
||||
@ -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`, обработка не повторяется
|
||||
21
openspec/changes/unified-normalization-tp/tasks.md
Normal file
21
openspec/changes/unified-normalization-tp/tasks.md
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user