From 47e6372cbef7488f643eff76da4ede274de57e37 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 09:25:43 +0400 Subject: [PATCH] =?UTF-8?q?chore(openspec):=20=D0=B0=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D0=B2=20unified-statement-normalization=20(#116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Спеки применены: 5 новых capability (arm-core, arm-web, scan-gateway, statement-normalization, tabular-recognition-service) с Purpose, изменения quality-checks и recognition-contract. Ссылки в 05-domain-rules, DBML v5.19, entities, dev/checks.md и dev/qa-statements-v2.md ведут в архив. Co-Authored-By: Claude Opus 5.5 --- context/05-domain-rules.md | 2 +- dev/checks.md | 2 +- dev/qa-statements-v2.md | 2 +- model/data-model-v5.19.dbml | 4 +- model/entities-v5.19.md | 2 +- .../.openspec.yaml | 0 .../design.md | 0 .../2026-10-18-plan-0-contract-identity.md | 0 .../plans/2026-10-18-plan-1-arm-statements.md | 0 .../2026-10-18-plan-2-sync-statements.md | 0 .../plans/2026-10-18-plan-3-arm-core.md | 0 .../plans/2026-10-18-plan-4-tabular-parser.md | 0 .../plans/2026-10-18-plan-5-scan-gateway.md | 0 .../plans/2026-10-18-plan-6-arm-web.md | 0 .../plans/README.md | 0 .../proposal.md | 0 .../review-2026-10-17-blind-2.md | 0 .../review-2026-10-17-blind.md | 0 .../review-2026-10-17-closure-2.md | 0 .../review-2026-10-17-closure.md | 0 .../review-2026-10-17.md | 0 .../review-2026-10-18-spec-2.md | 0 .../review-2026-10-18-spec.md | 0 .../specs/arm-core/spec.md | 0 .../specs/arm-web/spec.md | 0 .../specs/quality-checks/spec.md | 0 .../specs/recognition-contract/spec.md | 0 .../specs/scan-gateway/spec.md | 0 .../specs/statement-normalization/spec.md | 0 .../specs/tabular-recognition-service/spec.md | 0 openspec/specs/arm-core/spec.md | 535 ++++++++++ openspec/specs/arm-web/spec.md | 66 ++ openspec/specs/quality-checks/spec.md | 25 +- openspec/specs/recognition-contract/spec.md | 182 +++- openspec/specs/scan-gateway/spec.md | 57 ++ .../specs/statement-normalization/spec.md | 916 ++++++++++++++++++ .../specs/tabular-recognition-service/spec.md | 71 ++ 37 files changed, 1826 insertions(+), 38 deletions(-) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/.openspec.yaml (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/design.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/plans/2026-10-18-plan-0-contract-identity.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/plans/2026-10-18-plan-1-arm-statements.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/plans/2026-10-18-plan-2-sync-statements.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/plans/2026-10-18-plan-3-arm-core.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/plans/2026-10-18-plan-4-tabular-parser.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/plans/2026-10-18-plan-5-scan-gateway.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/plans/2026-10-18-plan-6-arm-web.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/plans/README.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/proposal.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/review-2026-10-17-blind-2.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/review-2026-10-17-blind.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/review-2026-10-17-closure-2.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/review-2026-10-17-closure.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/review-2026-10-17.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/review-2026-10-18-spec-2.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/review-2026-10-18-spec.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/specs/arm-core/spec.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/specs/arm-web/spec.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/specs/quality-checks/spec.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/specs/recognition-contract/spec.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/specs/scan-gateway/spec.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/specs/statement-normalization/spec.md (100%) rename openspec/changes/{unified-statement-normalization => archive/2026-10-19-unified-statement-normalization}/specs/tabular-recognition-service/spec.md (100%) create mode 100644 openspec/specs/arm-core/spec.md create mode 100644 openspec/specs/arm-web/spec.md create mode 100644 openspec/specs/scan-gateway/spec.md create mode 100644 openspec/specs/statement-normalization/spec.md create mode 100644 openspec/specs/tabular-recognition-service/spec.md diff --git a/context/05-domain-rules.md b/context/05-domain-rules.md index 789ac55..f15c9eb 100644 --- a/context/05-domain-rules.md +++ b/context/05-domain-rules.md @@ -1094,7 +1094,7 @@ OQ-92 (пустые «Организация»/«Группа компаний» ## §11. Нормализация форм ББ/ОФР — общая цепочка (scan-gateway и tabular-parser) **Статус: целевое правило** change `unified-statement-normalization` (RSHB/arm-ka-docs#116; дизайн — -`openspec/changes/unified-statement-normalization/design.md`, решения владельца Р-1…Р-19 от 17.10.2026), контракт +`openspec/changes/archive/2026-10-19-unified-statement-normalization/design.md`, решения владельца Р-1…Р-19 от 17.10.2026), контракт v2.0. Порядок выкладки — «сначала ядро» (ядро принимает 1.x и 2.0); пока сервис пишет 1.x, он работает по прежним правилам — прежний текст §11, §11.1 и §12 (Срезы 2b, 3a, 3b) — в истории git репозитория docs, коммит `43b4b80`. §11 объединяет прежние §11 (маппинг json ИС), §11.1 (очистка OCR-текста) и §12 (ББ и ОФР из Excel); §12 оставлен diff --git a/dev/checks.md b/dev/checks.md index 9e5e03a..fe4ce4d 100644 --- a/dev/checks.md +++ b/dev/checks.md @@ -61,7 +61,7 @@ docker compose -f ../docs/dev/docker-compose.yml --project-name arm-ka run --rm Общий код нормализации ББ/ОФР (`05-domain-rules.md` §11) живёт в репозитории `arm-statements` пакетом `arm_statements/`; в `arm-core`, `tabular-parser` и `scan-gateway` он лежит **копией** — прямые правки копии запрещены (Р-1, design -`openspec/changes/unified-statement-normalization/design.md`). Скрипт и цели docs (`make -C docs sync-statements`, +`openspec/changes/archive/2026-10-19-unified-statement-normalization/design.md`). Скрипт и цели docs (`make -C docs sync-statements`, `statements-check`, `test-sync-statements`) — arm-ka-docs#120; `statements-check` сервисов появляется с PR подключения копии в каждом хосте (план 2, задачи 7–9) — до шага 3 выкладки. Раздел описывает поведение по спеке `statement-normalization` (требование «`sync-statements`…»). diff --git a/dev/qa-statements-v2.md b/dev/qa-statements-v2.md index c49130d..ff4ef4e 100644 --- a/dev/qa-statements-v2.md +++ b/dev/qa-statements-v2.md @@ -7,7 +7,7 @@ Swagger — общий гайд [`qa-guide.md`](qa-guide.md) (разделы 4, Что выложено: arm-core `ddd607e` (DBML v5.19, приём результатов 1.x и 2.0, встроенный фронт = arm-web `test` `4a02512`), tabular-parser `4d1709a`, scan-gateway `1d05b9e`, общий пакет `arm-statements` `3390f92`. Задача — RSHB/arm-ka-docs#116; -правила — `docs/context/05-domain-rules.md` §7, §9.5, §11; решения Р-1…Р-44 — `openspec/changes/unified-statement-normalization/design.md`. +правила — `docs/context/05-domain-rules.md` §7, §9.5, §11; решения Р-1…Р-44 — `openspec/changes/archive/2026-10-19-unified-statement-normalization/design.md`. Перед выкладкой все сценарии ниже прогнаны на локальном стенде (18–19.10.2026: 25 книг Excel, 6 PDF, 129 ОСВ, сводная ОСВ двух периодов). Найденное там и не исправленное — в разделе 6: такие случаи **не заводите** как баги. diff --git a/model/data-model-v5.19.dbml b/model/data-model-v5.19.dbml index 5ecf73f..1196cd3 100644 --- a/model/data-model-v5.19.dbml +++ b/model/data-model-v5.19.dbml @@ -4,7 +4,7 @@ // Над каждой таблицей — блок «ИСТОЧНИК»: пункт ТЗ АРМ КА BIQ-34 / ТЗ ЕХФД (помечено «ЕХ ТЗ») / Методика; // страница аналитики (md); экран макета dkb_24.09.26; версия модели, в которой таблица появилась (v4 = исходная DBML аналитики). // -// v5.19 (решения владельца 17.10.2026, Р-1…Р-19; RSHB/arm-ka-docs#116, openspec/changes/unified-statement-normalization; +// v5.19 (решения владельца 17.10.2026, Р-1…Р-19; RSHB/arm-ka-docs#116, openspec/changes/archive/2026-10-19-unified-statement-normalization; // контракт результата v2.0; одна миграция arm-core): // 1. financial_statement_line: снята уникальность (meta_id, line_code) — повтор кода законен, хранятся все строки (Р-4); // line_code остаётся nullable и пуст у строк unreadable / missing / detail (Р-8, отменяет OQ-36 / OQ-141). @@ -1067,7 +1067,7 @@ Table financial_statement_value { // ИСТОЧНИК: // ТЗ: ТЗ 6.3 (логические проверки, акцепт); 6.2 «уверенность ниже 100% → Требуется ручная проверка» -// Аналитика: openspec/changes/unified-statement-normalization (design: шаг 8, «Что уходит из ядра») +// Аналитика: openspec/changes/archive/2026-10-19-unified-statement-normalization (design: шаг 8, «Что уходит из ядра») // Макет: «Результат распознавания»: ошибки проверок, подсветка ячеек // В модели: v5.19 новая // Зачем: предупреждения сервиса и нарушения §7 с привязкой к строкам, ячейкам и полям шапки — экран показывает их у diff --git a/model/entities-v5.19.md b/model/entities-v5.19.md index 3f4c293..6528474 100644 --- a/model/entities-v5.19.md +++ b/model/entities-v5.19.md @@ -4,7 +4,7 @@ ## Что изменилось в v5.19 (решения владельца 17.10.2026, Р-1…Р-19; RSHB/arm-ka-docs#116) -Change `openspec/changes/unified-statement-normalization`: формы ББ/ОФР нормализуются общим кодом сервисов, контракт +Change `openspec/changes/archive/2026-10-19-unified-statement-normalization`: формы ББ/ОФР нормализуются общим кодом сервисов, контракт результата v2.0; ядро хранит результат как пришёл (`context/05-domain-rules.md` §11, §9.3, §9.5). | № | Изменение | Решение | diff --git a/openspec/changes/unified-statement-normalization/.openspec.yaml b/openspec/changes/archive/2026-10-19-unified-statement-normalization/.openspec.yaml similarity index 100% rename from openspec/changes/unified-statement-normalization/.openspec.yaml rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/.openspec.yaml diff --git a/openspec/changes/unified-statement-normalization/design.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/design.md similarity index 100% rename from openspec/changes/unified-statement-normalization/design.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/design.md diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-0-contract-identity.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/plans/2026-10-18-plan-0-contract-identity.md similarity index 100% rename from openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-0-contract-identity.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/plans/2026-10-18-plan-0-contract-identity.md diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-1-arm-statements.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/plans/2026-10-18-plan-1-arm-statements.md similarity index 100% rename from openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-1-arm-statements.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/plans/2026-10-18-plan-1-arm-statements.md diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-2-sync-statements.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/plans/2026-10-18-plan-2-sync-statements.md similarity index 100% rename from openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-2-sync-statements.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/plans/2026-10-18-plan-2-sync-statements.md diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-3-arm-core.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/plans/2026-10-18-plan-3-arm-core.md similarity index 100% rename from openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-3-arm-core.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/plans/2026-10-18-plan-3-arm-core.md diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-4-tabular-parser.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/plans/2026-10-18-plan-4-tabular-parser.md similarity index 100% rename from openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-4-tabular-parser.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/plans/2026-10-18-plan-4-tabular-parser.md diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-5-scan-gateway.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/plans/2026-10-18-plan-5-scan-gateway.md similarity index 100% rename from openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-5-scan-gateway.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/plans/2026-10-18-plan-5-scan-gateway.md diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-6-arm-web.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/plans/2026-10-18-plan-6-arm-web.md similarity index 100% rename from openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-6-arm-web.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/plans/2026-10-18-plan-6-arm-web.md diff --git a/openspec/changes/unified-statement-normalization/plans/README.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/plans/README.md similarity index 100% rename from openspec/changes/unified-statement-normalization/plans/README.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/plans/README.md diff --git a/openspec/changes/unified-statement-normalization/proposal.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/proposal.md similarity index 100% rename from openspec/changes/unified-statement-normalization/proposal.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/proposal.md diff --git a/openspec/changes/unified-statement-normalization/review-2026-10-17-blind-2.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/review-2026-10-17-blind-2.md similarity index 100% rename from openspec/changes/unified-statement-normalization/review-2026-10-17-blind-2.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/review-2026-10-17-blind-2.md diff --git a/openspec/changes/unified-statement-normalization/review-2026-10-17-blind.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/review-2026-10-17-blind.md similarity index 100% rename from openspec/changes/unified-statement-normalization/review-2026-10-17-blind.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/review-2026-10-17-blind.md diff --git a/openspec/changes/unified-statement-normalization/review-2026-10-17-closure-2.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/review-2026-10-17-closure-2.md similarity index 100% rename from openspec/changes/unified-statement-normalization/review-2026-10-17-closure-2.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/review-2026-10-17-closure-2.md diff --git a/openspec/changes/unified-statement-normalization/review-2026-10-17-closure.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/review-2026-10-17-closure.md similarity index 100% rename from openspec/changes/unified-statement-normalization/review-2026-10-17-closure.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/review-2026-10-17-closure.md diff --git a/openspec/changes/unified-statement-normalization/review-2026-10-17.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/review-2026-10-17.md similarity index 100% rename from openspec/changes/unified-statement-normalization/review-2026-10-17.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/review-2026-10-17.md diff --git a/openspec/changes/unified-statement-normalization/review-2026-10-18-spec-2.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/review-2026-10-18-spec-2.md similarity index 100% rename from openspec/changes/unified-statement-normalization/review-2026-10-18-spec-2.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/review-2026-10-18-spec-2.md diff --git a/openspec/changes/unified-statement-normalization/review-2026-10-18-spec.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/review-2026-10-18-spec.md similarity index 100% rename from openspec/changes/unified-statement-normalization/review-2026-10-18-spec.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/review-2026-10-18-spec.md diff --git a/openspec/changes/unified-statement-normalization/specs/arm-core/spec.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/specs/arm-core/spec.md similarity index 100% rename from openspec/changes/unified-statement-normalization/specs/arm-core/spec.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/specs/arm-core/spec.md diff --git a/openspec/changes/unified-statement-normalization/specs/arm-web/spec.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/specs/arm-web/spec.md similarity index 100% rename from openspec/changes/unified-statement-normalization/specs/arm-web/spec.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/specs/arm-web/spec.md diff --git a/openspec/changes/unified-statement-normalization/specs/quality-checks/spec.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/specs/quality-checks/spec.md similarity index 100% rename from openspec/changes/unified-statement-normalization/specs/quality-checks/spec.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/specs/quality-checks/spec.md diff --git a/openspec/changes/unified-statement-normalization/specs/recognition-contract/spec.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/specs/recognition-contract/spec.md similarity index 100% rename from openspec/changes/unified-statement-normalization/specs/recognition-contract/spec.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/specs/recognition-contract/spec.md diff --git a/openspec/changes/unified-statement-normalization/specs/scan-gateway/spec.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/specs/scan-gateway/spec.md similarity index 100% rename from openspec/changes/unified-statement-normalization/specs/scan-gateway/spec.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/specs/scan-gateway/spec.md diff --git a/openspec/changes/unified-statement-normalization/specs/statement-normalization/spec.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/specs/statement-normalization/spec.md similarity index 100% rename from openspec/changes/unified-statement-normalization/specs/statement-normalization/spec.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/specs/statement-normalization/spec.md diff --git a/openspec/changes/unified-statement-normalization/specs/tabular-recognition-service/spec.md b/openspec/changes/archive/2026-10-19-unified-statement-normalization/specs/tabular-recognition-service/spec.md similarity index 100% rename from openspec/changes/unified-statement-normalization/specs/tabular-recognition-service/spec.md rename to openspec/changes/archive/2026-10-19-unified-statement-normalization/specs/tabular-recognition-service/spec.md diff --git a/openspec/specs/arm-core/spec.md b/openspec/specs/arm-core/spec.md new file mode 100644 index 0000000..87ff399 --- /dev/null +++ b/openspec/specs/arm-core/spec.md @@ -0,0 +1,535 @@ +# arm-core Specification + +## Purpose +Требования к ядру arm-core со стороны единой нормализации ББ/ОФР: хранение результата формы контракта 2.0 (DBML v5.19), приём результатов 1.x и 2.0 в окне перехода, пересчёт формы после правки общим кодом `arm_statements`, правка строк, кодов и дат колонок, блокировки подтверждения и очистка форм перед выкладкой. + +## Requirements + +### Requirement: Хранение результата формы v2.0 (DBML v5.19) +Маппер результата `2.0` SHALL писать документ формы как пришёл, без повторной дедупликации колонок, кодов и значений: +каждая строка результата — `financial_statement_line` (`row_index`, `source_ref` = `ref`, `line_status`, `line_code`, +`line_code_original_value` = `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` и `header_source` (раздел «Шапка формы»); +предупреждения и нарушения документа — `document_issue` (источник `service`); версия общего кода — +`recognition_job.statements_version`. Уникальность `(meta_id, line_code)` снята; `(meta_id, source_ref)` уникален там, +где `source_ref` не `null`. Каждый `BALANCE` / `INCOME` результата SHALL становиться отдельным `document`; вторая +форма того же типа в файле хранится (Р-10); ОСВ по счёту, `DISCLOSURE` и `OTHER` — по-прежнему один документ на тип (и +счёт) на файл. **Каждый** создаваемый `document` (ОСВ, формы, `DISCLOSURE`, `OTHER`) при приёме результата любого +`MAJOR` (мапперы `1.x` и `2.0`) SHALL получать `file_ordinal` = индекс документа в `documents[]` результата (с 0); +документ, который не создаётся (второй лист ОСВ с тем же счётом, второй `DISCLOSURE`), номер не занимает, но и не +сдвигает остальные — номера уникальны в файле и могут идти с пропусками. + +#### Scenario: Книга из двух ОСВ +- **WHEN** результат содержит две ОСВ по счетам 60 и 62 (`documents[0]`, `documents[1]`), принятый маппером `1.x` или `2.0` +- **THEN** два документа с `file_ordinal` 0 и 1; уникальность `(stored_file_id, file_ordinal)` не нарушена, файл не `ERROR` + +#### Scenario: PDF с ББ и ОФР по 1.x +- **WHEN** результат `1.2` содержит `BALANCE` и `INCOME` +- **THEN** документы с `file_ordinal` 0 и 1 + +#### Scenario: Повтор кода сохраняется +- **WHEN** в результате две строки `ok` с кодом 2410 (пустая и с `(649)`) +- **THEN** в БД две строки 2410 с разными `row_index` и `source_ref`, обе со своими значениями + +#### Scenario: Строка без кода +- **WHEN** в результате строка `unreadable` с `line_code_text = "ИЧ,250"` и суммой +- **THEN** строка хранится с `line_code = null`, `line_code_original_value = null`, `line_status = unreadable`, + `line_code_text = "ИЧ,250"` и `row_index` из результата + +#### Scenario: Два баланса одного периода в одном файле +- **WHEN** результат содержит два `BALANCE` с одной парой `period_year`, `period_quarter` (лист-продолжение другого + типа или двойная печать) +- **THEN** создаются два документа с разными `file_ordinal`; ни один не автоподтверждается (Р-18) + +#### Scenario: Два баланса разных периодов в одном файле +- **WHEN** результат содержит `BALANCE` на 30.09.2025 и `BALANCE` на 31.12.2024 +- **THEN** два документа с разными `file_ordinal`; каждый получает статус по своим условиям, правило Р-18 их не касается + +### Requirement: Признак строки, созданной правкой +Строка формы SHALL считаться созданной правкой аналитика (шаблонной, `origin = template`) тогда и только тогда, когда +`row_index IS NULL`. Маппер `1.x` и маппер `2.0` SHALL всегда писать `row_index` (у `1.x` — по порядку строк результата с +0), поэтому у строки из источника он не `null`. `line_code_original_value` SHALL хранить код строки из результата как +пришёл (`null` у `unreadable` / `missing` / `detail` и у строк, созданных правкой) и правками кода MUST NOT меняться. +Признак `line_code_original_value IS NULL` для `origin` MUST NOT использоваться. + +#### Scenario: Строка источника без кода — не шаблонная +- **WHEN** у строки `unreadable` из результата `line_code_original_value = null` и `row_index = 7` +- **THEN** `origin = source`; правила шаблонных строк (удаление без сумм, 409) к ней не применяются + +#### Scenario: Назначение одного кода двум строкам источника +- **WHEN** аналитик назначил код 1250 строке `unreadable` (`row_index = 7`, без сумм), а затем тот же код — строке + `missing` (`row_index = 2`) с суммой +- **THEN** обе строки `ok` с кодом 1250, ни одна не удалена, 409 нет (повтор кода законен, Р-4) + +### Requirement: Шапка формы — сырой текст и уверенность +Маппер `2.0` SHALL писать в `financial_statement_meta.header_source` объект `{"<поле>": {"text": <поле>_text, +"confidence": <поле>_confidence}}` для всех полей шапки результата (`organization_name`, `inn`, `report_date`, `units`, +`okud`, `okpo`, `okopf`, `okfs`, `okei`, `address`); разобранные значения — в одноимённые колонки; `*_original_value` — +как прежде: `inn_original_value = inn`, `report_date_original_value = report_date_text`, `units_original_value = +units_text`; `organization_name_original_value = organization_name`, `inn_confidence = inn_confidence`. +`header_source` и `*_original_value` правками MUST NOT меняться. Маппер `1.x` заполняет `header_source` из полей шапки +1.x. `StatementOut.header.source` SHALL отдавать `header_source`. + +#### Scenario: Подсказка у поля шапки +- **WHEN** в результате `organization_name_text = "ООО «Ромашкa»"`, `organization_name_confidence = 67` +- **THEN** `header_source.organization_name = {"text": "ООО «Ромашкa»", "confidence": 67}`; экран показывает их в подсказке + у поля + +### 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`) распознанное наименование совпало со справочным по коду ∧ в файле нет второй формы того +же типа и периода (та же пара `period_year`, `period_quarter`; формы без квартала не считаются); иначе `NEEDS_CHECK`. +**Согласованность:** ядро SHALL вызывать общую функцию статуса пакета (`statement-normalization`, «Шаг 8 — статус +документа и конверта») по входу, собранному из результата: `layout_found` — `lines[]` непуст, `has_organization_or_inn` +и `period_known` — по шапке и колонкам результата, строки и значения с их `needs_review` (значения `detail` функция не +учитывает), число `checks.relations_failed`, `accepted = false`, уровни `warnings[]` документа. `done` согласован, если +функция тоже даёт `done`; иначе ядро считает документ `needs_check` и пишет `document_issue` +`RESULT_STATUS_INCONSISTENT` (`info`, источник `core`). Это вызов той же функции, а не пересчёт §7 и `needs_review`. Статус конверта и `needs_check` соседнего документа в условие +MUST NOT входить (Р-19). Проверки §7, `needs_review` и предупреждения сервиса при загрузке MUST NOT пересчитываться. + +**Совпадение наименований:** обе строки приводятся к нижнему регистру, `ё` → `е`, всё, кроме букв и цифр, — пробел, +пробелы схлопываются, крайние пробелы обрезаются (`strip`); совпало, если строки равны, или более короткая (не короче 5 +символов) входит в более длинную, или отношение сходства `difflib.SequenceMatcher` ≥ 0,8; сравнение — с **любым** +справочным именем кода (у кода может быть несколько имён — синонимы и законные повторы, Р-20) в любой редакции словаря +ядра. Если хотя бы одно из сравниваемых имён начинается с «итого по разделу», сравниваются **номера раздела**: номер — +начальная часть первого слова после фразы из букв `i`, `v`, `x` (латиница; кириллические «і», «х» приводятся к ним), +остальное в слове отбрасывается («v1» → V, «v» → V); совпало ⇔ номера равны; номер не выделен хотя бы у одного имени — +совпадение только при равенстве строк целиком. Кода нет в словаре имён или наименование пусто — не совпало. +Несовпадение — `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` (или `TABLE_PARSE_ERROR`) уровня `error` +- **THEN** все документы формы файла `NEEDS_CHECK` + +#### Scenario: Подмена неуверенного кода на соседа +- **WHEN** строка с кодом 1120 (уверенность 50) и наименованием «Нематериальные активы» +- **THEN** наименование не совпало со справочным «Результаты исследований и разработок», документ `NEEDS_CHECK`, + `NAME_MISMATCH` у строки + +#### Scenario: Законный повтор с другим именем +- **WHEN** строка 1220 (уверенность 50) с наименованием «НДС, начисленный по отгрузке», а в словаре имён `fsbu4` у 1220 + есть это имя вторым +- **THEN** наименование совпало, `NAME_MISMATCH` нет + +#### Scenario: Подмена итога раздела +- **WHEN** строка с кодом 1200 (уверенность 50) и наименованием «Итого по разделу I» +- **THEN** номер раздела I не равен II у справочного «Итого по разделу II», `NAME_MISMATCH` + +#### Scenario: Хвост OCR после номера раздела (Счастливочная, РБ) +- **WHEN** строка 1500 (уверенность 56, разлепленная) с наименованием «Итого по разделу V1», или строка с наименованием + «Итого по разделу V /» +- **THEN** номер раздела V равен номеру справочного «Итого по разделу V», наименование совпало, `NAME_MISMATCH` нет + +#### Scenario: Сервис прислал done при неразобранной строке без значений +- **WHEN** документ пришёл `done`, но в нём строка `unreadable` без значений +- **THEN** функция статуса даёт `needs_check` (не все строки `ok` / `detail`), документ `NEEDS_CHECK`, + `RESULT_STATUS_INCONSISTENT`; автоподтверждения нет + +#### Scenario: Сервис ошибся со статусом +- **WHEN** документ пришёл `done`, но в нём строка `unreadable` с суммой +- **THEN** документ `NEEDS_CHECK`, `RESULT_STATUS_INCONSISTENT` у документа; автоподтверждения нет + +#### 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` (колонка формы) или `column_index` (столбец +предупреждения, которого нет среди колонок формы), поле шапки, источник (`service` | `core`); для нарушений — `codes` +(коды цели и членов), текст соотношения, `expected`, `actual`. Нарушение из `checks.relations_failed` — `kind = +relation`, `code = RELATION_FAILED`, `severity = error`; из `soft_relations_failed` — `kind = soft`, `code = +RELATION_SOFT`, `severity = info`; `line_ids` — все строки `ok` кодов `codes` в документе. При загрузке пишутся +предупреждения и `checks` результата (источник `service`) и проверки ядра уровня `info` (источник `core`): +`CODE_NOT_IN_DICTIONARY` (кода нет в словаре имён редакции документа), `CODE_NOT_IN_EDITION` (код не из перечня +редакции документа и его варианта — по копии перечня `arm_statements`), `NAME_MISMATCH`, `RESULT_STATUS_INCONSISTENT`. +**Редакция документа** для этих проверок — `edition` точки входа пакета для пересчёта (`statement-normalization`): +редакция с лучшей подпоследовательностью кодов строк `ok` документа, при равенстве — по эффективной дате; так бланк 66н, +сданный в 2025 году, проверяется по `66n`, а не по дате. Поле `edition` ответа `GET …/statement` и выбор шаблонных +строк по-прежнему — по дате периода (§7 «Полный состав формы»). `document_issue` ведётся у **любого** документа файла: у +ОСВ — предупреждения `osvDocument.warnings` результата `2.0` (вид `warning`, источник `service`, `OSV_*`, `info`). При перезагрузке файла записи документа +удаляются вместе с ним. При пересчёте после правки записи `relation` и `soft` заменяются целиком независимо от источника; +записи `warning` источника `service` сохраняются, кроме снимаемых правкой: правка `header.report_date` (в том числе +подтверждение тем же значением) снимает `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`, `REPORT_DATE_UNCERTAIN`; правка +`header.inn` или `header.organization_name` — `NO_ORGANIZATION_NO_INN`; правка `columns[].period_date` — +`COLUMN_WITHOUT_DATE` и `COLUMN_DATE_UNCERTAIN` этой колонки; прочие `error` сервиса снимаются только перезагрузкой +файла. При удалении шаблонной строки её `id` SHALL убираться из `line_ids` всех записей документа. `GET …/statement` и +`POST …/confirm` SHALL читать нарушения §7 только из `document_issue`. + +#### Scenario: Дата колонке +- **WHEN** у колонки предупреждение `COLUMN_WITHOUT_DATE`, аналитик ставит ей `period_date` через PATCH +- **THEN** предупреждение удаляется, статус пересчитывается + +#### Scenario: Неснимаемое предупреждение +- **WHEN** у документа `SEGMENT_COLUMNS_MISMATCH`, аналитик правит значения +- **THEN** предупреждение остаётся; документ можно подтвердить вручную (оно подтверждение не блокирует) + +#### Scenario: Бланк 66н в 2025 году без ложного info +- **WHEN** форма Межениновской за 3 кв. 2025 на бланке 66н с кодом 1120 (в перечне `fsbu4` его нет) +- **THEN** редакция документа для проверок ядра — `66n` (подпоследовательность длиннее), `CODE_NOT_IN_EDITION` у 1120 нет + +#### Scenario: Пропущенная колонка +- **WHEN** в результате предупреждение `COLUMN_SKIPPED` с `column_index = 5`, колонки 5 среди `columns[]` нет +- **THEN** запись `document_issue` с `column_index = 5` и `column_id = null`; экран показывает её у документа + +### Requirement: Формы результата 1.x — §7 при приёме +Для формы, принятой по результату `1.x` (окно перехода после выкладки ядра; `checks` контракта 2.0 у неё нет), ядро +SHALL **один раз при приёме** посчитать §7 точкой входа пакета для пересчёта (`statement-normalization`; вариант формы — +по структуре кодов, как у 2.0) по суммам результата и записать нарушения в `document_issue` (источник `core`, виды +`relation` / `soft`, как в требовании о `document_issue`) и `form_variant` в `financial_statement_meta`. Условие D-6 +«проверки сошлись» для `1.x` SHALL браться из этого расчёта; `needs_review` значения при приёме `1.x` = **НЕ** +`values_ok` (`values_ok` — `is_recognized` не `false` и `confidence` равна 100 или пуста), `needs_review` строки — +`false`. Предупреждения маппера `1.x` по документу (повтор кода, значение вне колонок) — `document_issue` +`MAPPING_WARNING` (`info`, источник `core`); строки `metrics.warnings` результата `1.x` — в +`recognition_job.metrics.warnings` структурами `{code: "LEGACY_WARNING", severity: "info", message}`. После правки такая +форма пересчитывается общим кодом, как форма 2.0; `MAPPING_WARNING` и `LEGACY_WARNING` статус не держат. Формы, +существовавшие в БД до миграции v5.19, §7 не пересчитываются (требования «Миграция DBML v5.19» и «Очистка форм перед +выкладкой»). + +#### Scenario: Полярность needs_review при приёме 1.x +- **WHEN** в результате `1.2` у значения `confidence = 100`, `is_recognized = true`, у другого — `confidence = 80` +- **THEN** у первого `needs_review = false`, у второго — `true` + +#### Scenario: Форма 1.x с нарушением +- **WHEN** результат `1.2` содержит ББ с несошедшимся `1600 = 1700` +- **THEN** у формы запись `document_issue` `relation` `RELATION_FAILED` с `codes = ["1600", "1700"]` (источник `core`), + документ `NEEDS_CHECK`; `POST …/confirm` без акцепта — 409 `CHECKS_NOT_PASSED` + +#### Scenario: 1.x-документ после PATCH +- **WHEN** форма принята по результату `1.2`, аналитик правит сумму 1230 так, что `1200 = 1210 + … + 1260` сходится +- **THEN** ядро пересчитывает §7 и статус общим кодом, запись `relation` по 1200 заменена (её больше нет), статус + пересчитан; `STATEMENTS_VERSION_MISMATCH` не пишется (у задания 1.x версии нет) + +### Requirement: Пересчёт после правки +После каждого PATCH формы ядро SHALL пересчитывать `checks`, `needs_review`, `form_variant`, родителей строк `detail` +и статус точкой входа пакета для пересчёта по сохранённым данным (`statement-normalization`; конвенция знака — по +`source_text`), с правилами: `is_modified` или `is_confirmed` значения ⇒ `needs_review = false`; `line_code_modified` или +`is_confirmed` строки ⇒ `needs_review` строки = `false`; значения строк `detail` — `needs_review = false` (Р-23), у +строки, выведенной из `detail` назначением кода, `needs_review` её значений считается по общему правилу; акцепт ⇒ +жёсткие нарушения не учитываются; правленые ячейки в голосование знака не входят. Результат пишется: `needs_review` +строк и значений, `form_variant`, `parent_row_index` всех строк `detail` (ближайшая строка `ok` выше по `row_index`; +нет такой — `null`, строка остаётся `detail`), записи `document_issue` видов `relation` / `soft`, статус. Отличие `statements_version` задания от версии копии ядра — `document_issue` +`STATEMENTS_VERSION_MISMATCH` (`info`); у задания без версии (`1.x`) запись не пишется. Один PATCH — один пересчёт и одна +запись аудита `RECOGNIZED_DATA_EDIT`. Акцепт SHALL сбрасываться правкой данных — суммы, кода или статуса строки, даты +колонки, поля шапки; подтверждение или снятие подтверждения (`confirmed`) и подтверждение даты отчёта или даты колонки +тем же значением акцепт MUST NOT сбрасывать (Р-25, S-47). + +#### Scenario: Подтверждение ячейки без правки +- **WHEN** аналитик подтверждает ячейку с `needs_review = true` без изменения суммы +- **THEN** `is_confirmed = true`, `confirmed_by/at`, `is_modified` не ставится, `needs_review = false`, аудит; акцепт + проверок, если был, сохраняется + +#### Scenario: Снятие подтверждения +- **WHEN** аналитик снимает подтверждение с ячейки (`confirmed: false`), которая не покрыта соотношением +- **THEN** `is_confirmed = false`, `needs_review = true` после пересчёта, аудит + +#### Scenario: Родитель уходит в расшифровки +- **WHEN** у строки 1150 две расшифровки (`detail`, `parent_row_index` = 1150), аналитик отмечает саму 1150 расшифровкой +- **THEN** после пересчёта родитель всех трёх строк `detail` — ближайшая строка `ok` выше 1150; строк над ними нет — `null` + (на экране — в начале раздела) + +### Requirement: PATCH формы — строки, колонки, подтверждение, наименование организации +`PATCH /documents/{id}/statement` SHALL принимать помимо `header {inn, report_date, units}` и `values` (сумма): +- `header.organization_name` — непустая строка до 1000 символов: `organization_name_modified = true`, + `organization_name_original_value` не меняется, пересчитывается `counterparty_check`, аудит `before/after`; +- `header.report_date`, равный текущему, у поля из `needs_review_fields` — подтверждение даты: поле уходит из + `needs_review_fields`, снимается `REPORT_DATE_UNCERTAIN`, аудит; иначе запрос без изменений — 200 без эффектов, как + прежде; +- `values[] {line_id, column_id, confirmed: true | false}` — подтвердить ячейку без изменения значения или снять + подтверждение; +- `columns[] {column_id, period_date}` — дата колонке (обязательная дата; `null` — 422 ошибка валидации; пересчитывает + `is_current` и период документа по правилу §9.5 п. 2); ручная дата ставит колонке `period_kind = absolute`, чтобы + следующая правка даты отчёта её не перезаписала; дата, равная текущей, у колонки с `COLUMN_DATE_UNCERTAIN` или + `COLUMN_WITHOUT_DATE` — подтверждение: предупреждение снимается, `period_kind = absolute`, аудит (иначе запрос без + изменений — 200 без эффектов); правка `header.report_date` (в том числе `null`) пересчитывает `period_date` колонок с + `period_kind = relative:*` общим кодом (`null` даёт `null`); +- `lines[] {line_id, line_code}` — назначить или сменить код; `lines[] {line_id, line_status: "detail"}` — отметить + расшифровкой; `lines[] {line_id, confirmed: true | false}` — подтвердить код или снять подтверждение. + +Переходы: `unreadable` / `missing` / `detail` → `ok` назначением кода; `unreadable` / `missing` / `ok` → `detail` +отметкой; `ok` → `ok` сменой кода; `ok` с неуверенным кодом — подтверждением. Обратного перехода `detail` → +`unreadable` / `missing` нет: ошибочная отметка исправляется назначением кода, исходное состояние — перезагрузкой файла. +`line_status` принимает только `"detail"`; `"ok"` — 422 `LINE_CODE_REQUIRED` (в `ok` строку переводит только +`line_code`). Назначенный код: `line_code_confidence = 100`, `line_code_modified = true`, `needs_review` строки = +`false`. Подтверждённый код (`confirmed: true`): `is_confirmed = true`, `needs_review` строки = `false`, +`line_code_confidence` остаётся как пришло, `line_code_modified` не ставится; снятие подтверждения (`confirmed: false`) +возвращает `needs_review` по прежнему правилу (Р-34). `line_code_original_value` не меняется; после любого изменения статусов или кодов +`parent_row_index` **всех** строк `detail` документа пересчитывается как ближайшая строка `ok` выше по `row_index` +(требование «Пересчёт после правки»). Код — 4–5 цифр (иначе 422 `INVALID_LINE_CODE`), может совпадать с +существующим; незнакомый словарю — `info`. Строка с тем же кодом, **созданная правкой** (`row_index IS NULL`), с суммами — +409 `LINE_CODE_TAKEN` (сначала очистить её суммы или выбрать другой код); без сумм — удаляется (её `id` убирается из +`document_issue.line_ids`), её место занимает назначенная строка. Строки источника с тем же кодом не трогаются. В одном +элементе больше одного действия или ни одного — 422 `INVALID_LINE_PATCH`; строка не этого документа — 422 +`UNKNOWN_LINE`; колонка не этого документа — 422 `UNKNOWN_COLUMN`. Ошибки элемента `lines[]` (`LINE_CODE_TAKEN`, +`LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`) SHALL нести в `detail` `line_id` и +`line_code` первого неверного элемента (`line_code` — код элемента или `null`). Правка суммы по `line_code` (для достроенной строки) +при наличии строки `ok` с этим кодом — 422 `UNKNOWN_CELL` (передайте `line_id`). Удаления строк источника нет. Строки, +созданные правкой: `row_index = null`, `source_ref = null`, `line_status = ok`, `line_code_original_value = null`. Аудит +`RECOGNIZED_DATA_EDIT` с `before/after` по коду, статусу, родителю, подтверждениям и наименованию организации. +Ограничения состояния — как прежде (`DOCUMENT_READONLY`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_STATEMENT`). + +#### Scenario: Назначение кода строке-мусору (AURORA-1462) +- **WHEN** аналитик назначает строке `unreadable` «ИЧ,250» (`row_index = 2`) код 1250 +- **THEN** строка `ok`, `line_code = 1250`, `line_code_confidence = 100`, `line_code_modified = true`, + `line_code_original_value` остаётся `null`, `row_index` остаётся 2 (`origin = source`); §7 и статус пересчитаны + +#### Scenario: Отметка расшифровкой +- **WHEN** аналитик отмечает строку `missing` с суммой как расшифровку +- **THEN** `line_status = detail`, строка выходит из сумм и не блокирует подтверждение + +#### Scenario: Код занят шаблонной строкой с суммами +- **WHEN** аналитик назначает строке `unreadable` код 1250, а строка 1250 создана правкой аналитика (`row_index = null`) и + имеет сумму +- **THEN** 409 `LINE_CODE_TAKEN`, ничего не изменено + +#### Scenario: Код занят пустой шаблонной строкой +- **WHEN** аналитик назначает строке `unreadable` код 1250, а строка 1250 создана правкой и сумм не имеет +- **THEN** шаблонная строка удалена, её `id` убран из `document_issue.line_ids`; строка источника стала `ok` с кодом 1250 + +#### Scenario: Правка наименования организации +- **WHEN** у формы `NO_ORGANIZATION_NO_INN`, аналитик вводит `header.organization_name` +- **THEN** наименование сохранено, `organization_name_modified = true`, исходное — в `organization_name_original_value`, + `counterparty_check` пересчитан, предупреждение снято, аудит + +#### Scenario: Подтверждение неуверенной даты отчёта +- **WHEN** `report_date` в `needs_review_fields`, аналитик отправляет PATCH с тем же `header.report_date` +- **THEN** `needs_review_fields` пуст, `REPORT_DATE_UNCERTAIN` снят, статус пересчитан, аудит + +#### Scenario: Ручная дата относительной колонки +- **WHEN** колонке `relative:prev_year_end` аналитик ставит `period_date` 31.12.2024, затем правит дату отчёта +- **THEN** после первой правки `period_kind = absolute`; правка даты отчёта её дату не меняет + +#### Scenario: Подтверждение неуверенной даты колонки +- **WHEN** у колонки `COLUMN_DATE_UNCERTAIN`, аналитик отправляет `columns: [{column_id, period_date}]` с той же датой +- **THEN** предупреждение снято, `period_kind = absolute`, статус пересчитан, аудит + +#### Scenario: Статус ok без кода +- **WHEN** приходит `lines: [{line_id, line_status: "ok"}]` +- **THEN** 422 `LINE_CODE_REQUIRED` + +### Requirement: Подтверждение формы — порядок 409 +`POST /documents/{id}/confirm` для формы ББ/ОФР SHALL проверять в порядке: `FILE_SUPERSEDED` → `ALREADY_CONFIRMED` → +`DOCUMENT_NOT_CONFIRMABLE` → `PERIOD_REQUIRED` → `LINES_UNRESOLVED` (есть строки `unreadable` / `missing`, у которых хотя +бы в одной ячейке `amount` или `string_value` не `null`; `detail.lines[] {line_id, row_index, line_code_text}`; строки без +значений не мешают) → `DUPLICATE_FORM` (в файле уже подтверждена другая форма того же типа, `period_year` и +`period_quarter`; `detail.document_id`) → `CHECKS_NOT_PASSED` (записи `document_issue` вида `relation` без акцепта). +Между файлами повтор формы решает автозамена §9.5 п. 3. + +#### Scenario: Неразобранная строка с суммой +- **WHEN** у формы строка `missing` с суммой и одновременно жёсткое нарушение +- **THEN** 409 `LINES_UNRESOLVED` с этой строкой в `detail.lines` + +#### Scenario: Неразобранная строка только с текстом +- **WHEN** у формы строка `unreadable` без сумм, но с мусором `211(1 |` в ячейке значения (`string_value`) +- **THEN** 409 `LINES_UNRESOLVED` — потерянная сумма тоже требует разбора + +#### 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 оставаться только словарём имён (код → наименования по +редакциям): у кода может быть несколько имён (синонимы и законные повторы кода в бланке, Р-20), загрузчик повтор кода в +редакции MUST NOT запрещать; сверка наименования — с любым именем кода; шаблонная строка берёт первое имя. Порядок и +состав редакций — из копии перечня кодов пакета; `sync-statements --check` сверяет, что все коды словаря имён есть в +перечне. В PR перехода ядра на v2.0 словарь имён SHALL быть дополнен: в редакции `fsbu4` — вторые имена законных +повторов 1220 «НДС, начисленный по отгрузке», 1520 «Обязательство по аренде», 2410 «Налог при упрощенной системе +налогообложения» (Р-20) и коды печатной формы 1С 1105 «Гудвил», 1130 «Нематериальные поисковые активы», 1140 +«Материальные поисковые активы», 1530 «Доходы будущих периодов», 2460 «Прочее» (имена — по `fixtures/ocr` и шаблонам +банка); без этого законные повторы РБ, Счастливочной и aep_6 и любой неуверенный 1105 дают `NAME_MISMATCH`. Шаблонные строки достраиваются по словарю имён (одна на код); код, у которого есть хотя бы одна строка `ok` из +файла, шаблоном MUST NOT достраиваться; строки `unreadable` / `missing` не заменяются шаблонной строкой молча. + +#### Scenario: Код есть только у строки unreadable +- **WHEN** у формы нет строки `ok` с кодом 1250, но есть строка `unreadable` «ИЧ,250» +- **THEN** шаблонная строка 1250 достраивается пустой, строка `unreadable` показывается отдельно со статусом + +#### Scenario: Два имени у кода +- **WHEN** в редакции `fsbu4` словаря имён код 1520 записан дважды — «Кредиторская задолженность» и «Обязательство по + аренде» +- **THEN** словарь загружается; шаблонная строка 1520 одна, с именем «Кредиторская задолженность» + +#### Scenario: Гудвил с заголовком раздела в имени +- **WHEN** строка 1105 (уверенность 67) с наименованием «АКТИВ I. Внеоборотные активы Гудвил» +- **THEN** справочное «Гудвил» (≥ 5 символов) входит в него — совпало, `NAME_MISMATCH` нет + +### Requirement: Ответ `GET …/statement` по v2.0 +`StatementOut` SHALL отдавать: у документа `form_variant`; у шапки `needs_review_fields`, `source` (`header_source`; +`confidence` — строкой Decimal, как прочие уверенности API), `organization_name_modified` и `okei`; у колонок `period_kind`, `label_confidence`; у строк `id`, `origin` (`template` ⇔ +`row_index = null`), `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`, `column_index`, `field`, `codes`; `checks` — нарушения из `document_issue` с `codes` и `line_ids`; +`edition_lines[] {code, name}` — справочник строк для подсказки кода: коды словаря имён формы в редакции ответа по одному +(имя — первое), у формы без редакции — коды всех редакций по одному. `DocumentBrief` (`FileOut.documents[]`) и +`DocumentOut` SHALL отдавать `issue_counts {error, info}` — число записей `document_issue` документа по уровню. Порядок +`lines` — по возрастанию кода, как сейчас (`schemas/statements.py`), одинаковые коды — по `row_index`; строки +`unreadable` / `missing` — после ближайшей предыдущей по `row_index` строки, размещённой по коду, без такой — в начале; +`detail` — после родителя. Разделы и размещение итогов на экране («как на бумаге», Р-14) строит экран. + +#### Scenario: Документ, принятый по 1.x +- **WHEN** форма загружена до перехода сервиса (результат 1.2) +- **THEN** у строк `line_status = ok`, `row_index` по порядку, `source_ref = null`, `origin = source`, у значений `text = + string_value`, `issues` содержат нарушения §7, посчитанные ядром, и `MAPPING_WARNING`; экран работает + +### Requirement: Миграция DBML v5.19 +Одна миграция Alembic SHALL приводить схему к DBML v5.19 и **только** добавлять колонки, таблицу, enum'ы и индексы и +заполнять значения по умолчанию (Р-24); §7 по существующим формам миграция MUST NOT считать, импорта копии +`arm_statements` в ревизии нет. Перед выкладкой база очищается от форм ББ/ОФР (требование «Очистка форм перед +выкладкой»), но миграция SHALL проходить и на непустой базе (стенды разработчиков, база, очищенная не полностью): +`row_index` — `NULL` у строк с `line_code_original_value IS NULL` (созданных правкой), у остальных — по возрастанию +`line_code` внутри формы с 0; `line_status = ok`, `needs_review` строк — `false`, `needs_review` значений — **НЕ** +`values_ok` (`values_ok` — правлено, или `is_recognized` не `false` и `confidence` равна 100 или пуста), `source_text = +string_value`, `is_confirmed = false`, `period_kind` — `absolute` при `period_date`, иначе `none`, `form_variant = +null`, `header_source` — из `*_original_value` и `inn_confidence`, `organization_name_original_value = +organization_name`, `document.file_ordinal` — по `(created_at, id)` внутри файла с 0 (у всех документов), +`related_group.crm_id` — `null`; `document_issue` — пуст. **Корректность нарушений у форм, оставшихся с до-миграционных +времён, не гарантируется:** у них нет записей `document_issue`, поэтому до первой правки экран не показывает нарушений +§7, а `POST …/confirm` не отвечает `CHECKS_NOT_PASSED`; первый PATCH пересчитывает форму общим кодом. Это цена Р-24; на +контуре её снимает очистка. Сверка `check_dbml_vs_orm.py` — против `data-model-v5.19.dbml`. + +Откат (`downgrade`) SHALL быть гарантирован на пустой БД и на данных, которые схема v5.18 может представить без потери +первичных данных и ручного ввода. `downgrade` SHALL останавливаться с ошибкой, называющей причину и число записей, и +ничего не менять, если есть: две строки с одним кодом в форме, строка с `line_code = null`, две формы одного типа в файле, +непустой `related_group.crm_id`, `organization_name_modified = true` (исходное наименование хранится только в новой +колонке). Остальные новые колонки и таблица `document_issue` — производные и сигнальные данные v5.19 (`needs_review`, +`source_text`, `header_source`, `is_confirmed` с `confirmed_by/at`, `period_kind`, `label_confidence`, `form_variant`, +`needs_review_fields`, `file_ordinal`, `statements_version`, `document_issue`): `downgrade` удаляет их и пишет в журнал +миграции, сколько записей с непустыми значениями удалено (подтверждения аналитика остаются в `audit_event`). Это не +«молчаливое удаление»: потеря названа в журнале и в этом требовании. + +#### Scenario: Цикл миграций +- **WHEN** `alembic upgrade head` → `downgrade base` → `upgrade head` на чистой БД +- **THEN** все три шага проходят, сверка ORM с DBML v5.19 — без расхождений + +#### Scenario: Миграция на непустой базе +- **WHEN** на стенде есть форма ББ, загруженная до v5.19, с несошедшимся `1600 = 1700`, и выполнена миграция +- **THEN** миграция проходит: у строк `row_index` по коду, `line_status = ok`, у документа `file_ordinal`, записей + `document_issue` нет — нарушение до первой правки не показано (корректность не гарантируется); после любого PATCH форма + пересчитана, и запись `RELATION_FAILED` появляется + +#### Scenario: Строка правки до миграции +- **WHEN** до миграции у формы есть строка 1250, созданная правкой (`line_code_original_value = null`), с суммой +- **THEN** после миграции у неё `row_index = null`, `origin = template`; строки источника пронумерованы с 0 + +#### Scenario: Откат на данных v2.0 +- **WHEN** в БД есть форма с двумя строками 2410, выполняется `downgrade` ниже v5.19 +- **THEN** откат останавливается с ошибкой о повторе кода; данные не изменены + +#### Scenario: Откат после подтверждений +- **WHEN** в БД нет несовместимых данных, но у ячеек есть `is_confirmed = true` и записи `document_issue` +- **THEN** `downgrade` проходит, удаляет колонки и таблицу и пишет в журнал число удалённых подтверждений и записей + +### Requirement: Очистка форм перед выкладкой +Перед выкладкой ядра с миграцией v5.19 на контур (шаг 3 порядка выкладки) база SHALL очищаться от форм ББ/ОФР (Р-24): +формы пересоздаются повторной загрузкой файлов уже по новым правилам. Очистку выполняет владелец или платформенная +команда по его заявке, на контуре — после резервной копии БД и до `alembic upgrade` v5.19 (на стендах разработчиков — по +желанию). Инструмент — скрипт ядра `scripts/purge_statements.py` (пишется в PR шага 3; описание здесь, кода нет) с +режимами `--dry-run` (по умолчанию: только отчёт) и `--apply`, одной транзакцией: +1. **Отбор:** документы `BALANCE` / `INCOME` (у файлов любого статуса, включая `SUPERSEDED`). +2. **Отказ без изменений**, если хотя бы один отобранный документ упомянут в `ex_snapshot_item` или привязан + (`consolidation_document`) к версии консолидации в статусе «Верифицирована» или дальше — `VERIFIED` или `ARCHIVED` + верифицированной версии (есть `ex_snapshot` с `source_version_id` версии): такая версия read-only и заморожена в ЕХ. + Отчёт называет версии, решение — за владельцем. Версии в работе, на согласовании, отклонённые (`REJECTED`) и + отменённые (`CANCELLED`, в том числе архивированные) очистке не мешают: привязки к ним снимаются, как к версиям + «в работе» (п. 3, 4), статус версий не меняется (Р-35). +3. **Файлы только с формами** — у файла нет документов `OSV`: удаляется файл целиком, как `DELETE /files/{id}` (§9.5 п. 5), + но без запрета `FILE_HAS_CONFIRMED_DOCUMENTS`: привязки документов к неверифицированным версиям (п. 2) снимаются + (статус версий не меняется), удаляются документы всех типов файла (`BALANCE`, `INCOME`, `DISCLOSURE`, `OTHER`) с + `financial_statement_*` и комментариями документов, задания `recognition_job` файла и `stored_file`; объектам S3 + ставится `expires_at = now()`; ссылка `superseded_by_file_id` других файлов на удаляемый обнуляется, а заменённые им + файлы остаются `SUPERSEDED` (повторная загрузка заменит их снова; отчёт перечисляет такие файлы, если в них есть ОСВ). +4. **Смешанные файлы** — у файла есть и ОСВ, и формы: удаляются только документы `BALANCE` / `INCOME` с их + `financial_statement_*`, комментариями и привязками; файл, ОСВ, `DISCLOSURE` / `OTHER` и задания остаются. Повторно + загрузить такой файл нельзя (дедуп по `sha256`), поэтому отчёт `--dry-run` перечисляет смешанные файлы отдельно, и + владелец решает до `--apply`, удалять ли их целиком (ключ `--include-mixed`). +5. **Аудит:** на каждый удалённый файл — системное `FILE_DELETE` (`user_id = NULL`, описание «очистка форм перед v5.19», + карточка файла в `before_value`); на документ смешанного файла — то же событие по файлу с перечнем удалённых + документов. +6. **Не трогаются:** ОСВ (`osv_entry`), консолидации и их версии, корректировки, гудвилл, справочники, пользователи, + журнал аудита. Результаты КФО неверифицированных версий, посчитанные с удалёнными формами, пересчитываются, как после + удаления файла. + +#### Scenario: Отчёт перед очисткой +- **WHEN** владелец запускает скрипт без ключей +- **THEN** скрипт ничего не меняет и печатает число файлов только с формами, смешанных файлов, документов форм, + подтверждённых документов и привязок к неверифицированным версиям + +#### Scenario: Форма в верифицированной версии +- **WHEN** документ ББ привязан к версии `VERIFIED` +- **THEN** `--apply` отказывает с перечнем версий, база не изменена + +#### Scenario: Форма в версии на согласовании или отклонённой +- **WHEN** документ ББ привязан к версии `VERIFICATION_RF`, а ОФР — к версии `REJECTED`; верифицированных версий нет +- **THEN** `--apply` выполняется: привязки сняты, статусы версий не изменились + +#### Scenario: Очистка и повторная загрузка +- **WHEN** очистка выполнена, ядро v5.19 выложено, аналитик загружает тот же PDF с ББ и ОФР +- **THEN** дедуп не мешает (файла больше нет), формы создаются по мапперу 1.x или 2.0 — смотря что шлёт сервис + +### Requirement: Предупреждения файла и ОСВ в API, статус ОСВ при приёме 2.0 +`recognition_job.metrics.warnings` SHALL хранить предупреждения уровня файла структурами `{code, severity, message, +locator?}` (`fileWarning` контракта): у `2.0` — как пришли, у `1.x` — строки, обёрнутые в `LEGACY_WARNING` `info`. +`JobBrief.warnings` карточки файла (`FileOut.job`) SHALL отдавать список этих структур, а не строк; строки заданий, +сохранённых до выкладки ядра v5.19, при чтении конвертируются в `{code: "LEGACY_WARNING", severity: "info", message: +<строка>, locator: null}` (данные в БД не переписываются). `JobBrief.mapping_warnings` (предупреждения маппинга ядра) +остаётся списком строк. Это несовместимое изменение типа поля для фронта — фиксируется в гайде фронта. + +Предупреждения листа ОСВ результата `2.0` (`osvDocument.warnings`, коды `OSV_*`, `info`) SHALL храниться в +`document_issue` документа ОСВ (вид `warning`, источник `service`) и отдаваться в `DocumentOut.issues[]` (те же поля, +что `issues` формы) вместе с предупреждениями маппинга ядра этого листа; `DocumentOut.warnings` (строки маппинга файла) +сохраняется на окно перехода. `osvDocument.status` SHALL использоваться: при приёме `2.0` документ ОСВ `RECOGNIZED` ⇔ +`osvDocument.status = done` ∧ `checks` листа без нарушений ∧ в `metrics.warnings` файла нет предупреждений уровня +`error` ∧ `counterparty_check ≠ MISMATCH` ∧ нет предупреждений маппинга ядра по этому листу (повтор счёта в файле, строки +без родителя, нарастающая по данным Q1); статус конверта в условие не входит (как Р-19 для форм). При приёме `1.x` — как +прежде (§9.3). + +#### Scenario: Карточка файла задания до выкладки +- **WHEN** у задания, сохранённого до v5.19, `metrics.warnings = ["sheet=ОСВ 60 строки без родителя"]` +- **THEN** `JobBrief.warnings = [{code: "LEGACY_WARNING", severity: "info", message: "sheet=ОСВ 60 строки без родителя", + locator: null}]` + +#### Scenario: Потерянный json в карточке файла +- **WHEN** результат `2.0` scan-gateway несёт `metrics.warnings` с `JSON_FAILED` +- **THEN** `JobBrief.warnings` содержит `{code: "JSON_FAILED", severity: "error", message, locator}`; предупреждение не + теряется + +#### Scenario: ОСВ в файле с ОФР, требующим проверки +- **WHEN** книга из ОСВ (`status = done`, контрольные суммы сошлись) и ОФР (`needs_check`), конверт `needs_check`, + предупреждений файла `error` нет +- **THEN** ОСВ `RECOGNIZED` и автоподтверждается; её предупреждения `OSV_*` — в `DocumentOut.issues` + +### 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/specs/arm-web/spec.md b/openspec/specs/arm-web/spec.md new file mode 100644 index 0000000..ee26b4f --- /dev/null +++ b/openspec/specs/arm-web/spec.md @@ -0,0 +1,66 @@ +# arm-web Specification + +## Purpose +Требования к экрану проверки формы ББ/ОФР в arm-web по контракту 2.0: раскладка строк со статусами (`ok` / `detail` / `unreadable` / `missing`), подсветка «требует внимания», предупреждения у места и правка кодов строк, сумм, дат колонок и шапки. + +## 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` (в подсказке у поля — `header.source.<поле>`: сырой текст и +уверенность). Предупреждения и нарушения (`issues`, `checks`) SHALL показываться у места: у строк (`line_ids`), ячеек +(`line_ids` + `column_id`), полей шапки (`field`) или у документа (в том числе запись с `column_index` без `column_id` — +столбец вне колонок формы); уровень `error` отличается от `info`. Подсветки по `confidence < 100` и `is_recognized = +false` напрямую нет. У значений строк `detail` `needs_review` всегда `false` (Р-23): при показанных расшифровках их +сомнительные ячейки не подсвечиваются, но подсказка с `text` и уверенностью есть. + +#### Scenario: Догадка подтверждена проверкой +- **WHEN** значение `is_recognized = false`, `needs_review = false` +- **THEN** ячейка не подсвечена; в подсказке сырой текст и уверенность + +### Requirement: Правка строк, колонок и подтверждение в режиме «Редактировать» +В режиме «Редактировать» экран SHALL давать: у `unreadable` / `missing` — поле ввода кода с подсказкой из справочника +(код → справочное имя) и пункт меню строки «Это расшифровка»; у `detail` — «Назначить код»; у строки `ok` — «Сделать +расшифровкой», смену кода и «Подтвердить код» у строки с `needs_review`; подтверждение ячейки без правки; ввод даты у +колонки без даты (`period_kind = none`) или с предупреждением о дате (в том числе подтверждение той же даты); снятие подтверждения ячейки и кода (`confirmed: +false`); правка наименования организации в шапке; подтверждение даты отчёта из `needs_review_fields` без изменения (тем +же значением). «Сохранить» отправляет один `PATCH …/statement` (`header`, `values`, `columns`, `lines`). Кнопка «Подтвердить данные» при строках `unreadable` / `missing` с суммами +SHALL быть отключена с подсказкой «Разберите строки без кода: N» и переходом к первой такой строке; ответ сервера 409 +`LINES_UNRESOLVED` показывается тем же текстом, 409 `DUPLICATE_FORM` — со ссылкой на подтверждённую форму, 409 +`LINE_CODE_TAKEN` — у поля кода. Кнопка «Подтвердить данные» учитывает строки `unreadable` / `missing` с суммой или +нераспознанным текстом в ячейках. + +#### Scenario: Назначить код +- **WHEN** аналитик вводит 1250 в ячейку кода строки `unreadable` и сохраняет +- **THEN** уходит `PATCH` с `lines: [{line_id, line_code: "1250"}]`; после ответа строка встаёт на место 1250 + +#### Scenario: Подтверждение заблокировано +- **WHEN** у формы две строки `missing` с суммами +- **THEN** «Подтвердить данные» отключена, подсказка «Разберите строки без кода: 2», клик по подсказке ведёт к первой + +### Requirement: Предупреждения файла и документа в карточке файла +Карточка файла (панель файлов) SHALL читать `job.warnings` как список структур `{code, severity, message, locator}` +(после выкладки ядра v5.19 это не строки; у старых заданий ядро отдаёт строки, обёрнутые в `LEGACY_WARNING`), показывать +`message` и `locator`, отличая `error` от `info`; предупреждения листа ОСВ и документа — из `DocumentOut.issues[]`. +`job.mapping_warnings` остаётся списком строк. + +#### Scenario: Потерянный ОФР в карточке +- **WHEN** у задания `job.warnings = [{code: "JSON_FAILED", severity: "error", message: "…", locator: "file:x_ofr.json"}]` +- **THEN** карточка показывает предупреждение уровня «ошибка» с текстом и местом; экран не падает на объекте вместо строки diff --git a/openspec/specs/quality-checks/spec.md b/openspec/specs/quality-checks/spec.md index ec744ff..d5fb095 100644 --- a/openspec/specs/quality-checks/spec.md +++ b/openspec/specs/quality-checks/spec.md @@ -9,9 +9,13 @@ CI не используется, пайплайн платформы банка ### Requirement: `make check` — быстрые проверки без Docker Каждый сервисный репозиторий SHALL предоставлять цель `make check`, выполняющую из `api/`: `ruff check .`, -`ruff format --check .`, `mypy src`, `pytest -m "not integration"`, тест идентичности вендоренного контракта с -`../docs/contracts`. Цель SHALL завершаться ненулевым кодом при первой упавшей проверке и MUST NOT требовать запущенного -Docker, PostgreSQL или NATS. +`ruff format --check .`, `mypy src` (в сервисе с копией общего кода `arm_statements` — `mypy src arm_statements`, в +`arm-core` — ещё `scripts`), `pytest -m "not integration"` (включая тесты копии `arm_statements/tests/`), тест +идентичности вендоренного контракта с `../docs/contracts` (схема копии — с `<имя>.v.schema.json` своего `MAJOR`, +если такой файл есть в docs, иначе с основным; примеры — с `examples/v/`, если там есть одноимённый файл, иначе с +`examples/`; правило — спека `recognition-contract`, требование «Копия контракта в сервисе идентична источнику»). Цель SHALL завершаться ненулевым кодом при первой упавшей проверке и MUST NOT требовать +запущенного Docker, PostgreSQL или NATS. Свежесть копии `arm_statements` относительно репозитория `arm-statements` в +`make check` MUST NOT проверяться (соседний репозиторий в `make check` не читается). #### Scenario: Файл не отформатирован - **WHEN** в репозитории есть файл, не отформатированный `ruff format` @@ -21,12 +25,19 @@ Docker, PostgreSQL или NATS. - **WHEN** все проверки проходят - **THEN** `make check` завершается с кодом 0 за время, не превышающее нескольких минут, без обращения к сети +#### Scenario: Тесты копии общего кода +- **WHEN** в `tabular-parser` лежит копия `api/arm_statements/` с тестами пакета +- **THEN** `make check` гоняет их вместе с тестами сервиса, `mypy` проверяет и копию + ### Requirement: `make check-full` — полная проверка против compose Каждый сервисный репозиторий SHALL предоставлять цель `make check-full`: `make check` плюс `pytest -m integration` против инфраструктуры `docs/dev/docker-compose.yml` (PostgreSQL 16, NATS, MinIO, для `scan-gateway` — эмулятор). Для `arm-core` цель SHALL дополнительно выполнять «`alembic upgrade head` → `alembic downgrade base` → `alembic upgrade head`» на чистой -БД и `python scripts/check_dbml_vs_orm.py` против текущей версии модели (`../docs/model/data-model-v<текущая>.dbml`, путь — переменная `DBML` Makefile). `make check-full` SHALL быть -выполнен перед созданием PR, его итог вписывается в описание PR. +БД и `python scripts/check_dbml_vs_orm.py` против текущей версии модели (`../docs/model/data-model-v<текущая>.dbml`, путь — переменная `DBML` Makefile). В сервисе с копией общего +кода `arm_statements` цель SHALL выполнять `statements-check` — `../docs/scripts/sync-statements.sh --check` по этому +хосту (спека `statement-normalization`, требование «`sync-statements` — копии пакета в потребителях»): коды `0` и `10` +— шаг зелёный (`10` — с предупреждением «копия отстала»), `20` и `2` — красный. `make check-full` SHALL быть +выполнен перед созданием PR, его итог (и предупреждение `statements-check`, если было) вписывается в описание PR. #### Scenario: Расхождение ORM и DBML - **WHEN** в ORM добавлена колонка, которой нет в DBML @@ -36,6 +47,10 @@ Docker, PostgreSQL или NATS. - **WHEN** `downgrade` ревизии не удаляет созданный ею объект - **THEN** шаг «upgrade → downgrade → upgrade» падает +#### Scenario: Прямая правка копии общего кода +- **WHEN** в `scan-gateway` поправлен файл `api/arm_statements/steps/…`, а в `arm-statements` такого изменения нет +- **THEN** `statements-check` завершается с кодом 20 и называет файл, `make check-full` красный + ### Requirement: Фикстуры и соседние репозитории — опциональны с предупреждением Проверки SHALL брать `docs` и `fixtures` из соседних каталогов workspace (`../docs`, `../fixtures`) или из переменных `ARM_KA_DOCS`, `ARM_KA_FIXTURES`. При отсутствии фикстур тесты с маркером `fixtures` SHALL пропускаться с явной причиной; diff --git a/openspec/specs/recognition-contract/spec.md b/openspec/specs/recognition-contract/spec.md index aab281a..983ae07 100644 --- a/openspec/specs/recognition-contract/spec.md +++ b/openspec/specs/recognition-contract/spec.md @@ -71,16 +71,18 @@ ack не получен за `ACK_TIMEOUT_SECONDS` (по умолчанию 30), ### Requirement: Сообщение результата распознавания Тело сообщения на subject ответа (настройка окружения `NATS_RESULT_SUBJECT`, по умолчанию `files.tabular_parsed`) SHALL соответствовать `docs/contracts/recognition-result-message.schema.json` -v1.0: обязательные поля `schema_version` (`"1.0"`), `job_id`, `file_id`, `status` (`processing` | `done` | `needs_check` | +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`. Поле `group_schema` в конверт NOT включается — источник `recognition_job.group_schema` -— файл результата. +(только `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` указывает на этот файл, заголовок `Nats-Msg-Id` = `result::` + со `status = done | needs_check`, `result_s3` указывает на этот файл, `result_schema_version = "2.0"`, заголовок + `Nats-Msg-Id` = `result::` #### Scenario: Ошибка обработки - **WHEN** сервис не смог обработать файл (нечитаем, таймаут внешней системы, тип не поддерживается) @@ -88,50 +90,90 @@ v1.0: обязательные поля `schema_version` (`"1.0"`), `job_id`, `f `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` v1.2: один файл (`stored_file`) +Файл результата 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`. -У строки `osvDocument.rows[]` SHALL быть необязательное поле `extra` — объект «заголовок колонки → строка или null» для -колонок внутри таблицы, не сопоставленных с шестью суммами и ИНН (§1.2 «не терять»). У шапки `osvDocument.header` SHALL -быть необязательные `period_start`, `period_end` (дата или null — границы периода по шапке; для квартала и года — -первое и последнее число, для диапазона месяцев — его границы) и `is_from_year_start` (boolean или null; `is_cumulative` -не переопределяется). `statementDocument.document_type` SHALL допускать `DISCLOSURE` (расшифровка или иная форма -0710xxx: документ без `columns` и `lines`). У `statementDocument` SHALL быть необязательный объект `checks`: -`relations_failed` и `soft_relations_failed` — массивы `{column_index, relation, expected, actual}` (нарушенные -соотношения §7 по колонке; `expected`/`actual` — `money`), `duplicate_line_codes` — массив строк; отсутствие объекта -означает «не проверялось», пустые массивы — «проверено, сошлось». Потребители, не знающие новых полей и значений, -игнорируют их (MINOR). Производитель v1.2 пишет `schema_version = "1.2"`; файлы v1.0 и v1.1 остаются валидными. +Предупреждение уровня файла пишется там, где документа нет (потерянный json, таблица ИС, исключение разбора таблицы, +лист «прочее»). +`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?}` (`line_refs` — только `ref` строк из `lines[]`); +у `osvDocument` все предупреждения — `info`, `status = done`, когда контрольные суммы §1.4 листа сошлись, иначе +`needs_check`. Остальное в `osvDocument` — как в v1.2: у строки `rows[]` необязательное поле `extra` — объект «заголовок +колонки → строка или null» для колонок внутри таблицы, не сопоставленных с шестью суммами и ИНН (§1.2 «не терять»); у +шапки — `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`, `okei`, `address` и для каждого `<поле>_text` и +`<поле>_confidence`, плюс `needs_review_fields[]`; `columns[]` — `column_index` (номер столбца в сетке кандидата: у OCR +— `column_index` ИС, у Excel — номер столбца листа с 0), `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` (у Excel `<лист>!`, +имя листа в кавычках по правилу Excel), `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` в v2.0 не +порождается: лист «прочее» — предупреждение файла `SHEET_OTHER`, документа нет; значение остаётся только в схеме 1.x и в +`document_type` ядра для документов прежних результатов); `form_variant` обязателен (`null` у `DISCLOSURE`); у документа +без строк `columns` и `lines` — пустые массивы. `ref` уникален в документе. #### Scenario: Результат валидируется примерами - **WHEN** CI сервиса запускает тесты контракта -- **THEN** все файлы `docs/contracts/examples/result-*.json` проходят схему файла результата, а все +- **THEN** все файлы `docs/contracts/examples/result-*.json` проходят схему файла результата v2.0, а все `request-*.json` / `result-message-*.json` — схемы сообщений #### Scenario: Лишняя колонка сохранена - **WHEN** в ОСВ между ИНН и суммами есть колонка «КПП» -- **THEN** у строк контрагентов `extra = {"КПП": "<значение>"}`, файл проходит схему v1.2 и схему v1.0 (поле - игнорируется) +- **THEN** у строк контрагентов `extra = {"КПП": "<значение>"}`, файл проходит схему v2.0 #### Scenario: Форма из Excel с проверками -- **WHEN** tabular-parser разобрал книгу с балансом на двух листах, ОФР и пояснениями (`examples/result-statement-excel.json`) -- **THEN** файл проходит схему v1.2: `BALANCE` с `locator` из двух имён листов и `checks` с нарушением в - `soft_relations_failed`, `INCOME` с пустыми массивами `checks`, `DISCLOSURE` без `columns`/`lines`; тот же файл - без `checks` и с `document_type = OTHER` вместо `DISCLOSURE` проходит схему v1.1 +- **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`; файл проходит схему - v1.2, а потребитель v1.1 читает его без ошибок +- **THEN** `period_start = "2024-10-01"`, `period_end = "2025-09-30"`, `is_from_year_start = false`; документ ОСВ с + `status` и `warnings`, файл проходит схему v2.0 + +#### Scenario: Документ OTHER в v2.0 +- **WHEN** в результате v2.0 `statementDocument` с `document_type = OTHER` или без `form_variant` +- **THEN** файл не проходит схему v2.0 + +#### Scenario: Строка без кода с кодом в поле +- **WHEN** у строки `line_status = unreadable` передан непустой `line_code` +- **THEN** файл не проходит схему v2.0 ### Requirement: Версионирование контракта Поле `schema_version` SHALL быть строкой `MAJOR.MINOR`. Добавление необязательных полей или значений enum — -`MINOR` (1.1, обратно совместимо); удаление/переименование полей, смена типов или семантики — `MAJOR` (2.0). +`MINOR` (обратно совместимо); удаление/переименование полей, смена типов или семантики — `MAJOR`. Сервис SHALL отвергать запрос с неизвестным `MAJOR` (`error.code = SCHEMA_MISMATCH`); ядро SHALL принимать результат -с тем же `MAJOR` и любым известным или новым `MINOR`. +с известным `MAJOR` и любым известным или новым `MINOR`. Запрос `recognition-request` остаётся v1.0; файл результата и +конверт — v2.0 (`MAJOR` поднят: `line_code` стал nullable, `metrics.warnings` и `document.warnings` — структуры, удалён +`checks.duplicate_line_codes`, изменён смысл `is_recognized` у OCR). На окне перехода схемы прежнего `MAJOR` SHALL лежать +в `docs/contracts/` рядом с текущими как `recognition-result.v1.schema.json` и `recognition-result-message.v1.schema.json` +(байтовые копии 1.2 и 1.0), их примеры — в `docs/contracts/examples/v1/`; `scripts/validate_contracts.py` проверяет оба +набора. Они удаляются тем же изменением, что удаляет маппер `1.x` ядра. #### Scenario: Несовместимая версия запроса -- **WHEN** сервис, поддерживающий `1.x`, получает запрос `schema_version = "2.0"` +- **WHEN** сервис, поддерживающий запрос `1.x`, получает запрос `schema_version = "2.0"` - **THEN** публикуется результат `status = error`, `error.code = SCHEMA_MISMATCH` **без ack `processing`**: ответом на запрос служит сам финальный конверт, ядро закрывает им задание (`published → error`), и повторной доставки запроса не будет. Конверт публикуется только если из тела извлекаются корректные `job_id` и `file_id`; иначе — как для нечитаемого @@ -139,6 +181,11 @@ v1.0: обязательные поля `schema_version` (`"1.0"`), `job_id`, `f отсутствии реализации расходятся (`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`, повтор не + выполняется (как для любого результата не по схеме) + ### Requirement: Идемпотентность результатов без брокера Публикующий SHALL ставить заголовок `Nats-Msg-Id` на каждое сообщение (запрос `:`, ack `ack::`, результат `result::`). Поскольку серверного дедупа нет, ядро SHALL считать @@ -169,11 +216,59 @@ v1.0: обязательные поля `schema_version` (`"1.0"`), `job_id`, `f ### Requirement: Копия контракта в сервисе идентична источнику Каждый сервис, читающий или пишущий контракт, SHALL хранить копию файлов схем в `api/contracts/` и проверять в `make check` их байтовую идентичность файлам `docs/contracts/` (`../docs` или путь `ARM_KA_DOCS`; при недоступности — пропуск с предупреждением). +Файл копии SHALL сверяться с файлом docs по правилу, одинаковому у ядра и сервисов и верному до, во время и после окна +перехода между `MAJOR`: +- **схема** `<имя>.schema.json` копии: `M` — номер из её `$id` (`…/v`); пара — `docs/contracts/<имя>.v.schema.json`, + **если такой файл есть**, иначе основной `docs/contracts/<имя>.schema.json` (у `recognition-request` файла `.v1` нет + — его копия `…/v1` сверяется с основным файлом; до слияния v2.0 в docs файлов `.v1` нет вовсе — все копии сверяются с + основными); +- **пример** `examples/<имя>.json` копии: вид — по префиксу имени (`request-` — запрос, `result-message-` — конверт, + `result-` — файл результата), `M` — `MAJOR` копии схемы этого вида; пара — `docs/contracts/examples/v/<имя>.json`, + если такой файл есть, иначе `docs/contracts/examples/<имя>.json`; +- **прочие файлы копии** (`<имя>.v.schema.json`, `examples/v/*.json` — их держит ядро на окне) — с одноимённым + файлом docs; нет его в docs — расхождение; +- **набор:** каждой схеме и каждому примеру верхнего уровня docs (`contracts/*.schema.json` без `.v`, + `contracts/examples/*.json`) SHALL соответствовать файл копии; файлы docs `*.v.schema.json` и `examples/v/` + требуются в копии, только если копия держит этот `MAJOR` (ядро на окне); файлы другого `MAJOR` сервисом не + сверяются. + +Правило вводится в тест идентичности **всех трёх** потребителей (`arm-core`, `tabular-parser`, `scan-gateway`) до +слияния контракта v2.0 в docs (шаг 0 выкладки); иначе их `make check` упадёт на окне перехода. Ядро держит копии обоих +`MAJOR` с PR шага 3; при удалении `MAJOR` 1 (шаг 6) ядро сначала удаляет свои копии `.v1`, затем docs — файлы `.v1`. #### Scenario: Копия устарела - **WHEN** в `docs/contracts/` изменена схема, а копия в сервисе — нет - **THEN** тест идентичности в `make check` сервиса падает с указанием имени файла +#### Scenario: Сервис ещё пишет 1.x после слияния v2.0 в docs +- **WHEN** в docs основной `recognition-result.schema.json` уже v2.0, а копия в `tabular-parser` — v1.2 (`$id` `…/v1`) +- **THEN** тест идентичности сверяет копию с `recognition-result.v1.schema.json` и проходит; посторонний PR в сервис на + окне перехода не краснеет + +#### Scenario: Сервис перешёл на 2.0 +- **WHEN** `scan-gateway` в PR шага 5 обновил копию до v2.0 (`$id` `…/v2`) и примеры до v2.0 +- **THEN** тест сверяет схему с основным файлом docs (файла `.v2` нет), примеры `result-*` — с `examples/*.json`; + `examples/v1/` и `*.v1.schema.json` docs не сверяются + +#### Scenario: Запрос v1.0 после слияния v2.0 +- **WHEN** в docs уже есть `recognition-result.v1.schema.json`, а `recognition-request.v1.schema.json` нет; копия + запроса в сервисе — v1.0 (`$id` `…/v1`), примеры `request-image.json`, `request-tabular.json` +- **THEN** копия запроса сверяется с основным `recognition-request.schema.json`, примеры запроса — с + `examples/request-*.json` (в `examples/v1/` их нет); тест проходит + +#### Scenario: Примеры прежнего MAJOR +- **WHEN** копия `tabular-parser` — v1.2, в ней `examples/result-statement-excel.json` версии 1.2, а в docs + `examples/result-statement-excel.json` уже 2.0, `examples/v1/result-statement-excel.json` — 1.2 +- **THEN** пример копии сверяется с `examples/v1/result-statement-excel.json` и проходит + +#### Scenario: Шаг 0 до слияния docs +- **WHEN** тест с новым правилом слит в `arm-core`, `tabular-parser` и `scan-gateway`, а docs ещё на v1.2 без файлов `.v1` +- **THEN** все копии сверяются с основными файлами и примерами верхнего уровня, тест проходит, как прежде + +#### Scenario: Ядро на окне перехода +- **WHEN** копия ядра после шага 3 держит основные схемы v2.0, `*.v1.schema.json` и `examples/v1/` +- **THEN** основные сверяются с основными docs, `.v1` и `examples/v1/` — с одноимёнными файлами docs; набор совпадает + ### Requirement: Subject'ы NATS — настройки окружения Subject'ы обмена SHALL задаваться секретами группы `OPENBAO__NATS__` с одинаковыми ключами во всех трёх сервисах: `NATS_TABULAR_REQUEST_SUBJECT` (по умолчанию `files.tabular_uploaded`), `NATS_IMAGE_REQUEST_SUBJECT` (по умолчанию @@ -207,3 +302,36 @@ Queue group'ы и `schema_version` от настроек subject'ов не за #### Scenario: Ошибка конфигурации не раскрывает JWT - **WHEN** сервис не стартует из-за ошибки валидации настроек при заданных `NATS_JWT` и `NATS_SEED` - **THEN** текст ошибки и лог не содержат значений JWT и seed + +### Requirement: Переход на контракт v2.0 — «сначала ядро» +Ядро SHALL до перехода любого сервиса принимать конверты (включая ack `processing`) и файлы результата и `1.x`, и +`2.0`, с двумя мапперами файла результата. При приёме `1.x` статус документа формы SHALL считаться по прежнему правилу +D-6, где «проверки §7 сошлись» — по расчёту ядра копией `arm_statements` (полный набор), записанному в `document_issue`, +а поля v2.0 заполняться по умолчанию: `line_status = ok`, `row_index` — по порядку строк в результате с 0, `ref = +null`, `line_code_original_value = line_code`, `text = string_value`, `needs_review` значения = **НЕ** `values_ok` +(`values_ok` — `is_recognized` не `false` и `confidence` 100 или пуста: чистое значение — `needs_review = false`), +`needs_review` строки — `false`, `period_kind` — `absolute` при `period_date`, иначе `none`, `form_variant` — по +структуре кодов (точка входа пакета для пересчёта, спека `arm-core`), `file_ordinal` — индекс в `documents[]`; строки `metrics.warnings` — в `recognition_job.metrics.warnings` +структурами `{code: "LEGACY_WARNING", severity: "info", message}`; предупреждения маппера по документу — +`document_issue` `MAPPING_WARNING` (`info`); `statements_version` задания — `null`. Сервисы SHALL переходить на `2.0` +только после выкладки ядра, принимающего `2.0`. Маппер `1.x` и схемы `*.v1.schema.json` удаляются после перехода обоих +сервисов отдельным изменением. + +#### Scenario: Сервис ещё на v1.2 +- **WHEN** после выкладки ядра tabular-parser присылает результат `1.2` с повтором кода и `checks.duplicate_line_codes` +- **THEN** ядро принимает его маппером `1.x`: из двух строк с одним кодом хранится первая (прежнее правило маппера + `1.x`), вторая не хранится, у документа `MAPPING_WARNING` о повторе; нарушения §7 — в `document_issue` от ядра, статус + документа — по D-6, файл не `ERROR` + +#### Scenario: Чистое значение 1.x +- **WHEN** в результате `1.2` значение с `confidence = 100` и `is_recognized = true` +- **THEN** `needs_review = false`; значение с `confidence = 60` — `needs_review = true` + +#### 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/specs/scan-gateway/spec.md b/openspec/specs/scan-gateway/spec.md new file mode 100644 index 0000000..8ad5be0 --- /dev/null +++ b/openspec/specs/scan-gateway/spec.md @@ -0,0 +1,57 @@ +# scan-gateway Specification + +## Purpose +Требования к scan-gateway в единой нормализации ББ/ОФР: адаптер json-ответов ИС Сканирование (шаг 1) в общую сетку ячеек и выдача результата распознавания контракта 2.0, нормализованного общим кодом `arm_statements`. + +## 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`, `locator = file:<имя json>;table:`; документа у таблицы нет), остальные таблицы +разбираются; ни одной таблицы не разобрано — `error RECOGNITION_FAILED`. + +#### Scenario: Таблица упала при разборе +- **WHEN** разбор `table:1` json `aep_3.json` бросил исключение, `table:0` разобрана +- **THEN** документ `BALANCE` из `table:0`; в `metrics.warnings` — `TABLE_PARSE_ERROR` уровня `error` с `locator = + "file:aep_3.json;table:1"`; конверт `needs_check`; ядро не автоподтверждает документы файла + +#### 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` в начале таблицы) и сырым + текстом; значение `211(1 |` — `amount = null`, `string_value = "211(1 |"`, `is_recognized = false` (скобка внутри + цифр, правило шага 4), а не 2111; счётчиков «строк без кода с суммами пропущено» нет + +#### Scenario: Таблица ИС только с текстом +- **WHEN** в json есть таблица без столбца кодов и без распознанного типа, в ячейках только текст подписей +- **THEN** документа нет, в `metrics.warnings` — `TABLE_SKIPPED` уровня `info` с `locator` таблицы; конверт из-за неё + не `needs_check` diff --git a/openspec/specs/statement-normalization/spec.md b/openspec/specs/statement-normalization/spec.md new file mode 100644 index 0000000..f9f7171 --- /dev/null +++ b/openspec/specs/statement-normalization/spec.md @@ -0,0 +1,916 @@ +# statement-normalization Specification + +## Purpose +Общий алгоритм нормализации форм ББ/ОФР (шаги 2–8) в пакете `arm_statements`, одинаковый для tabular-parser, scan-gateway и пересчёта в arm-core: колонки кодов и периодов, статусы строк, разбор сумм, код и редакция формы, проверки §7, признак «требует внимания», предупреждения и статус документа; копирование пакета в хосты скриптом sync-statements. + +## 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`, заголовков форм и ОКУД, +подписей колонок («Код», «Наименование», исключения), букв-двойников, единиц измерения `statement_units.yaml` (написания +и коды ОКЕИ), упорядоченные перечни кодов форм по редакциям (`66n`, `fsbu4`) и вариантам (`full`, `simplified`) без +наименований — байтовая копия `docs/contracts/statement-code-orders.yaml` как `rules/statement_code_orders.yaml`, +диапазоны кодов — и загрузчик словарей. Словаря +«код → наименование» в пакете MUST NOT быть (Р-3). Пакет SHALL владеть своими типами входа и выхода; конвертацию +выхода в модель контракта делает хост. Golden-тесты пакета — внутри пакета на синтетических и обезличенных данных; +тесты на реальных фикстурах остаются в сервисах и соседние репозитории не читают. + +В tabular-parser, scan-gateway и arm-core пакет SHALL лежать копией вместе с тестами и конфигом линтеров и +импортироваться по имени `arm_statements`; раскладку, проверку свежести и маркер версии задаёт требование +«`sync-statements` — копии пакета в потребителях». + +#### Scenario: Наименований в пакете нет +- **WHEN** ревьюер ищет в пакете соответствие «код → наименование» +- **THEN** его нет: в перечне кодов только коды, наименования строк — словарь ядра + +### Requirement: `sync-statements` — копии пакета в потребителях +Копия пакета в хосте (`arm-core`, `tabular-parser`, `scan-gateway`) SHALL лежать в `api/arm_statements/` (рядом с +`api/src/`, импорт `import arm_statements`) вместе с тестами пакета (`api/arm_statements/tests/`); конфиг линтеров пакета в копию не кладётся (применяется конфиг +хоста); `ruff` и `pytest` хоста SHALL охватывать копию (тесты пакета — в `testpaths`), `mypy` хоста — запускаться и по +ней (`mypy src arm_statements`, у `arm-core` — `mypy src scripts arm_statements`). Маркер версии — файл +`api/arm_statements/VERSION` с полным хэшем коммита репозитория `arm-statements`, из которого разложена копия; его пишет +только скрипт, сервис читает при старте и пишет в результат как `service.statements_version`. Прямые правки копии MUST +NOT делаться. + +Копию SHALL раскладывать и проверять скрипт `docs/scripts/sync-statements.sh` (по образцу `sync-web.sh`; цель `make -C +docs sync-statements`). Источник — локальный checkout `arm-statements` рядом с workspace или `--source `, ссылка — +`origin/main` или `--ref`; хост — `--host <имя>` или `--host-dir `. Без `--check` скрипт раскладывает копию коммита +`--ref` в новый worktree хоста на ветке фичи, пишет `VERSION` и готовит issue и PR (как `sync-web.sh`). С `--in-place` скрипт SHALL раскладывать +копию коммита `--ref` в рабочее дерево указанного хоста (существующий worktree) без issue, коммита и PR — для +подключения хоста и для PR фичи, которой нужна новая версия пакета; при прямой правке копии или недостающей зависимости — +отказ с кодом `20`, ничего не пишется. Копия +сравнивается с деревом `arm_statements/` источника без файла `VERSION` (его в источнике нет). `--check` SHALL +возвращать: `0` — копия равна дереву `arm_statements/` коммита из `VERSION`, это коммит `--ref`, и копия перечня кодов +в пакете равна `docs/contracts/statement-code-orders.yaml`; `10` — копия равна дереву коммита из `VERSION`, но в +`--ref` есть более новый (копия отстала), или копия перечня кодов отличается от файла docs (перечень в docs правился +раньше `arm-statements` или пересинхронизации хоста — предупреждение называет файл); `20` — копия отличается от дерева +коммита из `VERSION` (прямая правка), `VERSION` нет в истории источника, зависимости пакета (список `dependencies` +его `pyproject`) отсутствуют в зависимостях хоста или (только у `arm-core`) код словаря имён +`api/rules/statement_lines.yaml` отсутствует в перечне своей редакции **копии пакета**; `2` — нет checkout источника +или хоста, неизвестный аргумент. Файлы и коды расхождения SHALL печататься. Порядок правки перечня кодов: PR в docs → +PR в `arm-statements` (байтовая копия yaml) → `sync-statements` в хостах «сначала ядро»; до конца этой цепочки хосты +получают `10`, а не `20`. + +Код пакета SHALL проходить самый строгий конфиг `ruff` и `mypy` среди хостов (пакет проверяется им в своём +репозитории): конфиг хоста применяется к копии, а правка копии под хост запрещена. Тесты пакета SHALL не зависеть от +фикстур и `conftest` хоста: в `api/arm_statements/tests/` свой `conftest.py`, а `conftest` хоста MUST NOT содержать +`autouse`-фикстур без ограничения каталогом `tests/` хоста. Зависимости пакета — только те, что есть в зеркале банка +(сейчас PyYAML); хост держит их в своих зависимостях с версией зеркала. + +Цель `statements-check` хоста (`sync-statements.sh --check --host-dir .`, docs — `../docs` или `ARM_KA_DOCS`) SHALL +входить в `make check-full` (коды `0` и `10` — зелёная, `10` с предупреждением «копия отстала», вписывается в описание PR; +`20` и `2` — красная) и MUST NOT входить в `make check` и пайплайн платформы. + +#### Scenario: Прямая правка копии +- **WHEN** в `tabular-parser` изменён файл копии пакета, а источник в `arm-statements` — нет +- **THEN** `sync-statements --check` завершается с кодом 20 и называет файл; `make check-full` красный; `make check` + сервиса этого не проверяет + +#### Scenario: Копия отстала +- **WHEN** копия в `scan-gateway` равна коммиту `abc1234`, а в `arm-statements` `origin/main` уже `def5678` +- **THEN** `--check` завершается с кодом 10, `make check-full` зелёный с предупреждением «копия отстала» + +#### Scenario: Нет checkout источника +- **WHEN** рядом нет `arm-statements` и `--source` не задан +- **THEN** `--check` завершается с кодом 2 и подсказкой, `make check-full` красный + +#### Scenario: Перечень кодов в docs новее копии +- **WHEN** в docs в `statement-code-orders.yaml` добавлен код, а `arm-statements` и копии в хостах ещё не обновлены +- **THEN** `--check` в каждом хосте завершается с кодом 10 и называет файл перечня; `make check-full` зелёный с + предупреждением, посторонние PR в сервисы не краснеют + +#### Scenario: Код словаря ядра вне перечня +- **WHEN** в `arm-core/api/rules/statement_lines.yaml` редакции `fsbu4` есть код 1120, а в перечне `fsbu4` его нет +- **THEN** `--check` в `arm-core` завершается с кодом 20 и называет код и редакцию + +#### Scenario: Копия в PR фичи +- **WHEN** PR scan-gateway нужна версия пакета новее, чем в `main` хоста +- **THEN** `sync-statements.sh --in-place --host-dir .` в worktree фичи раскладывает копию и пишет `VERSION`, копия + коммитится отдельным коммитом этого PR; `--check` после раскладки — `0` + +#### Scenario: Версия общего кода в результате +- **WHEN** сервис собирает результат копией пакета, у которой в `VERSION` коммит `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`, `okei`, `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 — `<лист>!` по правилу 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: Имя листа с пробелом +- **WHEN** код строки стоит в ячейке C14 листа «Бухгалтерский баланс. Лист 1» +- **THEN** `ref = "'Бухгалтерский баланс. Лист 1'!C14"` + +#### 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); единицы — словарём единиц `statement_units.yaml` по тексту, а если по тексту не найдены — по коду ОКЕИ +тем же словарём (384 → «тыс. руб.», 383 → «руб.», 385 → «млн руб.»); ОКЕИ — только цифры. Сырой текст и уверенность +каждого поля SHALL доезжать до контракта (`<поле>_text`, `<поле>_confidence`). + +#### Scenario: ИНН с мусором клеток +- **WHEN** поле ИНН шапки OCR — `"3|1 21 2|"` с цифрами ИНН между вертикальными чертами +- **THEN** ИНН собирается только из цифр, `|` единицей не считается; `inn_text` хранит исходный текст + +#### Scenario: Единицы только кодом ОКЕИ +- **WHEN** в шапке Excel-формы нет текста единиц, а в блоке «Коды» стоит ОКЕИ 384 +- **THEN** `units = "тыс. руб."`, `okei = "384"`, предупреждения `UNITS_NOT_FOUND` нет + +### 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 — многостраничная форма +Требование относится к адаптеру Excel: у OCR каждая таблица ИС — отдельный кандидат, склейки таблиц нет (повторная +шапка внутри таблицы ИС — сегмент шага 2). У Excel одна форма 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» или четырёхзначная сумма «1050» или «7450» +- **THEN** кодом это не считается (вне отрезков); «3450» — код (отрезок 3100–3699 отчёта об изменениях капитала) + +#### 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, а если +сегмент не первый и кодов в нём меньше трёх — ослабленный поиск: столбец с наибольшим числом кодов из диапазона +столбца первого сегмента, минимум один. Колонки документа (`columns[]`) SHALL задаваться первым сегментом; колонки +любого следующего сегмента новых `columns[]` не создают, а сопоставляются с колонками первого сегмента: +- сегмент со своей шапкой (повторная шапка OCR, лист-продолжение Excel со своей строкой заголовка) — по `period_date`, + если у каждой колонки сегмента дата есть и совпадает с датой ровно одной колонки первого сегмента; иначе по порядку; +- сегмент без шапки — колонки значений — столбцы правее кода, где в строках с кодами есть числа, сопоставление по + порядку. + +Значения сегмента несут `column_index` сопоставленной колонки первого сегмента. При сопоставлении по порядку число +колонок сегмента не совпало с первым — предупреждение `SEGMENT_COLUMNS_MISMATCH` (`error`): значения по позиции в +пределах меньшего числа колонок, +лишние столбцы сегмента в `columns[]` не попадают, их номера в сетке перечислены в `message`. Столбец кодов не найден ни +так, ни так — предупреждение `SEGMENT_WITHOUT_CODES` (`error`) сегмента; строки сегмента не теряются (роль `data`, +статусы по шагу 3). + +#### Scenario: Лист-продолжение НПЗ со своей шапкой +- **WHEN** у ББ НПЗ («Бухгалтерский баланс. Лист 4») своя строка заголовка, колонки периодов — в столбцах 8–10, у первого + листа — в столбцах 16, 21, 24, даты подписей те же (30.09.2025, 31.12.2024, 31.12.2023) +- **THEN** новых колонок нет: значения листа 4 получают `column_index` 16, 21, 24 по совпадению `period_date`; + предупреждения нет, §7 ББ считается по одной колонке на дату, документ остаётся `done` + +#### Scenario: Повторная шапка с другими подписями +- **WHEN** повторная шапка второй страницы скана содержит две колонки без разобранной даты, у первого сегмента — две + колонки с датами +- **THEN** сопоставление по порядку, значения несут номера колонок первого сегмента, предупреждения нет + +#### Scenario: Сегмент шире первого +- **WHEN** у сегмента со своей шапкой четыре колонки периодов, у первого — три +- **THEN** `SEGMENT_COLUMNS_MISMATCH` уровня `error`, значения первых трёх — по позиции, номер четвёртого столбца — в + `message` + +#### Scenario: Лист «_2» НПЗ со столбцом кодов в другой колонке +- **WHEN** второй лист формы без шапки содержит единственную строку 2400, и код стоит не в той колонке, что на первом + листе +- **THEN** у сегмента свой столбец кодов (ослабленный поиск), значения строки 2400 не сдвинуты + +#### Scenario: Повторная шапка на второй странице скана +- **WHEN** в таблице ИС посередине стоит повторная строка «Наименование показателя | Код | На 30.09.2025 | …» +- **THEN** с неё начинается новый сегмент со своей раскладкой; строка-шапка в данные не попадает + +### Requirement: Шаг 2 — строка заголовка, нумерация и роли строк +Строка заголовка SHALL быть последней над первой строкой с кодом, где в столбце кодов стоит подпись кода; иначе +последней над первой строкой с кодом с двумя и более подписями-периодами правее кода (или одной, не голым годом); +двухстрочный заголовок склеивается. Строка нумерации SHALL получать роль `numbering` и в данные не попадать; строка — +нумерация, если в ней не меньше трёх непустых ячеек, каждая после замены букв-двойников — целое число, и числа слева +направо возрастают на 1, начинаются с 1 или 2 и допускают не больше одного пропуска. Роль +`title` — только строки выше найденной строки заголовка; в сегменте без заголовка роли `title` нет. Заголовка нет ни у +одного сегмента документа → колонки значений первого сегмента — столбцы правее кода, где в строках с кодами числа, +`label = ""`, `period_date = null`, `period_kind = none`, одно предупреждение `HEADER_ROW_NOT_FOUND` (`error`). +Заголовок есть хотя бы у одного сегмента — остальные сегменты берут его колонки без предупреждения. + +#### Scenario: Нумерация в скане (AURORA-1517) +- **WHEN** под шапкой таблицы ИС строка «1 | 2 | 3 | 4 | 5» +- **THEN** роль `numbering`, строки данных из неё нет + +#### Scenario: Нумерация с двойником +- **WHEN** строка нумерации скана распознана как «1 | 2 | З | 4» +- **THEN** «З» читается как 3, роль `numbering`; строки `unreadable` с «суммами» 3 и 4 нет + +#### Scenario: Нумерация без первой ячейки +- **WHEN** ИС потеряла первую ячейку нумерации: «2 | 3 | 4 | 5» +- **THEN** роль `numbering` (начало с 2 допускается) + +#### Scenario: Нумерация с пропуском +- **WHEN** строка «1 | 2 | 4 | 5» (тройка не распознана) +- **THEN** роль `numbering` (один пропуск допускается); «1 | 3 | 5» — не нумерация + +#### Scenario: Нумерация в Excel со «2» в столбце кодов +- **WHEN** в форме Межениновской под шапкой строка «1 | 2 | 3 | 4 | 5», где «2» стоит в столбце кодов +- **THEN** роль `numbering`; «2» кодом не считается + +#### 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_date` (конец периода колонки) и `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_date = null`, `period_kind` остаётся относительным. + +Подпись из словаря исключений («Изменение», «Отклонение», «%», «Темп», «Доля», «Примечание») SHALL пропускаться с +предупреждением `COLUMN_SKIPPED` (`info`). Подпись, не разобранная ни как период, ни как исключение, при числах в +столбце в строках с кодами SHALL оставлять колонку с подписью как есть, `period_date = null`, `period_kind = none` и +предупреждением `COLUMN_WITHOUT_DATE` (`error`, `column_index`); без чисел — пропуск. Подпись OCR с уверенностью ниже +100, разобранная в дату, подтверждается, если дата входит в ожидаемые: эффективная дата, она же минус один и минус два +года, 31 декабря предыдущего и предшествующего ему года; иначе предупреждение `COLUMN_DATE_UNCERTAIN` (`error`, +`column_index`). Уверенность подписи — `label_confidence` (у Excel 100; подписи нет — `null`). + +Если среди колонок значений сегмента есть хотя бы одна с подписью периода (абсолютной или относительной), столбец без +текста подписи правее **последней** такой колонки SHALL НЕ считаться колонкой сумм: его числа не попадают в значения +строк, `COLUMN_WITHOUT_DATE` не выдаётся, вместо этого выдаётся одно предупреждение `NUMBERS_OUTSIDE_TABLE` (`info`) с +адресами ячеек, собранными по всем сегментам формы (в тексте — первые 10, дальше «и ещё N»). Предупреждение SHALL НЕ +влиять на статус документа. Решение владельца 18.10.2026 (Р-44). Правило общее для Excel и OCR и живёт в пакете. +У сегмента без подписанных колонок (лист-продолжение без шапки) столбцы без текста подписи сверх числа колонок документа +SHALL считаться числами правее таблицы. Подписанный столбец без чисел в строках с кодами границей таблицы не служит. +Адрес ячейки — `<лист>!` у Excel и `row:` у OCR. Без подписанных колонок, а также для столбца без подписи между +подписанными и для столбца с любым текстом подписи, не разобранным как период или исключение, поведение прежнее (`COLUMN_WITHOUT_DATE`). + +Подтверждение SHALL опираться только на уверенные источники, чтобы неуверенное не подтверждало само себя: для +неуверенной подписи эффективная дата — дата отчёта с уверенностью 100, иначе самая поздняя абсолютная дата колонок с +уверенной подписью (`label_confidence = 100`); такой нет — подпись не подтверждена. Неуверенная дата отчёта (шаг 8, поля +шапки) подтверждается только самой поздней абсолютной датой колонок с уверенной подписью. Для периода документа и +относительных подписей эффективная дата считается, как выше, по всем источникам. + +#### Scenario: Бланк 66н без впечатанных дат +- **WHEN** подписи колонок ББ — «На отчетную дату», «На 31 декабря предыдущего года», «На 31 декабря года, + предшествующего предыдущему», дата отчёта шапки 30.09.2025 +- **THEN** `period_date` колонок 2025-09-30, 2024-12-31, 2023-12-31, `period_kind` — `relative:current`, + `relative:prev_year_end`, `relative:prev_prev_year_end`; предупреждений о дате колонок нет + +#### Scenario: Неуверенная подпись не подтверждает сама себя +- **WHEN** даты отчёта в шапке нет, единственная колонка с абсолютной датой — подпись OCR «На 30.09.2О25» с уверенностью + 60, остальные колонки без подписи +- **THEN** эффективная дата для подтверждения строится без этой подписи — уверенных источников нет, подпись не + подтверждена: `COLUMN_DATE_UNCERTAIN` уровня `error`; период документа (`period_known`) по-прежнему определяется этой + датой + +#### Scenario: Мусорная подпись над числами +- **WHEN** подпись колонки — `"| | г"` с уверенностью 17, а в колонке суммы +- **THEN** колонка остаётся, `period_date = null`, `period_kind = none`, предупреждение `COLUMN_WITHOUT_DATE` уровня + `error` с `column_index`, документ `needs_check` + +#### Scenario: Числа справа от таблицы +- **WHEN** у ББ подписи периодов стоят над столбцами M, Q, S, а правее таблицы в строках с кодами лежат формулы + аналитика (V45, W57, U58, V58, V72, W72) над пустой шапкой (Межениновская 2025-Q3) +- **THEN** колонок значений три, числа U–W в значения не попадают, `COLUMN_WITHOUT_DATE` и `SEGMENT_COLUMNS_MISMATCH` + нет; одно предупреждение `NUMBERS_OUTSIDE_TABLE` уровня `info` с адресами этих ячеек; документ `done`, если нет других + причин + +#### Scenario: Столбец без подписи между подписанными +- **WHEN** между колонками с подписями периода стоит столбец без подписи с числами +- **THEN** он остаётся колонкой с `COLUMN_WITHOUT_DATE` уровня `error`, `NUMBERS_OUTSIDE_TABLE` нет + +#### Scenario: Подписанных колонок нет +- **WHEN** ни у одного столбца значений нет подписи периода +- **THEN** правило «числа справа от таблицы» не применяется, столбцы с числами — колонки с `COLUMN_WITHOUT_DATE` + +#### Scenario: Колонка «Изменение» +- **WHEN** правее периодов колонка «Изменение, %» +- **THEN** колонка пропускается, предупреждение `COLUMN_SKIPPED` уровня `info` + +### Requirement: Шаг 2 — номер колонки +`column_index` колонки значений (`columns[]`, `values[]`, предупреждения) SHALL быть номером столбца в сетке кандидата +шага 1: у OCR — `column_index` ИС как есть (не обязательно подряд), у Excel — номер столбца листа первого сегмента с 0. +Номер уникален в документе; колонки сегментов без шапки сопоставляются с колонками первого сегмента по порядку и несут их +номера. Предупреждение о столбце, которого нет среди `columns[]` (`COLUMN_SKIPPED`), SHALL нести номер этого столбца в +сетке. + +#### Scenario: Разреженные колонки ИС +- **WHEN** в таблице ИС код стоит в колонке 1, суммы — в колонках 2, 4, 5 (колонки 3 нет) +- **THEN** `columns[].column_index` — 2, 4, 5, у значений — те же номера + +#### Scenario: Пропущенная колонка Excel +- **WHEN** на листе Excel коды в столбце C, периоды в D и E, «Изменение, %» в F +- **THEN** `columns[].column_index` — 3 и 4, предупреждение `COLUMN_SKIPPED` с `column_index = 5` + +### Requirement: Шаг 2 — столбец наименования +Столбец наименования SHALL быть столбцом с подписью, начинающейся с «наименование» (после нормализации), в строке +заголовка; иначе — столбцом левее кода с наибольшим числом текстов, кроме столбца «Пояснения». Наименование строки — +ячейка этого столбца; пустая — ближайшая текстовая ячейка левее кода, кроме «Пояснения». + +#### Scenario: Наименование без подписи +- **WHEN** у формы нет подписи «Наименование показателя», а левее кода два столбца — «Пояснения» (короткие номера) и + тексты статей +- **THEN** наименование берётся из столбца текстов статей + +### Requirement: Шаг 3 — строки данных и их статусы +Шаг 3 SHALL выдавать `RawLine` на каждую строку сетки с ролью `data`, у которой есть содержимое: в ячейке кода есть +хотя бы одна цифра (разобранный код или нет), или хотя бы одна ячейка значений даёт сумму (`amount` не `null`), или +непуста с уверенностью ниже 100, или её текст — «цифра под видом буквы»: после замены двойников без условия «вплотную к +цифре» в нём есть цифра и нет других букв (`'I'`, `'| О'`). Строка только с текстом наименования — не строка данных; +текст из настоящих букв в колонке значений при уверенности 100 (подписи «Руководитель … Косарев», «(расшифровка +подписи)» под формой Excel) содержимым не считается — осознанное исключение из «ничего не выбрасывать молча». У строки — `row_index` (порядковый номер в документе с 0, +по порядку сетки после разлепления), `ref` (уникален в документе: OCR `row:`, разлепленные +`row:#` с k от 1; Excel — адрес ячейки столбца кодов строки `<лист>!` по правилу шага 1, без столбца кодов — первой непустой +ячейки строки), ячейка кода, `line_status`, ячейка наименования, ячейки значений по колонкам сегмента, `parent_ref`. +Статусы: +- `ok` — код разобран; +- `unreadable` — в ячейке кода есть хотя бы одна цифра, но код не разобран; ячейка кода без цифр (в том числе только из + букв-двойников вне соседства с цифрой: «изо :», «}», «:») считается пустой; +- `detail` — строка без кода с содержимым, над которой (в том же или в предыдущих сегментах документа) есть строка `ok`; + `parent_ref` — `ref` ближайшей строки `ok` выше (строки `unreadable` / `missing` между ними пропускаются); +- `missing` — строка без кода с содержимым, над которой во всём документе нет строки `ok`; строки роли `title` + сюда не попадают. + +Строка без кода и без содержимого — не строка данных. Счётчиков отброшенных строк MUST NOT быть. Повтор кода — не +статус и не предупреждение: все строки с кодом — `ok`, порядок как в файле. + +#### Scenario: Код-мусор (AURORA-1462) +- **WHEN** ячейка кода — `"ИЧ,250"`, в колонках суммы +- **THEN** строка `unreadable` с `line_code_text = "ИЧ,250"`, суммами и `ref`; строка не пропадает + +#### Scenario: Мусор в ячейке кода без значений +- **WHEN** в ББ aep_6 ячейка кода — `"44 30\nI |"`, ячейки значений пусты +- **THEN** строка `unreadable` без значений: документ `needs_check` (не все строки `ok` / `detail`), подтверждение она не + блокирует (`LINES_UNRESOLVED` — только строки со значениями) + +#### Scenario: Расшифровка «в том числе» +- **WHEN** под строкой 1150 стоит строка «в том числе: Оборудование к установке» без кода с суммой +- **THEN** строка `detail`, `parent_ref` = `ref` строки 1150 + +#### Scenario: Расшифровка под строкой-мусором +- **WHEN** порядок строк: 1150 (`ok`), «ИЧ,250» (`unreadable`), строка без кода с суммой +- **THEN** последняя — `detail` с `parent_ref` строки 1150 (ближайшая строка `ok`, `unreadable` пропускается) + +#### Scenario: Код из букв-двойников без цифр (aep_3) +- **WHEN** ячейка кода — `"изо :"`, в колонке значений `'3'` с уверенностью 0, выше есть строки `ok` +- **THEN** цифр в ячейке кода нет — она пустая, строка `detail`; если это потерянная строка 1130, её сумма не войдёт в + 1100, и соотношение `1100 = …` подсветит раздел (цена правила, S-61) + +#### Scenario: Цифра под видом буквы (aep_6) +- **WHEN** в строке без кода под строкой `ok` единственная непустая ячейка значений — `'I'` с уверенностью 100 +- **THEN** строка данных `detail` (раньше терялась молча); значение `amount = null`, `string_value = "I"`, + `is_recognized = false` + +#### Scenario: Потерянный код в начале таблицы +- **WHEN** первая строка данных «Выручка» без кода с суммами (код уехал в ячейку значения), выше строк с кодом нет +- **THEN** строка `missing` + +#### Scenario: Повтор кода (AURORA-1406) +- **WHEN** код 2410 стоит у двух строк с разными наименованиями +- **THEN** обе строки `ok`, порядок как в файле, предупреждения нет + +### Requirement: Шаг 3 — разлепление склеенных строк +До разбора кода SHALL выполняться разлепление: ячейка кода содержит N ≥ 2 частей через перевод строки, и каждая +непустая ячейка значений — ровно N частей; разлепление делается, если хотя бы одна часть кода разобралась как код +диапазона столбца. Получается N строк с `ref` `row:#`; нечитаемая часть кода — строка `unreadable`; наименование — +k-я из непустых частей, если их тоже N, иначе целиком у каждой; `line_code_text` и `text` значений — k-е части. Значения разлепленных строк получают +`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)` по стадиям: + 1. пусто или прочерк (`-`, `–`, `—`, `−` без цифр) → `amount = null`, `is_recognized = true`; многострочное значение + в строке с однострочным кодом → `amount = null`, `string_value` = текст, `is_recognized = false`; + 2. крайние `|` (след границы таблицы) удаляются до замены двойников, а не становятся единицей (`"|122477"` → 122477); + затем буквы-двойники вплотную к цифре → цифры (Р-11); и то и другое — исправление (`is_recognized = false`); + 3. **ядро числа** — участок от первого из символов «цифра, `(`, `-`, `−`, `+`» до последнего из «цифра, `)`, `}`, + `]`»; всё вне ядра — **краевой мусор** (`|`, `:`, `=`, `■`, `•`, кавычки, точки, `{`, `[` и любые другие символы): + выбрасывается, а если среди выброшенного есть что-то кроме пробелов — `is_recognized = false`; + 4. в ядре допустимы только цифры, пробелы (в том числе NBSP и тонкие), `,`, `.`, знак (`-`, `−` как минус, `+`) в + начале и одна пара скобок вокруг всего числа: `(` первым символом и `)`, `}` или `]` последним (`}` / `]` — + исправление); `{` и `[` открывающей скобкой не считаются. Иначе — буква в ядре (после стадии 2), скобка без пары + или не на краю ядра, знак не в начале, любой другой символ между цифрами (`:`, `/`, `=`, `|`) — `amount = null`, + `string_value` = текст, `is_recognized = false`; + 5. цифр нет вовсе → `amount = null`, `string_value` = текст, `is_recognized = false`; + 6. скобки → минус; разделители: у `ocr` запятая — тысячи при шаблоне `[+-]?[1-9]\d{0,2}(,\d{3})+`, точка — + тысячи при `[+-]?[1-9]\d*(\.\d{3})+`, иначе десятичный разделитель, три и более знаков после него → `amount = null`, + `string_value` = текст; у `excel` запятая и точка — десятичный разделитель; пробел допустим только как разделитель + тысяч (группы ровно по три цифры), иначе `amount` — цифры подряд («лучшая догадка», не длиннее 16 цифр, иначе + `null`) и `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: Скобка внутри цифр (aep_3) +- **WHEN** ячейка OCR — `"211(1 |"` +- **THEN** крайняя `|` — краевой мусор; в ядре `211(1` скобка без пары и не на краю → `amount = null`, `string_value = + "211(1 |"`, `is_recognized = false` + +#### Scenario: Буква в ядре числа (aep_6) +- **WHEN** ячейка OCR — `"(Ю4)"` +- **THEN** «Ю» — не двойник и стоит в ядре → `amount = null`, `string_value = "(Ю4)"`, `is_recognized = false` (не −4) + +#### Scenario: Краевой мусор с двух сторон +- **WHEN** ячейка OCR — `": : 2856983 = ■"` или `"| : 122477"` +- **THEN** `amount = 2856983` и `amount = 122477`, `is_recognized = false` (выброшен мусор вне ядра) + +#### Scenario: Фигурная скобка не открывает +- **WHEN** ячейка OCR — `"{38378)"` +- **THEN** `{` — краевой мусор, в ядре `38378)` закрывающая скобка без пары → `amount = null`, `string_value = "{38378)"`, + `is_recognized = false` + +#### Scenario: Запятая по источнику +- **WHEN** текст `"4,200"` +- **THEN** у `ocr` `amount = 4200`; у `excel` `amount = 4.20` + +#### Scenario: Длинное тире (OQ-148, закрыт решением владельца 18.10.2026) +- **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`) в сервисах нужна только для перечня кодов (шаг 8): проверяются обе +редакции, эффективная дата (`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`. Поиск столбца кодов (шаг 2) SHALL выполняться всегда, в том числе +при `type_hint = DISCLOSURE`: если большинство кодов в отрезке ББ или ОФР, тип — по кодам и `TYPE_CONFLICT`. Без столбца +кодов: `type_hint` есть → документ без строк (`DISCLOSURE` — `done`, иначе `needs_check` с `CODE_COLUMN_NOT_FOUND` +уровня `error`); `type_hint` нет → у `ocr` таблица ИС с содержимым — предупреждение **файла** `TABLE_LOST` (`error`, +`locator` таблицы), таблица ИС без содержимого — предупреждение **файла** `TABLE_SKIPPED` (`info`, `locator` таблицы); +у `excel` — лист «прочее», предупреждение **файла** `SHEET_OTHER` (`info`, `locator = sheet:<имя>`); документа во всех +этих случаях нет. Содержимое таблицы ИС — хотя бы одна ячейка с цифрой или с «цифрой под видом буквы» (шаг 3); таблица +только из текста (подписи, пояснительный текст) содержимого не имеет. У `DISCLOSURE` (по `type_hint` без кодов форм ББ/ОФР или по кодам прочих форм) шаги 3–7 +не выполняются: `columns` и `lines` — пустые массивы, `checks` нет, документ `done`. + +`form_variant` SHALL определяться только по структуре кодов всех строк `ok` документа (независимо от уверенности +кода; у ядра — заново при каждом пересчёте после правки, точка входа ниже): ББ — есть 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 = DISCLOSURE`), а в столбце кодов — + 1110…1700 +- **THEN** столбец кодов найден, `document_type = BALANCE`, предупреждение `TYPE_CONFLICT` уровня `error`; данные не + выброшены + +#### Scenario: Лист «прочее» +- **WHEN** в книге Excel лист «Примечания» без заголовка формы, ОКУД и кодов +- **THEN** документа нет; в `metrics.warnings` — `SHEET_OTHER` уровня `info` с `locator = "sheet:Примечания"` + +#### 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` (полный или упрощённый); `form_variant = null` — полный. В упрощённом наборе + соотношения ББ жёсткие, а `2400 = 2110 − 2120 − 2330 + 2340 − 2350 − 2410` ОФР — мягкое, как `2400` полного набора + (знак 2410, OQ-12); +- родитель пятизначной подстроки — ближайшая четырёхзначная строка выше с теми же первыми тремя цифрами, иначе код из + первых четырёх цифр; «сумма подстрок = родитель» (мягкое) — только если хотя бы одна подстрока в колонке непуста. + +`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** у упрощённого ОФР 2410 записан со знаком дохода, и `2400 = 2110 − 2120 − 2330 + 2340 − 2350 − 2410` по модулю + не сходится +- **THEN** нарушение в `soft_relations_failed`, документ из-за него не `needs_check` + +#### Scenario: Округление итогов +- **WHEN** `1600 = 1700` расходится на 9,02 при итогах ~17 млрд +- **THEN** нарушение в `soft_relations_failed`, на статус и подсветку не влияет + +### Requirement: Шаг 8 — `needs_review` значения +Сервис SHALL вычислять `needs_review` у каждого значения строк `ok`, `unreadable` и `missing`. У значений строк +`detail` `needs_review = false` всегда (Р-23): у них нет кода, покрыть их соотношением нельзя, в проверки §7, агрегаты и +статус документа они не входят; сомнение остаётся видно в подсказке ячейки по `text` и `confidence`. Для остальных +значений: +- значение **нуждается в покрытии**, если `confidence < 100` или `is_recognized = false`; чистые значения в покрытии не + нуждаются; +- значение **покрыто**, если `amount` значения не `null` и в его колонке есть соотношение с участием его кода, которое + посчитано и сошлось: жёсткое — в пределах допуска или округления; мягкое — только в пределах допуска ±1, без + «округления» (Р-21; так покрываются 2400, 2410, 2411, 2412, 2460, 25xx и пятизначные подстроки — у них жёстких + соотношений нет); +- **знак**: у члена `abs_terms`, нуждающегося в покрытии, знак подтверждён, если форма записи знака совпадает с формой + строгого большинства остальных членов `abs_terms` этой колонки с непустым `amount`, `is_recognized = true` и без + правки аналитика (сама ячейка в голосование не входит); форма знака — по результату разбора `parse_amount`: «скобки» + (пара скобок, в том числе с `}` / `]`), «минус», «без знака»; голосующих нет или равенство голосов — знак не + подтверждён; +- `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"` с уверенностью 60 (форма «без знака»), и `2100 = 2110 − + 2120` сходится по модулю +- **THEN** `needs_review = true` у 2120 независимо от соотношения + +#### Scenario: Скобка, которая не открывает +- **WHEN** 2120 — `"{38378)"` +- **THEN** разбор даёт `amount = null` (шаг 4), значение не покрыто, `needs_review = true` + +#### Scenario: Расшифровки не держат документ (Р-23, РБ 1 кв. 2025) +- **WHEN** в ББ и ОФР РБ «Бухгалтерская отчетность 1 кв.25» все неуверенные значения строк с кодом покрыты сошедшимися + соотношениями, неуверенные коды подтверждены перечнем `fsbu4`, а неуверенные значения остались только у строк `detail` + (в ОФР — `'1 425 130'` с уверенностью 78, `'134 796'` с уверенностью 71, `'(15 567)'` с уверенностью 75) +- **THEN** у значений `detail` `needs_review = false`, они статус не держат; документ получает `done`, если выполнены + остальные условия функции статуса (шапка, период, предупреждения); подсказка у ячеек `detail` показывает `text` и + уверенность + +#### Scenario: Справочная строка без проверки +- **WHEN** значение 2900 с уверенностью 80 (строк 29xx нет ни в одном соотношении) +- **THEN** `needs_review = true` + +#### Scenario: Чистая прибыль подтверждена мягким соотношением (Р-21) +- **WHEN** значение 2400 с уверенностью 66, а `2400 = 2300 − 2410 + 2420` в колонке сошлось с расхождением 0 +- **THEN** у 2400 `needs_review = false` + +#### Scenario: Мягкое соотношение в пределах округления не подтверждает +- **WHEN** значение 2400 с уверенностью 66, а `2400 = 2300 − 2410 + 2420` расходится на 9 при итогах ~17 млрд (в пределах + 0,001 %, но больше ±1) +- **THEN** у 2400 `needs_review = true`; нарушение в `soft_relations_failed` + +### Requirement: Шаг 8 — подтверждение неуверенного кода структурой +Код строки SHALL считаться неуверенным, если `line_code_confidence < 100` или при разборе заменён двойник; только к +неуверенным кодам применяется структурная проверка (Р-12). Данные — упорядоченные перечни кодов по редакциям (`66n`, +`fsbu4`) и вариантам (`full`, `simplified`), источник — `docs/contracts/statement-code-orders.yaml`; законные повторы +кода (Р-20) — часть перечня (`repeats`: у `fsbu4` 1220, 1520, 2410 печатной формы 1С; у `66n` — раздел III НКО). +Четырёхзначные коды строк `ok` документа образуют последовательность; коды, которых нет в перечне редакции, в +последовательность этой редакции не входят. Для **обеих** редакций берётся лучшая подпоследовательность, согласованная +с порядком перечня варианта формы (итоги после слагаемых; код с повтором может войти столько раз, сколько он стоит в +перечне); «лучшая» — лексикографически: сначала наибольшее число уверенных кодов (уверенные коды — якоря: включаются +все, какие можно включить вместе), затем наибольшая длина. Якоря, противоречащие друг другу по порядку бланка, все +вместе войти не могут — входит наибольшее согласованное их множество; неуверенный код проверяется против выбранного. +Редакция — с лучшей подпоследовательностью (то же сравнение), при равенстве — по эффективной дате (`fsbu4` после +2024-12-31). Неуверенный код подтверждён, только если он входит во **все** лучшие подпоследовательности выбранной +редакции. Вариант формы для перечня — `form_variant` документа (шаг 6, по всем строкам `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: Якоря противоречат друг другу +- **WHEN** порядок кодов 1210 (уверенный), 1240 (уверенный), 1230 (уверенный), 1250 (уверенность 50) +- **THEN** 1240 и 1230 вместе войти не могут; лучшие подпоследовательности — 1210, 1240, 1250 и 1210, 1230, 1250; код 1250 + входит в обе и подтверждён + +#### Scenario: Законный повтор 1220 (Р-20, AURORA-1431) +- **WHEN** в ББ печатной формы 1С по ФСБУ 4/2023 стоят подряд 1215, 1220 «НДС по приобретенным ценностям» (уверенность + 100), 1220 «НДС, начисленный по отгрузке» (уверенность 50), 1230 +- **THEN** обе строки 1220 подтверждены структурой (повтор есть в перечне `fsbu4`), `needs_review = false` + +#### Scenario: Повтор вне перечня +- **WHEN** в ББ код 1230 стоит дважды подряд, у второй копии уверенность 50 +- **THEN** у второй копии `needs_review = true`, документ `needs_check` + +#### Scenario: Бланк 66н в 2025 году +- **WHEN** скан ББ за 3 кв. 2025 на бланке 66н с неуверенным кодом 1120 между 1110 и 1130 +- **THEN** проверяются обе редакции, подпоследовательность `66n` длиннее, код 1120 подтверждён + +#### Scenario: Нестандартный код Excel +- **WHEN** в Excel-форме код 1231 или пятизначная подстрока 11101 вне перечня +- **THEN** `needs_review` строки не ставится; проверка «код не из редакции» — `info` ядра + +### Requirement: Шаг 8 — поля шапки +Дата отчёта с уверенностью ниже 100 SHALL подтверждаться совпадением с самой поздней абсолютной датой колонок с +уверенной подписью (`label_confidence = 100`; неуверенная подпись дату отчёта не подтверждает, требование «Шаг 2 — +подпись и дата колонки»); иначе +поле `report_date` попадает в `needs_review_fields` и выдаётся `REPORT_DATE_UNCERTAIN` (`error`, `field = +report_date`). У бланка только с относительными подписями колонок абсолютных дат нет, и неуверенная дата отчёта +структурой не подтверждается никогда: документ `needs_check`, аналитик подтверждает или исправляет дату в ядре (PATCH +шапки). `needs_review_fields` — поля, которые держат документ в `needs_check`; ИНН и организация с уверенностью ниже 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.2025 с уверенностью 60, единственная абсолютная подпись колонки «На 30.09.2025» — с + уверенностью 60 +- **THEN** `needs_review_fields = ["report_date"]`, `REPORT_DATE_UNCERTAIN` и `COLUMN_DATE_UNCERTAIN` уровня `error`; + снимаются подтверждением даты отчёта и даты колонки аналитиком + +#### Scenario: Неуверенный год +- **WHEN** дата отчёта 30.09.2023 с уверенностью 60, колонки на 30.09.2025 и 31.12.2024 +- **THEN** `needs_review_fields = ["report_date"]`, `REPORT_DATE_UNCERTAIN` уровня `error`, документ `needs_check` + +#### Scenario: Бланк 66н с относительными подписями +- **WHEN** дата отчёта 30.09.2025 с уверенностью 60, подписи колонок — «На отчетную дату», «На 31 декабря предыдущего + года» +- **THEN** `needs_review_fields = ["report_date"]`, `REPORT_DATE_UNCERTAIN` уровня `error`; снимается подтверждением даты + аналитиком + +### Requirement: Шаг 8 — предупреждения +Предупреждение SHALL быть структурой `{code, severity, message, line_refs?, column_index?, field?}`: `severity` — +`error` | `info`; `line_refs` — список `ref` строк только из `lines[]` (о строках ролей `title` / `header` / +`numbering` — в `message`); `column_index` — номер столбца сетки (требование «Шаг 2 — номер колонки»); `field` — поле +шапки. Место в файле — `locator` документа, а не текст. Предупреждения уровня файла (`metrics.warnings`) дополнительно +имеют `locator?` (json набора ИС, таблица ИС или лист) и SHALL выдаваться там, где документа нет; предупреждение файла +уровня `error` делает конверт `needs_check` и блокирует `RECOGNIZED` всех документов файла в ядре. Уровни SHALL быть +такими: + +| Код | Уровень | Когда | +|---|---|---| +| `JSON_FAILED` | error (файл) | json набора ИС ошибочен при успешном соседе | +| `TABLE_PARSE_ERROR` | error (файл) | исключение при разборе таблицы ИС (`BACKEND_PARSING_ERROR`); документа нет | +| `TABLE_LOST` | error (файл) | таблица ИС с содержимым (ячейка с цифрой или «цифрой под видом буквы») без колонки кодов и без `type_hint` | +| `TABLE_SKIPPED` | info (файл) | таблица ИС без содержимого (только текст), без колонки кодов и без `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` запроса | +| `XLS_NO_CODEPAGE` | info (файл) | книга xls без кодовой страницы — текст мог прочитаться неверно | +| `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 | разлеплена склеенная строка | +| `NUMBERS_OUTSIDE_TABLE` | info | числа правее последней подписанной колонки значений не учтены; адреса ячеек в тексте | + +Предупреждения пути ОСВ — уровня `info`, код с префиксом `OSV_`. Новый код предупреждения вводится словарём пакета и +таблицей §11 `05-domain-rules.md`; `code` в контракте — строка `^[A-Z][A-Z0-9_]*$`, не перечисление. + +#### Scenario: Исключение при разборе таблицы ИС +- **WHEN** разбор второй таблицы json упал с исключением, первая разобрана +- **THEN** документ первой таблицы со своим статусом; в `metrics.warnings` — `TABLE_PARSE_ERROR` уровня `error` с + `locator = "file:<имя json>;table:1"`; конверт `needs_check` + +#### 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 быть одинаковым у сервиса и ядра и не зависеть от источника: `document_type`, +`layout_found` (сервис — столбец кодов найден хотя бы у одного сегмента; ядро — у формы есть строка из источника, +`row_index` не `null`), `has_organization_or_inn`, `period_known`, строки `{line_status, needs_review}`, значения +`{line_status строки, needs_review}`, число жёстких нарушений вне допуска и округления, `accepted`, уровни +предупреждений документа (у ядра — записи `document_issue` вида `warning`). `needs_review` на входе уже учитывает правки +и подтверждения аналитика (правленое или подтверждённое значение и строка — `false`); `accepted` — жёсткие нарушения не +учитываются. Значения строк `detail` функция MUST NOT учитывать, даже если на входе у них `needs_review = true` (Р-23). Статус ОСВ — +`done`, когда контрольные суммы §1.4 листа сошлись. Статус конверта: `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** ядро после PATCH вызывает функцию статуса по строкам БД формы, у которой все строки `ok`, правленая ячейка + была `needs_review`, нарушений и предупреждений `error` нет +- **THEN** функция даёт `done` — тот же результат, что дал бы сервис по тем же данным с подтверждённой ячейкой + +#### Scenario: Соседний документ не тянет файл +- **WHEN** ББ `done`, ОФР того же файла `needs_check` из-за одной ячейки, предупреждений файла нет +- **THEN** конверт `needs_check`, у ББ `document.status = done` + +#### Scenario: Сомнительная ячейка расшифровки +- **WHEN** у формы все строки `ok` или `detail`, нарушений и `error` нет, единственное значение с уверенностью ниже 100 — + в строке `detail` +- **THEN** документ `done` + +### Requirement: Точка входа пакета для пересчёта по сохранённым данным +Пакет SHALL предоставлять функцию пересчёта формы по сохранённым данным без сетки и `FormCandidate` — ею ядро +пересчитывает форму после PATCH и при приёме результата `1.x`, сервисы ею не пользуются. Вход — тип пакета +`StoredForm`, собираемый хостом из своих данных: +- `document_type`; `effective_date` (дата отчёта, иначе самая поздняя абсолютная дата колонок — у ядра + `document.reporting_date`); `has_organization_or_inn`; `period_known`; `needs_review_fields` (как хранит ядро: поле + уходит из него только правкой или подтверждением даты отчёта — сам пересчёт поля шапки не переоценивает); `accepted`; + `warnings` — предупреждения документа `{code, severity}` (у ядра — записи `document_issue` вида `warning`); +- `columns[] {key, column_index, period_date, period_kind}` (`key` — идентификатор хоста, у ядра `column_id`); +- `lines[] {key, row_index | null, line_status, line_code, line_code_confidence, line_name, code_fixed, parent_key, + values[] {column_key, amount, text, confidence, is_recognized, value_fixed}}`: `row_index = null` — строка, созданная + правкой (участвует в §7, как строка `ok`, пока у неё есть сумма); `code_fixed` — код назначен, сменён или подтверждён + аналитиком (`line_code_modified` или `is_confirmed` строки); `value_fixed` — значение правлено или подтверждено + (`is_modified` или `is_confirmed`); `text` — сырой текст источника (`source_text`), по нему считается форма знака; + у значения, созданного правкой, `text = null`. + +Выход — `{form_variant, edition, checks {relations_failed, soft_relations_failed}, line_needs_review {key → bool}, +value_needs_review {key → bool}, detail_parents {key → parent_key | null}, status}`. Правила — те же шаги 6–8, что у +сервисов, с поправками: `form_variant` определяется заново по кодам строк `ok` (код мог смениться правкой); `edition` — +редакция с лучшей подпоследовательностью кодов документа по всем строкам `ok` (как в структурной проверке, при +равенстве — по `effective_date`; кодов нет — по `effective_date`); `value_fixed` ⇒ `needs_review = false`, такое значение +в голосование знака не входит; `code_fixed` ⇒ `needs_review` строки = `false`; значения строк `detail` — `false`; +`accepted` ⇒ жёсткие нарушения статус не держат; `detail_parents` — ближайшая строка `ok` выше по `row_index` (строки +`row_index = null` родителями не бывают), нет такой — `null`. Пересчёт не меняет `line_status`, суммы и коды — только +выводит признаки. + +#### Scenario: Пересчёт после правки по строкам БД +- **WHEN** ядро передаёт форму: ячейка 1230 правлена (`value_fixed`), код 1250 назначен строке бывшей `unreadable` + (`code_fixed`), шаблонная строка 1260 с суммой (`row_index = null`), строка `detail` с уверенностью 60 +- **THEN** `checks` посчитаны с 1250 и 1260 в `1200 = …`; у 1230 и у значения `detail` `needs_review = false`, у строки + 1250 `needs_review = false`; `status` — по общей функции статуса; `form_variant` и `edition` — по кодам после правки + +#### Scenario: Смена кода меняет вариант +- **WHEN** у упрощённого по структуре ББ (есть 1600, нет 1100 и 1200) аналитик назначает строке код 1100 +- **THEN** точка входа возвращает `form_variant = full`, нарушения считаются полным набором diff --git a/openspec/specs/tabular-recognition-service/spec.md b/openspec/specs/tabular-recognition-service/spec.md new file mode 100644 index 0000000..50462dc --- /dev/null +++ b/openspec/specs/tabular-recognition-service/spec.md @@ -0,0 +1,71 @@ +# tabular-recognition-service Specification + +## Purpose +Требования к tabular-parser в единой нормализации ББ/ОФР: адаптер листов Excel (шаг 1) в общую сетку ячеек и выдача результата распознавания контракта 2.0, нормализованного общим кодом `arm_statements`. + +## Requirements + +### Requirement: Адаптер Excel шага 1 +tabular-parser SHALL оставить у себя чтение книги (лимиты `PARSE_MAX_BYTES`, `PARSE_TIMEOUT_SECONDS`, формат по +сигнатуре, лимиты zip и объединённых ячеек, `UNREADABLE`), классификацию листов (ОСВ — отдельный путь; форма; +продолжение; прочее) и адаптер «блок формы на листе → `FormCandidate`» общего кода; шаги 2–8 по формам SHALL +выполняться копией пакета `arm_statements`. Адаптер SHALL: выделять блоки форм на листе по заголовку или ОКУД (без +кодов между ними блоки сливаются, заголовок после кодов — новая форма); собирать шапку каждого блока в его границах +(организация, ИНН без ИНН аудитора, ОКПО, ОКОПФ, ОКФС, единицы, дата, адрес) как `Cell` с `confidence = 100`; +заполнять у каждой ячейки `text` по числовому формату (tp#42) и `value`, `ref = <лист>!` (имя листа в одинарных +кавычках по правилу Excel, если в нём есть что-то кроме букв, цифр и `_`); дописывать +листы-продолжения в сетку открытой формы до раскладки (маркер «Форма … с.2» или позиция листа; проверки типа, +диапазона кодов и, при трёх и более кодах, числа колонок — как `join_blocker`), записывая границы листов в +`segment_breaks`. Несовпадение формата с `file_type` — предупреждение файла `FILE_TYPE_MISMATCH` (`info`). Лист без +признаков ОСВ и формы, без `type_hint` и кодов — предупреждение файла `SHEET_OTHER` (`info`, `locator = sheet:<имя>`), +документа нет. + +#### Scenario: Книга ББ + ОФР +- **WHEN** книга содержит лист «Баланс» (ОКУД 0710001), лист «Баланс (2)» с маркером «Форма 0710001 с.2» и лист «ОФР» +- **THEN** адаптер выдаёт два кандидата: `BALANCE` с `locator = sheet:Баланс,Баланс (2)` и одной сеткой из двух листов + (`segment_breaks` — первая строка второго листа) и `INCOME` с `locator = sheet:ОФР` + +#### Scenario: Ссылки ячеек +- **WHEN** код строки стоит в ячейке C27 листа «Баланс» +- **THEN** `ref` строки — `Баланс!C27`; у листа «Бухгалтерский баланс. Лист 1» — `'Бухгалтерский баланс. Лист 1'!C27` + +### Requirement: Строки без кода и неизвестные колонки в Excel +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: Путь ОСВ в контракте v2.0 +У `osvDocument` tabular-parser SHALL выдавать `status` (`done`, когда контрольные суммы §1.4 листа сошлись, иначе +`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`