From acaa5021cefc25dcf396d7fca8c0a66c487e5592 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 09:26:49 +0400 Subject: [PATCH] =?UTF-8?q?chore(openspec):=20=D0=B0=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D0=B2=20contract-identity-window=20=D0=B8=20unified-normalizat?= =?UTF-8?q?ion-tp=20(#50,=20#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../proposal.md | 0 .../specs/tabular-recognition-service/spec.md | 0 .../tasks.md | 0 .../design.md | 0 .../proposal.md | 0 .../specs/osv-parsing/spec.md | 0 .../specs/statement-parsing/spec.md | 0 .../specs/tabular-recognition-service/spec.md | 0 .../tasks.md | 0 openspec/specs/osv-parsing/spec.md | 61 ++-- openspec/specs/statement-parsing/spec.md | 292 +++++++++--------- .../specs/tabular-recognition-service/spec.md | 66 +++- 12 files changed, 228 insertions(+), 191 deletions(-) rename openspec/changes/{contract-identity-window => archive/2026-10-19-contract-identity-window}/proposal.md (100%) rename openspec/changes/{contract-identity-window => archive/2026-10-19-contract-identity-window}/specs/tabular-recognition-service/spec.md (100%) rename openspec/changes/{contract-identity-window => archive/2026-10-19-contract-identity-window}/tasks.md (100%) rename openspec/changes/{unified-normalization-tp => archive/2026-10-19-unified-normalization-tp}/design.md (100%) rename openspec/changes/{unified-normalization-tp => archive/2026-10-19-unified-normalization-tp}/proposal.md (100%) rename openspec/changes/{unified-normalization-tp => archive/2026-10-19-unified-normalization-tp}/specs/osv-parsing/spec.md (100%) rename openspec/changes/{unified-normalization-tp => archive/2026-10-19-unified-normalization-tp}/specs/statement-parsing/spec.md (100%) rename openspec/changes/{unified-normalization-tp => archive/2026-10-19-unified-normalization-tp}/specs/tabular-recognition-service/spec.md (100%) rename openspec/changes/{unified-normalization-tp => archive/2026-10-19-unified-normalization-tp}/tasks.md (100%) diff --git a/openspec/changes/contract-identity-window/proposal.md b/openspec/changes/archive/2026-10-19-contract-identity-window/proposal.md similarity index 100% rename from openspec/changes/contract-identity-window/proposal.md rename to openspec/changes/archive/2026-10-19-contract-identity-window/proposal.md diff --git a/openspec/changes/contract-identity-window/specs/tabular-recognition-service/spec.md b/openspec/changes/archive/2026-10-19-contract-identity-window/specs/tabular-recognition-service/spec.md similarity index 100% rename from openspec/changes/contract-identity-window/specs/tabular-recognition-service/spec.md rename to openspec/changes/archive/2026-10-19-contract-identity-window/specs/tabular-recognition-service/spec.md diff --git a/openspec/changes/contract-identity-window/tasks.md b/openspec/changes/archive/2026-10-19-contract-identity-window/tasks.md similarity index 100% rename from openspec/changes/contract-identity-window/tasks.md rename to openspec/changes/archive/2026-10-19-contract-identity-window/tasks.md diff --git a/openspec/changes/unified-normalization-tp/design.md b/openspec/changes/archive/2026-10-19-unified-normalization-tp/design.md similarity index 100% rename from openspec/changes/unified-normalization-tp/design.md rename to openspec/changes/archive/2026-10-19-unified-normalization-tp/design.md diff --git a/openspec/changes/unified-normalization-tp/proposal.md b/openspec/changes/archive/2026-10-19-unified-normalization-tp/proposal.md similarity index 100% rename from openspec/changes/unified-normalization-tp/proposal.md rename to openspec/changes/archive/2026-10-19-unified-normalization-tp/proposal.md diff --git a/openspec/changes/unified-normalization-tp/specs/osv-parsing/spec.md b/openspec/changes/archive/2026-10-19-unified-normalization-tp/specs/osv-parsing/spec.md similarity index 100% rename from openspec/changes/unified-normalization-tp/specs/osv-parsing/spec.md rename to openspec/changes/archive/2026-10-19-unified-normalization-tp/specs/osv-parsing/spec.md diff --git a/openspec/changes/unified-normalization-tp/specs/statement-parsing/spec.md b/openspec/changes/archive/2026-10-19-unified-normalization-tp/specs/statement-parsing/spec.md similarity index 100% rename from openspec/changes/unified-normalization-tp/specs/statement-parsing/spec.md rename to openspec/changes/archive/2026-10-19-unified-normalization-tp/specs/statement-parsing/spec.md diff --git a/openspec/changes/unified-normalization-tp/specs/tabular-recognition-service/spec.md b/openspec/changes/archive/2026-10-19-unified-normalization-tp/specs/tabular-recognition-service/spec.md similarity index 100% rename from openspec/changes/unified-normalization-tp/specs/tabular-recognition-service/spec.md rename to openspec/changes/archive/2026-10-19-unified-normalization-tp/specs/tabular-recognition-service/spec.md diff --git a/openspec/changes/unified-normalization-tp/tasks.md b/openspec/changes/archive/2026-10-19-unified-normalization-tp/tasks.md similarity index 100% rename from openspec/changes/unified-normalization-tp/tasks.md rename to openspec/changes/archive/2026-10-19-unified-normalization-tp/tasks.md diff --git a/openspec/specs/osv-parsing/spec.md b/openspec/specs/osv-parsing/spec.md index 9161711..416c3a3 100644 --- a/openspec/specs/osv-parsing/spec.md +++ b/openspec/specs/osv-parsing/spec.md @@ -9,10 +9,10 @@ ### Requirement: Листы ОСВ находятся по содержимому Парсер SHALL считать листом ОСВ любой лист, где ячейка в первых 30 строках листа (`SHEET_MARKER_ROWS`: шапка ведомости всегда над таблицей) матчит признак ОСВ из `rules/osv_headers.yaml` (без учёта регистра, «ё/е», переносов строк и -дефисов). Листы без признаков ОСВ SHALL пропускаться с предупреждением -`sheet=<имя> skipped: нет признаков ОСВ` в `metrics.warnings`; лист с признаками ББ/ОФР (§12 п. 1) — с предупреждением -`sheet=<имя> skipped: statement (slice 2b)`. Книга без единого листа ОСВ → -`status = error`, `error.code = INVALID_DOCUMENT`. Несколько листов ОСВ → несколько `documents` с `locator = sheet:<имя>`. +дефисов). Лист с признаками формы ББ/ОФР или её продолжения уходит в конвейер форм (спека `statement-parsing`); прочие листы +SHALL пропускаться с предупреждением файла `SHEET_OTHER` (`severity = info`, `locator = sheet:<имя>`) в +`metrics.warnings`. Книга без единого листа ОСВ и без форм → `status = error`, `error.code = INVALID_DOCUMENT` +(«в книге нет листов ОСВ или форм»). Несколько листов ОСВ → несколько `documents` с `locator = sheet:<имя>`. Имя листа MUST NOT использоваться для поиска. #### Scenario: Один лист — одна ОСВ (все реальные фикстуры) @@ -21,7 +21,7 @@ #### Scenario: Два листа ОСВ и посторонний лист - **WHEN** разбирается синтетический файл с листами «ОСВ 60», «Справка», «ОСВ 62» -- **THEN** `documents` содержит два документа в порядке листов, в `warnings` есть запись о пропуске листа «Справка» +- **THEN** `documents` содержит два документа в порядке листов, в `metrics.warnings` есть `SHEET_OTHER` с `locator = "sheet:Справка"` #### Scenario: Нет ОСВ - **WHEN** разбирается книга с одним листом произвольной таблицы @@ -103,27 +103,27 @@ или ведущие пробелы; шаблон кода счёта и жирность; равенство «сумма детей = родитель». Стратегия подходит, если даёт дерево, где у каждой строки есть предок с меньшим уровнем (кроме уровня 0). Строка «Итого» стратегиям MUST NOT передаваться: она всегда имеет уровень 0 и не имеет родителя (R-14). Если строк данных (кроме «Итого») меньше двух (пустая ОСВ или одна -строка) — стратегия `trivial`: дерево из корней (R-13). Выбранная стратегия SHALL быть записана в `metrics.warnings` как -`sheet=<имя> hierarchy=<имя стратегии>`. `parent_row_number` SHALL указывать на ближайшую +строка) — стратегия `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** `hierarchy=outline`, строки `66` (0) → `66.01` (1) → контрагент (2); у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» +- **THEN** предупреждение документа `OSV_HIERARCHY_BY_OUTLINE`, строки `66` (0) → `66.01` (1) → контрагент (2); у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» `parent_row_number` — строка `66.03` #### Scenario: Без outline level - **WHEN** разбирается синтетическая ОСВ без группировок Excel, с отступами ячеек (`s02`) -- **THEN** `hierarchy=indent`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по +- **THEN** `OSV_HIERARCHY_BY_INDENT`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по кодам счетов и жирности (`s03`) #### Scenario: Без outline и отступов - **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`) -- **THEN** `hierarchy=account_pattern`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт +- **THEN** `OSV_HIERARCHY_BY_ACCOUNT_PATTERN`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт #### Scenario: Пустая ОСВ — тривиальное дерево - **WHEN** в ОСВ нет строк данных кроме «Итого» -- **THEN** `hierarchy=trivial`, «Итого» — уровень 0 без родителя +- **THEN** `OSV_HIERARCHY_BY_TRIVIAL`, «Итого» — уровень 0 без родителя ### Requirement: Классификация строк Классификация SHALL выполняться над деревом, из которого уже убраны склеенные строки ИНН (требование «Строки ИНН рядом с @@ -195,8 +195,13 @@ turnover_credit = closing_debit − closing_credit` (±0.01, `null` = 0); по родителями не проверяются; строка 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}`. Любое нарушение → `status = needs_check` всего результата и запись -причины в `metrics.warnings`; парсер MUST NOT подгонять структуру под сходимость. +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` @@ -229,8 +234,8 @@ balance_identity_violations, parent_sum_violations}`. Любое нарушен #### Scenario: Испорченная сумма - **WHEN** в синтетической ОСВ у одного контрагента `closing_debit` увеличен на 1 -- **THEN** `status = needs_check`, `balance_identity_violations` содержит его `row_number`, `parent_sum_violations` — - `row_number` родителя, в `warnings` — текст с номерами строк +- **THEN** `status = needs_check` документа, `balance_identity_violations` содержит его `row_number`, `parent_sum_violations` — + `row_number` родителя, в `warnings[]` документа — `OSV_BALANCE_IDENTITY` / `OSV_PARENT_SUM` (`info`) с номерами строк #### Scenario: Сальдо родителя свёрнуто нетто - **WHEN** у родителя сальдо показано одной стороной (нетто), а у детей — обеими сторонами, и нетто детей равно нетто родителя @@ -245,18 +250,23 @@ balance_identity_violations, parent_sum_violations}`. Любое нарушен #### Scenario: «Итого» не найдена - **WHEN** в таблице нет строки с текстом из словаря «Итого» -- **THEN** `total_row_matches_root = false`, `status = needs_check`, в `warnings` — «строка «Итого» не найдена» +- **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), +- **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` сигнатуре файла → попытка по сигнатуре и предупреждение файла `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 ×); @@ -277,12 +287,17 @@ balance_identity_violations, parent_sum_violations}`. Любое нарушен #### Scenario: XLSB - **WHEN** разбирается синтетический xlsb той же ОСВ, что `s02` (`s13`, собран тестовым писателем BIFF12, а не Excel — O-7) -- **THEN** результат совпадает с xlsx-оригиналом во всём, кроме `locator` и предупреждения «xlsb: outline level - недоступен»; `hierarchy=indent` +- **THEN** результат совпадает с xlsx-оригиналом во всём, кроме `locator` и предупреждения документа `OSV_XLSB_NO_OUTLINE` + («xlsb: outline level недоступен», `info`); `hierarchy=indent` #### Scenario: Расширение не совпадает с содержимым - **WHEN** запрос говорит `XLSX`, а байты — BIFF8 -- **THEN** файл прочитан как xls, в `warnings` есть запись о несоответствии, статус по содержимому +- **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 МиБ @@ -333,7 +348,7 @@ SHALL загружаться из YAML и валидироваться pydantic- ### Requirement: Золотые снимки и property-тесты Для каждого файла `fixtures/osv/**` SHALL существовать снимок `fixtures/golden/expected_osv_parse/<зеркальный путь>.json` -(= файл результата без `job_id`, `file_id`, `service.version`, `metrics.duration_ms`); тест сравнивает разбор со снимком, +(= файл результата без `job_id`, `file_id`, `service.version`, `service.statements_version`, `metrics.duration_ms`); тест сравнивает разбор со снимком, обновление — только явной командой (`make golden-update`) с просмотром diff. Property-тест по всем файлам SHALL проверять инварианты независимо от снимков (статус ≠ `error`, контрольные суммы, корень = счёт шапки, период распознан). Без каталога fixtures оба теста пропускаются с причиной (`ARM_KA_FIXTURES`). diff --git a/openspec/specs/statement-parsing/spec.md b/openspec/specs/statement-parsing/spec.md index f0c0c2d..810d23a 100644 --- a/openspec/specs/statement-parsing/spec.md +++ b/openspec/specs/statement-parsing/spec.md @@ -9,15 +9,19 @@ ## Requirements ### Requirement: Листы форм и их продолжения находятся по содержимому -Классификация листа SHALL различать `OSV`, `STATEMENT`, `STATEMENT_CONTINUATION` и `OTHER`. Лист SHALL считаться -`STATEMENT`, если в любой строке до первой строки заголовка таблицы формы или первой строки с кодом формы есть заголовок -формы или код ОКУД из словаря `statement_headers.yaml`. Лист без заголовка формы SHALL считаться `STATEMENT_CONTINUATION`, +Классификация листа SHALL различать `OSV`, `STATEMENT`, `STATEMENT_CONTINUATION` и `OTHER` и выполняться в порядке: +признак ОСВ → форма → продолжение → прочее. Признак ОСВ SHALL проверяться первым. Лист SHALL считаться `STATEMENT`, если +в любой строке до первой строки заголовка таблицы формы или первой строки с кодом формы есть заголовок формы или код +ОКУД из словаря заголовков пакета `arm_statements`. Лист без заголовка формы SHALL считаться `STATEMENT_CONTINUATION`, если на нём есть маркер продолжения («Форма 0710001 с.2»), либо строка заголовка таблицы формы (ячейка «Код» и правее -ячейки-периоды), либо колонка, в которой не меньше 60 % непустых значений — коды строк форм. Признак ОСВ SHALL -проверяться первым. Тип формы SHALL определяться по заголовку или ОКУД: баланс / 0710001 → `BALANCE`, отчёт о -финансовых результатах (о прибылях и убытках) / 0710002 → `INCOME`, пояснения, расшифровки и прочие формы 0710xxx → -`DISCLOSURE`. Титул «Бухгалтерская отчетность» MUST NOT задавать тип: тип берётся из ближайшего следующего заголовка. -Два заголовка форм на одном листе SHALL давать два документа, второй — со строки своего заголовка. +ячейки-периоды), либо колонка, в которой не меньше 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», @@ -38,62 +42,29 @@ #### Scenario: Расшифровка - **WHEN** разбирается лист с заголовком «Пояснения к бухгалтерскому балансу и отчёту о финансовых результатах» -- **THEN** документ `DISCLOSURE` с `locator`, без `columns` и `lines`; статус книги от него не зависит +- **THEN** документ `DISCLOSURE` с `locator`, без `columns` и `lines`, со статусом `done`; статус книги от него не зависит -### Requirement: Заголовок таблицы, колонка кодов и колонки-периоды определяются по тексту -Строкой заголовка таблицы SHALL считаться первая после заголовка формы строка с ячейкой «Код»/«Коды» (синонимы в -словаре) и хотя бы одной ячейкой-периодом правее; заголовок, разнесённый на две строки или с переносами внутри ячейки, -SHALL склеиваться; строка нумерации колонок («1 2 3 4 5») SHALL пропускаться. Колонкой кодов SHALL считаться колонка, -в которой не меньше 60 % непустых значений блока — четырёх- или пятизначные коды строк форм (1100–1799 для ББ, 2100–2999 -для ОФР и их пятизначные расширения, 5xxx для расшифровок); коды читаются как текст из ячеек любого типа. Колонкой -наименований SHALL считаться ближайшая левее кодов колонка с преобладанием текста; колонка «Пояснения» в наименования -MUST NOT попадать. Колонками-периодами SHALL быть все ячейки заголовка правее кодов с распознанным периодом, по порядку -слева направо, `column_index` с нуля, `period_date` — дата или конец периода из `statement_periods.yaml`. Ячейки заголовка -без периода MUST NOT становиться колонками; их значения не читаются, в предупреждения пишется одна строка. Если колонки -кодов нет, документ SHALL отдаваться без строк со статусом `needs_check` и предупреждением «колонка кодов не найдена». - -#### Scenario: Три раскладки, одни и те же колонки -- **WHEN** разбираются `unagrande-co/2024-FY/Б.Б Лист 1.xls` (колонки «Пояснения | Наименование показателя | Код | На 31 - декабря 2024 г. | … 2023 | … 2022»), `npz/2025-Q3/…xlsx` («Пояснения | Наименование показателя | Код | На 30 сентября - 2025 г. | …») и `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` («Наименование показателя | Коды | За 9 месяцев 2025 года - | На 31 декабря 2024 года | …», под шапкой строка «1 2 3 4 5») -- **THEN** у каждого документа три колонки с `period_date` `2024-12-31/2023-12-31/2022-12-31`, `2025-09-30/2024-12-31/2023-12-31` - и `2025-09-30/2024-12-31/2023-12-31` соответственно, строка нумерации не стала строкой данных, «Пояснения» не попали в - `line_name` - -#### Scenario: Заголовок в две строки -- **WHEN** разбирается синтетический лист `st02_csv_like` («На 31 декабря» в одной строке, «2024 г.» в следующей, коды - без подписи «Код») -- **THEN** колонки-периоды найдены по склеенному тексту (`2024-12-31`, `2023-12-31`), колонка кодов найдена без подписи - -#### Scenario: Пустые шаблоны форм -- **WHEN** разбираются `templates/*.xlsx` (образцы 66н и ФСБУ 4/2023: коды есть, сумм и шапки организации нет; год в - заголовке колонки разнесён по ячейкам «20» и « г.») -- **THEN** документы `BALANCE`/`INCOME` со строками-кодами без значений, статус `needs_check`, ошибки разбора нет; - колонки-периоды могут отсутствовать - -#### Scenario: Колонка без периода -- **WHEN** в синтетическом листе правее колонок дат есть колонка «Изменение, %» -- **THEN** она не входит в `columns`, предупреждение «колонка без периода пропущена: Изменение, %» - -#### Scenario: Нет колонки кодов -- **WHEN** разбирается синтетический лист `st03_no_codes` (наименования и суммы без кодов) -- **THEN** документ `BALANCE` с пустыми `lines`, статус `needs_check`, предупреждение «колонка кодов не найдена» +#### Scenario: Лист «прочее» +- **WHEN** в книге лист «Примечания» без заголовка формы, ОКУД и кодов +- **THEN** документа нет; в `metrics.warnings` — `SHEET_OTHER` уровня `info` с `locator = "sheet:Примечания"` ### Requirement: Шапка документа -Поля шапки SHALL искаться по меткам из `statement_headers.yaml` в области от начала блока формы до строки заголовка -таблицы; значение — ближайшая непустая ячейка правее метки, иначе первая непустая ячейка следующей строки. `inn` SHALL -быть 10 или 12 цифр у метки «ИНН», причём строки после метки аудитора MUST NOT использоваться. `organization_name` SHALL -браться после метки «Организация», а при пустом значении — из ближайшей строки выше с признаком ОПФ. `units` SHALL -отдаваться текстом как в файле без нормализации. `report_date_text`/`report_date` SHALL браться из подзаголовка формы -(«на <дата>», «за <период>»), иначе из тройки «число, месяц, год»; для ОФР `report_date` — конец периода. Найденные поля -SHALL иметь `*_confidence = 100`. У документа без шапки все поля SHALL быть `null`, кроме `form_name` из маркера. +Адаптер 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» + `report_date_text = "на 31 декабря 2024 г."`, `address` начинается с «242440»; `*_confidence = 100` #### Scenario: Организация над меткой, ОФР за период - **WHEN** разбирается лист «Отчет о финансовых результата» книги НПЗ @@ -105,125 +76,142 @@ SHALL иметь `*_confidence = 100`. У документа без шапки - **WHEN** разбирается `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` (ячейки 30 | 9 | 2025 как числа) - **THEN** `report_date = 2025-09-30`, `inn = "7016006635"`, `okpo = "85262693"` -### Requirement: Строки с кодом и значения -Каждая строка блока с кодом в колонке кодов SHALL давать `line` с `line_code` (текст без пробелов), `line_name` (trim, -переносы заменены пробелом), `line_code_confidence = line_name_confidence = 100` и по одному `value` на каждую -колонку-период. Строки без кода MUST NOT отдаваться (ASSUMPTION(OQ-36)); пятизначные подстроки SHALL отдаваться как -обычные строки. Число SHALL приводиться к `Decimal` по §1.2 (скобки → минус, пробелы убираются); пусто, «–», «-» → -`amount = null`, `is_recognized = true`; нечисловой текст → `amount = null`, `string_value` = текст, `is_recognized = -false`; `confidence = 100`. Повтор кода внутри документа SHALL оставлять первую строку, записывать код в -`checks.duplicate_line_codes` и переводить документ в `needs_check`. Единицы MUST NOT нормализоваться: при `units` с -«тыс.» и дробной частью не меньше чем у 10 % значений в предупреждения SHALL писаться «значений с дробной частью при -единице тыс. руб.: N». - -#### Scenario: Подстроки и значения с копейками -- **WHEN** разбирается `unagrande-co/2024-FY/Б.Б Лист 1.xls` -- **THEN** есть строки `1110` («Нематериальные активы», `5971264.14`) и `11101` («Нематериальные активы в организации», - `5852464.14`); строка без кода с нулями после 11103 не отдана; предупреждение о дробной части при «тыс. рублей» есть - -#### Scenario: Пустые значения и прочерки -- **WHEN** в `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` у строки 1170 пусто в первой колонке и 10 в остальных -- **THEN** `values[0].amount = null`, `values[1].amount = "10"`, все `is_recognized = true` - -#### Scenario: Отрицательные в скобках -- **WHEN** в синтетическом ОФР 2120 записана как «(6 162 067)» -- **THEN** `amount = "-6162067"` - -### Requirement: Соотношения §7 — данные, проверка по колонкам -Соотношения SHALL храниться в `statement_checks.yaml` в виде `{form, relation, strict, abs_terms}` и вычисляться чистой -функцией над «код → сумма» по каждой колонке-периоду отдельно и только по четырёхзначным кодам; отсутствующий код — -0; соотношение SHALL считаться выполненным, если сходится правая часть как записана либо с членами `abs_terms` по -модулю, с допуском ±1 в единицах формы. Единицы шапки считаются верными (OQ-138, решение владельца 06.10.2026): -расхождение жёсткого соотношения больше ±1, но не больше `rounding_tolerance_percent` (0,001 %) от наибольшего по модулю -числа соотношения SHALL считаться округлением итогов — попадать в `checks.soft_relations_failed` с предупреждением -«сошлось в пределах округления» и не менять статус. Нарушение жёсткого соотношения сверх этого SHALL попадать в `checks.relations_failed` -(`column_index`, `relation`, `expected`, `actual`) и переводить документ в `needs_check`; нарушение мягкого — в -`checks.soft_relations_failed` и предупреждение. Проверка «сумма пятизначных подстрок = родитель» SHALL генерироваться по -коду как мягкая, когда подстроки есть; пятизначная строка SHALL относиться к ближайшей четырёхзначной строке выше с теми же -первыми тремя цифрами (12310 под 1230), а без такой строки — к строке с её первыми четырьмя цифрами. Перечень нарушений -SHALL дублироваться строками в `metrics.warnings`. - -#### Scenario: Полные формы сходятся -- **WHEN** разбираются обе формы Межениновской (`1_Форма1_бух_09.xlsx`, `1_Форма2_бух_09.xlsx`) -- **THEN** у документов `BALANCE` и `INCOME` `checks.relations_failed = []` по всем колонкам, статус `done` - -#### Scenario: Знаки 1С -- **WHEN** в ОФР Межениновской расходы записаны со знаком минус (2120 = −417659 при 2110 = 276851 и 2100 = −140808) -- **THEN** `2100 = 2110 − 2120` выполнено через вариант с `abs_terms`, в `relations_failed` его нет; то же доказывает - unit-тест `test_relation_holds_with_abs_terms` - -#### Scenario: Округление итогов -- **WHEN** в ОФР Унагранде Компани (страница 1) итог 2100 = 2 871 145 000 при 2110 − 2120 = 2 871 144 583.67, а в ОФР - Унагранде Групп 3 кв. 2025 итог 2200 = −1 365 000 при 2220 = −393 623 601.56 (расхождение 398.44) -- **THEN** нарушения в `soft_relations_failed` с предупреждением «сошлось в пределах округления», `relations_failed = []`, - статус `done`; расхождение 11 при итоге 1 000 000 (больше 0,001 %) — в `relations_failed`, `needs_check` - -#### Scenario: Испорченный итог -- **WHEN** в синтетическом балансе 1600 на 5 больше суммы 1100 + 1200 -- **THEN** `relations_failed` содержит `{column_index: 0, relation: "1600 = 1100 + 1200", expected, actual}`, статус `needs_check` - -#### Scenario: Мягкое соотношение -- **WHEN** в ОФР подстроки 2411 + 2412 не равны 2410 -- **THEN** нарушение в `soft_relations_failed`, статус документа не меняется - ### Requirement: Склейка листов-продолжений и статус документа -Листы книги SHALL обрабатываться по порядку; лист `STATEMENT_CONTINUATION` SHALL присоединяться к последнему открытому -документу формы при совпадении числа колонок-сумм, строки добавляются в конец, `locator` SHALL перечислять имена листов -через запятую. Продолжение без открытого документа SHALL давать документ с пустой шапкой, типом по ОКУД маркера (иначе -`OTHER`), статусом `needs_check` и предупреждением «страница-продолжение без шапки: загрузите форму одним файлом». -Документ SHALL считаться `ok`, если все жёсткие соотношения сошлись по всем колонкам, дубликатов кодов нет, колонка -кодов найдена, есть хотя бы одна колонка-период, в шапке есть организация или ИНН и документ не является одиночным -продолжением. Книга SHALL получать `done`, если все документы ОСВ и форм `ok`, иначе `needs_check`; книга без документов -ОСВ и форм SHALL давать `error INVALID_DOCUMENT`. `metrics.rows` книги SHALL быть суммой строк данных ОСВ (без `TOTAL`) -и строк с кодом документов форм. Если книга завершилась ошибкой листа ОСВ, ответ с ошибкой SHALL называть в -`metrics.warnings` листы форм, оставшиеся неразобранными. +Листы книги 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"` - и строками от 1105 до 1700 включительно, `INCOME` на трёх листах (лист отчёта с единственной строкой 2400 присоединяется - как продолжение: после листа формы хватает одного кода, `min_codes_continuation`), строка 2400 присутствует; - `checks.relations_failed` пусты у обоих; `1600 = 1700` (1.94 / 9.02 / 2.48) и `2100`/`2200`/`2300` — в - `soft_relations_failed` как округление (OQ-138); статус книги `done` +- **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`, меньше `min_codes`), коды из - диапазона формы открытого документа и то же число колонок сумм -- **THEN** лист присоединяется к последнему открытому документу, строка присутствует, предупреждение «присоединён к - документу как продолжение по N кодовой строке»; если присоединить нельзя (число колонок, тип формы маркера или коды - другой формы — лист «Справка» с кодом баланса после ОФР), книга `needs_check` и предупреждение «лист с кодами форм - (N < min_codes) не присоединён: …»; лист с одной кодовой строкой после листа ОСВ или прочего листа, а также первая - страница формы с одной кодовой строкой формой не считаются (пропуск с предупреждением) +- **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`, предупреждение о продолжении +- **THEN** один документ `INCOME` со строками 2510–2910, пустой шапкой, статус `needs_check` #### Scenario: Страница 1 без продолжения - **WHEN** разбирается `unagrande-co/2024-FY/Б.Б Лист 1.xls` (только актив) -- **THEN** документ `BALANCE` со строками 1110–1600, шапка полная, `1600 = 1100 + 1200` выполнено, `1700`/`1300` отсутствуют - (считаются нулём: соотношение `1600 = 1700` нарушено → `needs_check` с перечнем) +- **THEN** документ `BALANCE` со строками 1110–1600, шапка полная, `1600 = 1100 + 1200` выполнено, `1600 = 1700` + нарушено как «непустая цель при пустых членах» → `needs_check` #### Scenario: Смесь ОСВ и форм в одной книге - **WHEN** разбирается синтетическая книга `st04_mixed_book` (лист ОСВ 60 и лист баланса) - **THEN** результат содержит `osvDocument` и `statementDocument`, статус книги по обоим ### Requirement: Словари форм, координаты, golden -Словари `statement_headers.yaml`, `statement_periods.yaml`, `statement_checks.yaml` SHALL валидироваться при старте; -каждая запись SHALL срабатывать хотя бы на одном файле `../fixtures/statements-xlsx/**` или синтетике. Код -`src/parser/statements/**` и `src/parser/book.py` MUST NOT содержать литералов координат (линтер `test_no_coordinates`). -Снимки `../fixtures/golden/expected_statement_parse/**` SHALL покрывать все файлы `statements-xlsx/**` и обновляться -только `make golden-update`; property-тест по манифесту `tests/fixtures/statements_manifest.yaml` SHALL проверять тип, -ИНН, ожидаемый статус и сходимость жёстких соотношений для полных форм. +Словари форм (заголовки и ОКУД, периоды, соотношения §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** в `statement_headers.yaml` добавлен синоним заголовка «Баланс предприятия» и файл с таким заголовком -- **THEN** лист классифицирован как `BALANCE` без изменения кода +- **WHEN** в словарь заголовков пакета `arm-statements` добавлен синоним заголовка «Баланс предприятия» и копия пакета + обновлена скриптом `sync-statements` +- **THEN** лист классифицирован как `BALANCE` без изменения кода tabular-parser; собственного словаря форм в сервисе нет #### Scenario: Мёртвая запись словаря -- **WHEN** запись словаря не срабатывает ни на одном файле -- **THEN** тест покрытия падает с именем записи +- **WHEN** запись словаря форм не срабатывает ни на одном файле +- **THEN** это проверяет тест покрытия словарей пакета `arm-statements` (внутри пакета, на его данных); тест покрытия + tabular-parser словари форм не охватывает #### Scenario: Регрессия по снимкам - **WHEN** результат разбора любого файла `statements-xlsx/**` отличается от снимка - **THEN** golden-тест падает с diff; обновление — только `make golden-update` и отдельный коммит в fixtures + +### 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/specs/tabular-recognition-service/spec.md b/openspec/specs/tabular-recognition-service/spec.md index 00670e7..6e8c432 100644 --- a/openspec/specs/tabular-recognition-service/spec.md +++ b/openspec/specs/tabular-recognition-service/spec.md @@ -33,9 +33,14 @@ Stateless-сервис разбора табличных файлов (XLS/XLSX/ ### Requirement: Результат по контракту После подтверждения сервис SHALL прочитать объект `s3.key` из бакета `s3.bucket`, вызвать парсер, записать файл результата -(по `recognition-result.schema.json`) в `results/{file_id}/{job_id}/result.json` и опубликовать конверт результата (по -`recognition-result-message.schema.json`) с заголовком `Nats-Msg-Id = result::`. При неудаче публикации -сервис SHALL повторить её до трёх раз с паузой, затем залогировать ошибку. +(по `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` (только тесты воркера; реализация по умолчанию — реальный парсер), @@ -49,6 +54,15 @@ Stateless-сервис разбора табличных файлов (XLS/XLSX/ книга — `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`, обработка не повторяется @@ -88,12 +102,13 @@ Stateless-сервис разбора табличных файлов (XLS/XLSX/ Разбор файла SHALL выполняться через единый интерфейс «байты + тип файла → документы/метрики/ошибка», не знающий о NATS и S3; воркер MUST NOT зависеть от конкретной реализации. Реализация по умолчанию (`get_parser()`) SHALL быть оркестратором книги `BookParser`: он читает книгу один раз, классифицирует листы по содержимому и отдаёт листы ОСВ -конвейеру спеки `osv-parsing`, листы форм и их продолжения — конвейеру спеки `statement-parsing`; прочие листы -пропускаются с предупреждением. Файл результата SHALL писаться с `schema_version = "1.2"`; документы книги — ОСВ в -порядке листов, затем формы в порядке листов. Заглушка `NullParser` (`error NOT_IMPLEMENTED`) SHALL использоваться только -в тестах воркера и MUST NOT быть реализацией по умолчанию. Реализация по умолчанию SHALL создаваться один раз при старте -приложения (словари читаются и проверяются там же). Каталог `api/rules/` SHALL содержать словари ОСВ (`osv_headers`, -`osv_groups`, `periods`, `data_types`), словари форм (`statement_headers`, `statement_periods`, `statement_checks`) и README. +конвейеру спеки `osv-parsing`, листы форм и их продолжения — конвейеру спеки `statement-parsing` (адаптер Excel и копия +пакета `arm_statements`); прочие листы дают предупреждение файла `SHEET_OTHER`. Файл результата SHALL писаться с +`schema_version = "2.0"`; документы книги — ОСВ в порядке листов, затем формы в порядке листов. Заглушка `NullParser` +(`error NOT_IMPLEMENTED`) SHALL использоваться только в тестах воркера и MUST NOT быть реализацией по умолчанию. +Реализация по умолчанию SHALL создаваться один раз при старте приложения (словари ОСВ читаются и проверяются там же, +словари форм — `default_rules()` пакета). Каталог `api/rules/` SHALL содержать словари ОСВ (`osv_headers`, `osv_groups`, +`periods`, `data_types`, `osv_checks`) и README; словарей форм ББ/ОФР в сервисе нет — они часть копии `arm_statements`. #### Scenario: Подмена реализации в тесте - **WHEN** в тесте воркеру передан парсер, возвращающий один `osvDocument` @@ -101,27 +116,46 @@ NATS и S3; воркер MUST NOT зависеть от конкретной р #### Scenario: Реальная ОСВ через воркер - **WHEN** в интеграционном тесте в S3 лежит `rb/2024-FY/ОСВ_66 за 2024.xls` и опубликован запрос `file_type = XLS` -- **THEN** после ack публикуется конверт `done` с `result_schema_version = "1.2"`, файл результата проходит схему v1.2 и - содержит один `osvDocument` с `account_code_raw = "66"` +- **THEN** после ack публикуется конверт `done` с `result_schema_version = "2.0"`, файл результата проходит схему v2.0 и + содержит один `osvDocument` со `status = done` и `account_code_raw = "66"` #### Scenario: Реальные формы через воркер - **WHEN** в интеграционном тесте в S3 лежит книга `statements-xlsx/npz/2025-Q3/…xlsx` и опубликован запрос `file_type = XLSX` -- **THEN** после ack публикуется конверт `done` с `result_schema_version = "1.2"`, файл результата содержит два - `statementDocument` (`BALANCE`, `INCOME`) с пустыми `checks.relations_failed`; расхождения итогов в пределах округления - (`1600 = 1700` на 1.94 / 9.02 / 2.48, `2100`/`2200`/`2300`) — в `soft_relations_failed` (OQ-138) +- **THEN** после ack публикуется конверт `done` с `result_schema_version = "2.0"`, файл результата проходит схему v2.0 и + содержит два `statementDocument` (`BALANCE`, `INCOME`) со строками `line_status`, `ref`, `checks` и пустыми + `checks.relations_failed`; расхождения итогов в пределах округления (`1600 = 1700` на 1.94 / 9.02 / 2.48, `2100`/`2200`/ + `2300`) — в `soft_relations_failed` (OQ-138); `service.statements_version` равен коммиту копии пакета #### Scenario: Книга без ОСВ и форм - **WHEN** в S3 лежит книга Excel, ни один лист которой не имеет признаков ОСВ или формы - **THEN** публикуется конверт `error`, `error.code = INVALID_DOCUMENT`, сообщение называет число просмотренных листов +#### Scenario: Сервис без версии общего кода не стартует +- **WHEN** в копии `arm_statements` нет файла `VERSION` +- **THEN** `require_statements_version()` бросает `RuntimeError` с текстом «копия arm_statements без VERSION», сервис не + стартует + ### Requirement: Копия контракта идентична источнику -`api/contracts/*.schema.json` SHALL быть байт в байт равны файлам `../docs/contracts/`; pydantic-модели SHALL валидировать -все `../docs/contracts/examples/*.json` и порождать JSON, проходящий схему. Проверка входит в `make check`. +Каждый файл `api/contracts/**/*.json` SHALL быть байт в байт равен своей паре в `../docs/contracts/` (или в `contracts` +каталога из `ARM_KA_DOCS`) по правилу спеки docs `recognition-contract`, требование «Копия контракта в сервисе +идентична источнику»: схема с `$id` `…/v` — с `<имя>.v.schema.json`, если такой файл есть в docs, иначе с +основным; пример — с `examples/v/<имя>.json`, если он есть, иначе с `examples/<имя>.json`; файлы docs другого +`MAJOR` не сверяются. pydantic-модели SHALL валидировать все примеры копии `api/contracts/examples/*.json` и порождать +JSON, проходящий схему копии. Проверка входит в `make check`. #### Scenario: `make check` - **WHEN** выполняются тесты контракта при доступном `../docs` (или `ARM_KA_DOCS`) - **THEN** идентичность подтверждена, примеры валидны; при недоступном docs тест идентичности пропущен с предупреждением +#### Scenario: Окно перехода на контракт 2.0 +- **WHEN** в docs основной `recognition-result.schema.json` уже v2.0, рядом `recognition-result.v1.schema.json` и + `examples/v1/`, а копия сервиса — v1.2 +- **THEN** копия сверяется с файлами `.v1` и `examples/v1/` (запрос — с основным файлом), тест проходит + +#### Scenario: Копия устарела +- **WHEN** в docs изменён файл, с которым сверяется копия, а копия — нет +- **THEN** тест идентичности падает, сообщение называет файл + ### Requirement: Соединение с NATS по правилам шаблона, без JetStream При пустых `NATS_SEED`/`NATS_JWT` сервис SHALL подключаться без учётных данных, иначе — с nkeys/JWT из `OPENBAO__NATS__NATS_SEED`/`NATS_JWT` (значения — секреты). Сервис MUST NOT требовать stream'ов или consumer'ов JetStream; при разрыве соединения SHALL переподключаться автоматически и восстанавливать