From 90ea429642a26302fbb7b9d7429518a0ec08a7f6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 00:40:50 +0400 Subject: [PATCH] =?UTF-8?q?docs(#116):=20proposal=20=D0=B8=20=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=8B=20=D1=81=D0=BF=D0=B5=D0=BA=20=D0=B5?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=D0=BE=D0=B9=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D0=91=D0=91/?= =?UTF-8?q?=D0=9E=D0=A4=D0=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - proposal: зачем, что меняется, порядок выкладки «сначала ядро», non-goals - statement-normalization: шаги 1–8 общего кода arm_statements (кандидат, раскладка, статусы строк, значения, §7, needs_review, структурная проверка кода, уровни предупреждений, статус) - recognition-contract: файл результата и конверт v2.0, переход 1.x → 2.0 - tabular-recognition-service, scan-gateway: адаптеры шага 1 и что уходит из сервисов - arm-core: хранение v2.0, агрегат по коду, document_issue, RECOGNIZED, PATCH lines/columns/confirmed, порядок 409, миграция v5.19, crm_id ГСК/ГСЗ - arm-web: экран проверки формы по новым полям - design.md: автозамена между файлами — §9.5 п. 3, а не §12 п. 3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- .../unified-statement-normalization/design.md | 4 +- .../proposal.md | 114 ++++ .../specs/arm-core/spec.md | 202 +++++++ .../specs/arm-web/spec.md | 46 ++ .../specs/recognition-contract/spec.md | 129 +++++ .../specs/scan-gateway/spec.md | 41 ++ .../specs/statement-normalization/spec.md | 530 ++++++++++++++++++ .../specs/tabular-recognition-service/spec.md | 49 ++ 8 files changed, 1113 insertions(+), 2 deletions(-) create mode 100644 openspec/changes/unified-statement-normalization/proposal.md create mode 100644 openspec/changes/unified-statement-normalization/specs/arm-core/spec.md create mode 100644 openspec/changes/unified-statement-normalization/specs/arm-web/spec.md create mode 100644 openspec/changes/unified-statement-normalization/specs/recognition-contract/spec.md create mode 100644 openspec/changes/unified-statement-normalization/specs/scan-gateway/spec.md create mode 100644 openspec/changes/unified-statement-normalization/specs/statement-normalization/spec.md create mode 100644 openspec/changes/unified-statement-normalization/specs/tabular-recognition-service/spec.md diff --git a/openspec/changes/unified-statement-normalization/design.md b/openspec/changes/unified-statement-normalization/design.md index 8b8f52f..abf215f 100644 --- a/openspec/changes/unified-statement-normalization/design.md +++ b/openspec/changes/unified-statement-normalization/design.md @@ -746,7 +746,7 @@ line_name, line_name_confidence, parent_ref, values}`: `line_code` — разо одна запись. - **Подтверждение документа** (Р-13, Р-18): порядок проверок 409 по §9.5 п. 4: `PERIOD_REQUIRED` → `LINES_UNRESOLVED` (есть строки `unreadable` / `missing` с непустым `amount`; строки без сумм не мешают) → `DUPLICATE_FORM` (в файле уже - подтверждена другая форма того же типа и периода; 409 со ссылкой на неё; между файлами работает автозамена §12 п. 3) + подтверждена другая форма того же типа и периода; 409 со ссылкой на неё; между файлами работает автозамена §9.5 п. 3) → жёсткие нарушения без акцепта. Автоподтверждения `LINES_UNRESOLVED` не касается (`done` таких строк не допускает). `detail` в агрегат, КФО и снимок ЕХ не попадают — цена Р-8. - Правка даты отчёта (PATCH шапки): для колонок с `period_kind = relative:<вид>` ядро досчитывает `period_end` общим @@ -850,7 +850,7 @@ line_name, line_name_confidence, parent_ref, values}`: `line_code` — разо само разлепление — предупреждение `info`. - **Р-18** (ядро) — при двух и более формах одного типа и периода в одном файле ни одна не автоподтверждается; подтверждение второй при подтверждённой первой — 409 `DUPLICATE_FORM` только внутри файла (между файлами работает - автозамена §12 п. 3). + автозамена §9.5 п. 3). - **Р-19** (ядро) — `RECOGNIZED` не зависит от статуса конверта: достаточно `document.status = done` и отсутствия предупреждений `error` уровня файла (потерянный json или таблица ИС блокируют, `needs_check` соседнего документа из-за одной ячейки — нет). diff --git a/openspec/changes/unified-statement-normalization/proposal.md b/openspec/changes/unified-statement-normalization/proposal.md new file mode 100644 index 0000000..7626c1b --- /dev/null +++ b/openspec/changes/unified-statement-normalization/proposal.md @@ -0,0 +1,114 @@ +## Why + +Формы ББ/ОФР сегодня нормализуются двумя разными конвейерами: scan-gateway (PDF → ИС Сканирование) и tabular-parser +(Excel). У них разные правила раскладки таблицы, отбора строк, разбора сумм, проверок §7 и статуса; ядро не доверяет +ни одному из них и пересчитывает проверки и статус заново по косвенным признакам, дважды дедуплицирует строки и держит +третью и четвёртую копии словарей (§7 и «код → наименование»). Пользователь видит последствия как отдельные «ошибки +распознавания» — AURORA-1406, 1462, 1511, 1517, 1519, 1522, 1523, 1524, 1634: строки молча пропадают, повтор кода +теряет сумму, «чистая» ячейка с потерянной цифрой не подсвечивается, предупреждения сервисов нигде не видны. + +Дизайн `design.md` (финальная версия, решения владельца Р-1…Р-19 от 17.10.2026, три раунда ревью) приводит оба +сервиса к одной цепочке шагов 1–9 с единым выходом каждого шага и общим кодом шагов 2–8; ядро хранит результат как +пришёл и пересчитывает его тем же общим кодом только после правок аналитика. + +Срез: вне плана срезов `09-delivery-plan.md` — переработка Среза 2b (Excel-формы), Среза 3a (OCR-нормализатор) и +Среза 3b (формы в ядре). Issue RSHB/arm-ka-docs#116. + +## What Changes + +- **Общий код нормализации** — новый репозиторий `arm-statements`, пакет `arm_statements/` (Р-1): типы шага 1 + (`FormCandidate`, `Cell`), шаги 2–8 (раскладка, отбор строк, значения, код и наименование, сборка документа, + самопроверки §7, статус и `needs_review`), словари §7 (полный и упрощённый наборы), периодов, заголовков форм, + подписей колонок, упорядоченные перечни кодов форм по редакциям и вариантам, диапазоны кодов. Словаря «код → + наименование» в пакете нет (Р-3). В tabular-parser, scan-gateway и arm-core пакет лежит копией, которую раскладывает + и проверяет скрипт `sync-statements` репозитория docs (по образцу `sync-web.sh`). +- **Строки не выбрасываются молча:** статусы строк `ok` | `unreadable` | `missing` | `detail`, `row_index`, `ref`, + сырой текст кода и ячейки (`text`) доезжают до БД и экрана; повтор кода — законный случай, все строки сохраняются + (Р-4, Р-8). **BREAKING** для потребителей: `line_code` становится nullable. +- **Качество структурно и с привязкой к месту:** `needs_review` у значения, строки и полей шапки; «подтверждение + проверкой» §7 вместо порога уверенности (Р-5, Р-7, Р-11, Р-12, Р-16, Р-17); `document.status` у каждого документа; + предупреждения — структуры `{code, severity, message, line_refs, column_index, field}` вместо строк. +- **Контракт `recognition-result.schema.json` v2.0** (MAJOR, Р-9) и конверт `recognition-result-message.schema.json` + v2.0 (структурные `metrics.warnings`); запрос `recognition-request` остаётся v1.0. Путь ОСВ в tabular-parser + меняется только в части структуры предупреждений и `document.status`. +- **arm-core:** DBML v5.19 и одна миграция (статусы и порядок строк, `needs_review`, `is_confirmed`, `source_text`, + `period_kind`, `form_variant`, таблица `document_issue`, снятие уникальности `(meta_id, line_code)` и — для ББ/ОФР — + `document (stored_file_id, document_type, osv_account_code)`, `recognition_job.statements_version`, + `related_group.crm_id` по arm-core#112); «агрегат формы по коду» вместо «одна строка на код»; приём 1.x и 2.0 на + время перехода; новое условие `RECOGNIZED`; PATCH `…/statement` с `lines`, `columns`, `values[].confirmed`; 409 + `LINES_UNRESOLVED` и `DUPLICATE_FORM` при подтверждении. +- **arm-web:** экран проверки формы — строки со статусами на своих местах, расшифровки под переключателем, подсветка + только по `needs_review`, предупреждения и нарушения у места, назначение кода, «Это расшифровка», подтверждение кода + и ячейки, дата колонки. +- **Документы:** `05-domain-rules.md` — §11 становится общей главой нормализации ББ/ОФР с подразделами по + источникам, §12 — заголовок-редирект; §7 — «посчитанное» соотношение, `abs_terms` и знак, упрощённый набор; §9.3, + §9.5 — статус, маппинг и подтверждение формы по v2.0. `07-open-questions.md` — закрыты OQ-36, OQ-141 (Р-8) и + вопросы, снятые решениями дизайна; новые вопросы — склейка половинок формы (Р-15) и открытое в дизайне. + `frontend-api-guide.md` — новые поля формы и PATCH (после реализации). + +Читают и пишут контракт: `arm-core` (читает результат и конверт, публикует запрос), `tabular-parser` и `scan-gateway` +(читают запрос, пишут результат и конверт). Дочерние change'и (заводятся при планировании, по одному на репозиторий): +`arm-statements` (новый), RSHB/arm-core, RSHB/arm-tabular-parser, RSHB/arm-scan-gateway, RSHB/arm-web; связанная +задача — RSHB/arm-core#112 (`related_group.crm_id` входит в ту же миграцию, Р-10). + +### Порядок выкладки — «сначала ядро» (обязателен) + +1. `arm-statements` — пакет с тестами; `sync-statements` в docs. +2. docs: контракт v2.0, DBML v5.19, правила (этот change). +3. arm-core: миграция v5.19, маппер 2.0 рядом с маппером 1.x, приём ack и конвертов 1.x и 2.0, PATCH, экранные поля + API. До этого шага ни один сервис не шлёт 2.0: первый же `line_code = null` дал бы файлу `ERROR`. +4. arm-web: экран по новым полям (поля 1.x-документов заполнены по умолчанию — экран работает и на них). +5. tabular-parser и scan-gateway переходят на 2.0 в любом порядке относительно друг друга. +6. После перехода обоих сервисов маппер 1.x удаляется из ядра отдельным PR (не в этом change). + +Копии схем в сервисах (`api/contracts/`, тест идентичности в `make check`) после слияния контракта v2.0 в docs +устаревают: каждый сервис обновляет копию в своём PR шага 5, ядро — в PR шага 3. + +## Non-goals + +- Разбор ОСВ (`classify.py`, `to_money` ОСВ, правила §1) не меняется; путь ОСВ в tabular-parser меняется только в + структуре предупреждений и `document.status`. +- Переключатель «как в файле» (порядок строк по `row_index`) на экране — не в этой работе (Р-14). +- Склейка двух половинок формы из одного файла в ядре не предоставляется — файл перезагружают; вопрос в + `07-open-questions.md` (Р-15). Склейка страниц из разных файлов (OQ-140) — тоже нет. +- Восстановление строк по наименованию (OQ-149) и сопоставление формы без колонки кодов по именам строк (OQ-139) — нет. +- Запрос `recognition-request` остаётся v1.0; subject'ы NATS, `Nats-Msg-Id`, ack и повторы не меняются по механике. +- Межформенная проверка `1370 − 1370(нач. года) = 2400` остаётся в ядре и мягкой; формулы §7 по редакциям (OQ-146) и + знаки 2410/2420 (OQ-12) не пересматриваются. +- Замена пакета-копии на зависимость из Nexus платформы — не сейчас (платформа Python-пакеты не собирает, Р-1). +- Приведение единиц измерения сверх числового формата ячейки (OQ-138) — нет. + +## Capabilities + +### New Capabilities +- `statement-normalization`: общая цепочка нормализации ББ/ОФР (шаги 1–8) — пакет `arm_statements`, его типы, + правила и словари, `sync-statements`. +- `tabular-recognition-service`: адаптер Excel шага 1 в tabular-parser и что остаётся в сервисе (чтение книги, листы + ОСВ, сборка конверта v2.0). +- `scan-gateway`: адаптер json ИС шага 1 в scan-gateway и что остаётся в сервисе (горячие папки, склейка набора json, + `raw/`, сборка конверта v2.0). +- `arm-core`: хранение результата v2.0 (DBML v5.19), агрегат по коду, `document_issue`, условие `RECOGNIZED`, PATCH + формы, порядок 409 подтверждения, приём 1.x и 2.0. +- `arm-web`: экран проверки формы ББ/ОФР по новым полям. + +### Modified Capabilities +- `recognition-contract`: файл результата и конверт — v2.0; правила перехода (ядро принимает 1.x и 2.0, «сначала + ядро»); статус и структурные предупреждения у всех документов, включая ОСВ. + +## Impact + +- Новый репозиторий `arm-statements`; скрипт `sync-statements` и цель Makefile в docs (вызывается из `make check-full` + потребителей, не из `make check`). +- tabular-parser: `parser/statements/*` (кроме разбора книги и адаптера шага 1) уходит в общий код; словари + `statement_checks.yaml`, `statement_periods.yaml`, `statement_headers.yaml` переезжают в пакет; путь ОСВ — структура + предупреждений и `document.status`. +- scan-gateway: `normalizer/ocr/text|dates|amounts` становятся общими разборами, `layout|rows|header|document` — шагами + 2–6 общего кода; `api/rules/statement_lines.yaml` удаляется, `statement_labels.yaml` и `ocr_lookalikes.yaml` уходят в + словари пакета. +- arm-core: миграция v5.19 (`financial_statement_*`, `document`, `document_issue`, `recognition_job`, + `related_group`), `result_mapping`, `statement_mapping`, `statements`, `documents`, `auto_confirm`, схемы API формы; + `api/rules/statement_checks.yaml` удаляется (копия словаря §7), `statement_lines.yaml` остаётся только словарём имён. +- arm-web: `widgets/DocumentReview` (экран [41]), типы API. +- Решение v5.13 «`needs_review` считается, не хранится» пересматривается (design, Decisions № 17). +- Риск окна выкладки: пока сервис шлёт 1.x, документы его конвейера получают статус по прежнему правилу D-6 и поля + v2.0 по умолчанию — экран и подтверждение работают, новых сигналов качества нет. diff --git a/openspec/changes/unified-statement-normalization/specs/arm-core/spec.md b/openspec/changes/unified-statement-normalization/specs/arm-core/spec.md new file mode 100644 index 0000000..35c81d5 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/specs/arm-core/spec.md @@ -0,0 +1,202 @@ +## ADDED Requirements + +### Requirement: Хранение результата формы v2.0 (DBML v5.19) +Маппер результата `2.0` SHALL писать документ формы как пришёл, без повторной дедупликации колонок, кодов и значений: +каждая строка результата — `financial_statement_line` (`row_index`, `source_ref` = `ref`, `line_status`, `line_code`, +`line_code_text`, `line_code_confidence`, `line_name`, `line_name_confidence`, `parent_row_index` — `row_index` строки +с `ref = parent_ref`, `needs_review`, `is_confirmed = false`); каждая пара «строка × колонка» — одна +`financial_statement_value` (`amount`, `string_value`, `source_text` = `values[].text`, `confidence`, `is_recognized`, +`needs_review`; значения, которого нет в результате, — пустая запись с `needs_review = false`); колонки — +`financial_statement_column` с `period_kind`, `label_confidence`; шапка — `financial_statement_meta` с `form_variant` +и `needs_review_fields`; предупреждения и нарушения документа — `document_issue` (источник `service`); версия общего +кода — `recognition_job.statements_version`. Уникальность `(meta_id, line_code)` снята; `(meta_id, source_ref)` +уникален там, где `source_ref` не `null`. Каждый `BALANCE` / `INCOME` результата SHALL становиться отдельным +`document` с `file_ordinal` (порядковый номер документа в результате); вторая форма того же типа в файле хранится +(Р-10); ОСВ по счёту, `DISCLOSURE` и `OTHER` — по-прежнему один документ на тип (и счёт) на файл. + +#### Scenario: Повтор кода сохраняется +- **WHEN** в результате две строки `ok` с кодом 2410 (пустая и с `(649)`) +- **THEN** в БД две строки 2410 с разными `row_index` и `source_ref`, обе со своими значениями + +#### Scenario: Строка без кода +- **WHEN** в результате строка `unreadable` с `line_code_text = "ИЧ,250"` и суммой +- **THEN** строка хранится с `line_code = null`, `line_status = unreadable`, `line_code_text = "ИЧ,250"` + +#### Scenario: Два баланса в одном файле +- **WHEN** результат содержит два `BALANCE` (лист-продолжение другого типа или двойная печать) +- **THEN** создаются два документа с разными `file_ordinal`; ни один не автоподтверждается (Р-18) + +### Requirement: Агрегат формы по коду +Все чтения формы «по коду» SHALL идти через одну чистую функцию «агрегат формы по коду» без хранения (DECISION-5): +для кода и колонки — сумма `amount` строк `ok` с этим кодом (`null`, если ни одной непустой), список `line_id`, +справочное имя. На неё переводятся межформенная проверка `1370 / 2400`, суммы формы для консолидации и выгрузок, +`parent_code` подстрок, раскладка строк на экране, правка ячеек по `line_code`, метрики `fields_*`. Строки `detail`, +`unreadable`, `missing` в агрегат, КФО и снимок ЕХ MUST NOT попадать. + +#### Scenario: Межформенная проверка по агрегату +- **WHEN** в ББ две строки 1370 (сумма 500 и 300 на отчётную дату) +- **THEN** межформенная проверка берёт 1370 = 800 + +### Requirement: Условие `RECOGNIZED` при приёме 2.0 +При приёме `2.0` статус документа формы SHALL быть `RECOGNIZED` ⇔ `document.status = done` ∧ в `metrics.warnings` файла +нет предупреждений уровня `error` ∧ `counterparty_check ≠ MISMATCH` ∧ период — конец квартала (`period_year` и +`period_quarter` определены) ∧ у каждой строки `ok` с неуверенным кодом (`line_code_confidence < 100`) распознанное +наименование совпало со справочным по коду ∧ в файле нет второй формы того же типа и периода; иначе `NEEDS_CHECK`. +Статус конверта и `needs_check` соседнего документа в условие MUST NOT входить (Р-19). Проверки §7, `needs_review` и +предупреждения сервиса при загрузке MUST NOT пересчитываться. Совпадение наименований: обе строки приводятся к нижнему +регистру, `ё` → `е`, всё, кроме букв и цифр, — пробел, пробелы схлопываются; совпало, если строки равны, или более +короткая (не короче 5 символов) входит в более длинную, или отношение сходства `difflib.SequenceMatcher` ≥ 0,8; с +любым справочным именем кода в любой редакции словаря ядра. Кода нет в словаре имён или наименование пусто — не +совпало. Несовпадение — `document_issue` `NAME_MISMATCH` (`info`, источник `core`, строка). +`RECOGNIZED` SHALL, как и прежде, автоподтверждаться (OQ-157). + +#### Scenario: Ячейка соседнего документа +- **WHEN** ББ `done`, ОФР того же файла `needs_check`, предупреждений файла уровня `error` нет, ИНН совпал, период — + 30.09.2025 +- **THEN** ББ `RECOGNIZED` и автоподтверждается, ОФР — `NEEDS_CHECK` + +#### Scenario: Потерянный json +- **WHEN** все документы `done`, но в `metrics.warnings` файла `JSON_FAILED` уровня `error` +- **THEN** все документы формы файла `NEEDS_CHECK` + +#### Scenario: Подмена неуверенного кода на соседа +- **WHEN** строка с кодом 1120 (уверенность 50) и наименованием «Нематериальные активы» +- **THEN** наименование не совпало со справочным «Результаты исследований и разработок», документ `NEEDS_CHECK`, + `NAME_MISMATCH` у строки + +#### Scenario: Период не конец квартала +- **WHEN** документ `done` с датой отчёта 15.08.2025 +- **THEN** квартал не определён, документ `NEEDS_CHECK` (как в D-6) и не автоподтверждается; ручное подтверждение — + 409 `PERIOD_REQUIRED`, пока аналитик не поправит дату отчёта + +### Requirement: Предупреждения и нарушения документа — `document_issue` +Ядро SHALL хранить предупреждения и нарушения документа в `document_issue`: вид (`warning` | `relation` | `soft`), код, +уровень (`error` | `info`), сообщение, список `line_id`, `column_id`, поле шапки, источник (`service` | `core`), для +нарушений — текст соотношения, `expected`, `actual`. При загрузке пишутся предупреждения и `checks` результата +(источник `service`) и проверки ядра уровня `info` (источник `core`): `CODE_NOT_IN_DICTIONARY` (кода нет в словаре +имён), `CODE_NOT_IN_EDITION` (код не из перечня редакции по эффективной дате — по копии перечня `arm_statements`), +`NAME_MISMATCH`. При перезагрузке файла записи документа удаляются вместе с ним. При пересчёте после правки записи +`relation` и `soft` заменяются целиком независимо от источника; записи `warning` источника `service` сохраняются, +кроме снимаемых правкой: правка `header.report_date` снимает `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`, +`REPORT_DATE_UNCERTAIN`; правка `header.inn` или `organization_name` — `NO_ORGANIZATION_NO_INN`; правка +`columns[].period_date` — `COLUMN_WITHOUT_DATE` и `COLUMN_DATE_UNCERTAIN` этой колонки; прочие `error` сервиса снимаются +только перезагрузкой файла. + +#### Scenario: Дата колонке +- **WHEN** у колонки предупреждение `COLUMN_WITHOUT_DATE`, аналитик ставит ей `period_date` через PATCH +- **THEN** предупреждение удаляется, статус пересчитывается + +#### Scenario: Неснимаемое предупреждение +- **WHEN** у документа `SEGMENT_COLUMNS_MISMATCH`, аналитик правит значения +- **THEN** предупреждение остаётся; документ можно подтвердить вручную (оно подтверждение не блокирует) + +### Requirement: Пересчёт после правки +После каждого PATCH формы ядро SHALL пересчитывать `checks`, `needs_review` и статус тем же общим кодом +`arm_statements` по сохранённым данным (конвенция знака — по `source_text`), с правилами: `is_modified` или +`is_confirmed` значения ⇒ `needs_review = false`; `line_code_modified` или `is_confirmed` строки ⇒ `needs_review` +строки = `false`; акцепт ⇒ жёсткие нарушения не учитываются; правленые ячейки в голосование знака не входят. +Отличие `statements_version` задания от версии копии ядра — `document_issue` `STATEMENTS_VERSION_MISMATCH` (`info`). +Один PATCH — один пересчёт и одна запись аудита `RECOGNIZED_DATA_EDIT`. Акцепт сбрасывается любой правкой, как и +прежде. + +#### Scenario: Подтверждение ячейки без правки +- **WHEN** аналитик подтверждает ячейку с `needs_review = true` без изменения суммы +- **THEN** `is_confirmed = true`, `confirmed_by/at`, `is_modified` не ставится, `needs_review = false`, аудит + +### Requirement: PATCH формы — строки, колонки, подтверждение ячеек +`PATCH /documents/{id}/statement` SHALL принимать помимо `header` и `values` (сумма): +- `values[] {line_id, column_id, confirmed: true}` — подтвердить ячейку без изменения значения; +- `columns[] {column_id, period_date}` — дата колонке (пересчитывает `is_current` и период документа по правилу §9.5 + п. 2); правка `header.report_date` (в том числе `null`) пересчитывает `period_end` колонок с `period_kind = + relative:*` общим кодом (`null` даёт `null`); +- `lines[] {line_id, line_code}` — назначить или сменить код; `lines[] {line_id, line_status: "detail"}` — отметить + расшифровкой; `lines[] {line_id, confirmed: true}` — подтвердить код. + +Переходы: `unreadable` / `missing` / `detail` → `ok` назначением кода; `unreadable` / `missing` / `ok` → `detail` +отметкой; `ok` → `ok` сменой кода (прежний — в `line_code_original_value`); `ok` с неуверенным кодом — подтверждением. +Назначенный или подтверждённый код: `line_code_confidence = 100`, `line_code_modified = true`, `needs_review` +строки = `false`; у `detail` `parent_row_index` пересчитывается как ближайшая строка `ok` выше по `row_index`. Код — +4–5 цифр (иначе 422 `INVALID_LINE_CODE`), может совпадать с существующим; незнакомый словарю — `info`. Шаблонная строка +с тем же кодом, созданная правкой аналитика с суммами, — 409 `LINE_EXISTS` (сначала очистить её суммы или выбрать +другой код); без сумм — удаляется, её место занимает назначенная. `line_status = ok` без кода — 422 +`LINE_CODE_REQUIRED`; в одном элементе больше одного действия или ни одного — 422 `INVALID_LINE_PATCH`; строка не этого +документа — 422 `UNKNOWN_LINE`; колонка не этого документа — 422 `UNKNOWN_COLUMN`. Правка суммы по `line_code` (для +достроенной строки) при наличии строки `ok` с этим кодом — 422 `UNKNOWN_CELL` (передайте `line_id`). Удаления строк +нет. Строки, созданные правкой: `row_index = null`, `source_ref = null`, `line_status = ok`. Аудит +`RECOGNIZED_DATA_EDIT` с `before/after` по коду, статусу, родителю и подтверждениям. Ограничения состояния — как +прежде (`DOCUMENT_READONLY`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_STATEMENT`). + +#### Scenario: Назначение кода строке-мусору (AURORA-1462) +- **WHEN** аналитик назначает строке `unreadable` «ИЧ,250» код 1250 +- **THEN** строка `ok`, `line_code = 1250`, `line_code_confidence = 100`, `line_code_modified = true`, + `line_code_original_value = null`; §7 и статус пересчитаны + +#### Scenario: Отметка расшифровкой +- **WHEN** аналитик отмечает строку `missing` с суммой как расшифровку +- **THEN** `line_status = detail`, строка выходит из сумм и не блокирует подтверждение + +#### Scenario: Код занят шаблонной строкой с суммами +- **WHEN** аналитик назначает строке `unreadable` код 1250, а строка 1250 создана правкой аналитика и имеет сумму +- **THEN** 409 `LINE_EXISTS`, ничего не изменено + +### Requirement: Подтверждение формы — порядок 409 +`POST /documents/{id}/confirm` для формы ББ/ОФР SHALL проверять в порядке: `FILE_SUPERSEDED` → `ALREADY_CONFIRMED` → +`DOCUMENT_NOT_CONFIRMABLE` → `PERIOD_REQUIRED` → `LINES_UNRESOLVED` (есть строки `unreadable` / `missing` с непустым +`amount`; `detail.lines[] {line_id, row_index, line_code_text}`; строки без сумм не мешают) → `DUPLICATE_FORM` (в файле +уже подтверждена другая форма того же типа и периода; `detail.document_id`) → `CHECKS_NOT_PASSED` (жёсткие нарушения +без акцепта). Между файлами повтор формы решает автозамена §9.5 п. 3. + +#### Scenario: Неразобранная строка с суммой +- **WHEN** у формы строка `missing` с суммой и одновременно жёсткое нарушение +- **THEN** 409 `LINES_UNRESOLVED` с этой строкой в `detail.lines` + +#### Scenario: Вторая форма того же периода +- **WHEN** в файле два ББ на 30.09.2025, первый подтверждён, аналитик подтверждает второй +- **THEN** 409 `DUPLICATE_FORM` со ссылкой на первый + +### Requirement: Словари ядра и достройка шаблонных строк +Копия словаря §7 ядра (`api/rules/statement_checks.yaml`) SHALL удаляться: проверки ядро считает копией пакета +`arm_statements`. `api/rules/statement_lines.yaml` SHALL оставаться только словарём имён (код → наименование по +редакциям); порядок и состав редакций — из копии перечня кодов пакета; `sync-statements --check` сверяет, что все коды +словаря имён есть в перечне. Шаблонные строки достраиваются по словарю имён; код, у которого есть хотя бы одна строка +`ok` из файла, шаблоном MUST NOT достраиваться; строки `unreadable` / `missing` не заменяются шаблонной строкой молча. + +#### Scenario: Код есть только у строки unreadable +- **WHEN** у формы нет строки `ok` с кодом 1250, но есть строка `unreadable` «ИЧ,250» +- **THEN** шаблонная строка 1250 достраивается пустой, строка `unreadable` показывается отдельно со статусом + +### Requirement: Ответ `GET …/statement` по v2.0 +`StatementOut` SHALL отдавать: у документа `form_variant`; у шапки `needs_review_fields` и сырые тексты полей; у +колонок `period_kind`, `label_confidence`; у строк `id`, `origin`, `row_index`, `source_ref`, `line_status`, +`line_code`, `line_code_text`, `line_code_confidence`, `line_name` (из файла), `reference_name` (справочное имя по +коду или `null`), `parent_row_index`, `parent_code`, `needs_review`, `is_confirmed`, `line_code_modified`; у значений +`text` (`source_text`), `needs_review`, `is_confirmed` и прежние поля; `issues[]` — записи `document_issue` с `line_ids`, +`column_id`, `field`; `checks` — нарушения с `codes` и `line_ids`. Порядок `lines` — по коду «как на бумаге» (Р-14): +внутри строк `ok` по возрастанию кода, одинаковые коды по `row_index`; строки `unreadable` / `missing` — после +ближайшей предыдущей по `row_index` строки, размещённой по коду, без такой — в начале; `detail` — после родителя. + +#### Scenario: Документ, принятый по 1.x +- **WHEN** форма загружена до перехода сервиса (результат 1.2) +- **THEN** у строк `line_status = ok`, `row_index` по порядку, `source_ref = null`, у значений `text = string_value`, + `issues` содержат прежние предупреждения маппинга, экран работает + +### Requirement: Миграция DBML v5.19 +Одна миграция Alembic SHALL приводить схему к DBML v5.19 и откатываться; заполнение существующих данных: +`row_index` строк — по возрастанию `line_code` внутри формы с 0, `line_status = ok`, `needs_review` строк — `false`, +`needs_review` значений — по прежнему `values_ok`, `source_text = string_value`, `is_confirmed = false`, +`period_kind` — `absolute` при `period_date`, иначе `none`, `document.file_ordinal` — по `(created_at, id)` внутри +файла с 0; `related_group.crm_id` — `null`. Сверка `check_dbml_vs_orm.py` — против `data-model-v5.19.dbml`. + +#### Scenario: Цикл миграций +- **WHEN** `alembic upgrade head` → `downgrade base` → `upgrade head` на чистой БД +- **THEN** все три шага проходят, сверка ORM с DBML v5.19 — без расхождений + +### Requirement: `crm_id` ГСК/ГСЗ +`related_group.crm_id` (`varchar`, nullable, уникален) SHALL сохраняться в `POST …/related-groups` и +`PATCH /directories/related-groups/{id}` и отдаваться в `RelatedGroupOut` / `RelatedGroupDetail`; повтор — 409 +`CRM_ID_EXISTS`; изменение — аудит `GROUP_CHANGE` с `before/after` (arm-core#112). Импорт ЕЦП `crm_id` групп не +заполняет (OQ-91). + +#### Scenario: Занятый crm_id +- **WHEN** создаётся ГСЗ с `crm_id`, который уже есть у другой ГСК или ГСЗ +- **THEN** 409 `CRM_ID_EXISTS`, ничего не создано diff --git a/openspec/changes/unified-statement-normalization/specs/arm-web/spec.md b/openspec/changes/unified-statement-normalization/specs/arm-web/spec.md new file mode 100644 index 0000000..3110559 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/specs/arm-web/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: Раскладка строк формы со статусами +Экран проверки формы ББ/ОФР ([41]) SHALL сохранять раскладку «как на бумаге» (Р-14): разделы, внутри — строки `ok` по +возрастанию кода, несколько строк одного кода — в порядке файла (`row_index`), шаблонные строки на своих местах, +пятизначные подстроки под родителем. Строка `unreadable` / `missing` SHALL стоять под ближайшей предыдущей по +`row_index` строкой, размещённой по коду, а без такой — в начале первого раздела; она подсвечена целиком, в ячейке +кода серым — сырой текст `line_code_text` с подсказкой. Строки `detail` SHALL быть скрыты под переключателем +«Показать расшифровки (N)» и показываться под родителем (`parent_row_index`) с отступом. Экран показывает справочное +имя строки (`reference_name`), файловое (`line_name`) — рядом, если отличается. Переключателя «как в файле» нет. + +#### Scenario: Код-мусор на своём месте +- **WHEN** строка `unreadable` «ИЧ,250» в файле стоит после строки 1240 +- **THEN** на экране она стоит сразу под 1240, подсвечена, в ячейке кода серым «ИЧ,250» + +#### Scenario: Расшифровки скрыты +- **WHEN** у формы три строки `detail` +- **THEN** по умолчанию их не видно, переключатель «Показать расшифровки (3)»; включённый показывает их под родителями + +### Requirement: Подсветка и предупреждения у места +Экран SHALL подсвечивать ячейки только по `needs_review` (в подсказке — `text` и уверенность), строки — по +`needs_review` строки, поля шапки — по `needs_review_fields`. Предупреждения и нарушения (`issues`, `checks`) SHALL +показываться у места: у строк (`line_ids`), ячеек (`line_ids` + `column_id`), полей шапки (`field`) или у документа; +уровень `error` отличается от `info`. Подсветки по `confidence < 100` и `is_recognized = false` напрямую нет. + +#### Scenario: Догадка подтверждена проверкой +- **WHEN** значение `is_recognized = false`, `needs_review = false` +- **THEN** ячейка не подсвечена; в подсказке сырой текст и уверенность + +### Requirement: Правка строк, колонок и подтверждение в режиме «Редактировать» +В режиме «Редактировать» экран SHALL давать: у `unreadable` / `missing` — поле ввода кода с подсказкой из справочника +(код → справочное имя) и пункт меню строки «Это расшифровка»; у `detail` — «Назначить код»; у строки `ok` — «Сделать +расшифровкой», смену кода и «Подтвердить код» у строки с `needs_review`; подтверждение ячейки без правки; ввод даты у +колонки без даты (`period_kind = none`) или с предупреждением о дате. «Сохранить» отправляет один `PATCH …/statement` +(`header`, `values`, `columns`, `lines`). Кнопка «Подтвердить данные» при строках `unreadable` / `missing` с суммами +SHALL быть отключена с подсказкой «Разберите строки без кода: N» и переходом к первой такой строке; ответ сервера 409 +`LINES_UNRESOLVED` показывается тем же текстом, 409 `DUPLICATE_FORM` — со ссылкой на подтверждённую форму, 409 +`LINE_EXISTS` — у поля кода. + +#### Scenario: Назначить код +- **WHEN** аналитик вводит 1250 в ячейку кода строки `unreadable` и сохраняет +- **THEN** уходит `PATCH` с `lines: [{line_id, line_code: "1250"}]`; после ответа строка встаёт на место 1250 + +#### Scenario: Подтверждение заблокировано +- **WHEN** у формы две строки `missing` с суммами +- **THEN** «Подтвердить данные» отключена, подсказка «Разберите строки без кода: 2», клик по подсказке ведёт к первой diff --git a/openspec/changes/unified-statement-normalization/specs/recognition-contract/spec.md b/openspec/changes/unified-statement-normalization/specs/recognition-contract/spec.md new file mode 100644 index 0000000..f19e922 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/specs/recognition-contract/spec.md @@ -0,0 +1,129 @@ +## MODIFIED Requirements + +### Requirement: Сообщение результата распознавания +Тело сообщения на subject ответа (настройка окружения `NATS_RESULT_SUBJECT`, по умолчанию `files.tabular_parsed`) +SHALL соответствовать `docs/contracts/recognition-result-message.schema.json` +v2.0: обязательные поля `schema_version` (`"2.0"`), `job_id`, `file_id`, `status` (`processing` | `done` | `needs_check` | +`error`), `service {name, version}`; при `status ∈ {done, needs_check}` обязательны `result_s3 {bucket, key}` и +`result_schema_version`; при `status = error` обязателен `error {code, message}`; опциональны `raw_s3_key` +(только `scan-gateway`) и `metrics`. `metrics.warnings[]` — структуры `warning` (как в файле результата), не строки. +Поле `group_schema` в конверт NOT включается — источник `recognition_job.group_schema` — файл результата. +Производитель v2.0 SHALL писать в конверт тот же MAJOR, что и в файл результата. + +#### Scenario: Успешный результат +- **WHEN** сервис завершил обработку файла +- **THEN** файл результата записан в S3 по ключу `results/{file_id}/{job_id}/result.json`, а затем опубликован конверт + со `status = done | needs_check`, `result_s3` указывает на этот файл, `result_schema_version = "2.0"`, заголовок + `Nats-Msg-Id` = `result::` + +#### Scenario: Ошибка обработки +- **WHEN** сервис не смог обработать файл (нечитаем, таймаут внешней системы, тип не поддерживается) +- **THEN** публикуется конверт со `status = error` и `error.code` из перечня схемы (`UNSUPPORTED_FORMAT`, `UNREADABLE`, + `TIMEOUT`, `SCHEMA_MISMATCH`, `NOT_IMPLEMENTED`, `FILE_READ_ERROR`, `INVALID_FORMAT`, `INVALID_DOCUMENT`, + `RECOGNITION_FAILED`, `UNKNOWN_ERROR`); `result_s3` может отсутствовать + +#### Scenario: Предупреждения в конверте +- **WHEN** у файла есть предупреждение уровня файла (ошибочный json набора ИС) +- **THEN** `metrics.warnings` конверта содержит объект `{code: "JSON_FAILED", severity: "error", message, locator}` + +### Requirement: Файл результата +Файл результата SHALL соответствовать `docs/contracts/recognition-result.schema.json` v2.0: один файл (`stored_file`) +→ один `job_id` → массив `documents` из `osvDocument` и/или `statementDocument`; денежные значения — строки +`^-?\d+(\.\d{1,2})?$`; `confidence` — 0–100. Пустой массив `documents` допустим только при `status = error`. +`service.statements_version` — коммит копии общего кода `arm_statements`, которым посчитан результат (обязателен при +наличии `statementDocument`). `metrics.warnings[]` — предупреждения уровня файла (структура `warning` с `locator`). + +Каждый документ (и `osvDocument`, и `statementDocument`) SHALL иметь `status` (`done` | `needs_check`) и `warnings[]` — +структуры `{code, severity, message, line_refs?, column_index?, field?}`; у `osvDocument` все предупреждения — `info`, +`status = done`, когда ведомость разобрана. Остальное в `osvDocument` — как в v1.2 (`extra`, `period_start`, +`period_end`, `is_from_year_start`). + +`statementDocument` SHALL иметь `form_variant` (`full` | `simplified` | `null`); `header` — поля `organization_name`, +`inn`, `report_date`, `units`, `okud`, `okpo`, `okopf`, `okfs`, `address` и для каждого `<поле>_text` и +`<поле>_confidence`, плюс `needs_review_fields[]`; `columns[]` — `column_index`, `label`, `label_confidence`, +`period_date`, `period_kind` (`absolute` | `relative:current` | `relative:prev_year_end` | +`relative:prev_prev_year_end` | `relative:prev_period` | `none`); `lines[]` — `row_index`, `ref`, `line_status` (`ok` | +`unreadable` | `missing` | `detail`), `line_code` (строка 4–5 цифр или `null`; `null` ⇔ статус не `ok`), +`line_code_text`, `line_code_confidence`, `line_name` (строка или `null`), `line_name_confidence`, `parent_ref` (у +`detail`), `needs_review`, `values[]` — `column_index`, `amount`, `string_value`, `text`, `confidence`, +`is_recognized`, `needs_review`. `checks` SHALL присутствовать у `BALANCE` / `INCOME` со строками и отсутствовать у +документа без строк; `relations_failed` и `soft_relations_failed` — `{column_index, relation, codes[], expected, +actual}`; `duplicate_line_codes` удалён. `document_type` — `BALANCE` | `INCOME` | `DISCLOSURE` | `OTHER`. `ref` +уникален в документе. + +#### Scenario: Результат валидируется примерами +- **WHEN** CI сервиса запускает тесты контракта +- **THEN** все файлы `docs/contracts/examples/result-*.json` проходят схему файла результата v2.0, а все + `request-*.json` / `result-message-*.json` — схемы сообщений + +#### Scenario: Лишняя колонка сохранена +- **WHEN** в ОСВ между ИНН и суммами есть колонка «КПП» +- **THEN** у строк контрагентов `extra = {"КПП": "<значение>"}`, файл проходит схему v2.0 + +#### Scenario: Форма из Excel с проверками +- **WHEN** tabular-parser разобрал книгу с балансом на двух листах, ОФР со строкой без кода под 2110 и пояснениями + (`examples/result-statement-excel.json`) +- **THEN** файл проходит схему v2.0: `BALANCE` с `locator` из двух листов, `status`, `checks` и мягким нарушением; + `INCOME` со строкой `detail` (`line_code = null`, `parent_ref`); `DISCLOSURE` без `columns`/`lines` и без `checks` + +#### Scenario: Форма из OCR с сомнительными значениями +- **WHEN** scan-gateway разобрал ББ, где одна сумма собрана из грязного текста и покрыта соотношением, а одна строка + `unreadable` (`examples/result-statement-minimal.json`) +- **THEN** файл проходит схему v2.0; у покрытого значения `is_recognized = false`, `needs_review = false`, `text` — + сырой; у строки `unreadable` `line_code = null`, `line_code_text` — сырой текст; документ `needs_check` + +#### Scenario: Границы периода ОСВ +- **WHEN** в шапке ОСВ период «Октябрь 2024 г. – Сентябрь 2025 г.» +- **THEN** `period_start = "2024-10-01"`, `period_end = "2025-09-30"`, `is_from_year_start = false`; документ ОСВ с + `status` и `warnings`, файл проходит схему v2.0 + +#### Scenario: Строка без кода с кодом в поле +- **WHEN** у строки `line_status = unreadable` передан непустой `line_code` +- **THEN** файл не проходит схему v2.0 + +### Requirement: Версионирование контракта +Поле `schema_version` SHALL быть строкой `MAJOR.MINOR`. Добавление необязательных полей или значений enum — +`MINOR` (обратно совместимо); удаление/переименование полей, смена типов или семантики — `MAJOR`. +Сервис SHALL отвергать запрос с неизвестным `MAJOR` (`error.code = SCHEMA_MISMATCH`); ядро SHALL принимать результат +с известным `MAJOR` и любым известным или новым `MINOR`. Запрос `recognition-request` остаётся v1.0; файл результата и +конверт — v2.0 (`MAJOR` поднят: `line_code` стал nullable, `metrics.warnings` и `document.warnings` — структуры, удалён +`checks.duplicate_line_codes`, изменён смысл `is_recognized` у OCR). + +#### Scenario: Несовместимая версия запроса +- **WHEN** сервис, поддерживающий запрос `1.x`, получает запрос `schema_version = "2.0"` +- **THEN** публикуется результат `status = error`, `error.code = SCHEMA_MISMATCH` **без ack `processing`**: ответом на + запрос служит сам финальный конверт, ядро закрывает им задание (`published → error`), и повторной доставки запроса не + будет. Конверт публикуется только если из тела извлекаются корректные `job_id` и `file_id`; иначе — как для нечитаемого + запроса (ответа нет, ядро закроет задание по `NO_ACK` / `dlq`). `attempt` для `Nats-Msg-Id` берётся из тела запроса; при его + отсутствии реализации расходятся (`tabular-parser` берёт `attempt` из заголовка `Nats-Msg-Id` запроса или не отвечает, + `scan-gateway` подставляет 1) — см. OQ-71 + +#### Scenario: Ядро получает MAJOR, которого не знает +- **WHEN** ядро, знающее `1.x` и `2.x`, получает конверт или файл результата `schema_version = "3.0"` +- **THEN** результат не проходит проверку схемы, файл получает `ERROR` с причиной в `metrics.mapping_error`, повтор не + выполняется (как для любого результата не по схеме) + +## ADDED Requirements + +### Requirement: Переход на контракт v2.0 — «сначала ядро» +Ядро SHALL до перехода любого сервиса принимать конверты (включая ack `processing`) и файлы результата и `1.x`, и +`2.0`, с двумя мапперами файла результата. При приёме `1.x` статус документа формы SHALL считаться по прежнему правилу +D-6, а поля v2.0 заполняться по умолчанию: `line_status = ok`, `row_index` — по порядку строк в результате, `ref = +null`, `text = string_value`, `needs_review` значения — по прежнему `values_ok` (`is_recognized` не `false` и +`confidence` 100 или пуста), `needs_review` строки — `false`, `period_kind` — `absolute` при `period_date`, иначе `none`, +`form_variant = null`, предупреждения-строки — `warning` `{code: "LEGACY_WARNING", severity: "info", message}`. +Сервисы SHALL переходить на `2.0` только после выкладки ядра, принимающего `2.0`. Маппер `1.x` удаляется после +перехода обоих сервисов отдельным изменением. + +#### Scenario: Сервис ещё на v1.2 +- **WHEN** после выкладки ядра tabular-parser присылает результат `1.2` с повтором кода и `checks.duplicate_line_codes` +- **THEN** ядро принимает его маппером `1.x`, статус документа — по D-6, файл не `ERROR` + +#### Scenario: Ack нового сервиса +- **WHEN** scan-gateway после перехода публикует ack `processing` со `schema_version = "2.0"` +- **THEN** ядро переводит задание в `processing`, как для ack `1.0` + +#### Scenario: Порядок нарушен +- **WHEN** сервис прислал результат `2.0` с `line_code = null` ядру, знающему только `1.x` +- **THEN** результат не проходит проверку схемы, файл получает `ERROR` (`metrics.mapping_error`) — поэтому порядок + «сначала ядро» обязателен diff --git a/openspec/changes/unified-statement-normalization/specs/scan-gateway/spec.md b/openspec/changes/unified-statement-normalization/specs/scan-gateway/spec.md new file mode 100644 index 0000000..d66b4d7 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/specs/scan-gateway/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: Адаптер json ИС шага 1 +scan-gateway SHALL оставить у себя обмен с ИС Сканирование (горячие папки, опрос, срок, склейку набора json в +`raw/{job_id}.json` до нормализации, очистку папки) и адаптер «таблица `table_structure[i]` json → `FormCandidate`»; +шаги 2–8 SHALL выполняться копией пакета `arm_statements`. Адаптер SHALL: создавать кандидата на каждую таблицу каждого +успешного json; копировать шапку json (`organization`, `inn`, `units`, `date`) в шапку каждой своей таблицы как `Cell` +(`text = form_name`, `confidence` ИС); передавать `label.form_name` таблицы как заголовок в `evidence` (тип — общим +правилом шага 1 по словарю заголовков, `statement_labels.yaml` уходит в словарь пакета); строить плотную сетку по +`row_index` и `column_index` ИС (пустые позиции — `null`), `Cell.ref = row:`; `locator = +file:<имя json>;table:`. `value` у OCR-ячеек всегда `null`. + +#### Scenario: Один json — ББ и ОФР +- **WHEN** json `aep_3.json` содержит две таблицы — «Бухгалтерский баланс» и «Отчет о финансовых результатах» — под + общей шапкой +- **THEN** два кандидата с `locator` `file:aep_3.json;table:0` и `…;table:1`, у каждого копия шапки json + +### Requirement: Ошибки набора json +Ошибочный json набора (`parse_error`, непустой `error.code`, корень не объект) при хотя бы одном успешном соседе SHALL +давать предупреждение файла `JSON_FAILED` (`error`, `locator = file:<имя json>`, в `message` — код и сообщение ИС); +все json ошибочны — конверт `error` с кодом первого. Исключение при разборе таблицы — предупреждение `TABLE_PARSE_ERROR` +(`error`) документа-кандидата, остальные таблицы разбираются; ни одной таблицы не разобрано — `error +RECOGNITION_FAILED`. + +#### Scenario: Потерянный ОФР (решение № 28 среза 3a) +- **WHEN** `.json` успешен (ББ), а `_ofr.json` вернул `INVALID_DOCUMENT` +- **THEN** документ ББ получает свой статус; в `metrics.warnings` — `JSON_FAILED` уровня `error`; конверт + `needs_check`; ядро не автоподтверждает документы файла + +### Requirement: Что уходит из шлюза +scan-gateway SHALL удалить `api/rules/statement_lines.yaml` и подстановку наименований по справочнику, срезание +уверенности кода до 99 по справочнику, выбор редакции для имён (`_choose_edition`), пропуск строк с нечисловым и пустым +кодом со счётчиками, правило «оставлена первая строка» при повторе кода, синтез шапки, правило «ячейки под колонкой +нет → `confidence = 0`, не распознано», привязку `is_recognized` к `confidence` и правило «любое предупреждение → +`needs_check`». Модули `normalizer/ocr/text|dates|amounts` переезжают в общие разборы пакета, `layout|rows|header| +document` — в шаги 2–6 пакета; `ocr_lookalikes.yaml` — в словарь пакета. + +#### Scenario: Строки без кода в aep_3 +- **WHEN** в `aep_3.json` строки «БАЛАНС» и «Выручка» имеют пустой код, а в колонке значения мусор `211(1 |` +- **THEN** строки попадают в результат со статусами (`detail` под строкой с кодом, `missing` в начале таблицы) и сырым + текстом; счётчиков «строк без кода с суммами пропущено» нет diff --git a/openspec/changes/unified-statement-normalization/specs/statement-normalization/spec.md b/openspec/changes/unified-statement-normalization/specs/statement-normalization/spec.md new file mode 100644 index 0000000..7bac561 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/specs/statement-normalization/spec.md @@ -0,0 +1,530 @@ +## ADDED Requirements + +### Requirement: Пакет общего кода `arm_statements` и его копии +Общий код нормализации ББ/ОФР SHALL жить в репозитории `arm-statements` пакетом `arm_statements/` с `pyproject`, +своими тестами и конфигом линтеров. Пакет SHALL содержать: типы шага 1 (`FormCandidate`, `Cell`; сериализуемые в +json dataclass-ы), шаги 2–8, общие разборы текста (двойники, даты, суммы, коды), словари — проверок §7 +`statement_checks.yaml` (полный и упрощённый наборы), периодов `statement_periods.yaml`, заголовков форм и ОКУД, +подписей колонок («Код», «Наименование», исключения), букв-двойников, упорядоченные перечни кодов форм по редакциям +(`66n`, `fsbu4`) и вариантам (`full`, `simplified`) без наименований, диапазоны кодов — и загрузчик словарей. Словаря +«код → наименование» в пакете MUST NOT быть (Р-3). Пакет SHALL владеть своими типами входа и выхода; конвертацию +выхода в модель контракта делает хост. Golden-тесты пакета — внутри пакета на синтетических и обезличенных данных; +тесты на реальных фикстурах остаются в сервисах и соседние репозитории не читают. + +В tabular-parser, scan-gateway и arm-core пакет SHALL лежать копией вместе с тестами и конфигом линтеров, подключённым +к `pytest` и `ruff` хоста как include, и импортироваться по имени `arm_statements`. Копию SHALL раскладывать и +проверять скрипт `sync-statements` репозитория docs (по образцу `sync-web.sh`): `--check` возвращает код 20, если +копия отличается от источника или правилась напрямую; скрипт вызывается из `make check-full` потребителя и MUST NOT +вызываться из `make check` и пайплайна платформы. Маркер коммита источника копии SHALL попадать в результат как +`service.statements_version`. + +#### Scenario: Прямая правка копии +- **WHEN** в `tabular-parser` изменён файл копии пакета, а источник в `arm-statements` — нет +- **THEN** `sync-statements --check` завершается с кодом 20 и называет файл; `make check` сервиса этого не проверяет + +#### Scenario: Версия общего кода в результате +- **WHEN** сервис собирает результат копией пакета, разложенной из коммита `abc1234` репозитория `arm-statements` +- **THEN** в результате `service.statements_version = "abc1234"` + +### Requirement: Шаг 1 — кандидат в форму +Каждый адаптер шага 1 SHALL выдавать на файл список `FormCandidate`, и только с ним работают шаги 2–8. Поля +кандидата: `source` (`ocr` | `excel`); `locator` (`file:<имя json>;table:` у OCR, `sheet:[,…]` у Excel); +`type_hint` (`BALANCE` | `INCOME` | `DISCLOSURE` | `null`); `evidence` (ОКУД и заголовок формы как `Cell`); `header` +(поля `organization_name`, `inn`, `report_date`, `units`, `okud`, `okpo`, `okopf`, `okfs`, `address` — каждое `Cell` +или `null`); `rows` — плотная сетка строк × ячеек, все страницы и листы формы уже в одной сетке, пустая позиция — +`null`; `segment_breaks` — номера строк сетки, с которых начинается новый лист Excel (у OCR пусто); `warnings`. + +`Cell` SHALL быть одной для шапки и таблицы: `text` — как показывает источник; `value` — число или дата, если +источник их знает (у OCR всегда `null`; у Excel — значение по числовому формату ячейки, tp#42); `confidence` 0–100 +(у Excel всегда 100); `ref` — позиция в источнике (OCR — `row:`, Excel — `<лист>!`). + +`type_hint` SHALL вычисляться одним правилом у обоих адаптеров: ОКУД по словарю (0710001 → `BALANCE`, 0710002 → +`INCOME`, прочие 0710xxx → `DISCLOSURE`) → иначе заголовок по словарю заголовков (у OCR заголовок — `label` таблицы +ИС; «Пояснения», «Расшифровка» → `DISCLOSURE`) → иначе `null`. + +#### Scenario: Excel-ячейка с числовым форматом +- **WHEN** в Excel ячейка хранит 106311116.46 с форматом `#,##0,` +- **THEN** у `Cell` `text = "106 311"`, `value = 106311`, `confidence = 100`, `ref = "Баланс!D27"` (лист и адрес + ячейки) + +#### Scenario: OCR-ячейка +- **WHEN** в таблице ИС ячейка строки `row_index = 8` содержит `"[190 :"` с уверенностью 25 +- **THEN** у `Cell` `text = "[190 :"`, `value = null`, `confidence = 25`, `ref = "row:8"` + +#### Scenario: Тип по ОКУД против заголовка +- **WHEN** у блока формы ОКУД 0710002, а заголовок «Бухгалтерский баланс» +- **THEN** `type_hint = INCOME`; окончательный тип выбирается по кодам на шаге 6 + +### Requirement: Шаг 1 — шапка кандидата +У каждого кандидата SHALL быть своя шапка. Текст каждого поля разбирается общим кодом по `Cell.text`: ИНН — только +цифры, без замены двойников, длина 10 или 12, иначе `null`; дата отчёта — словарём периодов (тот же, что для подписей +колонок, шаг 2); единицы — словарём единиц. Сырой текст и уверенность каждого поля SHALL доезжать до контракта +(`<поле>_text`, `<поле>_confidence`). + +#### Scenario: ИНН с мусором клеток +- **WHEN** поле ИНН шапки OCR — `"3|1 21 2|"` с цифрами ИНН между вертикальными чертами +- **THEN** ИНН собирается только из цифр, `|` единицей не считается; `inn_text` хранит исходный текст + +### Requirement: Очистка OCR-текста — первая стадия типовых разборов +Очистка OCR SHALL выполняться не отдельным шагом, а первой стадией разборов даты, подписи колонки, кода строки, +разлепления склеенной строки и суммы — для любой ячейки без `value` (у Excel — только текстовые ячейки). Буквы- +двойники SHALL заменяться в датах, подписях, кодах и суммах (Р-11): `О/о/O/o → 0`, `З/з → 3`, `б → 6`, `S/s → 5`, +`I/l/|/і → 1`, `Ч → 4` — только вплотную к цифре, в несколько проходов; слова не портятся. В суммах крайние `|` +(след границы таблицы) SHALL убираться, а не заменяться. ИНН двойники MUST NOT трогать. Сумма с заменой двойника +получает `is_recognized = false`; код с заменой считается неуверенным. Текст длиннее 200 символов разбором даты не +обрабатывается (`null`). + +#### Scenario: Двойник в сумме +- **WHEN** ячейка суммы OCR — `"1 2О5"` с уверенностью 100 +- **THEN** `amount = 1205`, `is_recognized = false`, `text = "1 2О5"` + +#### Scenario: Двойник в коде +- **WHEN** ячейка кода — `"I1О0"` с уверенностью 100 +- **THEN** код разбирается как `1100` и считается неуверенным (подтверждается структурой, шаг 8) + +#### Scenario: Граница таблицы в сумме +- **WHEN** ячейка суммы — `"| : 122477"` +- **THEN** крайняя `|` удаляется, а не становится единицей; сумма 122477, `is_recognized = false` + +### Requirement: Шаг 1 — многостраничная форма +Одна форма SHALL быть одной сеткой на выходе шага 1. Строки продолжения дописываются в конец сетки по маркеру +«Форма … с.2» или по позиции листа; граница листа записывается в `segment_breaks`. Тип из маркера продолжения SHALL +сверяться с `type_hint` формы: расхождение — отдельный кандидат и предупреждение `CONTINUATION_TYPE_CONFLICT` +(`error`). Лист без маркера и без шапки присоединяется, только если его коды в диапазоне открытой формы и, при трёх и +более кодах, число колонок сумм совпадает с формой; иначе — отдельный кандидат и предупреждение +`SHEET_NOT_JOINED` (`error`). + +#### Scenario: Лист-продолжение с одной строкой +- **WHEN** после листа ОФР идёт лист без шапки с единственной строкой 2400 +- **THEN** лист присоединяется к ОФР (код в диапазоне формы, кодов меньше трёх — число колонок не сверяется), граница + листа — в `segment_breaks` + +#### Scenario: Лист другой ширины с тремя кодами +- **WHEN** лист без маркера и шапки содержит три кода ББ и четыре колонки сумм при трёх у открытой формы +- **THEN** лист — отдельный кандидат, предупреждение `SHEET_NOT_JOINED` уровня `error` + +### Requirement: Диапазоны кодов и разбор кода +Словарь диапазонов кодов SHALL содержать отрезки: ББ 1100–1799, ОФР 2100–2999, отчёт об изменениях капитала +3100–3699, отчёт о движении денежных средств 4100–4499, пояснения 5000–5999, отчёт о целевом использовании +6100–6499. Кодовые диапазоны для поиска столбца — объединение отрезков. Разбор кода: убрать пробелы и переносы → +заменить двойники (`|` → 1 только если результат попадает в кодовый диапазон) → убрать краевую пунктуацию → 4–5 цифр +(допускается хвост `.0`), первые четыре — в кодовом диапазоне. Многострочная ячейка — код, только если каждая её часть +код. «Диапазон столбца» — отрезок, в который попадает большинство кодов найденного столбца. + +#### Scenario: Год и сумма — не код +- **WHEN** в ячейке «2024» или четырёхзначная сумма «3450» +- **THEN** кодом это не считается (вне отрезков) + +#### Scenario: Краевая пунктуация и черта +- **WHEN** ячейка кода — `"1220 ,"` или `"| 140"` +- **THEN** коды `1220` и `1140` + +### Requirement: Шаг 2 — столбец кодов +Столбец кодов SHALL находиться одним правилом: (а) столбец с подписью из словаря подписей кода («код», «коды», «код +строки», «код показателя» — после нормализации регистра и пунктуации) над первой строкой с кодом, если под подписью +доля кодов среди непустых ячеек ≥ 0,6 или кодов ≥ 3; при нескольких кандидатах — с наибольшим числом кодов; берётся +последняя такая подпись над первой строкой с кодом; (б) иначе — столбец с наибольшим числом ячеек-кодов при кодах ≥ 3 +и доле ≥ 0,6; иначе предупреждение `CODE_COLUMN_NOT_FOUND`. + +#### Scenario: Титульный блок 1С с подписью «Коды» +- **WHEN** под подписью «Коды» титульного блока выгрузки 1С стоят ОКУД, дата, ОКПО, ИНН, ОКОПФ 12267, ОКФС, ОКЕИ + (один код из девяти, доля 0,11), а коды строк формы — в другом столбце +- **THEN** столбец под «Коды» не выбирается; столбец кодов — по правилу (б) + +#### Scenario: Документ из двух строк +- **WHEN** форма содержит две строки с кодами под подписью «Код» +- **THEN** столбец кодов найден (два из двух, доля 1,0) + +### Requirement: Шаг 2 — сегменты, повторная шапка и границы листов +Раскладка SHALL состоять из сегментов. Новый сегмент открывает строка-шапка (в ней есть ячейка из словаря подписей +кода, или не менее двух её непустых ячеек совпадают после нормализации с ячейками шапки первого сегмента) или граница +листа из `segment_breaks`. У сегмента своя раскладка: по его шапке; без шапки — столбец кодов по правилу шага 2, а если +сегмент не первый и кодов в нём меньше трёх — ослабленный поиск: столбец с наибольшим числом кодов из диапазона +столбца первого сегмента, минимум один. Колонки значений сегмента без шапки — столбцы правее кода, где в строках с +кодами есть числа; они сопоставляются с колонками первого сегмента по порядку; число не совпало — предупреждение +`SEGMENT_COLUMNS_MISMATCH` (`error`) и значения по позиции. Столбец кодов не найден ни так, ни так — предупреждение +`SEGMENT_WITHOUT_CODES` (`error`) сегмента; строки сегмента не теряются (роль `data`, статусы по шагу 3). + +#### Scenario: Лист «_2» НПЗ со столбцом кодов в другой колонке +- **WHEN** второй лист формы без шапки содержит единственную строку 2400, и код стоит не в той колонке, что на первом + листе +- **THEN** у сегмента свой столбец кодов (ослабленный поиск), значения строки 2400 не сдвинуты + +#### Scenario: Повторная шапка на второй странице скана +- **WHEN** в таблице ИС посередине стоит повторная строка «Наименование показателя | Код | На 30.09.2025 | …» +- **THEN** с неё начинается новый сегмент со своей раскладкой; строка-шапка в данные не попадает + +### Requirement: Шаг 2 — строка заголовка, нумерация и роли строк +Строка заголовка SHALL быть последней над первой строкой с кодом, где в столбце кодов стоит подпись кода; иначе +последней над первой строкой с кодом с двумя и более подписями-периодами правее кода (или одной, не голым годом); +двухстрочный заголовок склеивается. Строка нумерации — строка, где не меньше трёх непустых ячеек, каждая — целое число, +и числа идут подряд, начиная с 1, слева направо — SHALL получать роль `numbering` и в данные не попадать. Роль +`title` — только строки выше найденной строки заголовка; в сегменте без заголовка роли `title` нет. Заголовка нет ни у +одного сегмента документа → колонки значений первого сегмента — столбцы правее кода, где в строках с кодами числа, +`label = ""`, `period_end = null`, `period_kind = none`, одно предупреждение `HEADER_ROW_NOT_FOUND` (`error`). +Заголовок есть хотя бы у одного сегмента — остальные сегменты берут его колонки без предупреждения. + +#### Scenario: Нумерация в скане (AURORA-1517) +- **WHEN** под шапкой таблицы ИС строка «1 | 2 | 3 | 4 | 5» +- **THEN** роль `numbering`, строки данных из неё нет + +#### Scenario: Листы 2–5 ББ без шапки +- **WHEN** у первого листа ББ заголовок есть, у листов 2–5 — нет +- **THEN** листы 2–5 берут колонки первого, предупреждения о заголовке нет, документ может быть `done` + +### Requirement: Шаг 2 — подпись и дата колонки +Подпись колонки SHALL разбираться одним словарём периодов `statement_periods.yaml`, объединяющим словарь +tabular-parser и шаблоны `dates.py` scan-gateway; строки `test_dates.py::test_parses_real_forms` scan-gateway — +обязательные тесты словаря. Результат — `period_end` и `period_kind`: +- абсолютная подпись («На 31 марта 2025 г.», «31.12.2024», «30/09/25», «30092025», «За 9 месяцев 2025 г.», «с + 01.01.2025 по 31.03.2025», «2024», хвост «0:00:00», клетки «2|0|2|4|») → `absolute`; +- относительная: «На отчётную дату», «За отчётный период» → `relative:current` (= эффективная дата); «На 31 декабря + предыдущего года» → `relative:prev_year_end` (31.12 года эффективной даты − 1); «На 31 декабря года, + предшествующего предыдущему» → `relative:prev_prev_year_end` (31.12 года − 2); «За аналогичный период предыдущего + года» → `relative:prev_period` (эффективная дата − 1 год, 29 февраля → 28 февраля); +- подписи нет → `none`. + +Эффективная дата документа SHALL быть датой отчёта из шапки, иначе самой поздней абсолютной датой колонок; нет ни +той, ни другой → у относительных колонок `period_end = null`, `period_kind` остаётся относительным. + +Подпись из словаря исключений («Изменение», «Отклонение», «%», «Темп», «Доля», «Примечание») SHALL пропускаться с +предупреждением `COLUMN_SKIPPED` (`info`). Подпись, не разобранная ни как период, ни как исключение, при числах в +столбце в строках с кодами SHALL оставлять колонку с подписью как есть, `period_end = null`, `period_kind = none` и +предупреждением `COLUMN_WITHOUT_DATE` (`error`, `column_index`); без чисел — пропуск. Подпись OCR с уверенностью ниже +100, разобранная в дату, подтверждается, если дата входит в ожидаемые: эффективная дата, она же минус один и минус два +года, 31 декабря предыдущего и предшествующего ему года; иначе предупреждение `COLUMN_DATE_UNCERTAIN` (`error`, +`column_index`). Уверенность подписи — `label_confidence` (у Excel 100; подписи нет — `null`). + +#### Scenario: Бланк 66н без впечатанных дат +- **WHEN** подписи колонок ББ — «На отчетную дату», «На 31 декабря предыдущего года», «На 31 декабря года, + предшествующего предыдущему», дата отчёта шапки 30.09.2025 +- **THEN** `period_end` колонок 2025-09-30, 2024-12-31, 2023-12-31, `period_kind` — `relative:current`, + `relative:prev_year_end`, `relative:prev_prev_year_end`; предупреждений о дате колонок нет + +#### Scenario: Мусорная подпись над числами +- **WHEN** подпись колонки — `"| | г"` с уверенностью 17, а в колонке суммы +- **THEN** колонка остаётся, `period_end = null`, `period_kind = none`, предупреждение `COLUMN_WITHOUT_DATE` уровня + `error` с `column_index`, документ `needs_check` + +#### Scenario: Колонка «Изменение» +- **WHEN** правее периодов колонка «Изменение, %» +- **THEN** колонка пропускается, предупреждение `COLUMN_SKIPPED` уровня `info` + +### Requirement: Шаг 2 — столбец наименования +Столбец наименования SHALL быть столбцом с подписью, начинающейся с «наименование» (после нормализации), в строке +заголовка; иначе — столбцом левее кода с наибольшим числом текстов, кроме столбца «Пояснения». Наименование строки — +ячейка этого столбца; пустая — ближайшая текстовая ячейка левее кода, кроме «Пояснения». + +#### Scenario: Наименование без подписи +- **WHEN** у формы нет подписи «Наименование показателя», а левее кода два столбца — «Пояснения» (короткие номера) и + тексты статей +- **THEN** наименование берётся из столбца текстов статей + +### Requirement: Шаг 3 — строки данных и их статусы +Шаг 3 SHALL выдавать `RawLine` на каждую строку сетки с ролью `data`, у которой есть содержимое: код, число или +непустой текст в колонке значений с уверенностью ниже 100. У строки — `row_index` (порядковый номер в документе с 0, +по порядку сетки после разлепления), `ref` (уникален в документе: OCR `row:`, разлепленные +`row:#` с k от 1; Excel — адрес ячейки столбца кодов строки `<лист>!`, без столбца кодов — первой непустой +ячейки строки), ячейка кода, `line_status`, ячейка наименования, ячейки значений по колонкам сегмента, `parent_ref`. +Статусы: +- `ok` — код разобран; +- `unreadable` — в ячейке кода есть хотя бы одна цифра, но код не разобран; ячейка кода без цифр считается пустой; +- `detail` — строка без кода с содержимым под строкой с кодом (в том же или в предыдущих сегментах документа); + `parent_ref` — `ref` ближайшей строки с кодом выше; +- `missing` — строка без кода с содержимым, над которой во всём документе нет строки с кодом; строки роли `title` + сюда не попадают. + +Строка без кода и без содержимого — не строка данных. Счётчиков отброшенных строк MUST NOT быть. Повтор кода — не +статус и не предупреждение: все строки с кодом — `ok`, порядок как в файле. + +#### Scenario: Код-мусор (AURORA-1462) +- **WHEN** ячейка кода — `"ИЧ,250"`, в колонках суммы +- **THEN** строка `unreadable` с `line_code_text = "ИЧ,250"`, суммами и `ref`; строка не пропадает + +#### Scenario: Расшифровка «в том числе» +- **WHEN** под строкой 1150 стоит строка «в том числе: Оборудование к установке» без кода с суммой +- **THEN** строка `detail`, `parent_ref` = `ref` строки 1150 + +#### Scenario: Потерянный код в начале таблицы +- **WHEN** первая строка данных «Выручка» без кода с суммами (код уехал в ячейку значения), выше строк с кодом нет +- **THEN** строка `missing` + +#### Scenario: Повтор кода (AURORA-1406) +- **WHEN** код 2410 стоит у двух строк с разными наименованиями +- **THEN** обе строки `ok`, порядок как в файле, предупреждения нет + +### Requirement: Шаг 3 — разлепление склеенных строк +До разбора кода SHALL выполняться разлепление: ячейка кода содержит N ≥ 2 частей через перевод строки, и каждая +непустая ячейка значений — ровно N частей; разлепление делается, если хотя бы одна часть кода разобралась как код +диапазона столбца. Получается N строк с `ref` `row:#`; нечитаемая часть кода — строка `unreadable`; наименование — +k-я из непустых частей, если их тоже N, иначе целиком у каждой. Значения разлепленных строк получают +`is_recognized = false`; разлепление — предупреждение `ROW_SPLIT` (`info`). Число частей не совпало или ни одна часть +не код — одна строка, многострочное значение → `amount = null`, `string_value` = текст, `is_recognized = false`. + +#### Scenario: Склейка «1500\n1700» (OQ-156) +- **WHEN** ячейка кода — `"1500\n1700"`, суммы — `"917 775\n2 371 333"` +- **THEN** две строки `ok` (`row:i#1`, `row:i#2`) с суммами 917775 и 2371333, `is_recognized = false`, предупреждение + `ROW_SPLIT`; при сошедшихся соотношениях §7 значения не подсвечиваются (Р-17) + +#### Scenario: Часть не из диапазона +- **WHEN** в ББ ячейка кода — `"44 30\nI |"` +- **THEN** строка не разлепляется (4430 вне диапазона ББ) + +### Requirement: Шаг 4 — значение ячейки +Шаг 4 SHALL выдавать `StatementValue {column_index, amount, string_value, text, confidence, is_recognized}`: `text` — +всегда сырой текст источника; `string_value` — только когда `amount = null` и текст непуст и не прочерк. Правила: +- `value` — число: `amount` квантуется до 2 знаков half-up; больше 16 целых цифр → `amount = null`, `string_value` = + текст, `is_recognized = false`, предупреждение `VALUE_OUT_OF_RANGE` (`info`); +- `value` — дата или логическое → `amount = null`, `string_value` = текст, `is_recognized = false`; +- `value` нет → `parse_amount(text, source)`: двойники (Р-11), пробелы и тонкие пробелы, минус «−» (U+2212) как знак, + скобки → минус, `}` и `]` — закрывающая скобка; прочерк (`-`, `–`, `—`, `−` без цифр) и пусто → `amount = null`, + `is_recognized = true`; разделители: у `ocr` запятая — тысячи при шаблоне `[+-]?[1-9]\d{0,2}(,\d{3})+`, точка — + тысячи при `[+-]?[1-9]\d*(\.\d{3})+`, иначе десятичный разделитель, три и более знаков после него → `amount = null`, + `string_value` = текст; у `excel` запятая и точка — десятичный разделитель; пробел допустим только как разделитель + тысяч (группы ровно по три цифры), иначе `amount` — цифры подряд («лучшая догадка», не длиннее 16 цифр, иначе + `null`) и `is_recognized = false`; многострочное значение в строке с однострочным кодом → `amount = null`, + `string_value` = текст, `is_recognized = false`; +- `is_recognized = true` — текст разобран как число без выброшенных и исправленных символов (замена двойника, `}` или + `]` вместо скобки — исправление); `confidence` — от источника и в `is_recognized` не входит; +- позиции нет, текст пуст или прочерк → `amount = null`, `string_value = null`, `is_recognized = true`, `confidence` + ячейки (позиции нет — 100). + +`parse_amount` — единственное место шагов 2–8, где разбор зависит от `source`. Разбор сумм ОСВ этим правилом MUST NOT +затрагиваться. + +#### Scenario: Потерянная цифра (AURORA-1511) +- **WHEN** ячейка OCR — `"42831 1"` с уверенностью 100 +- **THEN** `amount = 428311`, `is_recognized = false`, `text = "42831 1"` + +#### Scenario: Скобка `}` (OQ-147) +- **WHEN** ячейка OCR — `"(7744536}"` +- **THEN** `amount = -7744536`, `is_recognized = false` + +#### Scenario: Запятая по источнику +- **WHEN** текст `"4,200"` +- **THEN** у `ocr` `amount = 4200`; у `excel` `amount = 4.20` + +#### Scenario: Длинное тире (OQ-148) +- **WHEN** ячейка OCR — `"—"` с уверенностью 100 +- **THEN** `amount = null`, `string_value = null`, `is_recognized = true` + +### Requirement: Шаг 4 — единицы и дробные значения +При единицах «тыс. руб.» и доле значений с дробной частью ≥ 10 % SHALL выдаваться предупреждение документа +`FRACTIONAL_IN_THOUSANDS` (`info`); то же сочетание в книге xlsb без числовых форматов — `XLSB_SCALE_SUSPECT` +(`error`). Книга xlsb без числовых форматов сама по себе — `XLSB_NO_FORMATS` (`info`); формат ячейки не разобран — +`NUMBER_FORMAT_UNPARSED` (`info`, одно на документ с перечнем форматов). + +#### Scenario: xlsb с копейками при «тыс. руб.» +- **WHEN** книга xlsb без стилей, в шапке «тыс. руб.», 40 % значений дробные +- **THEN** предупреждение `XLSB_SCALE_SUSPECT` уровня `error`, документ `needs_check` + +### Requirement: Шаг 5 — строка формы +Шаг 5 SHALL выдавать `StatementLine {row_index, ref, line_status, line_code, line_code_text, line_code_confidence, +line_name, line_name_confidence, parent_ref, needs_review, values}`: `line_code` — разобранный код (4–5 цифр) или +`null` у `unreadable` / `missing` / `detail`; `line_code_text` — сырой текст ячейки кода; `line_code_confidence` — от +источника, у кода с заменой двойника — не выше 99; `line_name` — распознанный текст наименования как есть, с +уверенностью источника. Сервисы MUST NOT подставлять наименование из справочника и MUST NOT срезать уверенность кода +по справочнику (Р-3). Редакция (`66n` / `fsbu4`) в сервисах выбирается по эффективной дате (`fsbu4` при дате после +2024-12-31) только для перечня кодов; без даты проверяются обе редакции. + +#### Scenario: Наименование из OCR (AURORA-1522) +- **WHEN** у строки 1230 OCR-наименование «Дебиторская задолжен-ность» +- **THEN** `line_name = "Дебиторская задолжен-ность"` с уверенностью ячейки; справочное имя показывает ядро + +### Requirement: Шаг 6 — тип формы, вариант и документ +Шаг 6 SHALL собирать `StatementDocument {document_type, form_variant, locator, header, columns, lines}`. Тип — +большинство кодов строк `ok` в отрезке ББ → `BALANCE`, ОФР → `INCOME`, прочих форм и пояснений → `DISCLOSURE`; +совпало с `type_hint` или `type_hint = null` — тип по кодам; противоречит — тип по кодам и предупреждение +`TYPE_CONFLICT` (`error`); кодов нет — `type_hint`. Без столбца кодов: `type_hint` есть → документ без строк +(`DISCLOSURE` — `done`, иначе `needs_check` с `CODE_COLUMN_NOT_FOUND` уровня `error`); `type_hint` нет → у `ocr` +таблица ИС с содержимым — предупреждение файла `TABLE_LOST` (`error`), у `excel` — лист «прочее», `SHEET_OTHER` +(`info`). `DISCLOSURE` по `type_hint` — шаги 2–7 не выполняются; `DISCLOSURE` по кодам — строки и колонки +отбрасываются, проверки не считаются, документ `done`. + +`form_variant` SHALL определяться только по структуре кодов: ББ — есть 1600 и нет ни 1100, ни 1200 → `simplified`; +ОФР — есть 2400 или 2110 и нет ни 2100, ни 2200, ни 2300 → `simplified`; иначе `full`; у `DISCLOSURE` — `null`. ОКУД +признаком варианта не служит. Шапка документа — поля `header` кандидата: `text` → `<поле>_text`, разобранное → поле, +`confidence` → `<поле>_confidence`. + +#### Scenario: Упрощённый баланс +- **WHEN** в ББ есть 1150, 1210, 1600, 1310, 1700 и нет 1100 и 1200 +- **THEN** `form_variant = simplified`; шаг 7 и перечень кодов берут упрощённый набор + +#### Scenario: Заголовок спорит с кодами +- **WHEN** `type_hint = BALANCE`, а коды строк — 2110, 2120, 2100, 2200 +- **THEN** `document_type = INCOME`, предупреждение `TYPE_CONFLICT` уровня `error` + +### Requirement: Шаг 7 — самопроверки §7 +Шаг 7 SHALL считаться у обоих источников одним словарём `statement_checks.yaml` и выдавать `StatementChecks +{relations_failed[], soft_relations_failed[]}`; каждое нарушение — `{column_index, relation, codes, expected, +actual}`, где `codes` — коды цели и членов соотношения. Правила: +- значение кода в колонке — сумма по всем строкам `ok` с этим кодом; строки `detail`, `unreadable`, `missing` в + соотношения не входят; нарушение относится ко всем строкам его кодов; +- соотношение **посчитано**, если непуста цель или хотя бы один член (есть строка `ok` с кодом и `amount` не `null`); + пусто всё — соотношение пропускается; пустая цель при непустых членах и непустая цель при пустых членах — нарушение; +- жёсткое соотношение не сошлось, если расхождение больше допуска ±1 в единицах формы **и** больше 0,001 % наибольшего + по модулю числа соотношения — тогда оно в `relations_failed`; расхождение в пределах округления и мягкие + соотношения — в `soft_relations_failed`; +- члены `abs_terms` (2120, 2210, 2220, 2330, 2350, 2410) сравниваются и как записаны, и по модулю; такое соотношение + подтверждает величину, но не знак (шаг 8); +- набор соотношений — по `form_variant` (полный или упрощённый); +- родитель пятизначной подстроки — ближайшая четырёхзначная строка выше с теми же первыми тремя цифрами, иначе код из + первых четырёх цифр; «сумма подстрок = родитель» (мягкое) — только если хотя бы одна подстрока в колонке непуста. + +`checks` SHALL быть у каждого `BALANCE` / `INCOME` со строками и отсутствовать у документа без строк. +`duplicate_line_codes` MUST NOT выдаваться. + +#### Scenario: «БАЛАНС» без кода +- **WHEN** строка «БАЛАНС» ушла в `detail` (код потерян), в колонке есть 1100 и 1200 +- **THEN** соотношение `1600 = 1100 + 1200` посчитано и нарушено (пустая цель при непустых членах), в + `relations_failed` + +#### Scenario: Лист только с активом +- **WHEN** в ББ заполнены 1100, 1200, 1600, а 1300–1700 отсутствуют +- **THEN** `1600 = 1700` нарушено (непустая цель при пустых членах), документ `needs_check` + +#### Scenario: Повтор кода в соотношении +- **WHEN** две строки 2410 с суммами 100 и 649 +- **THEN** в соотношениях код 2410 равен 749 + +#### Scenario: Округление итогов +- **WHEN** `1600 = 1700` расходится на 9,02 при итогах ~17 млрд +- **THEN** нарушение в `soft_relations_failed`, на статус и подсветку не влияет + +### Requirement: Шаг 8 — `needs_review` значения +Сервис SHALL вычислять `needs_review` у каждого значения: +- значение **нуждается в покрытии**, если `confidence < 100` или `is_recognized = false`; чистые значения в покрытии не + нуждаются; +- значение **покрыто**, если в его колонке есть жёсткое соотношение с участием его кода, которое посчитано и сошлось + (в пределах допуска или округления), и `amount` значения не `null`; +- **знак**: у члена `abs_terms`, нуждающегося в покрытии, знак подтверждён, если форма записи знака (скобки, минус, + без знака — по `text`) совпадает с формой у большинства членов `abs_terms` этой колонки с непустым `amount`, + `is_recognized = true` и без правки аналитика; не совпадает или сравнивать не с чем — знак не подтверждён; +- `needs_review = true`, если значение нуждается в покрытии и не покрыто или его знак не подтверждён; а также у всех + ячеек жёсткого соотношения, не сошедшегося вне допуска и округления, независимо от уверенности; +- иначе `needs_review = false`. + +#### Scenario: Догадка подтверждена соотношением (Р-7) +- **WHEN** у 1230 `"42831 1"` → 428311, `is_recognized = false`, и `1200 = 1210 + 1220 + 1230 + …` в колонке посчитано + и сошлось +- **THEN** `needs_review = false`; `text` и уверенность остаются в значении + +#### Scenario: Знак расхода в фигурной скобке +- **WHEN** в колонке ОФР 2210 и 2220 записаны в скобках, а 2120 — `"(7744536}"`, и `2100 = 2110 − 2120` сошлось +- **THEN** у 2120 знак подтверждён, `needs_review = false` + +#### Scenario: Знак вне конвенции +- **WHEN** 2210 и 2220 в скобках, а сомнительная 2120 — `"{38378)"` без минуса после разбора +- **THEN** `needs_review = true` у 2120 независимо от соотношения + +#### Scenario: Справочная строка без проверки +- **WHEN** значение 2510 с уверенностью 80 (строк 25xx нет в жёстких соотношениях) +- **THEN** `needs_review = true` + +### Requirement: Шаг 8 — подтверждение неуверенного кода структурой +Код строки SHALL считаться неуверенным, если `line_code_confidence < 100` или при разборе заменён двойник; только к +неуверенным кодам применяется структурная проверка (Р-12). Четырёхзначные коды строк `ok` документа образуют +последовательность; для каждой проверяемой редакции перечня (по эффективной дате; без даты — обе) берётся наибольшая +подпоследовательность, согласованная с порядком бланка (итоги после слагаемых); редакция — с более длинной +подпоследовательностью, при равенстве — по эффективной дате; уверенные коды участвуют как подтверждённые якоря; +при нескольких наибольших подпоследовательностях подтверждены только коды, общие для всех. Пятизначный код подтверждён, +если подтверждён его родитель и подстрока стоит после него. Повтор кода, у которого хотя бы одна копия неуверенна, — +`needs_review = true` у неуверенной копии (Р-16). Неподтверждённый неуверенный код — `StatementLine.needs_review = true`. +У Excel и чистых OCR-кодов `needs_review` строки по структуре MUST NOT ставиться. + +#### Scenario: Неуверенный код на своём месте +- **WHEN** код 1230 с уверенностью 50 стоит между 1220 и 1240 (уверенными) +- **THEN** код подтверждён, `needs_review = false` + +#### Scenario: Переставленные неуверенные соседи +- **WHEN** порядок кодов 1210, 1230 (уверенность 50), 1220 (уверенность 50), 1240 +- **THEN** у обеих неуверенных строк `needs_review = true`, документ `needs_check` + +#### Scenario: Нестандартный код Excel +- **WHEN** в Excel-форме код 1231 или пятизначная подстрока 11101 вне перечня +- **THEN** `needs_review` строки не ставится; проверка «код не из редакции» — `info` ядра + +### Requirement: Шаг 8 — поля шапки +Дата отчёта с уверенностью ниже 100 SHALL подтверждаться совпадением с самой поздней абсолютной датой колонок; иначе +поле `report_date` попадает в `needs_review_fields` и выдаётся `REPORT_DATE_UNCERTAIN` (`error`, `field = +report_date`). ИНН и организация с уверенностью ниже 100 — предупреждения `INN_UNCERTAIN` / `ORGANIZATION_UNCERTAIN` +(`info`, с `field`), в `needs_review_fields` не попадают; единицы и прочие поля на статус не влияют. + +#### Scenario: Неуверенная, но верная дата +- **WHEN** дата отчёта 30.09.2025 с уверенностью 60, самая поздняя дата колонок 30.09.2025 +- **THEN** `needs_review_fields` пуст, предупреждения о дате нет + +#### Scenario: Неуверенный год +- **WHEN** дата отчёта 30.09.2023 с уверенностью 60, колонки на 30.09.2025 и 31.12.2024 +- **THEN** `needs_review_fields = ["report_date"]`, `REPORT_DATE_UNCERTAIN` уровня `error`, документ `needs_check` + +### Requirement: Шаг 8 — предупреждения +Предупреждение SHALL быть структурой `{code, severity, message, line_refs?, column_index?, field?}`: `severity` — +`error` | `info`; `line_refs` — список `ref` строк; `column_index` — колонка; `field` — поле шапки. Место в файле — +`locator` документа, а не текст. Предупреждения уровня файла (`metrics.warnings`) дополнительно имеют `locator?` +(json набора ИС или лист). Уровни SHALL быть такими: + +| Код | Уровень | Когда | +|---|---|---| +| `JSON_FAILED` | error (файл) | json набора ИС ошибочен при успешном соседе | +| `TABLE_PARSE_ERROR` | error | исключение при разборе таблицы ИС (`BACKEND_PARSING_ERROR`) | +| `TABLE_LOST` | error (файл) | таблица ИС с содержимым без колонки кодов и без `type_hint` | +| `CODE_COLUMN_NOT_FOUND` | error | у формы с `type_hint` `BALANCE`/`INCOME` нет столбца кодов | +| `SEGMENT_WITHOUT_CODES` | error | сегмент с содержимым без столбца кодов (в том числе лист-продолжение) | +| `SHEET_NOT_JOINED` | error | лист с ≥ 3 кодами и другим числом колонок не присоединился | +| `CONTINUATION_TYPE_CONFLICT` | error | тип маркера продолжения противоречит форме | +| `SEGMENT_COLUMNS_MISMATCH` | error | колонки сегмента не совпали с первым | +| `COLUMN_WITHOUT_DATE` | error | числовая колонка без разобранной подписи | +| `COLUMN_DATE_UNCERTAIN` | error | неуверенная подпись не попала в ожидаемые даты | +| `HEADER_ROW_NOT_FOUND` | error | заголовка нет ни у одного сегмента | +| `TYPE_CONFLICT` | error | тип по кодам противоречит `type_hint` | +| `NO_ORGANIZATION_NO_INN` | error | в шапке нет ни организации, ни ИНН | +| `PERIOD_UNKNOWN` | error | нет ни даты отчёта, ни абсолютных дат колонок | +| `REPORT_DATE_UNCERTAIN` | error | неуверенная дата отчёта не совпала с датами колонок | +| `XLSB_SCALE_SUSPECT` | error | xlsb без форматов при «тыс. руб.» и ≥ 10 % дробных | +| `INN_NOT_FOUND`, `INN_UNCERTAIN` | info | ИНН не найден или неуверен при найденной организации | +| `ORGANIZATION_NOT_FOUND`, `ORGANIZATION_UNCERTAIN` | info | организация не найдена или неуверенна при найденном ИНН | +| `REPORT_DATE_UNPARSED` | info | дата отчёта не разобрана при наличии дат колонок | +| `UNITS_NOT_FOUND` | info | единицы не найдены | +| `FILE_TYPE_MISMATCH` | info (файл) | формат файла не совпал с `file_type` запроса | +| `COLUMN_SKIPPED` | info | подпись из словаря исключений пропущена | +| `FRACTIONAL_IN_THOUSANDS` | info | дробные при «тыс. руб.» (кроме xlsb без форматов) | +| `XLSB_NO_FORMATS`, `NUMBER_FORMAT_UNPARSED` | info | числовые форматы недоступны или не разобраны | +| `VALUE_OUT_OF_RANGE` | info | число Excel длиннее 16 целых цифр | +| `SHEET_OTHER` | info | лист Excel «прочее» без `type_hint` и кодов | +| `ROW_SPLIT` | info | разлеплена склеенная строка | + +Предупреждения пути ОСВ — уровня `info`, код с префиксом `OSV_`. Новый код предупреждения вводится словарём пакета и +таблицей §11 `05-domain-rules.md`; `code` в контракте — строка `^[A-Z][A-Z0-9_]*$`, не перечисление. + +#### Scenario: НПЗ ОФР без ИНН +- **WHEN** в шапке ОФР есть организация и нет ИНН +- **THEN** `INN_NOT_FOUND` уровня `info`, документ может быть `done` + +#### Scenario: Привязка к ячейке +- **WHEN** не сошлось `1200 = 1210 + … + 1260` в колонке 1 +- **THEN** нарушение в `checks.relations_failed` с `column_index = 1` и `codes`, `needs_review = true` у ячеек этих + кодов в колонке 1 + +### Requirement: Шаг 8 — статус документа и конверта +Статус документа SHALL вычисляться одной функцией общего кода: `done` ⇔ раскладка найдена ∧ (организация или ИНН) ∧ +период определён (дата отчёта или самая поздняя абсолютная дата колонок) ∧ все строки `ok` или `detail` ∧ нет +значений, строк и полей шапки с `needs_review` ∧ нет жёстких нарушений §7 вне допуска и округления ∧ нет +предупреждений документа уровня `error`; иначе `needs_check`. `DISCLOSURE` — `done` без условий о раскладке, строках и +проверках. Функция SHALL принимать на вход и подтверждения и акцепт аналитика (у ядра после правок): правленое или +подтверждённое значение и строка не имеют `needs_review`, акцепт снимает жёсткие нарушения. Статус конверта: `done` ⇔ +все документы `done` ∧ нет предупреждений файла уровня `error`; иначе `needs_check`; документов нет — `error` +(`RECOGNITION_FAILED` у scan-gateway, `INVALID_DOCUMENT` у tabular-parser). Квартал периода, сверка ИНН и наименований +со справочником в `done` MUST NOT входить. + +#### Scenario: Excel-форма без замечаний +- **WHEN** Excel-ББ с найденной раскладкой, организацией, датой, всеми строками `ok`, сошедшимися соотношениями +- **THEN** документ `done`; уверенность 100 у Excel выполняет условия покрытия автоматически + +#### Scenario: Строка без кода с суммой +- **WHEN** в форме есть строка `unreadable` с суммой +- **THEN** документ `needs_check` + +#### Scenario: Соседний документ не тянет файл +- **WHEN** ББ `done`, ОФР того же файла `needs_check` из-за одной ячейки, предупреждений файла нет +- **THEN** конверт `needs_check`, у ББ `document.status = done` diff --git a/openspec/changes/unified-statement-normalization/specs/tabular-recognition-service/spec.md b/openspec/changes/unified-statement-normalization/specs/tabular-recognition-service/spec.md new file mode 100644 index 0000000..77ca9ef --- /dev/null +++ b/openspec/changes/unified-statement-normalization/specs/tabular-recognition-service/spec.md @@ -0,0 +1,49 @@ +## 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 = <лист>!`; дописывать +листы-продолжения в сетку открытой формы до раскладки (маркер «Форма … с.2» или позиция листа; проверки типа, +диапазона кодов и, при трёх и более кодах, числа колонок — как `join_blocker`), записывая границы листов в +`segment_breaks`. Несовпадение формата с `file_type` — предупреждение файла `FILE_TYPE_MISMATCH` (`info`). + +#### Scenario: Книга ББ + ОФР +- **WHEN** книга содержит лист «Баланс» (ОКУД 0710001), лист «Баланс (2)» с маркером «Форма 0710001 с.2» и лист «ОФР» +- **THEN** адаптер выдаёт два кандидата: `BALANCE` с `locator = sheet:Баланс,Баланс (2)` и одной сеткой из двух листов + (`segment_breaks` — первая строка второго листа) и `INCOME` с `locator = sheet:ОФР` + +#### Scenario: Ссылки ячеек +- **WHEN** код строки стоит в ячейке C27 листа «Баланс» +- **THEN** `ref` строки — `Баланс!C27` + +### 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: Путь ОСВ в контракте v2.0 +У `osvDocument` tabular-parser SHALL выдавать `status` (`done`, когда ведомость разобрана, иначе `needs_check` — по +прежним условиям §1.6 листа) и `warnings[]` — прежние предупреждения листа структурами с `severity = info` и кодом с +префиксом `OSV_`; строк вида `sheet=<имя> <текст>` в `metrics.warnings` больше нет. Остальной разбор ОСВ не меняется. + +#### Scenario: Предупреждение ОСВ +- **WHEN** на листе ОСВ есть строка без родителя +- **THEN** у документа ОСВ предупреждение `{code: "OSV_…", severity: "info", message}`, статус документа по §1.6 + +### Requirement: Статус книги +Статус конверта tabular-parser SHALL вычисляться по общему правилу: `done` ⇔ все документы `done` ∧ нет предупреждений +файла уровня `error`; книга без листов ОСВ и форм — `error INVALID_DOCUMENT`. Порядок документов — сначала ОСВ в +порядке листов, затем формы. + +#### Scenario: Книга только из пояснений +- **WHEN** книга содержит только листы пояснений (0710003, «Пояснения») +- **THEN** документы `DISCLOSURE` со статусом `done`, конверт `done`