From 248f8b0c7494d9a29a86cff955d74ad2c1717ad1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 02:17:36 +0400 Subject: [PATCH] =?UTF-8?q?openspec(#116):=20=D1=81=D0=BF=D0=B5=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20K-1?= =?UTF-8?q?=E2=80=A6K-25=20=D0=B8=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=D0=BC=20=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB=D1=8C=D1=86?= =?UTF-8?q?=D0=B0=20=D0=A0-20=E2=80=A6=D0=A0-22;=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20S-26=E2=80=A6S-52?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design.md: Р-20 (законные повторы кода в перечне, синонимы в словаре имён), Р-21 (мягкое соотношение, сошедшееся точно, подтверждает неуверенное значение), Р-22 (S-1…S-25 приняты с оговорками; отмечены утверждённые владельцем); решения при доработке S-26…S-52 с ценой ошибки - statement-normalization: отдельное требование sync-statements (пути, VERSION, коды 0 / 10 / 20 / 2), словарь единиц и ОКЕИ, ref Excel с кавычками, нумерация с двойниками и пропуском, номер колонки, содержимое строки, столбец кодов у DISCLOSURE, упрощённый 2400 мягкий, Р-20 / Р-21 со сценариями, обе редакции перечня, интерфейс функции статуса, TABLE_PARSE_ERROR / SHEET_OTHER уровня файла - arm-core: признак строки правки row_index IS NULL, header_source, формы без checks (1.x и миграция) — §7 ядром в document_issue, codes / column_index, RESULT_STATUS_INCONSISTENT, синонимы и «итого по разделу», PATCH organization_name / confirmed: false / подтверждение даты, LINE_CODE_TAKEN, акцепт и подтверждения, LINES_UNRESOLVED с текстом, «того же периода», откат миграции - recognition-contract: MODIFIED «Копия контракта … идентична» — сверка по MAJOR копии, *.v1.schema.json на окне; 1.x — MAPPING_WARNING / LEGACY_WARNING; okei, column_index, пустые массивы DISCLOSURE, статус ОСВ одной формулировкой - quality-checks (новая дельта): make check охватывает копию arm_statements, make check-full — statements-check - tabular-recognition-service: манифест statements_manifest.yaml — failed_relations страниц 2 баланса (K-25) - scan-gateway, arm-web: TABLE_PARSE_ERROR файла; подсказки шапки, снятие подтверждений, LINE_CODE_TAKEN - proposal: шаг 0 выкладки, перечни кодов, ожидания на реальных сканах, Impact Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- .../unified-statement-normalization/design.md | 179 +++++++++- .../proposal.md | 33 +- .../specs/arm-core/spec.md | 315 +++++++++++++----- .../specs/arm-web/spec.md | 16 +- .../specs/quality-checks/spec.md | 44 +++ .../specs/recognition-contract/spec.md | 68 +++- .../specs/scan-gateway/spec.md | 11 +- .../specs/statement-normalization/spec.md | 273 ++++++++++++--- .../specs/tabular-recognition-service/spec.md | 27 +- 9 files changed, 769 insertions(+), 197 deletions(-) create mode 100644 openspec/changes/unified-statement-normalization/specs/quality-checks/spec.md diff --git a/openspec/changes/unified-statement-normalization/design.md b/openspec/changes/unified-statement-normalization/design.md index 871505d..7ddf620 100644 --- a/openspec/changes/unified-statement-normalization/design.md +++ b/openspec/changes/unified-statement-normalization/design.md @@ -3,8 +3,10 @@ Issue: arm-ka-docs#116. Статус: **финальная версия** — целевая картина по шагам 1–9 согласована с владельцем (решения Р-1…Р-19, 17.10.2026) и прошла три раунда независимого ревью (`review-2026-10-17.md`, `review-2026-10-17-closure.md`, `review-2026-10-17-blind.md`, `review-2026-10-17-closure-2.md`, -`review-2026-10-17-blind-2.md`); следующий шаг — спека (дельты OpenSpec, контракт v2.0, §11/§12 в -`05-domain-rules.md`, DBML v5.19). +`review-2026-10-17-blind-2.md`). Спека (дельты OpenSpec, контракт v2.0, §7/§9/§11 в `05-domain-rules.md`, DBML v5.19) +написана 18.10.2026 (решения S-1…S-25), прошла ревью вслепую (`review-2026-10-18-spec.md`, находки K-1…K-25) и +доработана по нему (решения владельца Р-20…Р-22, решения S-26…S-52 ниже). Где текст дизайна выше расходится с +решениями Р-20…Р-22 и S-n, действуют они и спека. ## Цель @@ -773,7 +775,7 @@ line_name, line_name_confidence, parent_ref, values}`: `line_code` — разо `07-open-questions.md` (Р-15). Вопрос о сопоставлении файлового наименования со справочным закрыт Р-16: для неуверенных кодов это условие `RECOGNIZED`, для остальных — только показ справочного имени. -### Решения владельца (Р-1…Р-19, 17.10.2026) +### Решения владельца (Р-1…Р-19 — 17.10.2026, Р-20…Р-22 — 18.10.2026) - **Р-1** (шаг 1, п. 2) — **копия по скрипту.** Платформа не собирает Python-пакеты и не кладёт их в свой Nexus, поэтому общий код — dataclass-ы шага 1 (сериализуемые в json), шаги 2–8, словари §7, периодов, заголовков форм, перечни и @@ -855,6 +857,24 @@ line_name, line_name_confidence, parent_ref, values}`: `line_code` — разо предупреждений `error` уровня файла (потерянный json или таблица ИС блокируют, `needs_check` соседнего документа из-за одной ячейки — нет). +- **Р-20** (18.10.2026; ревью спеки K-4, повторы кодов) — законные повторы кодов (в реальных ББ 2025 — 1220 «НДС, + начисленный по отгрузке» и 1520 «Обязательство по аренде») допускаются в упорядоченном перечне кодов: структура + подтверждает обе копии. Правило Р-16 «повтор с неуверенной копией — `needs_review`» применяется только к повтору, + которого нет в перечне. Словарь имён ядра — список имён (синонимов) на код; загрузчик перестаёт запрещать повтор кода в + этом смысле (сверка наименования — с любым из имён). В спеке: перечень `contracts/statement-code-orders.yaml` + (`repeats`: у `fsbu4` — 1220, 1520, 2410 печатной формы 1С; у `66n` — раздел III НКО, см. S-28), §11.9, §7 «Полный + состав формы», спеки `statement-normalization` и `arm-core`. +- **Р-21** (18.10.2026; ревью спеки K-4, мягкие соотношения ОФР) — неуверенное значение подтверждается и мягким + соотношением, если оно сошлось точно (расхождение 0 или в пределах допуска ±1; не «в пределах округления»). Применяется + к 2400, 2410, 2411, 2412, 2460 и прочим строкам, у которых есть только мягкие соотношения. Знак для `abs_terms` — по + прежнему правилу конвенции колонки. В спеке: §7, §11.9, требование «Шаг 8 — `needs_review` значения». +- **Р-22** (18.10.2026; решения писателя S-1…S-25) — приняты все, с оговорками ревьюера из таблицы S-n в + `review-2026-10-18-spec.md`: S-4 — синонимы на код и исключение «итого по разделу» из правила вхождения; S-5 — в + упрощённом ОФР `2400` мягкое, как в полном, ББ — жёсткие; S-7 — OQ-148 закрывается этим решением; S-10 / S-22 — одна + формулировка; S-13 — двойники внутри детектора нумерации и допуск одного пропуска; S-15 — экранирование имени листа в + Excel-`ref`; S-17 — доопределить по K-23; S-19 — `codes` и `column_index` у `document_issue`; S-21 — по K-2 / K-21. + Явно утверждены владельцем S-4, S-5, S-7, S-10, S-12, S-13, S-14, S-20, S-22, S-25 (отмечены ниже). + ## Decisions made without discussion Решения агента 17.10.2026, принятые при оформлении целевой картины без отдельного обсуждения с владельцем; все @@ -936,14 +956,16 @@ line_name, line_name_confidence, parent_ref, values}`: `line_code` — разо 27. **S-4. Совпадение наименований (Р-16):** нижний регистр, `ё` → `е`, всё кроме букв и цифр — пробел; совпало при равенстве, вхождении более короткой строки (≥ 5 символов) или `difflib.SequenceMatcher` ≥ 0,8 с любым справочным именем кода в любой редакции; нет кода в словаре имён — не совпало. Цена: ложное несовпадение — `NEEDS_CHECK` и - ручное подтверждение; ложное совпадение маловероятно у разных статей одного раздела. + ручное подтверждение; ложное совпадение маловероятно у разных статей одного раздела. **Утверждено владельцем 18.10.2026 (Р-22)** с оговоркой: у кода несколько имён (синонимы, Р-20); если + одно из имён начинается с «итого по разделу», совпадение — только равенство (S-49). 28. **S-5. Формулы упрощённого набора** по бланку приказа 66н, прил. 5 (ББ: `1600 = 1150 + 1170 + 1210 + 1230 + 1250`, `1700 = 1300 + 1350 + 1360 + 1410 + 1450 + 1510 + 1520 + 1550`, `1600 = 1700`; ОФР: `2400 = 2110 − 2120 − 2330 + 2340 - − 2350 − 2410`), все жёсткие; `ASSUMPTION` до образца (OQ-166). Цена: неверная формула — `needs_check` упрощённых форм. + − 2350 − 2410`), все жёсткие; `ASSUMPTION` до образца (OQ-166). Цена: неверная формула — `needs_check` упрощённых форм. **Утверждено владельцем 18.10.2026 (Р-22) с изменением:** ОФР `2400 = …` упрощённого набора — + мягкое, как `2400` полного (знак 2410, OQ-12; ревью K-11); соотношения ББ — жёсткие. 29. **S-6. Словари подписей:** кода — «код», «коды», «код строки», «код показателя»; наименования — подпись, начинающаяся с «наименование». Цена: пополняется словарём. 30. **S-7. Прочерк** (`-`, `–`, `—`, `−` без цифр) — пустое значение, `is_recognized = true` (как пустая ячейка, Р-2); - OQ-148 — допущение. Цена: тире-мусор на месте цифры не подсвечивается сам по себе, его ловит §7 (пустой член). + OQ-148 — допущение. Цена: тире-мусор на месте цифры не подсвечивается сам по себе, его ловит §7 (пустой член). **Утверждено владельцем 18.10.2026 (Р-22):** OQ-148 закрыт этим решением. 31. **S-8. Каталог кодов предупреждений** (таблица §11.9 `05-domain-rules.md`); `code` в контракте — строка `^[A-Z][A-Z0-9_]*$`, не перечисление (новый код — не MAJOR); `VALUE_OUT_OF_RANGE` — `info` (значение и так `needs_review`). Цена: опечатка кода не ловится схемой — ловится тестами пакета. @@ -951,42 +973,163 @@ line_name, line_name_confidence, parent_ref, values}`: `line_code` — разо 33. **S-10. `needs_review` полей шапки** — массив `header.needs_review_fields` в контракте и `jsonb` в `financial_statement_meta`; сейчас туда попадает только `report_date`: сомнения в ИНН и организации — `info` по дизайну, `needs_review` у них дал бы `needs_check` вопреки «НПЗ ОФР без ИНН остаётся `done`». Цена: экран подсвечивает - ИНН и организацию по `issues` с `field`, а не по `needs_review`. + ИНН и организацию по `issues` с `field`, а не по `needs_review`. **Утверждено владельцем 18.10.2026 (Р-22)**; формулировка одна во всех документах: + `needs_review_fields` — поля, которые держат документ в `needs_check`; сырой текст и уверенность всех полей шапки + хранятся (`header_source`, S-33). 34. **S-11. Значения `period_kind`:** `absolute`, `relative:current`, `relative:prev_year_end`, `relative:prev_prev_year_end`, `relative:prev_period`, `none`; «За аналогичный период» — эффективная дата − 1 год (29.02 → 28.02). Цена: новая относительная подпись — новое значение (MINOR). 35. **S-12. Ожидаемые даты неуверенной подписи колонки** — эффективная дата, она же − 1 и − 2 года **и 31 декабря предыдущего и предшествующего ему года**. Основание: у квартального ББ колонки «на 31.12 прошлого года», и набор - дизайна (дата, − 1 год, − 2 года) давал бы `error` на каждой такой форме. Цена: чуть шире окно подтверждения. + дизайна (дата, − 1 год, − 2 года) давал бы `error` на каждой такой форме. Цена: чуть шире окно подтверждения. **Утверждено владельцем 18.10.2026 (Р-22).** 36. **S-13. Строка нумерации** — не меньше трёх непустых ячеек, все целые, подряд с 1 слева направо. Цена: нумерация с - пропуском не распознаётся (строка уйдёт в данные со статусом). + пропуском не распознаётся (строка уйдёт в данные со статусом). **Утверждено владельцем 18.10.2026 (Р-22) с оговоркой:** двойники внутри детектора и допуск + одного пропуска — уточнено S-40. 37. **S-14. Признак повторной шапки** — подпись кода в строке или ≥ 2 непустых ячеек, совпавших с шапкой первого - сегмента. Цена: шапка без «Код» и с другими подписями не начнёт сегмент. + сегмента. Цена: шапка без «Код» и с другими подписями не начнёт сегмент. **Утверждено владельцем 18.10.2026 (Р-22).** 38. **S-15. `ref` и `row_index`:** `row_index` с 0; Excel `ref` строки — адрес ячейки столбца кодов (без столбца — первой непустой ячейки); разлепленные `row:#` с k от 1; у разлепленных строк `line_code_text` и `text` — - k-е части. Цена: нулевая. + k-е части. Цена: нулевая. Оговорка Р-22: имя листа в Excel-`ref` экранируется по правилу Excel (кавычки, удвоенный апостроф), + примеры контракта исправлены. 39. **S-16. Уверенность кода с заменой двойника** — не выше 99 (так «неуверенный» виден из одного поля). Цена: нулевая. 40. **S-17. Формат PATCH `lines`** — один элемент = одно действие (`line_code` | `line_status: "detail"` | `confirmed`); коды 422 `INVALID_LINE_CODE`, `LINE_CODE_REQUIRED`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`, `UNKNOWN_COLUMN`; правка суммы - по `line_code` при наличии строки `ok` — 422 `UNKNOWN_CELL`. Цена: имена кодов меняются до реализации без последствий. + по `line_code` при наличии строки `ok` — 422 `UNKNOWN_CELL`. Цена: имена кодов меняются до реализации без последствий. Оговорка Р-22: доопределено по K-23 (S-47); 409 `LINE_EXISTS` переименован в `LINE_CODE_TAKEN` + (S-43). 41. **S-18. Детали 409:** `LINES_UNRESOLVED` — `detail.lines[] {line_id, row_index, line_code_text}`; `DUPLICATE_FORM` — `detail.document_id`. Цена: нулевая. 42. **S-19. Имена DBML v5.19:** `document.file_ordinal` (с 0, уникален в файле), `financial_statement_meta.needs_review_fields` (`jsonb`), `document_issue.line_ids` (`jsonb` — сверка ORM↔DBML не знает массивов), enum'ы `statement_line_status`, `statement_form_variant`, `document_issue_kind`, `issue_severity`, `issue_source`; индекс `(meta_id, line_code)` остаётся неуникальным; у строки `is_confirmed` без `confirmed_by/at` (строго по дизайну, кто — в аудите). Новые колонки — последними - в таблицах (как их добавит миграция). Цена: нулевая до миграции. + в таблицах (как их добавит миграция). Цена: нулевая до миграции. Оговорка Р-22: у `document_issue` добавлены `codes` и `column_index` (S-31, S-35); висячий + `line_id` удалённой шаблонной строки убирается из `line_ids`. 43. **S-20. `related_group.crm_id` уникален** (как у ГК и ЮЛ), повтор — 409 `CRM_ID_EXISTS`. Цена: две группы с одним ID - CRM завести нельзя; так и должно быть. + CRM завести нельзя; так и должно быть. **Утверждено владельцем 18.10.2026 (Р-22).** 44. **S-21. Значения по умолчанию при приёме 1.x:** `ref = null`, `needs_review` строки `false`, `period_kind` — `absolute` при дате иначе `none`, `form_variant = null`, строки-предупреждения — `LEGACY_WARNING` `info`. Цена: на окне выкладки - документы 1.x без новых сигналов качества. + документы 1.x без новых сигналов качества. Оговорка Р-22: по K-2 / K-21 — S-36. 45. **S-22. Статус документа ОСВ** — `done`, когда контрольные суммы §1.4 листа сошлись, иначе `needs_check` (прежнее - правило конверта, применённое к листу). Цена: нулевая — конверт ведёт себя как сейчас. + правило конверта, применённое к листу). Цена: нулевая — конверт ведёт себя как сейчас. **Утверждено владельцем 18.10.2026 (Р-22)**; формулировка «контрольные суммы §1.4 листа сошлись» + теперь одна в контракте, §11, спеках `recognition-contract` и `tabular-recognition-service`. 46. **S-23. `service.statements_version` обязателен**, если в результате есть `statementDocument` (схема проверяет). Цена: результат только с ОСВ может его не нести. 47. **S-24. Редакция перечня кодов в сервисах** — `fsbu4` при эффективной дате после 2024-12-31 (то же правило, что - `edition` ядра, OQ-145). Цена: досрочный ФСБУ 4/2023 — проверяются обе через «длиннейшую подпоследовательность». + `edition` ядра, OQ-145). Цена: досрочный ФСБУ 4/2023 — проверяются обе через «длиннейшую подпоследовательность». Уточнено S-26: проверяются всегда обе, дата решает только ничью. 48. **S-25. Квартал периода (условие `RECOGNIZED`):** дата не конец квартала → `NEEDS_CHECK` (как D-6), ручное подтверждение — 409 `PERIOD_REQUIRED`. Основание: оговорка дизайна «иначе документ `RECOGNIZED`, но не подтверждаемый» - противоречит автоподтверждению `RECOGNIZED`. Цена: нулевая — поведение как сейчас. + противоречит автоподтверждению `RECOGNIZED`. Цена: нулевая — поведение как сейчас. **Утверждено владельцем 18.10.2026 (Р-22).** + +### Решения при доработке спеки по ревью K-1…K-25 (18.10.2026) + +Решения агента при закрытии находок `review-2026-10-18-spec.md` там, где ни ревьюер, ни владелец не задали значение, +или где агент отступил от предложения ревьюера. Маркер `S-n` продолжает нумерацию; все обратимы правкой спеки, словаря +или DBML до реализации. + +49. **S-26. Редакции перечня кодов — всегда обе** (K-5): для неуверенного кода считаются наибольшие + подпоследовательности по `66n` и `fsbu4`, берётся длиннейшая, эффективная дата решает только ничью. Основание: + Межениновская за 3 кв. 2025 сдана на бланке 66н — выбор только по дате (S-24) не подтвердил бы её 1120; формула + дизайна «редакция — где длиннее, при равенстве — по дате» иначе не имеет смысла. Цена: на ББ с кодами, общими для + обеих редакций, выбор безразличен; неверный выбор возможен только при равной длине — тогда решает дата, как в S-24. +50. **S-27. Перечень кодов — данные в docs** (K-5): источник — `contracts/statement-code-orders.yaml` (обращение «как + контракт»: общие данные сервисов и ядра), в пакете — байтовая копия `rules/statement_code_orders.yaml`, идентичность + проверяет `sync-statements --check`; форма файла — `scripts/validate_contracts.py`, для этого в `make venv` docs + добавлен PyYAML. Цена: ещё одна зависимость docs-проверок (`make venv` нужно перезапустить); перенос источника в + `arm-statements` — правкой одного требования. +51. **S-28. Состав перечня — только подтверждённые образцом коды** (K-5): шаблоны банка + коды печатных форм 1С и сканов + из `fixtures/` (1105, 1130, 1140, 1215, 1231, 1530, 2420, 2460, 2530, 2930) с источником у каждого; раздел III НКО + (1310 «Паевой фонд» … 1370, 1300 — aep_3) — законный повтор `66n`; клиентский 1231 — в `66n` (Унагранде 2024). + Коды без образца (1120 у `fsbu4`, 2421 / 2430 / 2450 у `66n`, упрощённые перечни) — OQ-168, OQ-166. Все 12 + последовательностей кодов из `fixtures` (Excel и OCR) укладываются в свою редакцию целиком. Цена: на скане с + неподтверждённым кодом и неуверенностью ниже 100 — `needs_review` строки; у Excel и уверенного кода — только `info`. +52. **S-29. Окно перехода контракта** (K-8): схемы 1.2 / 1.0 лежат в docs как `*.v1.schema.json` (байтовые копии, + сервисы сверяются с ними без правки копий), примеры — `examples/v1/`; тест идентичности сервиса выбирает файл по + `$id` копии (`…/v1` → `*.v1.schema.json`) — это шаг 0 выкладки в tabular-parser и scan-gateway до слияния docs. + Отступление от предложения ревьюера «тест сверяет с MAJOR, который сервис пишет»: признак — `$id` копии, а не + настройка сервиса, чтобы правило работало без знания кода сервиса. Цена: маленький PR в двух сервисах до слияния + docs; без него их `make check` красный на окне. +53. **S-30. Механика `sync-statements`** (K-9): копия — `api/arm_statements/` (импорт как `src`), тесты пакета в + `testpaths` хоста, `mypy src arm_statements`; маркер — файл `api/arm_statements/VERSION` с полным хэшем; `--check`: + `0` синхронно, `10` отстала (зелёный с предупреждением — иначе любой посторонний PR в сервис требовал бы + подтягивать чужие изменения пакета), `20` прямая правка / чужая версия / расхождение перечня кодов / код словаря ядра + вне перечня, `2` нет checkout или аргумент. Без checkout `arm-statements` `check-full` красный (код 2): это проверка + перед PR, а не `make check`. Цена: отставшая копия проходит `check-full`; видна в описании PR. +54. **S-31. `column_index` — номер столбца сетки кандидата** (K-6): у OCR — `column_index` ИС как есть (разреженный), у + Excel — номер столбца листа первого сегмента с 0; у `document_issue` — `column_id` для колонки формы и + `column_index` для столбца вне колонок; `line_refs` — только строки `lines[]`. Отступление от варианта «порядковый + номер колонки значений»: иначе пропущенная колонка (`COLUMN_SKIPPED`) не имела бы номера. Примеры контракта + приведены к правилу (Excel — 3, 4; OCR — 2). Цена: номера не с 0 и не подряд — экран и ядро не должны считать их + позициями. +55. **S-32. `TABLE_PARSE_ERROR` и `SHEET_OTHER` — предупреждения файла** (K-7): с `locator`; документа нет; + `TABLE_PARSE_ERROR` (`error`) блокирует `RECOGNIZED` всех документов файла, как `JSON_FAILED`. Цена: один упавший + разбор таблицы держит весь файл на ручном подтверждении. +56. **S-33. Шапка: `header_source jsonb` + прежние `*_original_value`** (K-3): сырой текст и уверенность всех десяти + полей — одной колонкой `{поле: {text, confidence}}`; `inn_original_value`, `report_date_original_value`, + `units_original_value` заполняются как прежде, `inn_confidence` остаётся. Отступление от варианта «колонки на каждое + поле»: 20 колонок ради подсказки на экране. Цена: поиск по сырому тексту поля шапки в SQL — через `jsonb`. +57. **S-34. `organization_name` в PATCH шапки** (K-12): непустая строка до 1000 символов, `organization_name_modified`, + `organization_name_original_value` (новые колонки DBML), пересчёт `counterparty_check`, аудит; снимает + `NO_ORGANIZATION_NO_INN`. Выбран вариант «добавить», а не «убрать из правила снятия»: иначе форма без шапки + снималась бы только перезагрузкой. Цена: правка наименования влияет на сверку контрагента по алиасам. +58. **S-35. Коды нарушений в `document_issue`** (K-13): `relation` → `RELATION_FAILED` / `error`, `soft` → + `RELATION_SOFT` / `info`, колонка `codes jsonb`, `line_ids` — все строки `ok` кодов. Цена: нулевая. +59. **S-36. Формы без `checks`** (K-2, K-21): у результата 1.x и у форм до миграции ядро один раз считает §7 копией + пакета (`form_variant = null` — полный набор) и пишет `document_issue` (`source = core`): при приёме 1.x и **в + ревизии миграции** (ревизия импортирует копию `arm_statements`). D-6 «проверки сошлись» для 1.x — из этого расчёта; + `GET` и `confirm` читают нарушения только из `document_issue`. Предупреждения маппера 1.x по документу — + `MAPPING_WARNING` `info`; строки `metrics.warnings` 1.x — `LEGACY_WARNING` в `recognition_job.metrics` (уровень + файла, не `document_issue`); `STATEMENTS_VERSION_MISMATCH` при `statements_version = null` не пишется. Цена: ревизия + миграции зависит от кода пакета на момент миграции (старая ревизия при смене пакета считает по новым правилам — для + разовой миграции приемлемо); отказ от миграционного расчёта дал бы потерю нарушений у существующих форм (K-2). +60. **S-37. Признак строки правки — `row_index IS NULL`** (K-1): маппер 1.x и 2.0 всегда пишет `row_index`; миграция + ставит `NULL` строкам с `line_code_original_value IS NULL`; `line_code_original_value` — код из источника, правками + не меняется. Отступление от дизайна («смена кода — прежний в `line_code_original_value`»): при первой смене это то + же значение, а при повторной правке исходный код источника не теряется. Цена: «предыдущий код» после двух правок — + только в аудите. +61. **S-38. Функция статуса и «содержимое» строки** (K-14): вход — данные без типов источника (`layout_found`, у ядра — + есть строка из источника; `has_organization_or_inn`; `period_known`; статусы и `needs_review`; число жёстких + нарушений; `accepted`; уровни предупреждений). Содержимое строки — цифра в ячейке кода, сумма в значениях или + непустая ячейка значений с уверенностью ниже 100; строка `unreadable` без значений («44 30\nI |» aep_6) — строка + данных: документ `needs_check`, подтверждение не блокирует. Цена: такая строка требует от аналитика «Это + расшифровка» или кода, хотя сумм в ней нет. +62. **S-39. Столбец кодов ищется всегда, и у `DISCLOSURE`** (K-10): `type_hint = DISCLOSURE` при кодах ББ/ОФР → тип по + кодам + `TYPE_CONFLICT`; у `DISCLOSURE` шаги 3–7 не выполняются, `columns` / `lines` — пустые массивы. Цена: лишний + проход поиска столбца по пояснениям. +63. **S-40. Детектор нумерации** (S-13 с оговоркой Р-22, K-15): ≥ 3 непустых ячеек, каждая после замены двойников — + целое; возрастают на 1, начинаются с 1 или 2, допускается один пропуск. Цена: настоящая строка данных из трёх + маленьких целых подряд («1 | 2 | 3» в суммах) станет нумерацией — на формах ББ/ОФР не встречалась. +64. **S-41. `LINES_UNRESOLVED` — сумма или текст** (K-16): блокирует строка `unreadable` / `missing`, у которой хотя бы + в одной ячейке `amount` или `string_value` не `null`. Цена: строка с мусором вместо суммы требует разбора. +65. **S-42. Подтверждение неуверенной даты отчёта** (K-17): PATCH `header.report_date` тем же значением у поля из + `needs_review_fields` снимает его и `REPORT_DATE_UNCERTAIN`; у бланка 66н только с относительными подписями это + единственный путь — признано в §11.9. Цена: аналитик должен явно подтвердить дату на каждом таком бланке. +66. **S-43. Имена** (K-18): 409 `LINE_EXISTS` → `LINE_CODE_TAKEN` (имя занято сообщением 422 `UNKNOWN_CELL` в коде + ядра); `period_end` колонки → `period_date` во всех документах (как в контракте и DBML); порядок `lines` в API — + «по возрастанию кода, как сейчас», «как на бумаге» — раскладка экрана; `DISCLOSURE` — пустые массивы. Цена: нулевая. +67. **S-44. Откат миграции** (K-19): гарантирован на пустой и совместимой с v5.18 БД; на несовместимых данных + `downgrade` останавливается с ошибкой и ничего не удаляет. Цена: откат после появления данных v2.0 — только + восстановлением из резервной копии. +68. **S-45. «Того же периода»** (K-20) — та же пара `period_year`, `period_quarter` (как автозамена §9.5 п. 3); формы + без квартала не считаются (их и так держит `PERIOD_REQUIRED`). Цена: нулевая. +69. **S-46. Единицы и ОКЕИ** (K-22): словарь `statement_units.yaml` в пакете (написания и коды ОКЕИ 383 / 384 / 385); + единицы по тексту, иначе по ОКЕИ; `okei` (+ `_text`, `_confidence`) — в `header` контракта v2.0 и `headerField`. + Цена: ОКЕИ, противоречащий тексту единиц, не ловится (текст главнее). +70. **S-47. Пробелы PATCH** (K-23, S-17): `confirmed: false` снимает подтверждение ячейки и кода; `line_status` принимает + только `"detail"` (`"ok"` — 422 `LINE_CODE_REQUIRED`); обратного `detail` → `unreadable` / `missing` нет (исправляется + назначением кода, исходное — перезагрузкой); `columns[].period_date = null` — 422 валидации; **акцепт сбрасывает + правка данных** (сумма, код, статус строки, дата колонки, поле шапки), подтверждения (`confirmed`) — нет. Последнее + сужает правило Среза 3b «акцепт сбрасывается любой правкой формы» — вынесено владельцу (отчёт O-n). Цена: если + владелец считает подтверждение правкой — одна строка спеки. +71. **S-48. Страховка `done`** (K-24): `done` из результата при строке `unreadable` / `missing` со значением, + `needs_review`, `relations_failed` или `error`-предупреждении документа → `NEEDS_CHECK` и `RESULT_STATUS_INCONSISTENT` + `info`. Цена: проверка на каждом маппинге — линейная по строкам. +72. **S-49. Словарь имён ядра** (Р-20, S-4): повтор кода в редакции разрешён — все имена кода синонимы; шаблонная строка + и `reference_name` берут первое; «итого по разделу» — только равенство. Цена: справочное имя законного повтора на + экране — первое имя кода, файловое — рядом. +73. **S-50. Толкование Р-21:** мягкое соотношение, сошедшееся в пределах ±1, покрывает значения любого своего кода; у + кода в несошедшемся жёстком соотношении подсветка остаётся (правило «все ячейки несошедшегося жёсткого»), поэтому + на практике Р-21 действует на строки без жёстких соотношений, как сформулировал владелец. Цена: нулевая. +74. **S-51. Ожидания на реальных сканах признаны в §11.9 и proposal** (K-4): большинство из шести OCR-документов + останутся `needs_check`; формулировка гайда фронта смягчена («может стать `RECOGNIZED`»). Цена: нулевая. +75. **S-52. Манифест tabular-parser** (K-25): в спеке `tabular-recognition-service` перечислены записи, получающие + `1600 = 1700` (страницы 2 баланса ×3), и записи, чьи статусы `done` меняться не должны. Цена: нулевая. diff --git a/openspec/changes/unified-statement-normalization/proposal.md b/openspec/changes/unified-statement-normalization/proposal.md index 7626c1b..1e22693 100644 --- a/openspec/changes/unified-statement-normalization/proposal.md +++ b/openspec/changes/unified-statement-normalization/proposal.md @@ -30,7 +30,10 @@ предупреждения — структуры `{code, severity, message, line_refs, column_index, field}` вместо строк. - **Контракт `recognition-result.schema.json` v2.0** (MAJOR, Р-9) и конверт `recognition-result-message.schema.json` v2.0 (структурные `metrics.warnings`); запрос `recognition-request` остаётся v1.0. Путь ОСВ в tabular-parser - меняется только в части структуры предупреждений и `document.status`. + меняется только в части структуры предупреждений и `document.status`. На окне перехода схемы 1.x лежат рядом + (`*.v1.schema.json`, примеры `examples/v1/`), тест идентичности сервиса сверяет копию со схемой своего MAJOR. +- **Упорядоченные перечни кодов форм** — данные `contracts/statement-code-orders.yaml` (66н / ФСБУ 4, полная / + упрощённая, законные повторы кода — Р-20); в пакет `arm_statements` — байтовой копией. - **arm-core:** DBML v5.19 и одна миграция (статусы и порядок строк, `needs_review`, `is_confirmed`, `source_text`, `period_kind`, `form_variant`, таблица `document_issue`, снятие уникальности `(meta_id, line_code)` и — для ББ/ОФР — `document (stored_file_id, document_type, osv_account_code)`, `recognition_job.statements_version`, @@ -53,6 +56,8 @@ ### Порядок выкладки — «сначала ядро» (обязателен) +0. tabular-parser и scan-gateway: тест идентичности копии контракта выбирает файл docs по MAJOR копии (`$id` `…/v1` → + `*.v1.schema.json`) — до слияния шага 2, иначе их `make check` краснеет на окне перехода. 1. `arm-statements` — пакет с тестами; `sync-statements` в docs. 2. docs: контракт v2.0, DBML v5.19, правила (этот change). 3. arm-core: миграция v5.19, маппер 2.0 рядом с маппером 1.x, приём ack и конвертов 1.x и 2.0, PATCH, экранные поля @@ -62,7 +67,12 @@ 6. После перехода обоих сервисов маппер 1.x удаляется из ядра отдельным PR (не в этом change). Копии схем в сервисах (`api/contracts/`, тест идентичности в `make check`) после слияния контракта v2.0 в docs -устаревают: каждый сервис обновляет копию в своём PR шага 5, ядро — в PR шага 3. +сверяются с `*.v1.schema.json` (шаг 0); каждый сервис обновляет копию до v2.0 в своём PR шага 5, ядро держит копии обоих +MAJOR с PR шага 3. Схемы `*.v1.schema.json` и `examples/v1/` удаляются вместе с маппером 1.x (шаг 6). + +**Чего ждать на реальных сканах.** На шести OCR-документах `fixtures/ocr` после перехода большинство форм останутся +`needs_check` (строки без кода с суммами, неуверенные значения вне соотношений, неуверенная дата); законные повторы кода +и мягкие строки ОФР перестают держать документ (Р-20, Р-21). Доля `done` фиксируется в PR scan-gateway. ## Non-goals @@ -93,21 +103,30 @@ ### Modified Capabilities - `recognition-contract`: файл результата и конверт — v2.0; правила перехода (ядро принимает 1.x и 2.0, «сначала - ядро»); статус и структурные предупреждения у всех документов, включая ОСВ. + ядро»); статус и структурные предупреждения у всех документов, включая ОСВ; тест идентичности копии — по MAJOR. +- `quality-checks`: `make check` охватывает копию `arm_statements` (тесты, `mypy`), `make check-full` — `statements-check` + (`sync-statements --check`). ## Impact - Новый репозиторий `arm-statements`; скрипт `sync-statements` и цель Makefile в docs (вызывается из `make check-full` - потребителей, не из `make check`). + потребителей, не из `make check`; коды 0 / 10 / 20 / 2). +- docs: `contracts/statement-code-orders.yaml`, схемы `*.v1.schema.json` и `examples/v1/` на окне перехода; + `scripts/validate_contracts.py` проверяет оба MAJOR и перечни кодов, `make venv` ставит PyYAML. - tabular-parser: `parser/statements/*` (кроме разбора книги и адаптера шага 1) уходит в общий код; словари `statement_checks.yaml`, `statement_periods.yaml`, `statement_headers.yaml` переезжают в пакет; путь ОСВ — структура - предупреждений и `document.status`. + предупреждений и `document.status`; манифест `api/tests/statements_manifest.yaml` обновляется: `failed_relations` + страниц 2 баланса (`unagrande-co/2024-FY/Б.Б. Лист 2.xls`, `unagrande-group/2024-FY/ББ. Лист 2 (2).xls`, + `unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls`) получают `1600 = 1700` («посчитанное» соотношение, пустая цель), + статусы `done` не меняются. - scan-gateway: `normalizer/ocr/text|dates|amounts` становятся общими разборами, `layout|rows|header|document` — шагами 2–6 общего кода; `api/rules/statement_lines.yaml` удаляется, `statement_labels.yaml` и `ocr_lookalikes.yaml` уходят в словари пакета. - arm-core: миграция v5.19 (`financial_statement_*`, `document`, `document_issue`, `recognition_job`, - `related_group`), `result_mapping`, `statement_mapping`, `statements`, `documents`, `auto_confirm`, схемы API формы; - `api/rules/statement_checks.yaml` удаляется (копия словаря §7), `statement_lines.yaml` остаётся только словарём имён. + `related_group`; `document_issue` существующих форм — §7 копией пакета), `result_mapping`, `statement_mapping`, + `statements`, `documents`, `auto_confirm`, схемы API формы; признак строки правки — `row_index IS NULL`; + `api/rules/statement_checks.yaml` удаляется (копия словаря §7), `statement_lines.yaml` остаётся только словарём имён + (несколько имён на код, загрузчик повтор кода не запрещает); PATCH шапки принимает `organization_name`. - arm-web: `widgets/DocumentReview` (экран [41]), типы API. - Решение v5.13 «`needs_review` считается, не хранится» пересматривается (design, Decisions № 17). - Риск окна выкладки: пока сервис шлёт 1.x, документы его конвейера получают статус по прежнему правилу D-6 и поля diff --git a/openspec/changes/unified-statement-normalization/specs/arm-core/spec.md b/openspec/changes/unified-statement-normalization/specs/arm-core/spec.md index 35c81d5..32c03f3 100644 --- a/openspec/changes/unified-statement-normalization/specs/arm-core/spec.md +++ b/openspec/changes/unified-statement-normalization/specs/arm-core/spec.md @@ -3,16 +3,17 @@ ### Requirement: Хранение результата формы v2.0 (DBML v5.19) Маппер результата `2.0` SHALL писать документ формы как пришёл, без повторной дедупликации колонок, кодов и значений: каждая строка результата — `financial_statement_line` (`row_index`, `source_ref` = `ref`, `line_status`, `line_code`, -`line_code_text`, `line_code_confidence`, `line_name`, `line_name_confidence`, `parent_row_index` — `row_index` строки -с `ref = parent_ref`, `needs_review`, `is_confirmed = false`); каждая пара «строка × колонка» — одна -`financial_statement_value` (`amount`, `string_value`, `source_text` = `values[].text`, `confidence`, `is_recognized`, -`needs_review`; значения, которого нет в результате, — пустая запись с `needs_review = false`); колонки — -`financial_statement_column` с `period_kind`, `label_confidence`; шапка — `financial_statement_meta` с `form_variant` -и `needs_review_fields`; предупреждения и нарушения документа — `document_issue` (источник `service`); версия общего -кода — `recognition_job.statements_version`. Уникальность `(meta_id, line_code)` снята; `(meta_id, source_ref)` -уникален там, где `source_ref` не `null`. Каждый `BALANCE` / `INCOME` результата SHALL становиться отдельным -`document` с `file_ordinal` (порядковый номер документа в результате); вторая форма того же типа в файле хранится -(Р-10); ОСВ по счёту, `DISCLOSURE` и `OTHER` — по-прежнему один документ на тип (и счёт) на файл. +`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` с +`file_ordinal` (порядковый номер документа в результате); вторая форма того же типа в файле хранится (Р-10); ОСВ по +счёту, `DISCLOSURE` и `OTHER` — по-прежнему один документ на тип (и счёт) на файл. #### Scenario: Повтор кода сохраняется - **WHEN** в результате две строки `ok` с кодом 2410 (пустая и с `(649)`) @@ -20,12 +21,43 @@ #### Scenario: Строка без кода - **WHEN** в результате строка `unreadable` с `line_code_text = "ИЧ,250"` и суммой -- **THEN** строка хранится с `line_code = null`, `line_status = 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` (лист-продолжение другого типа или двойная печать) - **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`, @@ -38,17 +70,24 @@ - **THEN** межформенная проверка берёт 1370 = 800 ### Requirement: Условие `RECOGNIZED` при приёме 2.0 -При приёме `2.0` статус документа формы SHALL быть `RECOGNIZED` ⇔ `document.status = done` ∧ в `metrics.warnings` файла -нет предупреждений уровня `error` ∧ `counterparty_check ≠ MISMATCH` ∧ период — конец квартала (`period_year` и -`period_quarter` определены) ∧ у каждой строки `ok` с неуверенным кодом (`line_code_confidence < 100`) распознанное -наименование совпало со справочным по коду ∧ в файле нет второй формы того же типа и периода; иначе `NEEDS_CHECK`. -Статус конверта и `needs_check` соседнего документа в условие MUST NOT входить (Р-19). Проверки §7, `needs_review` и -предупреждения сервиса при загрузке MUST NOT пересчитываться. Совпадение наименований: обе строки приводятся к нижнему -регистру, `ё` → `е`, всё, кроме букв и цифр, — пробел, пробелы схлопываются; совпало, если строки равны, или более -короткая (не короче 5 символов) входит в более длинную, или отношение сходства `difflib.SequenceMatcher` ≥ 0,8; с -любым справочным именем кода в любой редакции словаря ядра. Кода нет в словаре имён или наименование пусто — не -совпало. Несовпадение — `document_issue` `NAME_MISMATCH` (`info`, источник `core`, строка). -`RECOGNIZED` SHALL, как и прежде, автоподтверждаться (OQ-157). +При приёме `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`. +**Согласованность:** `done` противоречит содержимому, если есть строка `unreadable` / `missing` со значением (`amount` или +`string_value` не `null`), строка, значение или поле шапки с `needs_review`, `checks.relations_failed` или предупреждение +документа уровня `error`; тогда ядро считает документ `needs_check` и пишет `document_issue` +`RESULT_STATUS_INCONSISTENT` (`info`, источник `core`). Статус конверта и `needs_check` соседнего документа в условие +MUST NOT входить (Р-19). Проверки §7, `needs_review` и предупреждения сервиса при загрузке MUST NOT пересчитываться. + +**Совпадение наименований:** обе строки приводятся к нижнему регистру, `ё` → `е`, всё, кроме букв и цифр, — пробел, +пробелы схлопываются; совпало, если строки равны, или более короткая (не короче 5 символов) входит в более длинную, или +отношение сходства `difflib.SequenceMatcher` ≥ 0,8; сравнение — с **любым** справочным именем кода (у кода может быть +несколько имён — синонимы и законные повторы, Р-20) в любой редакции словаря ядра. Если хотя бы одно из имён начинается с +«итого по разделу», совпадение — только равенство. Кода нет в словаре имён или наименование пусто — не совпало. +Несовпадение — `document_issue` `NAME_MISMATCH` (`info`, источник `core`, строка). `RECOGNIZED` SHALL, как и прежде, +автоподтверждаться (OQ-157). #### Scenario: Ячейка соседнего документа - **WHEN** ББ `done`, ОФР того же файла `needs_check`, предупреждений файла уровня `error` нет, ИНН совпал, период — @@ -56,7 +95,7 @@ - **THEN** ББ `RECOGNIZED` и автоподтверждается, ОФР — `NEEDS_CHECK` #### Scenario: Потерянный json -- **WHEN** все документы `done`, но в `metrics.warnings` файла `JSON_FAILED` уровня `error` +- **WHEN** все документы `done`, но в `metrics.warnings` файла `JSON_FAILED` (или `TABLE_PARSE_ERROR`) уровня `error` - **THEN** все документы формы файла `NEEDS_CHECK` #### Scenario: Подмена неуверенного кода на соседа @@ -64,6 +103,20 @@ - **THEN** наименование не совпало со справочным «Результаты исследований и разработок», документ `NEEDS_CHECK`, `NAME_MISMATCH` у строки +#### Scenario: Законный повтор с другим именем +- **WHEN** строка 1220 (уверенность 50) с наименованием «НДС, начисленный по отгрузке», а в словаре имён `fsbu4` у 1220 + есть это имя вторым +- **THEN** наименование совпало, `NAME_MISMATCH` нет + +#### Scenario: Подмена итога раздела +- **WHEN** строка с кодом 1200 (уверенность 50) и наименованием «Итого по разделу I» +- **THEN** наименование не совпало со справочным «Итого по разделу II» (для «итого по разделу» — только равенство), + `NAME_MISMATCH` + +#### Scenario: Сервис ошибся со статусом +- **WHEN** документ пришёл `done`, но в нём строка `unreadable` с суммой +- **THEN** документ `NEEDS_CHECK`, `RESULT_STATUS_INCONSISTENT` у документа; автоподтверждения нет + #### Scenario: Период не конец квартала - **WHEN** документ `done` с датой отчёта 15.08.2025 - **THEN** квартал не определён, документ `NEEDS_CHECK` (как в D-6) и не автоподтверждается; ручное подтверждение — @@ -71,16 +124,21 @@ ### Requirement: Предупреждения и нарушения документа — `document_issue` Ядро SHALL хранить предупреждения и нарушения документа в `document_issue`: вид (`warning` | `relation` | `soft`), код, -уровень (`error` | `info`), сообщение, список `line_id`, `column_id`, поле шапки, источник (`service` | `core`), для -нарушений — текст соотношения, `expected`, `actual`. При загрузке пишутся предупреждения и `checks` результата -(источник `service`) и проверки ядра уровня `info` (источник `core`): `CODE_NOT_IN_DICTIONARY` (кода нет в словаре -имён), `CODE_NOT_IN_EDITION` (код не из перечня редакции по эффективной дате — по копии перечня `arm_statements`), -`NAME_MISMATCH`. При перезагрузке файла записи документа удаляются вместе с ним. При пересчёте после правки записи -`relation` и `soft` заменяются целиком независимо от источника; записи `warning` источника `service` сохраняются, -кроме снимаемых правкой: правка `header.report_date` снимает `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`, -`REPORT_DATE_UNCERTAIN`; правка `header.inn` или `organization_name` — `NO_ORGANIZATION_NO_INN`; правка -`columns[].period_date` — `COLUMN_WITHOUT_DATE` и `COLUMN_DATE_UNCERTAIN` этой колонки; прочие `error` сервиса снимаются -только перезагрузкой файла. +уровень (`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`. При перезагрузке файла записи документа +удаляются вместе с ним. При пересчёте после правки записи `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 @@ -90,107 +148,192 @@ - **WHEN** у документа `SEGMENT_COLUMNS_MISMATCH`, аналитик правит значения - **THEN** предупреждение остаётся; документ можно подтвердить вручную (оно подтверждение не блокирует) +#### Scenario: Пропущенная колонка +- **WHEN** в результате предупреждение `COLUMN_SKIPPED` с `column_index = 5`, колонки 5 среди `columns[]` нет +- **THEN** запись `document_issue` с `column_index = 5` и `column_id = null`; экран показывает её у документа + +### Requirement: Документы без `checks` — результаты 1.x и формы до миграции +Для формы без `checks` контракта 2.0 — принятой по результату `1.x` и существовавшей до миграции v5.19 — ядро SHALL +**один раз** посчитать §7 копией `arm_statements` по текущим суммам (`form_variant = null` — полный набор) и записать +нарушения в `document_issue` (источник `core`, виды `relation` / `soft`, как в требовании о `document_issue`): у `1.x` — +при приёме результата, у существующих форм — в миграции v5.19. Условие D-6 «проверки сошлись» для `1.x` SHALL браться из +этого расчёта. Предупреждения маппера `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` статус не держат. + +#### Scenario: Мигрированная форма с нарушением +- **WHEN** до миграции форма `NEEDS_CHECK` с несошедшимся `1600 = 1700` без акцепта; миграция v5.19 выполнена +- **THEN** у формы запись `document_issue` `relation` `RELATION_FAILED` с `codes = ["1600", "1700"]`; экран показывает + нарушение; `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` и статус тем же общим кодом -`arm_statements` по сохранённым данным (конвенция знака — по `source_text`), с правилами: `is_modified` или -`is_confirmed` значения ⇒ `needs_review = false`; `line_code_modified` или `is_confirmed` строки ⇒ `needs_review` -строки = `false`; акцепт ⇒ жёсткие нарушения не учитываются; правленые ячейки в голосование знака не входят. -Отличие `statements_version` задания от версии копии ядра — `document_issue` `STATEMENTS_VERSION_MISMATCH` (`info`). -Один PATCH — один пересчёт и одна запись аудита `RECOGNIZED_DATA_EDIT`. Акцепт сбрасывается любой правкой, как и -прежде. +`arm_statements` по сохранённым данным (конвенция знака — по `source_text`; `form_variant = null` — полный набор), с +правилами: `is_modified` или `is_confirmed` значения ⇒ `needs_review = false`; `line_code_modified` или `is_confirmed` +строки ⇒ `needs_review` строки = `false`; акцепт ⇒ жёсткие нарушения не учитываются; правленые ячейки в голосование +знака не входят. Отличие `statements_version` задания от версии копии ядра — `document_issue` +`STATEMENTS_VERSION_MISMATCH` (`info`); у задания без версии (`1.x`) запись не пишется. Один PATCH — один пересчёт и одна +запись аудита `RECOGNIZED_DATA_EDIT`. Акцепт SHALL сбрасываться правкой данных — суммы, кода или статуса строки, даты +колонки, поля шапки; подтверждение или снятие подтверждения (`confirmed`) акцепт MUST NOT сбрасывать. #### Scenario: Подтверждение ячейки без правки - **WHEN** аналитик подтверждает ячейку с `needs_review = true` без изменения суммы -- **THEN** `is_confirmed = true`, `confirmed_by/at`, `is_modified` не ставится, `needs_review = false`, аудит +- **THEN** `is_confirmed = true`, `confirmed_by/at`, `is_modified` не ставится, `needs_review = false`, аудит; акцепт + проверок, если был, сохраняется -### Requirement: PATCH формы — строки, колонки, подтверждение ячеек -`PATCH /documents/{id}/statement` SHALL принимать помимо `header` и `values` (сумма): -- `values[] {line_id, column_id, confirmed: true}` — подтвердить ячейку без изменения значения; -- `columns[] {column_id, period_date}` — дата колонке (пересчитывает `is_current` и период документа по правилу §9.5 - п. 2); правка `header.report_date` (в том числе `null`) пересчитывает `period_end` колонок с `period_kind = - relative:*` общим кодом (`null` даёт `null`); +#### Scenario: Снятие подтверждения +- **WHEN** аналитик снимает подтверждение с ячейки (`confirmed: false`), которая не покрыта соотношением +- **THEN** `is_confirmed = false`, `needs_review = true` после пересчёта, аудит + +### 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); правка `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}` — подтвердить код. + расшифровкой; `lines[] {line_id, confirmed: true | false}` — подтвердить код или снять подтверждение. Переходы: `unreadable` / `missing` / `detail` → `ok` назначением кода; `unreadable` / `missing` / `ok` → `detail` -отметкой; `ok` → `ok` сменой кода (прежний — в `line_code_original_value`); `ok` с неуверенным кодом — подтверждением. -Назначенный или подтверждённый код: `line_code_confidence = 100`, `line_code_modified = true`, `needs_review` -строки = `false`; у `detail` `parent_row_index` пересчитывается как ближайшая строка `ok` выше по `row_index`. Код — -4–5 цифр (иначе 422 `INVALID_LINE_CODE`), может совпадать с существующим; незнакомый словарю — `info`. Шаблонная строка -с тем же кодом, созданная правкой аналитика с суммами, — 409 `LINE_EXISTS` (сначала очистить её суммы или выбрать -другой код); без сумм — удаляется, её место занимает назначенная. `line_status = ok` без кода — 422 -`LINE_CODE_REQUIRED`; в одном элементе больше одного действия или ни одного — 422 `INVALID_LINE_PATCH`; строка не этого -документа — 422 `UNKNOWN_LINE`; колонка не этого документа — 422 `UNKNOWN_COLUMN`. Правка суммы по `line_code` (для -достроенной строки) при наличии строки `ok` с этим кодом — 422 `UNKNOWN_CELL` (передайте `line_id`). Удаления строк -нет. Строки, созданные правкой: `row_index = null`, `source_ref = null`, `line_status = ok`. Аудит -`RECOGNIZED_DATA_EDIT` с `before/after` по коду, статусу, родителю и подтверждениям. Ограничения состояния — как -прежде (`DOCUMENT_READONLY`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_STATEMENT`). +отметкой; `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`; `line_code_original_value` не меняется; у `detail` `parent_row_index` пересчитывается +как ближайшая строка `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`. Правка суммы по `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» код 1250 +- **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`; §7 и статус пересчитаны + `line_code_original_value` остаётся `null`, `row_index` остаётся 2 (`origin = source`); §7 и статус пересчитаны #### Scenario: Отметка расшифровкой - **WHEN** аналитик отмечает строку `missing` с суммой как расшифровку - **THEN** `line_status = detail`, строка выходит из сумм и не блокирует подтверждение #### Scenario: Код занят шаблонной строкой с суммами -- **WHEN** аналитик назначает строке `unreadable` код 1250, а строка 1250 создана правкой аналитика и имеет сумму -- **THEN** 409 `LINE_EXISTS`, ничего не изменено +- **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: Статус 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`; `detail.lines[] {line_id, row_index, line_code_text}`; строки без сумм не мешают) → `DUPLICATE_FORM` (в файле -уже подтверждена другая форма того же типа и периода; `detail.document_id`) → `CHECKS_NOT_PASSED` (жёсткие нарушения -без акцепта). Между файлами повтор формы решает автозамена §9.5 п. 3. +`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 оставаться только словарём имён (код → наименование по -редакциям); порядок и состав редакций — из копии перечня кодов пакета; `sync-statements --check` сверяет, что все коды -словаря имён есть в перечне. Шаблонные строки достраиваются по словарю имён; код, у которого есть хотя бы одна строка -`ok` из файла, шаблоном MUST NOT достраиваться; строки `unreadable` / `missing` не заменяются шаблонной строкой молча. +`arm_statements`. `api/rules/statement_lines.yaml` SHALL оставаться только словарём имён (код → наименования по +редакциям): у кода может быть несколько имён (синонимы и законные повторы кода в бланке, Р-20), загрузчик повтор кода в +редакции MUST NOT запрещать; сверка наименования — с любым именем кода; шаблонная строка берёт первое имя. Порядок и +состав редакций — из копии перечня кодов пакета; `sync-statements --check` сверяет, что все коды словаря имён есть в +перечне. Шаблонные строки достраиваются по словарю имён (одна на код); код, у которого есть хотя бы одна строка `ok` из +файла, шаблоном MUST NOT достраиваться; строки `unreadable` / `missing` не заменяются шаблонной строкой молча. #### Scenario: Код есть только у строки unreadable - **WHEN** у формы нет строки `ok` с кодом 1250, но есть строка `unreadable` «ИЧ,250» - **THEN** шаблонная строка 1250 достраивается пустой, строка `unreadable` показывается отдельно со статусом +#### Scenario: Два имени у кода +- **WHEN** в редакции `fsbu4` словаря имён код 1520 записан дважды — «Кредиторская задолженность» и «Обязательство по + аренде» +- **THEN** словарь загружается; шаблонная строка 1520 одна, с именем «Кредиторская задолженность» + ### Requirement: Ответ `GET …/statement` по v2.0 -`StatementOut` SHALL отдавать: у документа `form_variant`; у шапки `needs_review_fields` и сырые тексты полей; у -колонок `period_kind`, `label_confidence`; у строк `id`, `origin`, `row_index`, `source_ref`, `line_status`, -`line_code`, `line_code_text`, `line_code_confidence`, `line_name` (из файла), `reference_name` (справочное имя по -коду или `null`), `parent_row_index`, `parent_code`, `needs_review`, `is_confirmed`, `line_code_modified`; у значений -`text` (`source_text`), `needs_review`, `is_confirmed` и прежние поля; `issues[]` — записи `document_issue` с `line_ids`, -`column_id`, `field`; `checks` — нарушения с `codes` и `line_ids`. Порядок `lines` — по коду «как на бумаге» (Р-14): -внутри строк `ok` по возрастанию кода, одинаковые коды по `row_index`; строки `unreadable` / `missing` — после -ближайшей предыдущей по `row_index` строки, размещённой по коду, без такой — в начале; `detail` — после родителя. +`StatementOut` SHALL отдавать: у документа `form_variant`; у шапки `needs_review_fields`, `source` (`header_source`) и +`organization_name_modified`; у колонок `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`. Порядок +`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`, у значений `text = string_value`, - `issues` содержат прежние предупреждения маппинга, экран работает +- **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 и откатываться; заполнение существующих данных: -`row_index` строк — по возрастанию `line_code` внутри формы с 0, `line_status = ok`, `needs_review` строк — `false`, -`needs_review` значений — по прежнему `values_ok`, `source_text = string_value`, `is_confirmed = false`, -`period_kind` — `absolute` при `period_date`, иначе `none`, `document.file_ordinal` — по `(created_at, id)` внутри -файла с 0; `related_group.crm_id` — `null`. Сверка `check_dbml_vs_orm.py` — против `data-model-v5.19.dbml`. +Одна миграция Alembic SHALL приводить схему к DBML v5.19; заполнение существующих данных: `row_index` — `NULL` у строк с +`line_code_original_value IS NULL` (созданных правкой), у остальных — по возрастанию `line_code` внутри формы с 0; +`line_status = ok`, `needs_review` строк — `false`, `needs_review` значений — по прежнему `values_ok`, `source_text = +string_value`, `is_confirmed = false`, `period_kind` — `absolute` при `period_date`, иначе `none`, `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` каждой формы — нарушения §7, посчитанные копией `arm_statements` (требование «Документы без `checks`»). +Откат (`downgrade`) SHALL быть гарантирован на пустой БД и на данных, совместимых с v5.18 (нет двух строк с одним кодом в +форме, строк с `line_code = null`, двух форм одного типа в файле); на несовместимых данных `downgrade` SHALL +останавливаться с ошибкой, называющей причину, и MUST NOT удалять данные молча. Сверка `check_dbml_vs_orm.py` — против +`data-model-v5.19.dbml`. #### Scenario: Цикл миграций - **WHEN** `alembic upgrade head` → `downgrade base` → `upgrade head` на чистой БД - **THEN** все три шага проходят, сверка ORM с DBML v5.19 — без расхождений +#### Scenario: Строка правки до миграции +- **WHEN** до миграции у формы есть строка 1250, созданная правкой (`line_code_original_value = null`), с суммой +- **THEN** после миграции у неё `row_index = null`, `origin = template`; строки источника пронумерованы с 0 + +#### Scenario: Откат на данных v2.0 +- **WHEN** в БД есть форма с двумя строками 2410, выполняется `downgrade` ниже v5.19 +- **THEN** откат останавливается с ошибкой о повторе кода; данные не изменены + ### Requirement: `crm_id` ГСК/ГСЗ `related_group.crm_id` (`varchar`, nullable, уникален) SHALL сохраняться в `POST …/related-groups` и `PATCH /directories/related-groups/{id}` и отдаваться в `RelatedGroupOut` / `RelatedGroupDetail`; повтор — 409 diff --git a/openspec/changes/unified-statement-normalization/specs/arm-web/spec.md b/openspec/changes/unified-statement-normalization/specs/arm-web/spec.md index 3110559..3fec489 100644 --- a/openspec/changes/unified-statement-normalization/specs/arm-web/spec.md +++ b/openspec/changes/unified-statement-normalization/specs/arm-web/spec.md @@ -19,9 +19,11 @@ ### Requirement: Подсветка и предупреждения у места Экран SHALL подсвечивать ячейки только по `needs_review` (в подсказке — `text` и уверенность), строки — по -`needs_review` строки, поля шапки — по `needs_review_fields`. Предупреждения и нарушения (`issues`, `checks`) SHALL -показываться у места: у строк (`line_ids`), ячеек (`line_ids` + `column_id`), полей шапки (`field`) или у документа; -уровень `error` отличается от `info`. Подсветки по `confidence < 100` и `is_recognized = false` напрямую нет. +`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` напрямую нет. #### Scenario: Догадка подтверждена проверкой - **WHEN** значение `is_recognized = false`, `needs_review = false` @@ -31,11 +33,13 @@ В режиме «Редактировать» экран SHALL давать: у `unreadable` / `missing` — поле ввода кода с подсказкой из справочника (код → справочное имя) и пункт меню строки «Это расшифровка»; у `detail` — «Назначить код»; у строки `ok` — «Сделать расшифровкой», смену кода и «Подтвердить код» у строки с `needs_review`; подтверждение ячейки без правки; ввод даты у -колонки без даты (`period_kind = none`) или с предупреждением о дате. «Сохранить» отправляет один `PATCH …/statement` -(`header`, `values`, `columns`, `lines`). Кнопка «Подтвердить данные» при строках `unreadable` / `missing` с суммами +колонки без даты (`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_EXISTS` — у поля кода. +`LINE_CODE_TAKEN` — у поля кода. Кнопка «Подтвердить данные» учитывает строки `unreadable` / `missing` с суммой или +нераспознанным текстом в ячейках. #### Scenario: Назначить код - **WHEN** аналитик вводит 1250 в ячейку кода строки `unreadable` и сохраняет diff --git a/openspec/changes/unified-statement-normalization/specs/quality-checks/spec.md b/openspec/changes/unified-statement-normalization/specs/quality-checks/spec.md new file mode 100644 index 0000000..125c363 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/specs/quality-checks/spec.md @@ -0,0 +1,44 @@ +## MODIFIED Requirements + +### Requirement: `make check` — быстрые проверки без Docker +Каждый сервисный репозиторий SHALL предоставлять цель `make check`, выполняющую из `api/`: `ruff check .`, +`ruff format --check .`, `mypy src` (в сервисе с копией общего кода `arm_statements` — `mypy src arm_statements`, в +`arm-core` — ещё `scripts`), `pytest -m "not integration"` (включая тесты копии `arm_statements/tests/`), тест +идентичности вендоренного контракта с `../docs/contracts` (на окне перехода между `MAJOR` — со схемой того `MAJOR`, +который сервис пишет). Цель SHALL завершаться ненулевым кодом при первой упавшей проверке и MUST NOT требовать +запущенного Docker, PostgreSQL или NATS. Свежесть копии `arm_statements` относительно репозитория `arm-statements` в +`make check` MUST NOT проверяться (соседний репозиторий в `make check` не читается). + +#### Scenario: Файл не отформатирован +- **WHEN** в репозитории есть файл, не отформатированный `ruff format` +- **THEN** `make check` завершается с кодом 1 на шаге `ruff format --check`, следующие шаги не выполняются + +#### Scenario: Чистый репозиторий +- **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). В сервисе с копией общего +кода `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 +- **THEN** шаг `check_dbml_vs_orm` падает с перечнем расхождений, `make check-full` завершается с кодом 1 + +#### Scenario: Миграция без отката +- **WHEN** `downgrade` ревизии не удаляет созданный ею объект +- **THEN** шаг «upgrade → downgrade → upgrade» падает + +#### Scenario: Прямая правка копии общего кода +- **WHEN** в `scan-gateway` поправлен файл `api/arm_statements/steps/…`, а в `arm-statements` такого изменения нет +- **THEN** `statements-check` завершается с кодом 20 и называет файл, `make check-full` красный diff --git a/openspec/changes/unified-statement-normalization/specs/recognition-contract/spec.md b/openspec/changes/unified-statement-normalization/specs/recognition-contract/spec.md index f19e922..ea10b00 100644 --- a/openspec/changes/unified-statement-normalization/specs/recognition-contract/spec.md +++ b/openspec/changes/unified-statement-normalization/specs/recognition-contract/spec.md @@ -30,26 +30,32 @@ v2.0: обязательные поля `schema_version` (`"2.0"`), `job_id`, `f Файл результата 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`. +Предупреждение уровня файла пишется там, где документа нет (потерянный 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?}`; у `osvDocument` все предупреждения — `info`, -`status = done`, когда ведомость разобрана. Остальное в `osvDocument` — как в v1.2 (`extra`, `period_start`, -`period_end`, `is_from_year_start`). +структуры `{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`, `address` и для каждого `<поле>_text` и -`<поле>_confidence`, плюс `needs_review_fields[]`; `columns[]` — `column_index`, `label`, `label_confidence`, +`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`, `line_status` (`ok` | +`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`. `ref` -уникален в документе. +actual}`; `duplicate_line_codes` удалён. `document_type` — `BALANCE` | `INCOME` | `DISCLOSURE` | `OTHER`; у документа +без строк `columns` и `lines` — пустые массивы. `ref` уникален в документе. #### Scenario: Результат валидируется примерами - **WHEN** CI сервиса запускает тесты контракта @@ -64,7 +70,7 @@ actual}`; `duplicate_line_codes` удалён. `document_type` — `BALANCE` | ` - **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` + `INCOME` со строкой `detail` (`line_code = null`, `parent_ref`); `DISCLOSURE` с пустыми `columns`/`lines` и без `checks` #### Scenario: Форма из OCR с сомнительными значениями - **WHEN** scan-gateway разобрал ББ, где одна сумма собрана из грязного текста и покрыта соотношением, а одна строка @@ -87,7 +93,10 @@ actual}`; `duplicate_line_codes` удалён. `document_type` — `BALANCE` | ` Сервис SHALL отвергать запрос с неизвестным `MAJOR` (`error.code = SCHEMA_MISMATCH`); ядро SHALL принимать результат с известным `MAJOR` и любым известным или новым `MINOR`. Запрос `recognition-request` остаётся v1.0; файл результата и конверт — v2.0 (`MAJOR` поднят: `line_code` стал nullable, `metrics.warnings` и `document.warnings` — структуры, удалён -`checks.duplicate_line_codes`, изменён смысл `is_recognized` у OCR). +`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"` @@ -103,21 +112,46 @@ actual}`; `duplicate_line_codes` удалён. `document_type` — `BALANCE` | ` - **THEN** результат не проходит проверку схемы, файл получает `ERROR` с причиной в `metrics.mapping_error`, повтор не выполняется (как для любого результата не по схеме) +### Requirement: Копия контракта в сервисе идентична источнику +Каждый сервис, читающий или пишущий контракт, SHALL хранить копию файлов схем в `api/contracts/` и проверять в `make check` +их байтовую идентичность файлам `docs/contracts/` (`../docs` или путь `ARM_KA_DOCS`; при недоступности — пропуск с предупреждением). +На окне перехода между `MAJOR` тест идентичности SHALL сверять копию со схемой того `MAJOR`, который сервис пишет (или, +у ядра, читает): копия с `$id`, оканчивающимся на `/v1`, — с `docs/contracts/<имя>.v1.schema.json`, иначе — с основным +файлом `docs/contracts/<имя>.schema.json`; ядро держит копии обоих `MAJOR`. Правило теста вводится в `tabular-parser` и +`scan-gateway` до слияния контракта v2.0 в docs, иначе их `make check` упадёт на окне перехода. + +#### 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`) +- **THEN** тест сверяет её с основным файлом docs + ## ADDED Requirements ### Requirement: Переход на контракт v2.0 — «сначала ядро» Ядро SHALL до перехода любого сервиса принимать конверты (включая ack `processing`) и файлы результата и `1.x`, и `2.0`, с двумя мапперами файла результата. При приёме `1.x` статус документа формы SHALL считаться по прежнему правилу -D-6, а поля v2.0 заполняться по умолчанию: `line_status = ok`, `row_index` — по порядку строк в результате, `ref = -null`, `text = string_value`, `needs_review` значения — по прежнему `values_ok` (`is_recognized` не `false` и -`confidence` 100 или пуста), `needs_review` строки — `false`, `period_kind` — `absolute` при `period_date`, иначе `none`, -`form_variant = null`, предупреждения-строки — `warning` `{code: "LEGACY_WARNING", severity: "info", message}`. -Сервисы SHALL переходить на `2.0` только после выкладки ядра, принимающего `2.0`. Маппер `1.x` удаляется после -перехода обоих сервисов отдельным изменением. +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` +(`is_recognized` не `false` и `confidence` 100 или пуста), `needs_review` строки — `false`, `period_kind` — `absolute` при +`period_date`, иначе `none`, `form_variant = null`; строки `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`, статус документа — по D-6, файл не `ERROR` +- **THEN** ядро принимает его маппером `1.x`, обе строки кода — по прежнему правилу «остаётся первая» с + `MAPPING_WARNING`, нарушения §7 — в `document_issue` от ядра, статус документа — по D-6, файл не `ERROR` #### Scenario: Ack нового сервиса - **WHEN** scan-gateway после перехода публикует ack `processing` со `schema_version = "2.0"` diff --git a/openspec/changes/unified-statement-normalization/specs/scan-gateway/spec.md b/openspec/changes/unified-statement-normalization/specs/scan-gateway/spec.md index d66b4d7..b261738 100644 --- a/openspec/changes/unified-statement-normalization/specs/scan-gateway/spec.md +++ b/openspec/changes/unified-statement-normalization/specs/scan-gateway/spec.md @@ -18,9 +18,14 @@ file:<имя json>;table:`. `value` у OCR-ячеек всегда `null`. ### Requirement: Ошибки набора json Ошибочный json набора (`parse_error`, непустой `error.code`, корень не объект) при хотя бы одном успешном соседе SHALL давать предупреждение файла `JSON_FAILED` (`error`, `locator = file:<имя json>`, в `message` — код и сообщение ИС); -все json ошибочны — конверт `error` с кодом первого. Исключение при разборе таблицы — предупреждение `TABLE_PARSE_ERROR` -(`error`) документа-кандидата, остальные таблицы разбираются; ни одной таблицы не разобрано — `error -RECOGNITION_FAILED`. +все 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` diff --git a/openspec/changes/unified-statement-normalization/specs/statement-normalization/spec.md b/openspec/changes/unified-statement-normalization/specs/statement-normalization/spec.md index 463679a..7f63941 100644 --- a/openspec/changes/unified-statement-normalization/specs/statement-normalization/spec.md +++ b/openspec/changes/unified-statement-normalization/specs/statement-normalization/spec.md @@ -5,38 +5,80 @@ своими тестами и конфигом линтеров. Пакет SHALL содержать: типы шага 1 (`FormCandidate`, `Cell`; сериализуемые в json dataclass-ы), шаги 2–8, общие разборы текста (двойники, даты, суммы, коды), словари — проверок §7 `statement_checks.yaml` (полный и упрощённый наборы), периодов `statement_periods.yaml`, заголовков форм и ОКУД, -подписей колонок («Код», «Наименование», исключения), букв-двойников, упорядоченные перечни кодов форм по редакциям -(`66n`, `fsbu4`) и вариантам (`full`, `simplified`) без наименований, диапазоны кодов — и загрузчик словарей. Словаря +подписей колонок («Код», «Наименование», исключения), букв-двойников, единиц измерения `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 лежать копией вместе с тестами и конфигом линтеров, подключённым -к `pytest` и `ruff` хоста как include, и импортироваться по имени `arm_statements`. Копию SHALL раскладывать и -проверять скрипт `sync-statements` репозитория docs (по образцу `sync-web.sh`): `--check` возвращает код 20, если -копия отличается от источника или правилась напрямую; скрипт вызывается из `make check-full` потребителя и MUST NOT -вызываться из `make check` и пайплайна платформы. Маркер коммита источника копии SHALL попадать в результат как -`service.statements_version`. +В 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`). `--check` +SHALL возвращать: `0` — копия равна дереву `arm_statements/` коммита из `VERSION`, и это коммит `--ref`; `10` — копия +равна дереву коммита из `VERSION`, но в `--ref` есть более новый (копия отстала); `20` — копия отличается от дерева +коммита из `VERSION`, `VERSION` нет в истории источника, копия перечня кодов в пакете отличается от +`docs/contracts/statement-code-orders.yaml` или (только у `arm-core`) код словаря имён `api/rules/statement_lines.yaml` +отсутствует в перечне своей редакции; `2` — нет checkout источника или хоста, неизвестный аргумент. Файлы и коды +расхождения SHALL печататься. + +Цель `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` сервиса этого не проверяет +- **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: Код словаря ядра вне перечня +- **WHEN** в `arm-core/api/rules/statement_lines.yaml` редакции `fsbu4` есть код 1120, а в перечне `fsbu4` его нет +- **THEN** `--check` в `arm-core` завершается с кодом 20 и называет код и редакцию #### Scenario: Версия общего кода в результате -- **WHEN** сервис собирает результат копией пакета, разложенной из коммита `abc1234` репозитория `arm-statements` +- **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`, `address` — каждое `Cell` -или `null`); `rows` — плотная сетка строк × ячеек, все страницы и листы формы уже в одной сетке, пустая позиция — +(поля `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 всегда 100); `ref` — позиция в источнике (OCR — `row:`, Excel — `<лист>!` по правилу Excel: +имя листа в одинарных кавычках, если в нём есть что-то кроме букв, цифр и `_` или оно начинается с цифры, апостроф внутри +имени удваивается). `type_hint` SHALL вычисляться одним правилом у обоих адаптеров: ОКУД по словарю (0710001 → `BALANCE`, 0710002 → `INCOME`, прочие 0710xxx → `DISCLOSURE`) → иначе заголовок по словарю заголовков (у OCR заголовок — `label` таблицы @@ -47,6 +89,10 @@ json dataclass-ы), шаги 2–8, общие разборы текста (дв - **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"` @@ -58,13 +104,18 @@ json dataclass-ы), шаги 2–8, общие разборы текста (дв ### Requirement: Шаг 1 — шапка кандидата У каждого кандидата SHALL быть своя шапка. Текст каждого поля разбирается общим кодом по `Cell.text`: ИНН — только цифры, без замены двойников, длина 10 или 12, иначе `null`; дата отчёта — словарём периодов (тот же, что для подписей -колонок, шаг 2); единицы — словарём единиц. Сырой текст и уверенность каждого поля SHALL доезжать до контракта -(`<поле>_text`, `<поле>_confidence`). +колонок, шаг 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 — только текстовые ячейки). Буквы- @@ -157,17 +208,34 @@ json dataclass-ы), шаги 2–8, общие разборы текста (дв ### Requirement: Шаг 2 — строка заголовка, нумерация и роли строк Строка заголовка SHALL быть последней над первой строкой с кодом, где в столбце кодов стоит подпись кода; иначе последней над первой строкой с кодом с двумя и более подписями-периодами правее кода (или одной, не голым годом); -двухстрочный заголовок склеивается. Строка нумерации — строка, где не меньше трёх непустых ячеек, каждая — целое число, -и числа идут подряд, начиная с 1, слева направо — SHALL получать роль `numbering` и в данные не попадать. Роль +двухстрочный заголовок склеивается. Строка нумерации SHALL получать роль `numbering` и в данные не попадать; строка — +нумерация, если в ней не меньше трёх непустых ячеек, каждая после замены букв-двойников — целое число, и числа слева +направо возрастают на 1, начинаются с 1 или 2 и допускают не больше одного пропуска. Роль `title` — только строки выше найденной строки заголовка; в сегменте без заголовка роли `title` нет. Заголовка нет ни у одного сегмента документа → колонки значений первого сегмента — столбцы правее кода, где в строках с кодами числа, -`label = ""`, `period_end = null`, `period_kind = none`, одно предупреждение `HEADER_ROW_NOT_FOUND` (`error`). +`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` @@ -175,7 +243,7 @@ json dataclass-ы), шаги 2–8, общие разборы текста (дв ### Requirement: Шаг 2 — подпись и дата колонки Подпись колонки SHALL разбираться одним словарём периодов `statement_periods.yaml`, объединяющим словарь tabular-parser и шаблоны `dates.py` scan-gateway; строки `test_dates.py::test_parses_real_forms` scan-gateway — -обязательные тесты словаря. Результат — `period_end` и `period_kind`: +обязательные тесты словаря. Результат — `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 декабря @@ -185,11 +253,11 @@ tabular-parser и шаблоны `dates.py` scan-gateway; строки `test_dat - подписи нет → `none`. Эффективная дата документа SHALL быть датой отчёта из шапки, иначе самой поздней абсолютной датой колонок; нет ни -той, ни другой → у относительных колонок `period_end = null`, `period_kind` остаётся относительным. +той, ни другой → у относительных колонок `period_date = null`, `period_kind` остаётся относительным. Подпись из словаря исключений («Изменение», «Отклонение», «%», «Темп», «Доля», «Примечание») SHALL пропускаться с предупреждением `COLUMN_SKIPPED` (`info`). Подпись, не разобранная ни как период, ни как исключение, при числах в -столбце в строках с кодами SHALL оставлять колонку с подписью как есть, `period_end = null`, `period_kind = none` и +столбце в строках с кодами SHALL оставлять колонку с подписью как есть, `period_date = null`, `period_kind = none` и предупреждением `COLUMN_WITHOUT_DATE` (`error`, `column_index`); без чисел — пропуск. Подпись OCR с уверенностью ниже 100, разобранная в дату, подтверждается, если дата входит в ожидаемые: эффективная дата, она же минус один и минус два года, 31 декабря предыдущего и предшествующего ему года; иначе предупреждение `COLUMN_DATE_UNCERTAIN` (`error`, @@ -198,18 +266,33 @@ tabular-parser и шаблоны `dates.py` scan-gateway; строки `test_dat #### Scenario: Бланк 66н без впечатанных дат - **WHEN** подписи колонок ББ — «На отчетную дату», «На 31 декабря предыдущего года», «На 31 декабря года, предшествующего предыдущему», дата отчёта шапки 30.09.2025 -- **THEN** `period_end` колонок 2025-09-30, 2024-12-31, 2023-12-31, `period_kind` — `relative:current`, +- **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** подпись колонки — `"| | г"` с уверенностью 17, а в колонке суммы -- **THEN** колонка остаётся, `period_end = null`, `period_kind = none`, предупреждение `COLUMN_WITHOUT_DATE` уровня +- **THEN** колонка остаётся, `period_date = null`, `period_kind = none`, предупреждение `COLUMN_WITHOUT_DATE` уровня `error` с `column_index`, документ `needs_check` #### 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 быть столбцом с подписью, начинающейся с «наименование» (после нормализации), в строке заголовка; иначе — столбцом левее кода с наибольшим числом текстов, кроме столбца «Пояснения». Наименование строки — @@ -221,10 +304,11 @@ tabular-parser и шаблоны `dates.py` scan-gateway; строки `test_dat - **THEN** наименование берётся из столбца текстов статей ### Requirement: Шаг 3 — строки данных и их статусы -Шаг 3 SHALL выдавать `RawLine` на каждую строку сетки с ролью `data`, у которой есть содержимое: код, число или -непустой текст в колонке значений с уверенностью ниже 100. У строки — `row_index` (порядковый номер в документе с 0, +Шаг 3 SHALL выдавать `RawLine` на каждую строку сетки с ролью `data`, у которой есть содержимое: в ячейке кода есть +хотя бы одна цифра (разобранный код или нет), или хотя бы одна ячейка значений даёт сумму (`amount` не `null`), или +непуста с уверенностью ниже 100. Строка только с текстом наименования — не строка данных. У строки — `row_index` (порядковый номер в документе с 0, по порядку сетки после разлепления), `ref` (уникален в документе: OCR `row:`, разлепленные -`row:#` с k от 1; Excel — адрес ячейки столбца кодов строки `<лист>!`, без столбца кодов — первой непустой +`row:#` с k от 1; Excel — адрес ячейки столбца кодов строки `<лист>!` по правилу шага 1, без столбца кодов — первой непустой ячейки строки), ячейка кода, `line_status`, ячейка наименования, ячейки значений по колонкам сегмента, `parent_ref`. Статусы: - `ok` — код разобран; @@ -241,6 +325,11 @@ tabular-parser и шаблоны `dates.py` scan-gateway; строки `test_dat - **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 @@ -304,7 +393,7 @@ k-я из непустых частей, если их тоже N, иначе ц - **WHEN** текст `"4,200"` - **THEN** у `ocr` `amount = 4200`; у `excel` `amount = 4.20` -#### Scenario: Длинное тире (OQ-148) +#### Scenario: Длинное тире (OQ-148, закрыт решением владельца 18.10.2026) - **WHEN** ячейка OCR — `"—"` с уверенностью 100 - **THEN** `amount = null`, `string_value = null`, `is_recognized = true` @@ -324,8 +413,8 @@ line_name, line_name_confidence, parent_ref, needs_review, values}`: `line_code` `null` у `unreadable` / `missing` / `detail`; `line_code_text` — сырой текст ячейки кода; `line_code_confidence` — от источника, у кода с заменой двойника — не выше 99; `line_name` — распознанный текст наименования как есть, с уверенностью источника. Сервисы MUST NOT подставлять наименование из справочника и MUST NOT срезать уверенность кода -по справочнику (Р-3). Редакция (`66n` / `fsbu4`) в сервисах выбирается по эффективной дате (`fsbu4` при дате после -2024-12-31) только для перечня кодов; без даты проверяются обе редакции. +по справочнику (Р-3). Редакция (`66n` / `fsbu4`) в сервисах нужна только для перечня кодов (шаг 8): проверяются обе +редакции, эффективная дата (`fsbu4` при дате после 2024-12-31) разрешает только равенство. #### Scenario: Наименование из OCR (AURORA-1522) - **WHEN** у строки 1230 OCR-наименование «Дебиторская задолжен-ность» @@ -335,11 +424,13 @@ line_name, line_name_confidence, parent_ref, needs_review, values}`: `line_code` Шаг 6 SHALL собирать `StatementDocument {document_type, form_variant, locator, header, columns, lines}`. Тип — большинство кодов строк `ok` в отрезке ББ → `BALANCE`, ОФР → `INCOME`, прочих форм и пояснений → `DISCLOSURE`; совпало с `type_hint` или `type_hint = null` — тип по кодам; противоречит — тип по кодам и предупреждение -`TYPE_CONFLICT` (`error`); кодов нет — `type_hint`. Без столбца кодов: `type_hint` есть → документ без строк -(`DISCLOSURE` — `done`, иначе `needs_check` с `CODE_COLUMN_NOT_FOUND` уровня `error`); `type_hint` нет → у `ocr` -таблица ИС с содержимым — предупреждение файла `TABLE_LOST` (`error`), у `excel` — лист «прочее», `SHEET_OTHER` -(`info`). `DISCLOSURE` по `type_hint` — шаги 2–7 не выполняются; `DISCLOSURE` по кодам — строки и колонки -отбрасываются, проверки не считаются, документ `done`. +`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` таблицы), у `excel` — лист «прочее», предупреждение **файла** `SHEET_OTHER` (`info`, `locator = sheet:<имя>`); +документа в обоих случаях нет. У `DISCLOSURE` (по `type_hint` без кодов форм ББ/ОФР или по кодам прочих форм) шаги 3–7 +не выполняются: `columns` и `lines` — пустые массивы, `checks` нет, документ `done`. `form_variant` SHALL определяться только по структуре кодов: ББ — есть 1600 и нет ни 1100, ни 1200 → `simplified`; ОФР — есть 2400 или 2110 и нет ни 2100, ни 2200, ни 2300 → `simplified`; иначе `full`; у `DISCLOSURE` — `null`. ОКУД @@ -350,6 +441,16 @@ line_name, line_name_confidence, parent_ref, needs_review, values}`: `line_code` - **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` @@ -367,7 +468,9 @@ actual}`, где `codes` — коды цели и членов соотноше соотношения — в `soft_relations_failed`; - члены `abs_terms` (2120, 2210, 2220, 2330, 2350, 2410) сравниваются и как записаны, и по модулю; такое соотношение подтверждает величину, но не знак (шаг 8); -- набор соотношений — по `form_variant` (полный или упрощённый); +- набор соотношений — по `form_variant` (полный или упрощённый); `form_variant = null` — полный. В упрощённом наборе + соотношения ББ жёсткие, а `2400 = 2110 − 2120 − 2330 + 2340 − 2350 − 2410` ОФР — мягкое, как `2400` полного набора + (знак 2410, OQ-12); - родитель пятизначной подстроки — ближайшая четырёхзначная строка выше с теми же первыми тремя цифрами, иначе код из первых четырёх цифр; «сумма подстрок = родитель» (мягкое) — только если хотя бы одна подстрока в колонке непуста. @@ -387,6 +490,11 @@ actual}`, где `codes` — коды цели и членов соотноше - **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`, на статус и подсветку не влияет @@ -395,8 +503,10 @@ actual}`, где `codes` — коды цели и членов соотноше Сервис SHALL вычислять `needs_review` у каждого значения: - значение **нуждается в покрытии**, если `confidence < 100` или `is_recognized = false`; чистые значения в покрытии не нуждаются; -- значение **покрыто**, если в его колонке есть жёсткое соотношение с участием его кода, которое посчитано и сошлось - (в пределах допуска или округления), и `amount` значения не `null`; +- значение **покрыто**, если `amount` значения не `null` и в его колонке есть соотношение с участием его кода, которое + посчитано и сошлось: жёсткое — в пределах допуска или округления; мягкое — только в пределах допуска ±1, без + «округления» (Р-21; так покрываются 2400, 2410, 2411, 2412, 2460, 25xx и пятизначные подстроки — у них жёстких + соотношений нет); - **знак**: у члена `abs_terms`, нуждающегося в покрытии, знак подтверждён, если форма записи знака (скобки, минус, без знака — по `text`) совпадает с формой у большинства членов `abs_terms` этой колонки с непустым `amount`, `is_recognized = true` и без правки аналитика; не совпадает или сравнивать не с чем — знак не подтверждён; @@ -418,19 +528,32 @@ actual}`, где `codes` — коды цели и членов соотноше - **THEN** `needs_review = true` у 2120 независимо от соотношения #### Scenario: Справочная строка без проверки -- **WHEN** значение 2510 с уверенностью 80 (строк 25xx нет в жёстких соотношениях) +- **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). Четырёхзначные коды строк `ok` документа образуют -последовательность; для каждой проверяемой редакции перечня (по эффективной дате; без даты — обе) берётся наибольшая -подпоследовательность, согласованная с порядком бланка (итоги после слагаемых); редакция — с более длинной -подпоследовательностью, при равенстве — по эффективной дате; уверенные коды участвуют как подтверждённые якоря; -при нескольких наибольших подпоследовательностях подтверждены только коды, общие для всех. Пятизначный код подтверждён, -если подтверждён его родитель и подстрока стоит после него. Повтор кода, у которого хотя бы одна копия неуверенна, — -`needs_review = true` у неуверенной копии (Р-16). Неподтверждённый неуверенный код — `StatementLine.needs_review = true`. -У Excel и чистых OCR-кодов `needs_review` строки по структуре MUST NOT ставиться. +неуверенным кодам применяется структурная проверка (Р-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); уверенные коды участвуют как подтверждённые якоря; при нескольких +наибольших подпоследовательностях подтверждены только коды, общие для всех. Пятизначный код подтверждён, если +подтверждён его родитель и подстрока стоит после него. Повтор кода, которого нет в перечне редакции (или копий больше, +чем в перечне), у которого хотя бы одна копия неуверенна, — `needs_review = true` у неуверенной копии (Р-16); повтор из +перечня подтверждается структурой, как любой код. Неподтверждённый неуверенный код — `StatementLine.needs_review = +true`. У Excel и чистых OCR-кодов `needs_review` строки по структуре MUST NOT ставиться. #### Scenario: Неуверенный код на своём месте - **WHEN** код 1230 с уверенностью 50 стоит между 1220 и 1240 (уверенными) @@ -440,6 +563,19 @@ actual}`, где `codes` — коды цели и членов соотноше - **WHEN** порядок кодов 1210, 1230 (уверенность 50), 1220 (уверенность 50), 1240 - **THEN** у обеих неуверенных строк `needs_review = true`, документ `needs_check` +#### 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` ядра @@ -447,8 +583,11 @@ actual}`, где `codes` — коды цели и членов соотноше ### Requirement: Шаг 8 — поля шапки Дата отчёта с уверенностью ниже 100 SHALL подтверждаться совпадением с самой поздней абсолютной датой колонок; иначе поле `report_date` попадает в `needs_review_fields` и выдаётся `REPORT_DATE_UNCERTAIN` (`error`, `field = -report_date`). ИНН и организация с уверенностью ниже 100 — предупреждения `INN_UNCERTAIN` / `ORGANIZATION_UNCERTAIN` -(`info`, с `field`), в `needs_review_fields` не попадают; единицы и прочие поля на статус не влияют. +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 @@ -458,16 +597,25 @@ report_date`). ИНН и организация с уверенностью ни - **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` строк; `column_index` — колонка; `field` — поле шапки. Место в файле — -`locator` документа, а не текст. Предупреждения уровня файла (`metrics.warnings`) дополнительно имеют `locator?` -(json набора ИС или лист). Уровни SHALL быть такими: +`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_PARSE_ERROR` | error (файл) | исключение при разборе таблицы ИС (`BACKEND_PARSING_ERROR`); документа нет | | `TABLE_LOST` | error (файл) | таблица ИС с содержимым без колонки кодов и без `type_hint` | | `CODE_COLUMN_NOT_FOUND` | error | у формы с `type_hint` `BALANCE`/`INCOME` нет столбца кодов | | `SEGMENT_WITHOUT_CODES` | error | сегмент с содержимым без столбца кодов (в том числе лист-продолжение) | @@ -491,12 +639,17 @@ report_date`). ИНН и организация с уверенностью ни | `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` и кодов | +| `SHEET_OTHER` | info (файл) | лист Excel «прочее» без `type_hint` и кодов; документа нет | | `ROW_SPLIT` | 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` @@ -511,8 +664,13 @@ report_date`). ИНН и организация с уверенностью ни период определён (дата отчёта или самая поздняя абсолютная дата колонок) ∧ все строки `ok` или `detail` ∧ нет значений, строк и полей шапки с `needs_review` ∧ нет жёстких нарушений §7 вне допуска и округления ∧ нет предупреждений документа уровня `error`; иначе `needs_check`. `DISCLOSURE` — `done` без условий о раскладке, строках и -проверках. Функция SHALL принимать на вход и подтверждения и акцепт аналитика (у ядра после правок): правленое или -подтверждённое значение и строка не имеют `needs_review`, акцепт снимает жёсткие нарушения. Статус конверта: `done` ⇔ +проверках. Вход функции SHALL быть одинаковым у сервиса и ядра и не зависеть от источника: `document_type`, +`layout_found` (сервис — столбец кодов найден хотя бы у одного сегмента; ядро — у формы есть строка из источника, +`row_index` не `null`), `has_organization_or_inn`, `period_known`, строки `{line_status, needs_review}`, значения +`{needs_review}`, число жёстких нарушений вне допуска и округления, `accepted`, уровни предупреждений документа (у ядра — +записи `document_issue` вида `warning`). `needs_review` на входе уже учитывает правки и подтверждения аналитика +(правленое или подтверждённое значение и строка — `false`); `accepted` — жёсткие нарушения не учитываются. Статус ОСВ — +`done`, когда контрольные суммы §1.4 листа сошлись. Статус конверта: `done` ⇔ все документы `done` ∧ нет предупреждений файла уровня `error`; иначе `needs_check`; документов нет — `error` (`RECOGNITION_FAILED` у scan-gateway, `INVALID_DOCUMENT` у tabular-parser). Квартал периода, сверка ИНН и наименований со справочником в `done` MUST NOT входить. @@ -525,6 +683,11 @@ report_date`). ИНН и организация с уверенностью ни - **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` diff --git a/openspec/changes/unified-statement-normalization/specs/tabular-recognition-service/spec.md b/openspec/changes/unified-statement-normalization/specs/tabular-recognition-service/spec.md index 77ca9ef..ad8ad43 100644 --- a/openspec/changes/unified-statement-normalization/specs/tabular-recognition-service/spec.md +++ b/openspec/changes/unified-statement-normalization/specs/tabular-recognition-service/spec.md @@ -7,10 +7,13 @@ tabular-parser SHALL оставить у себя чтение книги (ли выполняться копией пакета `arm_statements`. Адаптер SHALL: выделять блоки форм на листе по заголовку или ОКУД (без кодов между ними блоки сливаются, заголовок после кодов — новая форма); собирать шапку каждого блока в его границах (организация, ИНН без ИНН аудитора, ОКПО, ОКОПФ, ОКФС, единицы, дата, адрес) как `Cell` с `confidence = 100`; -заполнять у каждой ячейки `text` по числовому формату (tp#42) и `value`, `ref = <лист>!`; дописывать +заполнять у каждой ячейки `text` по числовому формату (tp#42) и `value`, `ref = <лист>!` (имя листа в одинарных +кавычках по правилу Excel, если в нём есть что-то кроме букв, цифр и `_`); дописывать листы-продолжения в сетку открытой формы до раскладки (маркер «Форма … с.2» или позиция листа; проверки типа, диапазона кодов и, при трёх и более кодах, числа колонок — как `join_blocker`), записывая границы листов в -`segment_breaks`. Несовпадение формата с `file_type` — предупреждение файла `FILE_TYPE_MISMATCH` (`info`). +`segment_breaks`. Несовпадение формата с `file_type` — предупреждение файла `FILE_TYPE_MISMATCH` (`info`). Лист без +признаков ОСВ и формы, без `type_hint` и кодов — предупреждение файла `SHEET_OTHER` (`info`, `locator = sheet:<имя>`), +документа нет. #### Scenario: Книга ББ + ОФР - **WHEN** книга содержит лист «Баланс» (ОКУД 0710001), лист «Баланс (2)» с маркером «Форма 0710001 с.2» и лист «ОФР» @@ -19,7 +22,7 @@ tabular-parser SHALL оставить у себя чтение книги (ли #### Scenario: Ссылки ячеек - **WHEN** код строки стоит в ячейке C27 листа «Баланс» -- **THEN** `ref` строки — `Баланс!C27` +- **THEN** `ref` строки — `Баланс!C27`; у листа «Бухгалтерский баланс. Лист 1» — `'Бухгалтерский баланс. Лист 1'!C27` ### Requirement: Строки без кода и неизвестные колонки в Excel tabular-parser SHALL отдавать строки без кода с содержимым со статусами `detail` / `missing` / `unreadable` (Р-8; ранее @@ -30,9 +33,23 @@ tabular-parser SHALL отдавать строки без кода с содер - **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`, когда ведомость разобрана, иначе `needs_check` — по -прежним условиям §1.6 листа) и `warnings[]` — прежние предупреждения листа структурами с `severity = info` и кодом с +У `osvDocument` tabular-parser SHALL выдавать `status` (`done`, когда контрольные суммы §1.4 листа сошлись, иначе +`needs_check` — прежние условия §1.6, применённые к листу) и `warnings[]` — прежние предупреждения листа структурами с `severity = info` и кодом с префиксом `OSV_`; строк вида `sheet=<имя> <текст>` в `metrics.warnings` больше нет. Остальной разбор ОСВ не меняется. #### Scenario: Предупреждение ОСВ