From 42f0dc5ca777be2a54f53c979b92d5d6aeb01d66 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 19:54:40 +0400 Subject: [PATCH 01/13] =?UTF-8?q?openspec(core-b):=20change=20=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B9=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8,=20=D1=87=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20=D0=91=20(arm-ka-docs#116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../unified-normalization-core-b/design.md | 35 ++++++++++ .../unified-normalization-core-b/proposal.md | 48 +++++++++++++ .../specs/documents-api/spec.md | 67 +++++++++++++++++++ .../specs/osv-result-mapping/spec.md | 57 ++++++++++++++++ .../specs/recognition-job-exchange/spec.md | 32 +++++++++ .../specs/statement-checks/spec.md | 52 ++++++++++++++ .../unified-normalization-core-b/tasks.md | 9 +++ 7 files changed, 300 insertions(+) create mode 100644 openspec/changes/unified-normalization-core-b/design.md create mode 100644 openspec/changes/unified-normalization-core-b/proposal.md create mode 100644 openspec/changes/unified-normalization-core-b/specs/documents-api/spec.md create mode 100644 openspec/changes/unified-normalization-core-b/specs/osv-result-mapping/spec.md create mode 100644 openspec/changes/unified-normalization-core-b/specs/recognition-job-exchange/spec.md create mode 100644 openspec/changes/unified-normalization-core-b/specs/statement-checks/spec.md create mode 100644 openspec/changes/unified-normalization-core-b/tasks.md diff --git a/openspec/changes/unified-normalization-core-b/design.md b/openspec/changes/unified-normalization-core-b/design.md new file mode 100644 index 0000000..270ad02 --- /dev/null +++ b/openspec/changes/unified-normalization-core-b/design.md @@ -0,0 +1,35 @@ +## Context + +Сквозной дизайн — change docs `unified-statement-normalization` (`design.md`, `specs/arm-core/spec.md`, план +`plans/2026-10-18-plan-3-arm-core.md`). Здесь — решения уровня ядра для части Б; решения части А — в +`openspec/changes/unified-normalization-core-a/design.md` (D-1…, A1-1…A5-6, P3-1…P3-24). Схема данных — +`../docs/model/data-model-v5.19.dbml`; код части А уже в `main`. + +## Goals / Non-Goals + +Goals и Non-goals — см. `proposal.md`. + +## Decisions + +- **D-1. Единственная точка входа в пакет.** `import arm_statements` — только `api/src/services/statements_engine.py` + (протокол `StatementsEngine` и его реализация) и `api/src/domain/stored_form.py`; остальной код ядра работает с + протоколом. +- **D-2. §7 хранится только как `document_issue`.** `GET …/statement` и `POST …/confirm` читают нарушения из записей; + временный расчёт на лету (P3-14 части А) удаляется в B6. +- **D-3. Архивация вместе с частью А (R-7 части А).** `unified-normalization-core-b` архивируется только вместе с + `unified-normalization-core-a` и только после выкладки обеих частей; сначала архивируется А, затем Б (дельты Б + MODIFIED опираются на требования, которые А добавляет). +- **D-4. Дельта «копия словаря §7 ядра» оформлена как MODIFIED.** Требования с таким именем в `openspec/specs/` нет: + упоминание копии живёт в «Соотношения §7 по колонкам»; поэтому удаление выражено правкой этого требования + (и пунктом «Словари ядра» в `documents-api`), а не `REMOVED`. + +## Decisions made without discussion + +Решения, принятые без обсуждения с человеком на этапе плана и исполнения части Б (маркеры `P3-n` — из плана 3, +`R-n` — рулинги контроллера, `Р-n` — решения владельца). Таблица дополняется задачами B1–B6. + +| Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| 2026-10-18 | **R-1 (Р-40).** `CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` пишутся только для кодов из ровно четырёх цифр; пятизначные подстроки Excel не проверяются. **P3-7 плана в этой части заменён** (запись на код документа сохраняется, пятизначные коды — нет) | решение владельца 18.10.2026 (Р-40) перед исполнением Б; закрывает O-3 (поток `info` у пятизначных) | пропущенный `info` у нестандартного пятизначного кода; восстановление — снять условие длины кода в маппере 2.0 и пересчёте | +| 2026-10-18 | **R-2 (Р-34).** Подтверждение кода строки — флаг `is_confirmed`; `line_code_confidence` и `line_code_modified` не меняются (P3-16 части А) | решение владельца 18.10.2026; в пересчёте Б правило `is_confirmed ⇒ needs_review строки = false` берётся из P3-16 | нет — поведение уже реализовано в части А | +| 2026-10-18 | **R-3.** Удаление копии словаря §7 в дельте оформлено как MODIFIED «Соотношения §7 по колонкам», а не REMOVED (см. D-4); «Ответ `GET …/statement`» и порядок 409 оформлены новыми ADDED-требованиями, так как в `openspec/specs/` и дельтах части А одноимённых нет | `openspec archive` требует, чтобы имя REMOVED/MODIFIED существовало; иначе архив упадёт | при архивации — правка имён заголовков дельт | diff --git a/openspec/changes/unified-normalization-core-b/proposal.md b/openspec/changes/unified-normalization-core-b/proposal.md new file mode 100644 index 0000000..c2e4992 --- /dev/null +++ b/openspec/changes/unified-normalization-core-b/proposal.md @@ -0,0 +1,48 @@ +## Why + +Единая нормализация ББ/ОФР (RSHB/arm-ka-docs#116, change docs `unified-statement-normalization`, DBML v5.19): спека +`arm-core` разбита на две части поставки. Часть А (`unified-normalization-core-a`, RSHB/arm-core#126) уже в `main` и не +зависит от пакета `arm_statements`. Этот change — **часть Б** (RSHB/arm-core#131): переход ядра на пакет +`arm_statements` (копия `api/arm_statements/`, раскладывается `sync-statements`), мапперы результата `2.0`, удаление +временного кода и копии словаря §7 ядра. + +## What Changes + +Колонка «Б» таблицы покрытия спеки `arm-core` (план 3): + +- **B1** — адаптер пакета `StatementsEngine`, вход пересчёта из БД (`StoredForm`). +- **B2** — пересчёт после правки точкой входа пакета; §7, `checks` ответа и `CHECKS_NOT_PASSED` — только из + `document_issue`; относительные даты колонок; сценарий «после PATCH … `RELATION_FAILED`»: `documents-api`. +- **B3** — §7 и вариант формы при приёме `1.x` копией пакета (Р-30): `osv-result-mapping`. +- **B4** — маппер `2.0` для форм: хранение как пришло, `document_issue` (`service` и `core`), условие `RECOGNIZED` + (страховка — `engine.contract_document_status`): `osv-result-mapping`. +- **B5** — маппер `2.0` для ОСВ (Р-28), выбор маппера по MAJOR, приём файла результата: `osv-result-mapping`, + `recognition-job-exchange`. +- **B6** — удаление копии словаря §7 ядра (`api/rules/statement_checks.yaml`, `run_checks`, `load_check_rules`), + редакции — из перечня кодов пакета: `statement-checks`, `documents-api`. + +**Архивация (R-7 части А).** Change `unified-normalization-core-b` архивируется только вместе с +`unified-normalization-core-a` и только после выкладки обеих частей: дельты части А содержат поведение части Б, дельты +этой части уточняют его (порядок архивации — сначала А, затем Б). + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `osv-result-mapping`: маппер `2.0` для форм и ОСВ, §7 при приёме `1.x` копией пакета. +- `documents-api`: пересчёт, `CHECKS_NOT_PASSED` и `checks` из `document_issue`, словари без копии §7. +- `statement-checks`: §7 только копией пакета; копии словаря в ядре нет. +- `recognition-job-exchange`: выбор маппера результата по MAJOR. + +## Impact + +`api/src/domain/{stored_form,statement_mapping_v2,statement_checks,statement_lines}.py`, +`api/src/services/{statements_engine,statement_recompute,statements,documents,result_mapping}.py`, +`api/src/repositories/statements.py`, `api/src/schemas/statements.py`, `api/rules/statement_checks.yaml` (удаляется), +`CLAUDE.md`, `README.md`. `import arm_statements` — только в `statements_engine.py` и `stored_form.py`. + +## Non-goals + +Схема v5.19 и миграция (часть А), фронт (arm-web), копия `api/arm_statements/` (раскладывается `sync-statements`), +docs-worktree. diff --git a/openspec/changes/unified-normalization-core-b/specs/documents-api/spec.md b/openspec/changes/unified-normalization-core-b/specs/documents-api/spec.md new file mode 100644 index 0000000..f50334f --- /dev/null +++ b/openspec/changes/unified-normalization-core-b/specs/documents-api/spec.md @@ -0,0 +1,67 @@ +## MODIFIED Requirements + +### Requirement: Пересчёт после правки +После каждого PATCH формы ядро SHALL пересчитывать `checks`, `needs_review`, `form_variant`, родителей строк `detail` +и статус **точкой входа пакета `arm_statements` для пересчёта** по сохранённым данным (конвенция знака — по +`source_text`); временного пересчёта ядра MUST NOT быть. Правила: `is_modified` или `is_confirmed` значения ⇒ +`needs_review = false`; `line_code_modified` или `is_confirmed` строки ⇒ `needs_review` строки = `false`; значения строк +`detail` — `needs_review = false`; акцепт ⇒ жёсткие нарушения не учитываются; правленые ячейки в голосование знака не +входят. Результат пишется: `needs_review` строк и значений, `form_variant`, `parent_row_index` строк `detail`, записи +`document_issue` видов `relation` / `soft` (заменяются целиком), статус. Относительные даты колонок +(`period_kind`, `statement-normalization`) пересчитываются от даты отчёта после правки `header.report_date` и +`columns[].period_date`. Отличие `statements_version` задания от версии копии ядра — `document_issue` +`STATEMENTS_VERSION_MISMATCH` (`info`); у задания без версии (`1.x`) запись не пишется. Записи `core` +`CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION` (только четырёхзначные коды), `NAME_MISMATCH` и +`STATEMENTS_VERSION_MISMATCH` пересчитываются заменой; `NAME_MISMATCH` не ставится строке с назначенным аналитиком кодом; +`MAPPING_WARNING`, `RESULT_STATUS_INCONSISTENT`, `LEGACY_WARNING` сохраняются. Один PATCH — один пересчёт и одна запись +аудита `RECOGNIZED_DATA_EDIT`. + +#### Scenario: Правка исправляет нарушение +- **WHEN** форма принята с `RELATION_FAILED` по `1200 = 1210 + … + 1260`, аналитик правит сумму 1230 так, что + соотношение сходится +- **THEN** запись `relation` по 1200 заменена (её больше нет), статус пересчитан точкой входа пакета + +#### Scenario: После PATCH остаётся RELATION_FAILED +- **WHEN** аналитик правит сумму, но соотношение всё ещё не сходится +- **THEN** запись `RELATION_FAILED` пересоздана с новыми `expected` / `actual`; `POST …/confirm` без акцепта — 409 + `CHECKS_NOT_PASSED` + +### Requirement: Словари ядра и достройка шаблонных строк +Копия словаря §7 ядра (`api/rules/statement_checks.yaml`) SHALL быть удалена: проверки ядро считает копией пакета +`arm_statements`. `api/rules/statement_lines.yaml` SHALL оставаться только словарём имён (код → наименования по +редакциям; у кода может быть несколько имён, Р-20); порядок и состав редакций — из копии перечня кодов пакета +(`code_orders.editions`); код словаря имён, которого нет в перечне своей редакции, — ошибка проверки. В редакции +`fsbu4` словарь дополнен вторыми именами законных повторов 1220, 1520, 2410 и кодами печатной формы 1С 1105, 1130, 1140, +1530, 2460. Шаблонные строки достраиваются по словарю имён (одна на код, первое имя); код, у которого есть хотя бы одна +строка `ok` из файла, шаблоном MUST NOT достраиваться; строки `unreadable` / `missing` не заменяются шаблонной молча. + +#### Scenario: Код словаря вне перечня +- **WHEN** в `statement_lines.yaml` у редакции есть код, которого нет в перечне этой редакции копии пакета +- **THEN** проверка согласованности словаря с перечнем падает + +#### Scenario: Код есть только у строки unreadable +- **WHEN** у формы нет строки `ok` с кодом 1250, но есть строка `unreadable` «ИЧ,250» +- **THEN** шаблонная строка 1250 достраивается пустой, строка `unreadable` показывается отдельно со статусом + +## ADDED Requirements + +### Requirement: `CHECKS_NOT_PASSED` из `document_issue` +`POST /documents/{id}/confirm` для формы ББ/ОФР SHALL проверять 409 в порядке (требование «Подтверждение документа»): `FILE_SUPERSEDED` → `ALREADY_CONFIRMED` → +`DOCUMENT_NOT_CONFIRMABLE` → `PERIOD_REQUIRED` → `LINES_UNRESOLVED` (есть строки `unreadable` / `missing`, у которых хотя +бы в одной ячейке `amount` или `string_value` не `null`; `detail.lines[] {line_id, row_index, line_code_text}`) → +`DUPLICATE_FORM` (в файле уже подтверждена другая форма того же типа, `period_year` и `period_quarter`; +`detail.document_id`) → `CHECKS_NOT_PASSED`. Нарушения для `CHECKS_NOT_PASSED` SHALL читаться **только из записей +`document_issue` вида `relation`** без акцепта; пересчёта §7 в запросе подтверждения нет. + +#### Scenario: Жёсткое нарушение из document_issue +- **WHEN** у формы есть запись `relation` без акцепта и нет строк `unreadable` / `missing` со значениями +- **THEN** 409 `CHECKS_NOT_PASSED` + +### Requirement: Блок `checks` ответа формы из `document_issue` +Блок `checks` ответа `GET …/statement` SHALL строиться из записей `document_issue` видов `relation` (`failed`) и `soft` +(`soft_failed`) с `codes`, `expected`, `actual`, `line_ids`; собственного расчёта §7 при чтении нет. Межформенная +проверка `cross_form` считается по агрегату формы по коду. + +#### Scenario: checks из document_issue +- **WHEN** у формы две записи `relation` и одна `soft` +- **THEN** `checks.failed` содержит две записи, `checks.soft_failed` — одну; расчёт §7 при запросе не выполняется diff --git a/openspec/changes/unified-normalization-core-b/specs/osv-result-mapping/spec.md b/openspec/changes/unified-normalization-core-b/specs/osv-result-mapping/spec.md new file mode 100644 index 0000000..611a7f4 --- /dev/null +++ b/openspec/changes/unified-normalization-core-b/specs/osv-result-mapping/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Маппер результата 2.0 для форм ББ/ОФР +Для результата `2.0` ядро SHALL создавать документ формы, храня строки, значения, колонки и шапку **как пришли** +(DBML v5.19): строка — `row_index`, `source_ref = ref`, `line_status`, `line_code`, `line_code_original_value`, +`line_code_text`, `line_code_confidence`, `line_name`, `parent_row_index` (по `parent_ref`), `needs_review`, +`is_confirmed = false`; значение — на каждую пару «строка × колонка» (нет в результате — пустая запись с +`needs_review = false`); колонка — с `period_kind`, `label_confidence`; шапка — разобранные поля, `header_source` +(`{поле: {text, confidence}}`), `*_original_value`, `needs_review_fields`, `form_variant`. Значение с `column_index` вне +колонок формы и повтор `column_index` колонки — первое остаётся, остальное — `document_issue` `MAPPING_WARNING`. +Предупреждения и `checks` результата SHALL писаться в `document_issue` (источник `service`); проверки ядра — источник +`core`: `CODE_NOT_IN_DICTIONARY` и `CODE_NOT_IN_EDITION` (редакция — `edition` точки входа пакета, а не по дате; одна +запись на код документа, `line_ids` — все строки `ok` кода; **только для кодов из ровно четырёх цифр** — пятизначные +подстроки Excel не проверяются), `NAME_MISMATCH`, `RESULT_STATUS_INCONSISTENT`, `STATEMENTS_VERSION_MISMATCH`. Статус +документа — по требованию «Условие `RECOGNIZED` при приёме 2.0»; согласованность `done` — вызовом +`engine.contract_document_status` пакета. Проверки §7, `needs_review` и предупреждения сервиса MUST NOT пересчитываться +при загрузке. + +#### Scenario: Форма 2.0 хранится как пришла +- **WHEN** результат `2.0` содержит две строки с кодом 1230 и строку без кода (`ref` с `parent_ref`) +- **THEN** создано три строки с их `row_index`, `source_ref` и `parent_row_index`; повтор кода не сливается + +#### Scenario: Пятизначный код не даёт info +- **WHEN** в ББ строка с кодом `12301` (подстрока Excel), которого нет в словаре +- **THEN** `CODE_NOT_IN_DICTIONARY` и `CODE_NOT_IN_EDITION` для него не пишутся + +#### Scenario: Четырёхзначный код вне перечня +- **WHEN** в ББ строка 1120 на бланке `fsbu4`, где такого кода нет +- **THEN** запись `CODE_NOT_IN_EDITION` (`info`, `core`) с `line_ids` строк `ok` этого кода + +### Requirement: Ведомость ОСВ по контракту 2.0 +Для листа ОСВ результата `2.0` ядро SHALL создавать документ ОСВ по `osvDocument`, предупреждения листа +(`osvDocument.warnings`, `OSV_*`) писать в `document_issue` (вид `warning`, источник `service`), а статус документа +определять по требованию «Предупреждения файла и ОСВ в API, статус ОСВ при приёме 2.0». Статус ОСВ MUST NOT зависеть от +статуса соседнего документа книги и от статуса конверта. + +#### Scenario: ОСВ в книге с ОФР, требующим проверки +- **WHEN** книга из ОСВ (`status = done`, контрольные суммы сошлись) и ОФР (`needs_check`) +- **THEN** документ ОСВ `RECOGNIZED`, документ ОФР `NEEDS_CHECK` + +## MODIFIED Requirements + +### Requirement: Формы результата 1.x — §7 при приёме +Для формы, принятой по результату `1.x` (окно перехода после выкладки ядра; `checks` контракта 2.0 у неё нет), ядро +SHALL **один раз при приёме** посчитать §7 точкой входа пакета для пересчёта (`statement-normalization`; вариант формы — +по структуре кодов, как у 2.0) по суммам результата и записать нарушения в `document_issue` (источник `core`, виды +`relation` / `soft`) и `form_variant` в `financial_statement_meta`; собственного пересчёта §7 ядро MUST NOT выполнять. +Условие D-6 «проверки сошлись» для `1.x` SHALL браться из этого расчёта; `needs_review` значения при приёме `1.x` = +**НЕ** `values_ok` (`values_ok` — `is_recognized` не `false` и `confidence` равна 100 или пуста), `needs_review` строки — +`false`. Предупреждения маппера `1.x` по документу — `document_issue` `MAPPING_WARNING` (`info`, источник `core`); +строки `metrics.warnings` результата `1.x` — в `recognition_job.metrics.warnings` как `LEGACY_WARNING`. Формы, +существовавшие в БД до миграции v5.19, §7 не пересчитываются. + +#### Scenario: Форма 1.x с нарушением +- **WHEN** результат `1.2` содержит ББ с несошедшимся `1600 = 1700` +- **THEN** у формы запись `document_issue` `relation` `RELATION_FAILED` с `codes = ["1600", "1700"]` (источник `core`), + документ `NEEDS_CHECK`; `POST …/confirm` без акцепта — 409 `CHECKS_NOT_PASSED` diff --git a/openspec/changes/unified-normalization-core-b/specs/recognition-job-exchange/spec.md b/openspec/changes/unified-normalization-core-b/specs/recognition-job-exchange/spec.md new file mode 100644 index 0000000..f38f03e --- /dev/null +++ b/openspec/changes/unified-normalization-core-b/specs/recognition-job-exchange/spec.md @@ -0,0 +1,32 @@ +## MODIFIED Requirements + +### Requirement: Приём конвертов и результатов обоих MAJOR контракта +Inbox SHALL принимать конверты (включая ack `processing`) и файлы результата и `MAJOR` 1, и `MAJOR` 2 («сначала ядро», +`recognition-contract`): версия определяется по `schema_version`, конверт валидируется моделью своего `MAJOR`, неизвестный +`MAJOR` (например, `3.0`) отбрасывается с предупреждением в лог (задание закроется по сроку). Файл результата +обрабатывается маппером своего `MAJOR` (`2.0` — маппер результата `2.0`, `1.x` — маппер `1.x`); файл с неизвестным +`MAJOR` получает `ERROR` с `mapping_error`. Ядро SHALL держать копии схем и примеров обоих `MAJOR` (`api/contracts/`), +сверяемые с `docs/contracts/` общим правилом. Предупреждения уровня файла SHALL записываться в +`recognition_job.metrics.warnings` структурами `{code, severity, message, locator?}`: у `2.0` — как пришли, у `1.x` — +каждая строка как `{code: "LEGACY_WARNING", severity: "info", message: <строка>, locator: null}`. +`recognition_job.statements_version` SHALL сохранять `service.statements_version` результата `2.0` (у `1.x` — `NULL`). + +#### Scenario: Ack нового сервиса +- **WHEN** сервис публикует ack `processing` со `schema_version = "2.0"` +- **THEN** задание переходит в `processing`, как для ack `1.0` + +#### Scenario: Предупреждения 1.x оборачиваются +- **WHEN** конверт `1.0` несёт `metrics.warnings = ["sheet=ОСВ 60 строки без родителя"]` +- **THEN** в `recognition_job.metrics.warnings` — `{code: "LEGACY_WARNING", severity: "info", message: "sheet=ОСВ 60 строки без родителя", locator: null}` + +#### Scenario: Предупреждения 2.0 сохраняются как пришли +- **WHEN** конверт `2.0` несёт предупреждение `JSON_FAILED` уровня `error` с `locator` +- **THEN** структура сохранена без изменений + +#### Scenario: Неизвестный MAJOR +- **WHEN** приходит конверт со `schema_version = "3.0"` +- **THEN** конверт отбрасывается с предупреждением в лог, задание не меняется + +#### Scenario: Результат 2.0 разбирает маппер 2.0 +- **WHEN** inbox принял файл результата со `schema_version = "2.0"` +- **THEN** документы создаёт маппер результата `2.0`; результат `1.x` по-прежнему разбирает маппер `1.x` diff --git a/openspec/changes/unified-normalization-core-b/specs/statement-checks/spec.md b/openspec/changes/unified-normalization-core-b/specs/statement-checks/spec.md new file mode 100644 index 0000000..e3e47bd --- /dev/null +++ b/openspec/changes/unified-normalization-core-b/specs/statement-checks/spec.md @@ -0,0 +1,52 @@ +## MODIFIED Requirements + +### Requirement: Соотношения §7 по колонкам +Проверки SHALL считаться чистой функцией по текущим `financial_statement_value.amount` документа для каждой колонки +отдельно по словарю §7 копии пакета `arm_statements` (`statement-normalization`; своей копии словаря ядро не держит): соотношение проверяется, только +если присутствует его левая часть; отсутствующий код и пустая сумма — 0; выполнено, если `|левая − правая| ≤ tolerance` +(1, в единицах формы) хотя бы в одном варианте правой части — как записано или с кодами `abs_terms` по модулю. Нарушение +жёсткого (`strict`) соотношения, не превышающее `rounding_tolerance_percent` (0,001 %) от наибольшего по модулю числа +соотношения, SHALL считаться мягким («в пределах округления»). Мягкие: соотношения `strict: false` и «сумма пятизначных +подстрок = четырёхзначный родитель». Результат — `{failed: [...], soft_failed: [...]}`, элемент — `{column_index, +relation, expected, actual}`; не хранится как есть: нарушения при приёме и пересчёте записываются в `document_issue`. + +Присутствующими SHALL считаться суммы **агрегата формы по коду** (требование «Агрегат формы по коду»): строки `ok` формы из БД, кроме строк, созданных правкой аналитика (`row_index IS NULL`), у которых все суммы пустые; строки `detail`, `unreadable`, `missing` и строки, достроенные в ответе по справочнику (`documents-api`, `id = null`), в проверках MUST NOT участвовать. Созданная правкой строка с хотя бы одной непустой суммой проверяется по всем колонкам, пустые суммы — 0. Повтор кода в форме суммируется. Результат нарушения несёт `codes` (коды цели и членов соотношения) и `line_ids` (все строки `ok` этих кодов). Соотношения считает точка входа пакета для пересчёта по агрегату формы; запись в БД — только в виде `document_issue` при приёме и пересчёте, `api/rules/statement_checks.yaml` и код его загрузки в ядре MUST NOT существовать. + +#### Scenario: Сходящийся баланс +- **WHEN** проверяется форма `mezheninovskaya/2025-Q3/1_Форма1_бух_09` +- **THEN** `failed` пуст + +#### Scenario: Нарушение жёсткого соотношения +- **WHEN** у баланса 1600 = 1000, 1100 = 300, 1200 = 600 в колонке 0 +- **THEN** `failed` содержит `{column_index: 0, relation: "1600 = 1100 + 1200", expected: 1000, actual: 900}` + +#### Scenario: Расходы со знаком минус +- **WHEN** ОФР 2110 = 100, 2120 = −60, 2100 = 40 +- **THEN** `2100 = 2110 − 2120` выполнено через вариант с `abs_terms` + +#### Scenario: Правка исправляет нарушение +- **WHEN** аналитик правит 1200 с 600 на 700 в форме из предыдущего сценария +- **THEN** проверка по текущим суммам: `failed` пуст + +#### Scenario: Пустая шаблонная строка не проверяется +- **WHEN** в форме нет строки 1700 (в ответе она достроена пустой), 1300 + 1400 + 1500 = 2 371 333 в колонке 0 +- **THEN** соотношения с целью 1700 (`1700 = 1300 + 1400 + 1500`) не проверяются; `1600 = 1700` (цель 1600 есть в + форме, 1700 = 0) нарушено — как и без достройки: потерянная строка «БАЛАНС» сама по себе даёт `NEEDS_CHECK` + +#### Scenario: Заполненная шаблонная строка проверяется +- **WHEN** аналитик заполнил 1700 = 2 371 333 только в колонке 0, а в колонке 1 1300 + 1400 + 1500 = 2 000 000 +- **THEN** в колонке 0 соотношение выполнено; в колонке 1 `failed` содержит `1700 = 1300 + 1400 + 1500` с `expected 0`, + `actual 2000000` + +#### Scenario: Очищенная шаблонная строка +- **WHEN** затем аналитик очищает 1700 в колонке 0 (`amount = null`) +- **THEN** строка 1700 снова не участвует в проверках + +#### Scenario: Повтор кода суммируется +- **WHEN** в ББ две строки 1230 со значениями 100 и 50 в колонке 0 +- **THEN** соотношения берут 1230 = 150 + +#### Scenario: Копии словаря §7 в ядре нет +- **WHEN** проверяется репозиторий ядра после перехода на пакет +- **THEN** файла `api/rules/statement_checks.yaml` нет, `load_check_rules` / `run_checks` в `api/src` не встречаются; + допуск `tolerance` берётся из правил пакета diff --git a/openspec/changes/unified-normalization-core-b/tasks.md b/openspec/changes/unified-normalization-core-b/tasks.md new file mode 100644 index 0000000..7f1b2f0 --- /dev/null +++ b/openspec/changes/unified-normalization-core-b/tasks.md @@ -0,0 +1,9 @@ +## Задачи (SDD-план `docs/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-3-arm-core.md`, часть Б) + +- [ ] B0. Change OpenSpec части Б в arm-core +- [ ] B1. Адаптер пакета `StatementsEngine` и вход пересчёта из БД +- [ ] B2. Пересчёт после правки общим кодом и §7 из `document_issue` +- [ ] B3. Маппер `1.x` — §7 при приёме и вариант формы (Р-30) +- [ ] B4. Маппер `2.0` — формы +- [ ] B5. Маппер `2.0` — ОСВ, выбор по MAJOR, приём файла результата +- [ ] B6. Удаление копии словаря §7 ядра, редакции из перечня пакета -- 2.47.2 From 6ce36b3e2dace37342639177790f92791f943fc5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 20:00:12 +0400 Subject: [PATCH 02/13] =?UTF-8?q?feat(statements):=20=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D0=BF=D1=82=D0=B5=D1=80=20arm=5Fstatements=20=D0=B7=D0=B0=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=D0=BE=D0=BC=20?= =?UTF-8?q?StatementsEngine=20=D0=B8=20=D0=B2=D1=85=D0=BE=D0=B4=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=81=D1=87=D1=91=D1=82=D0=B0=20=D0=B8=D0=B7?= =?UTF-8?q?=20=D0=91=D0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/domain/stored_form.py | 126 ++++++++++++ api/src/services/statements_engine.py | 99 ++++++++++ api/tests/fake_engine.py | 88 +++++++++ api/tests/test_domain_stored_form.py | 185 ++++++++++++++++++ api/tests/test_statements_engine.py | 124 ++++++++++++ .../unified-normalization-core-b/design.md | 3 + 6 files changed, 625 insertions(+) create mode 100644 api/src/domain/stored_form.py create mode 100644 api/src/services/statements_engine.py create mode 100644 api/tests/fake_engine.py create mode 100644 api/tests/test_domain_stored_form.py create mode 100644 api/tests/test_statements_engine.py diff --git a/api/src/domain/stored_form.py b/api/src/domain/stored_form.py new file mode 100644 index 0000000..b6548b9 --- /dev/null +++ b/api/src/domain/stored_form.py @@ -0,0 +1,126 @@ +"""Вход пересчёта формы ББ/ОФР из строк БД: сборка `StoredForm` пакета `arm_statements` (часть Б, B1). + +Единственное место ядра (вместе с `services/statements_engine.py`), где импортируется пакет. Здесь же все соглашения +границы: перечисления пакета — `StrEnum`, уверенности — `float | None`, `NULL` у `is_recognized` — `False`, +`period_kind = NULL` — `PeriodKind.NONE`, ключи строки и колонки — `str(uuid)`. +""" + +from collections.abc import Sequence + +from arm_statements import ( + DocumentType, + LineStatus, + PeriodKind, + Severity, + StoredColumn, + StoredForm, + StoredLine, + StoredValue, + StoredWarning, +) +from src.contract import StatementDocumentV2 +from src.models.enums import DocumentIssueKind +from src.models.files import Document +from src.models.statements import ( + DocumentIssue, + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) + +__all__ = ["build_stored_form", "edition_codes"] + +_SURE = 100 # «уверенный» код результата — уверенность ровно 100 + + +def _confidence(value: object) -> float | None: + return None if value is None else float(value) # type: ignore[arg-type] + + +def _stored_column(column: FinancialStatementColumn) -> StoredColumn: + return StoredColumn( + key=str(column.id), + column_index=column.column_index, + period_date=column.period_date, + period_kind=PeriodKind(column.period_kind) if column.period_kind else PeriodKind.NONE, + ) + + +def _stored_value(value: FinancialStatementValue) -> StoredValue: + return StoredValue( + column_key=str(value.column_id), + amount=value.amount, + text=value.source_text, + confidence=_confidence(value.confidence), + is_recognized=bool(value.is_recognized), + value_fixed=bool(value.is_modified) or bool(value.is_confirmed), + ) + + +def _parent_keys(lines: Sequence[FinancialStatementLine]) -> dict[int, str]: + """`row_index` → ключ строки; при повторе `row_index` предпочитается строка `ok`.""" + result: dict[int, str] = {} + for line in lines: + if line.row_index is None: + continue + if line.row_index not in result or line.line_status.value == LineStatus.OK.value: + result[line.row_index] = str(line.id) + return result + + +def build_stored_form( + document: Document, + meta: FinancialStatementMeta, + columns: Sequence[FinancialStatementColumn], + lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], + warnings: Sequence[DocumentIssue], +) -> StoredForm: + """Собирает вход `recompute` из строк БД формы; `warnings` фильтруются до `kind = warning`. + + Тип формы — `document.document_type` (`BALANCE` / `INCOME` / `DISCLOSURE`): `meta.statement_type` знает только + `balance` / `income`. + """ + row_keys = _parent_keys([line for line, _ in lines]) + stored_lines: list[StoredLine] = [] + for line, values in lines: + status = LineStatus(line.line_status.value) + parent_key = ( + row_keys.get(line.parent_row_index) + if status is LineStatus.DETAIL and line.parent_row_index is not None + else None + ) + stored_lines.append( + StoredLine( + key=str(line.id), + row_index=line.row_index, + line_status=status, + line_code=line.line_code, + line_code_confidence=_confidence(line.line_code_confidence), + line_name=line.line_name, + code_fixed=bool(line.line_code_modified) or bool(line.is_confirmed), + parent_key=parent_key, + values=tuple(_stored_value(v) for v in values), + ) + ) + return StoredForm( + document_type=DocumentType(document.document_type.value), + effective_date=document.reporting_date, + has_organization_or_inn=bool(meta.organization_name) or bool(meta.inn), + period_known=document.reporting_date is not None, + needs_review_fields=tuple(meta.needs_review_fields or ()), + accepted=meta.checks_accepted_at is not None, + warnings=tuple( + StoredWarning(code=w.code, severity=Severity(w.severity.value)) + for w in warnings + if w.kind == DocumentIssueKind.warning + ), + columns=tuple(_stored_column(c) for c in columns), + lines=tuple(stored_lines), + ) + + +def edition_codes(doc: StatementDocumentV2) -> list[tuple[str, bool]]: + """Пары `(line_code, уверенный)` строк `ok` результата по `row_index` — вход `engine.edition`.""" + ok_lines = sorted((ln for ln in doc.lines if ln.line_status == "ok"), key=lambda ln: ln.row_index) + return [(str(ln.line_code), ln.line_code_confidence == _SURE) for ln in ok_lines] diff --git a/api/src/services/statements_engine.py b/api/src/services/statements_engine.py new file mode 100644 index 0000000..74b8365 --- /dev/null +++ b/api/src/services/statements_engine.py @@ -0,0 +1,99 @@ +"""Адаптер пакета `arm_statements` за протоколом `StatementsEngine` (часть Б, B1). + +Единственная точка вызова функций пакета в ядре; остальной код работает с протоколом, модульные тесты подменяют +его `FakeEngine`. Правила пакета читаются один раз на процесс (`default_engine` кэширует адаптер). +""" + +import datetime +import functools +from collections.abc import Mapping, Sequence +from decimal import Decimal +from typing import Any, Protocol + +from arm_statements import ( + DocStatus, + DocumentType, + Edition, + FormVariant, + PeriodKind, + RecomputeResult, + Rules, + StoredForm, + choose_edition, + default_rules, + document_status, + recompute, + resolve_relative, + statements_version, + status_input_from_contract, +) + +__all__ = ["ArmStatementsEngine", "StatementsEngine", "default_engine"] + + +class StatementsEngine(Protocol): + @property + def version(self) -> str | None: ... + def recompute(self, form: StoredForm) -> RecomputeResult: ... + def contract_document_status(self, document: Mapping[str, Any]) -> DocStatus: ... + def edition( + self, + document_type: DocumentType, + codes: Sequence[tuple[str, bool]], + effective_date: datetime.date | None, + form_variant: FormVariant | None, + ) -> Edition | None: ... + def code_order( + self, edition: Edition, document_type: DocumentType, form_variant: FormVariant + ) -> tuple[str, ...]: ... + def editions(self) -> tuple[Edition, ...]: ... + def resolve_relative(self, kind: PeriodKind, effective_date: datetime.date | None) -> datetime.date | None: ... + def tolerance(self) -> Decimal: ... + + +class ArmStatementsEngine: + """Реализация поверх пакета: правила — `default_rules()` пакета.""" + + def __init__(self, rules: Rules) -> None: + self._rules = rules + + @property + def version(self) -> str | None: + return statements_version() + + def recompute(self, form: StoredForm) -> RecomputeResult: + return recompute(form, self._rules) + + def contract_document_status(self, document: Mapping[str, Any]) -> DocStatus: + return document_status(status_input_from_contract(document)) + + def edition( + self, + document_type: DocumentType, + codes: Sequence[tuple[str, bool]], + effective_date: datetime.date | None, + form_variant: FormVariant | None, + ) -> Edition | None: + return choose_edition(document_type, codes, effective_date, self._rules, variant=form_variant) + + def code_order(self, edition: Edition, document_type: DocumentType, form_variant: FormVariant) -> tuple[str, ...]: + if document_type is DocumentType.DISCLOSURE: + return () + return self._rules.code_orders.sequence(edition, document_type, form_variant) + + def editions(self) -> tuple[Edition, ...]: + return self._rules.code_orders.editions + + def resolve_relative(self, kind: PeriodKind, effective_date: datetime.date | None) -> datetime.date | None: + if kind in (PeriodKind.ABSOLUTE, PeriodKind.NONE): + return None + return resolve_relative(kind, effective_date) + + def tolerance(self) -> Decimal: + return self._rules.checks.tolerance + + +@functools.cache +def default_engine() -> StatementsEngine: + """Адаптер с правилами пакета; кэш на процесс.""" + return ArmStatementsEngine(default_rules()) diff --git a/api/tests/fake_engine.py b/api/tests/fake_engine.py new file mode 100644 index 0000000..84fa0fd --- /dev/null +++ b/api/tests/fake_engine.py @@ -0,0 +1,88 @@ +"""`FakeEngine` для модульных тестов ядра: возвращает заданные ответы и запоминает вход (часть Б, B1).""" + +import datetime +from collections.abc import Mapping, Sequence +from decimal import Decimal +from typing import Any + +from arm_statements import ( + DocStatus, + DocumentType, + Edition, + FormVariant, + PeriodKind, + RecomputeResult, + StatementChecks, + StoredForm, +) + + +def empty_result(status: DocStatus = DocStatus.DONE) -> RecomputeResult: + """`RecomputeResult` без нарушений и признаков.""" + return RecomputeResult( + form_variant=FormVariant.FULL, + edition=Edition.FSBU4, + checks=StatementChecks((), ()), + line_needs_review={}, + value_needs_review={}, + detail_parents={}, + status=status, + ) + + +class FakeEngine: + """Подмена `StatementsEngine`: ответы задаются в конструкторе, вызовы запоминаются.""" + + def __init__( # noqa: PLR0913 + self, + result: RecomputeResult | None = None, + *, + document_status: DocStatus = DocStatus.DONE, + edition: Edition | None = Edition.FSBU4, + version: str | None = "fake", + code_orders: Mapping[tuple[Edition, DocumentType, FormVariant], tuple[str, ...]] | None = None, + tolerance: Decimal = Decimal(1), + ) -> None: + self.result = result if result is not None else empty_result() + self.document_status = document_status + self.edition_value = edition + self._version = version + self.orders = dict(code_orders or {}) + self._tolerance = tolerance + self.forms: list[StoredForm] = [] + self.edition_calls: list[ + tuple[DocumentType, list[tuple[str, bool]], datetime.date | None, FormVariant | None] + ] = [] + + @property + def version(self) -> str | None: + return self._version + + def recompute(self, form: StoredForm) -> RecomputeResult: + self.forms.append(form) + return self.result + + def contract_document_status(self, document: Mapping[str, Any]) -> DocStatus: + return self.document_status + + def edition( + self, + document_type: DocumentType, + codes: Sequence[tuple[str, bool]], + effective_date: datetime.date | None, + form_variant: FormVariant | None, + ) -> Edition | None: + self.edition_calls.append((document_type, list(codes), effective_date, form_variant)) + return self.edition_value + + def code_order(self, edition: Edition, document_type: DocumentType, form_variant: FormVariant) -> tuple[str, ...]: + return self.orders.get((edition, document_type, form_variant), ()) + + def editions(self) -> tuple[Edition, ...]: + return (Edition.RAS_66N, Edition.FSBU4) + + def resolve_relative(self, kind: PeriodKind, effective_date: datetime.date | None) -> datetime.date | None: + return None + + def tolerance(self) -> Decimal: + return self._tolerance diff --git a/api/tests/test_domain_stored_form.py b/api/tests/test_domain_stored_form.py new file mode 100644 index 0000000..258eed0 --- /dev/null +++ b/api/tests/test_domain_stored_form.py @@ -0,0 +1,185 @@ +"""`build_stored_form` и `edition_codes` (часть Б, B1): отображение строк БД в вход пересчёта пакета.""" + +import datetime +import json +import uuid +from decimal import Decimal +from pathlib import Path + +from arm_statements import DocumentType as PkgDocumentType +from arm_statements import LineStatus, PeriodKind, Severity +from src.contract import StatementDocumentV2 +from src.domain.stored_form import build_stored_form, edition_codes +from src.models.enums import ( + DocumentIssueKind, + DocumentStatus, + DocumentType, + IssueSeverity, + IssueSource, + StatementLineStatus, + StatementType, +) +from src.models.files import Document +from src.models.statements import ( + DocumentIssue, + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) + +CONFIDENCE = 67.5 +EXAMPLES = Path(__file__).resolve().parent.parent / "contracts" / "examples" + + +def make_document(reporting_date: datetime.date | None = datetime.date(2025, 9, 30)) -> Document: + return Document( + id=uuid.uuid4(), + document_type=DocumentType.BALANCE, + status=DocumentStatus.RECOGNIZED, + reporting_date=reporting_date, + ) + + +def make_meta(**kwargs: object) -> FinancialStatementMeta: + return FinancialStatementMeta(id=uuid.uuid4(), statement_type=StatementType.balance, **kwargs) + + +def make_column(index: int = 1, period_kind: str | None = "relative:current") -> FinancialStatementColumn: + return FinancialStatementColumn( + id=uuid.uuid4(), column_index=index, period_kind=period_kind, period_date=datetime.date(2025, 9, 30) + ) + + +def make_line( # noqa: PLR0913 + code: str | None, + *, + row_index: int | None, + status: StatementLineStatus = StatementLineStatus.ok, + confidence: str | None = "100", + modified: bool | None = False, + confirmed: bool = False, + parent_row_index: int | None = None, +) -> FinancialStatementLine: + return FinancialStatementLine( + id=uuid.uuid4(), + line_code=code, + line_code_confidence=None if confidence is None else Decimal(confidence), + line_code_modified=modified, + line_status=status, + row_index=row_index, + parent_row_index=parent_row_index, + is_confirmed=confirmed, + ) + + +def make_value( # noqa: PLR0913 + line: FinancialStatementLine, + column: FinancialStatementColumn, + amount: str | None, + *, + confidence: str | None = "100", + recognized: bool | None = True, + modified: bool = False, + confirmed: bool = False, + text: str | None = None, +) -> FinancialStatementValue: + return FinancialStatementValue( + id=uuid.uuid4(), + line_id=line.id, + column_id=column.id, + amount=None if amount is None else Decimal(amount), + confidence=None if confidence is None else Decimal(confidence), + is_recognized=recognized, + is_modified=modified, + is_confirmed=confirmed, + source_text=text, + ) + + +def make_issue(kind: DocumentIssueKind, code: str, severity: IssueSeverity = IssueSeverity.info) -> DocumentIssue: + return DocumentIssue( + id=uuid.uuid4(), kind=kind, code=code, severity=severity, message="m", source=IssueSource.service + ) + + +def test_build_stored_form_maps_flags() -> None: + column = make_column() + template = make_line("1260", row_index=None, confidence=None, modified=None) + edited = make_line("1230", row_index=0) + confirmed = make_line("1210", row_index=1) + assigned = make_line("1250", row_index=2, confidence="25", modified=True) + parent = make_line("1200", row_index=3) + detail = make_line(None, row_index=4, status=StatementLineStatus.detail, parent_row_index=3) + pairs = [ + (template, [make_value(template, column, "10", modified=True)]), + (edited, [make_value(edited, column, "5", modified=True, text="5 0")]), + (confirmed, [make_value(confirmed, column, "7", confirmed=True)]), + (assigned, [make_value(assigned, column, "1")]), + (parent, [make_value(parent, column, "1")]), + (detail, [make_value(detail, column, "2")]), + ] + warnings = [ + make_issue(DocumentIssueKind.warning, "MAPPING_WARNING", IssueSeverity.error), + make_issue(DocumentIssueKind.relation, "1200 = ..."), + make_issue(DocumentIssueKind.soft, "x"), + ] + meta = make_meta(organization_name="ООО", needs_review_fields=["inn"]) + form = build_stored_form(make_document(), meta, [column], pairs, warnings) + + by_key = {line.key: line for line in form.lines} + assert by_key[str(template.id)].row_index is None + assert by_key[str(template.id)].values[0].value_fixed + assert by_key[str(edited.id)].values[0].value_fixed + assert by_key[str(edited.id)].values[0].text == "5 0" + assert by_key[str(confirmed.id)].values[0].value_fixed + assert not by_key[str(confirmed.id)].code_fixed + assert by_key[str(assigned.id)].code_fixed + assert by_key[str(detail.id)].parent_key == str(parent.id) + assert by_key[str(detail.id)].line_status is LineStatus.DETAIL + assert by_key[str(parent.id)].parent_key is None + assert [(w.code, w.severity) for w in form.warnings] == [("MAPPING_WARNING", Severity.ERROR)] + assert form.effective_date == datetime.date(2025, 9, 30) + assert form.period_known + assert form.has_organization_or_inn + assert form.needs_review_fields == ("inn",) + assert not form.accepted + assert form.document_type is PkgDocumentType.BALANCE + + +def test_confirmed_line_is_code_fixed_and_accepted_flag() -> None: + column = make_column() + line = make_line("1210", row_index=0, confirmed=True) + meta = make_meta(checks_accepted_at=datetime.datetime(2026, 10, 18, tzinfo=datetime.UTC)) + form = build_stored_form(make_document(None), meta, [column], [(line, [])], []) + assert form.lines[0].code_fixed + assert form.accepted + assert not form.period_known + assert form.effective_date is None + assert not form.has_organization_or_inn + + +def test_stored_form_types() -> None: + column = make_column(period_kind=None) + line = make_line("1210", row_index=0, confidence="67.50") + value = make_value(line, column, "1", confidence="67.50", recognized=None) + form = build_stored_form(make_document(), make_meta(), [column], [(line, [value])], []) + stored = form.lines[0] + assert stored.line_code_confidence == CONFIDENCE + assert type(stored.line_code_confidence) is float + assert type(stored.values[0].confidence) is float + assert stored.values[0].is_recognized is False + assert form.columns[0].period_kind is PeriodKind.NONE + assert form.columns[0].key == str(column.id) + assert stored.line_status is LineStatus.OK + assert isinstance(stored.line_status, LineStatus) + + +def test_edition_codes_from_result_by_row_index() -> None: + raw = json.loads((EXAMPLES / "result-statement-minimal.json").read_text(encoding="utf-8")) + doc = StatementDocumentV2.model_validate(raw["documents"][0]) + pairs = edition_codes(doc) + # unreadable (row 2) не входит; порядок — по row_index + assert pairs == [(c, True) for c in ("1210", "1230", "1200", "1600", "1370", "1300", "1700")] + doc.lines.reverse() + assert edition_codes(doc) == pairs diff --git a/api/tests/test_statements_engine.py b/api/tests/test_statements_engine.py new file mode 100644 index 0000000..a5a3723 --- /dev/null +++ b/api/tests/test_statements_engine.py @@ -0,0 +1,124 @@ +"""Адаптер `StatementsEngine` поверх реального пакета `arm_statements` (часть Б, B1).""" + +import datetime +import json +from decimal import Decimal +from pathlib import Path + +import pytest + +from arm_statements import DocStatus, DocumentType, Edition, FormVariant, PeriodKind, default_rules, statements_version +from src.contract import StatementDocumentV2 +from src.domain.stored_form import build_stored_form, edition_codes +from src.models.enums import StatementLineStatus +from src.services.statements_engine import ArmStatementsEngine, default_engine +from tests.fake_engine import FakeEngine, empty_result +from tests.test_domain_stored_form import make_column, make_document, make_line, make_meta, make_value + +EXAMPLES = Path(__file__).resolve().parent.parent / "contracts" / "examples" +ENGINE = default_engine() + + +def _documents(name: str) -> list[dict[str, object]]: + raw = json.loads((EXAMPLES / name).read_text(encoding="utf-8")) + return [d for d in raw["documents"] if d["document_type"] in ("BALANCE", "INCOME", "DISCLOSURE")] + + +@pytest.mark.parametrize("name", ["result-statement-minimal.json", "result-statement-excel.json"]) +def test_contract_document_status_matches_examples(name: str) -> None: + documents = _documents(name) + assert documents + for doc in documents: + assert ENGINE.contract_document_status(doc) == DocStatus(doc["status"]) + if name == "result-statement-minimal.json": + assert ENGINE.contract_document_status(documents[0]) is DocStatus.NEEDS_CHECK # строка unreadable + + +def test_edition_codes_and_engine_edition() -> None: + doc = StatementDocumentV2.model_validate(_documents("result-statement-minimal.json")[0]) + pairs = edition_codes(doc) + effective = datetime.date(2025, 9, 30) + edition = ENGINE.edition(DocumentType.BALANCE, pairs, effective, FormVariant.FULL) + assert isinstance(edition, Edition) + + # та же форма, собранная из этого результата в строки БД: редакция пересчёта совпадает с редакцией приёма + column = make_column() + lines = [] + for ln in doc.lines: + line = make_line( + ln.line_code, + row_index=ln.row_index, + status=StatementLineStatus(ln.line_status), + confidence=None if ln.line_code_confidence is None else str(ln.line_code_confidence), + ) + lines.append((line, [make_value(line, column, v.amount, confidence=str(v.confidence)) for v in ln.values])) + form = build_stored_form(make_document(effective), make_meta(organization_name="ООО"), [column], lines, []) + assert ENGINE.recompute(form).edition == edition + + +def test_engine_scenario_recompute_after_edit() -> None: + column = make_column() + effective = datetime.date(2025, 9, 30) + l1210 = make_line("1210", row_index=0) + l1230 = make_line("1230", row_index=1) + l1250 = make_line("1250", row_index=2, confidence="25", modified=True) # бывшая unreadable с назначенным кодом + l1260 = make_line("1260", row_index=None, confidence=None, modified=None) # шаблонная строка + l1200 = make_line("1200", row_index=3) + detail = make_line(None, row_index=4, status=StatementLineStatus.detail, parent_row_index=3) + pairs = [ + (l1210, [make_value(l1210, column, "100")]), + (l1230, [make_value(l1230, column, "428311", confidence="60", modified=True)]), + (l1250, [make_value(l1250, column, "50", confidence="67", modified=True)]), + (l1260, [make_value(l1260, column, "10", confidence=None, recognized=None, modified=True)]), + (l1200, [make_value(l1200, column, "428471")]), + (detail, [make_value(detail, column, "5", confidence="60")]), + ] + form = build_stored_form(make_document(effective), make_meta(organization_name="ООО"), [column], pairs, []) + result = ENGINE.recompute(form) + + assert result.edition is Edition.FSBU4 + assert result.form_variant is FormVariant.FULL + key = str(column.id) + assert result.value_needs_review[(str(l1230.id), key)] is False + assert result.value_needs_review[(str(detail.id), key)] is False + assert result.line_needs_review[str(l1250.id)] is False + assert result.detail_parents[str(detail.id)] == str(l1200.id) # ближайшая выше строка ok + # 1200 = … + 1250 + 1260 сошлось только с учётом строки 1250 и шаблонной 1260 + assert [f for f in result.checks.relations_failed if f.relation.startswith("1200")] == [] + + # без шаблонной строки 1260 сумма не сходится: соотношение нарушено с ключом колонки БД + without_template = build_stored_form( + make_document(effective), + make_meta(organization_name="ООО"), + [column], + [p for p in pairs if p[0] is not l1260], + [], + ) + failed = [f for f in ENGINE.recompute(without_template).checks.relations_failed if f.relation.startswith("1200")] + assert [(f.column_key, f.expected, f.actual) for f in failed] == [(key, Decimal(428471), Decimal(428461))] + + +def test_engine_version_reads_file(tmp_path: Path) -> None: + assert ENGINE.version == statements_version() + assert statements_version(tmp_path / "VERSION") is None + + +def test_engine_rules_accessors() -> None: + engine = ArmStatementsEngine(default_rules()) + assert engine.tolerance() == Decimal(1) + assert engine.editions() == (Edition.RAS_66N, Edition.FSBU4) + assert engine.code_order(Edition.FSBU4, DocumentType.DISCLOSURE, FormVariant.FULL) == () + assert "1200" in engine.code_order(Edition.FSBU4, DocumentType.BALANCE, FormVariant.FULL) + assert engine.resolve_relative(PeriodKind.ABSOLUTE, datetime.date(2025, 9, 30)) is None + assert engine.resolve_relative(PeriodKind.NONE, datetime.date(2025, 9, 30)) is None + assert engine.resolve_relative(PeriodKind.PREV_YEAR_END, datetime.date(2025, 9, 30)) == datetime.date(2024, 12, 31) + + +def test_fake_engine_records_form() -> None: + fake = FakeEngine(empty_result(DocStatus.NEEDS_CHECK), document_status=DocStatus.NEEDS_CHECK, edition=None) + form = build_stored_form(make_document(), make_meta(), [], [], []) + assert fake.recompute(form).status is DocStatus.NEEDS_CHECK + assert fake.forms == [form] + assert fake.edition(DocumentType.BALANCE, [("1200", True)], None, None) is None + assert fake.edition_calls[0][1] == [("1200", True)] + assert fake.contract_document_status({}) is DocStatus.NEEDS_CHECK diff --git a/openspec/changes/unified-normalization-core-b/design.md b/openspec/changes/unified-normalization-core-b/design.md index 270ad02..01ff4b4 100644 --- a/openspec/changes/unified-normalization-core-b/design.md +++ b/openspec/changes/unified-normalization-core-b/design.md @@ -33,3 +33,6 @@ Goals и Non-goals — см. `proposal.md`. | 2026-10-18 | **R-1 (Р-40).** `CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` пишутся только для кодов из ровно четырёх цифр; пятизначные подстроки Excel не проверяются. **P3-7 плана в этой части заменён** (запись на код документа сохраняется, пятизначные коды — нет) | решение владельца 18.10.2026 (Р-40) перед исполнением Б; закрывает O-3 (поток `info` у пятизначных) | пропущенный `info` у нестандартного пятизначного кода; восстановление — снять условие длины кода в маппере 2.0 и пересчёте | | 2026-10-18 | **R-2 (Р-34).** Подтверждение кода строки — флаг `is_confirmed`; `line_code_confidence` и `line_code_modified` не меняются (P3-16 части А) | решение владельца 18.10.2026; в пересчёте Б правило `is_confirmed ⇒ needs_review строки = false` берётся из P3-16 | нет — поведение уже реализовано в части А | | 2026-10-18 | **R-3.** Удаление копии словаря §7 в дельте оформлено как MODIFIED «Соотношения §7 по колонкам», а не REMOVED (см. D-4); «Ответ `GET …/statement`» и порядок 409 оформлены новыми ADDED-требованиями, так как в `openspec/specs/` и дельтах части А одноимённых нет | `openspec archive` требует, чтобы имя REMOVED/MODIFIED существовало; иначе архив упадёт | при архивации — правка имён заголовков дельт | +| 2026-10-18 | **P3-25 (B1).** Тип формы во входе пересчёта — `document.document_type` (`BALANCE` / `INCOME` / `DISCLOSURE`), а не `meta.statement_type` (только `balance` / `income`); план писал «тип формы» без источника | `DISCLOSURE` не представим в `statement_type`; тип документа — единственный источник для всех трёх типов | форма `DISCLOSURE` собралась бы с ошибкой `KeyError` / потерей типа; правка — одна строка в `build_stored_form` | +| 2026-10-18 | **P3-26 (B1, O-3 часть А).** Копия `statement-code-orders.yaml` в `api/contracts/` не заводится: перечень кодов читает пакет из своей байтовой копии `api/arm_statements/rules/statement_code_orders.yaml` (`loader._ORDERS`), ядро файла не открывает; идентичность копии пакета с `docs/contracts/statement-code-orders.yaml` проверяет `sync-statements --check`; тест идентичности контракта сверяет только `*.json`. Сейчас копия пакета побайтно равна файлу docs | второй экземпляр файла в ядре никто бы не читал и он мог бы разойтись с пакетом | нет; если sync-statements когда-либо перестанет сверять YAML — добавить сверку в `contract_identity.py` | +| 2026-10-18 | **P3-27 (B1).** `edition_codes` берёт строки `ok` результата `2.0`, сортирует по `row_index` и помечает код уверенным при `line_code_confidence == 100` (ровно), `None` — неуверенный | как в разделе «Что ядро вызывает» и в `recompute` пакета (код правленый/подтверждённый там — `code_fixed`) | редакция приёма разойдётся с редакцией пересчёта; сверку делает `test_edition_codes_and_engine_edition` | -- 2.47.2 From 065153e470b6ef1e902531ac9e26fe82553a1e57 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 20:26:40 +0400 Subject: [PATCH 03/13] =?UTF-8?q?feat(statements):=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D1=87=D1=91=D1=82=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=BE=D0=B9=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0=20arm=5Fsta?= =?UTF-8?q?tements,=20=C2=A77=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20?= =?UTF-8?q?=D0=B8=D0=B7=20document=5Fissue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recompute_form (services/statement_recompute.py) собирает вход из БД, зовёт engine.recompute и пишет needs_review строк и значений, form_variant, parent_row_index расшифровок, записи relation/soft и core по P3-8, статус через form_status_v2. GET/confirm/accept читают нарушения §7 из document_issue. Правка header.report_date пересчитывает relative:* колонки. Временный код A12/A13 удалён. Co-Authored-By: Claude Sonnet 5.5 --- api/src/api/dependencies/files.py | 11 +- api/src/domain/statement_layout.py | 15 +- api/src/repositories/statements.py | 84 +++--- api/src/schemas/statements.py | 30 +- api/src/services/statement_line_edits.py | 14 - api/src/services/statement_recompute.py | 275 ++++++++++++++++++ api/src/services/statements.py | 110 +++---- api/src/services/statements_engine.py | 11 +- api/tests/test_api_statements.py | 224 +++++++++++++- api/tests/test_domain_statement_layout.py | 13 +- api/tests/test_form_confirmable.py | 15 +- api/tests/test_statement_recompute.py | 183 ++++++++++++ .../unified-normalization-core-b/design.md | 8 + 13 files changed, 825 insertions(+), 168 deletions(-) create mode 100644 api/src/services/statement_recompute.py create mode 100644 api/tests/test_statement_recompute.py diff --git a/api/src/api/dependencies/files.py b/api/src/api/dependencies/files.py index c18f316..63afe17 100644 --- a/api/src/api/dependencies/files.py +++ b/api/src/api/dependencies/files.py @@ -11,6 +11,7 @@ from src.domain.policy import VisibilityScope from src.services.documents import DocumentService from src.services.primary_files import PrimaryFilesService from src.services.statements import StatementService +from src.services.statements_engine import StatementsEngine, default_engine def get_primary_files_service( @@ -43,13 +44,19 @@ def get_document_service( DocumentServiceDep = Annotated[DocumentService, Depends(get_document_service)] +def get_statements_engine() -> StatementsEngine: + """Общий код нормализации `arm_statements` (правила — один раз на процесс); тесты подменяют зависимость.""" + return default_engine() + + def get_statement_service( session: SessionDep, audit: Annotated[RequestAudit, Depends(get_request_audit)], scope: Annotated[VisibilityScope, Depends(get_scope)], + engine: Annotated[StatementsEngine, Depends(get_statements_engine)], ) -> StatementService: - """Сервис формы ББ/ОФР: чтение, проверки §7 (видимость — через ЮЛ-владельца).""" - return StatementService(session, audit, scope) + """Сервис формы ББ/ОФР: чтение, пересчёт после правки, проверки §7 (видимость — через ЮЛ-владельца).""" + return StatementService(session, audit, scope, engine) StatementServiceDep = Annotated[StatementService, Depends(get_statement_service)] diff --git a/api/src/domain/statement_layout.py b/api/src/domain/statement_layout.py index 3df26e5..dc7870c 100644 --- a/api/src/domain/statement_layout.py +++ b/api/src/domain/statement_layout.py @@ -10,7 +10,7 @@ import bisect from collections.abc import Hashable, Sequence from dataclasses import dataclass -__all__ = ["LayoutLine", "nearest_ok_parents", "order_lines"] +__all__ = ["LayoutLine", "order_lines"] OK = "ok" DETAIL = "detail" @@ -51,16 +51,3 @@ def order_lines(lines: Sequence[LayoutLine]) -> list[Hashable]: result.append(line.key) result.extend(item.key for item in attached.get(line.key, [])) return result - - -def nearest_ok_parents(lines: Sequence[LayoutLine]) -> dict[Hashable, int | None]: - """Родитель строк `detail`: `row_index` ближайшей строки `ok` выше по `row_index` (строки без `row_index` — - шаблонные — родителями не бывают); нет такой — `None`. Временная: в части Б заменяется `detail_parents` пакета.""" - ok_rows = sorted(line.row_index for line in lines if line.line_status == OK and line.row_index is not None) - parents: dict[Hashable, int | None] = {} - for line in lines: - if line.line_status != DETAIL: - continue - position = bisect.bisect_left(ok_rows, line.row_index) - 1 if line.row_index is not None else -1 - parents[line.key] = ok_rows[position] if position >= 0 else None - return parents diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index d72c52f..7d39e37 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from decimal import Decimal from typing import Any -from sqlalchemy import delete, exists, func, insert, or_, select, update +from sqlalchemy import delete, func, insert, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql.expression import ScalarSelect @@ -22,6 +22,7 @@ from src.models.enums import ( FileStatus, IssueSeverity, IssueSource, + PeriodQuarter, StatementLineStatus, ) from src.models.files import Document, StoredFile @@ -47,15 +48,14 @@ __all__ = [ "form_aggregate", "form_lines", "get_meta", - "has_review_values", - "has_source_lines", "insert_statement", "issue_counts_by_document", "issues", "load_form", "lock_cells", + "file_form_periods", "pair_amounts", - "settle_edited_values", + "replace_issues", "value_flags", ] @@ -224,15 +224,40 @@ async def _delete_statements(session: AsyncSession, documents: ScalarSelect[uuid await session.execute(delete(FinancialStatementMeta).where(FinancialStatementMeta.document_id.in_(documents))) -async def has_source_lines(session: AsyncSession, meta_id: uuid.UUID) -> bool: - """В форме есть строка источника (`row_index IS NOT NULL`) — единое определение «есть строки» для статуса D-6; - шаблонные строки (созданные правкой) не в счёт.""" - stmt = select( - select(FinancialStatementLine.id) - .where(FinancialStatementLine.meta_id == meta_id, FinancialStatementLine.row_index.is_not(None)) - .exists() +async def replace_issues( # noqa: PLR0913 - фильтр замены и новые записи независимы + session: AsyncSession, + document_id: uuid.UUID, + drafts: Sequence[IssueDraft], + *, + kinds: Collection[DocumentIssueKind], + core_codes: Collection[str], + now: datetime.datetime, +) -> None: + """Пересчёт после правки: удаляет записи документа видов `kinds` (любого источника) и записи источника `core` с + кодами `core_codes`, затем вставляет `drafts`. Прочие записи (`warning` источника `service`, `MAPPING_WARNING` и + т. п.) сохраняются.""" + await session.execute( + delete(DocumentIssue).where( + DocumentIssue.document_id == document_id, + or_( + DocumentIssue.kind.in_(kinds), + (DocumentIssue.source == IssueSource.core) & DocumentIssue.code.in_(core_codes), + ), + ) ) - return bool((await session.execute(stmt)).scalar_one()) + await add_issues(session, document_id, drafts, now=now) + + +async def file_form_periods( + session: AsyncSession, file_id: uuid.UUID +) -> list[tuple[uuid.UUID, DocumentType, int | None, PeriodQuarter | None]]: + """Формы ББ/ОФР файла (документы с шапкой): `(id, тип, год, квартал)` — вход `duplicate_period_forms` (Р-18).""" + stmt = ( + select(Document.id, Document.document_type, Document.period_year, Document.period_quarter) + .join(FinancialStatementMeta, FinancialStatementMeta.document_id == Document.id) + .where(Document.stored_file_id == file_id) + ) + return [(row[0], row[1], row[2], row[3]) for row in (await session.execute(stmt)).all()] async def unresolved_lines(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[uuid.UUID, int | None, str | None]]: @@ -317,41 +342,6 @@ async def value_flags(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[b return [(bool(modified), recognized, confidence) for modified, recognized, confidence in rows] -async def has_review_values(session: AsyncSession, meta_id: uuid.UUID) -> bool: - """Есть ли в форме значение с `needs_review` у строки не `detail` (вход условия `RECOGNIZED`; значения строк - `detail` не требуют проверки, Р-23).""" - stmt = select( - exists().where( - FinancialStatementValue.line_id == FinancialStatementLine.id, - FinancialStatementLine.meta_id == meta_id, - FinancialStatementLine.line_status != StatementLineStatus.detail, - FinancialStatementValue.needs_review.is_(True), - ) - ) - return bool((await session.execute(stmt)).scalar()) - - -async def settle_edited_values(session: AsyncSession, meta_id: uuid.UUID) -> None: - """Правило пересчёта: `is_modified` или `is_confirmed` значения ⇒ `needs_review = false`; значения строк `detail` — - тоже `false` (Р-23).""" - lines = select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id == meta_id) - detail = lines.where(FinancialStatementLine.line_status == StatementLineStatus.detail) - await session.execute( - update(FinancialStatementValue) - .where( - FinancialStatementValue.line_id.in_(lines), - FinancialStatementValue.needs_review.is_(True), - or_( - FinancialStatementValue.is_modified.is_(True), - FinancialStatementValue.is_confirmed.is_(True), - FinancialStatementValue.line_id.in_(detail), - ), - ) - .values(needs_review=False) - .execution_options(synchronize_session="fetch") - ) - - async def lock_cells( session: AsyncSession, meta_id: uuid.UUID, cells: Sequence[tuple[uuid.UUID, uuid.UUID]] ) -> dict[tuple[uuid.UUID, uuid.UUID], FinancialStatementValue]: diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index b0cb8a6..a53b520 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -14,11 +14,12 @@ from typing import Annotated, Literal from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator from src.domain.statement_aggregate import CodeAggregate -from src.domain.statement_checks import ChecksReport, FailedCheck +from src.domain.statement_checks import FailedCheck from src.domain.statement_layout import LayoutLine, order_lines from src.domain.statement_lines import LineCatalog, TemplateLine from src.models.enums import ( DocumentCheckResult, + DocumentIssueKind, DocumentStatus, DocumentType, PeriodQuarter, @@ -133,8 +134,8 @@ class FailedCheckOut(BaseModel): column_index: int relation: str - expected: Decimal - actual: Decimal + expected: Decimal | None # None — цель или все члены пусты (P1-21) + actual: Decimal | None codes: list[str] = [] line_ids: list[uuid.UUID] = [] @@ -196,6 +197,23 @@ def _failed(check: FailedCheck, aggregates: Mapping[str, CodeAggregate]) -> Fail ) +def _failed_issue(issue: DocumentIssue, column_index: Mapping[uuid.UUID, int]) -> FailedCheckOut: + """Нарушение §7 из записи `document_issue` (`relation` / `soft`): `codes` и `line_ids` — из записи.""" + index = issue.column_index + if issue.column_id is not None and issue.column_id in column_index: + index = column_index[issue.column_id] + codes = issue.codes if isinstance(issue.codes, list) else [] + line_ids = issue.line_ids if isinstance(issue.line_ids, list) else [] + return FailedCheckOut( + column_index=index if index is not None else -1, + relation=issue.relation or "", + expected=issue.expected, + actual=issue.actual, + codes=[str(item) for item in codes], + line_ids=[uuid.UUID(str(item)) for item in line_ids], + ) + + def _value_out(value: FinancialStatementValue, column_index: dict[uuid.UUID, int]) -> ValueOut: return ValueOut( id=value.id, @@ -345,7 +363,6 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч meta: FinancialStatementMeta, columns: Sequence[FinancialStatementColumn], lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], - report: ChecksReport, cross: FailedCheck | None, *, edition: str | None, @@ -358,6 +375,7 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч """Ответ экрана проверки: документ, шапка, колонки, строки со значениями и проверки; строки достроены по справочнику редакции (D-3).""" form = document.document_type.value + by_column = {column.id: column.column_index for column in columns} return StatementOut( document=StatementDocumentOut( id=document.id, @@ -408,8 +426,8 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч columns=[ColumnOut.model_validate(column) for column in columns], lines=_lines_out(columns, lines, template, parents, lambda code: catalog.reference_name(edition, form, code)), checks=ChecksOut( - failed=[_failed(check, aggregates) for check in report.failed], - soft_failed=[_failed(check, aggregates) for check in report.soft_failed], + failed=[_failed_issue(i, by_column) for i in issues if i.kind is DocumentIssueKind.relation], + soft_failed=[_failed_issue(i, by_column) for i in issues if i.kind is DocumentIssueKind.soft], cross_form=_failed(cross, aggregates) if cross is not None else None, accepted=_accepted(meta), ), diff --git a/api/src/services/statement_line_edits.py b/api/src/services/statement_line_edits.py index 7ef893f..5ba24ca 100644 --- a/api/src/services/statement_line_edits.py +++ b/api/src/services/statement_line_edits.py @@ -12,7 +12,6 @@ from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from src.common.exceptions import ConflictError, DomainError, DomainValidationError -from src.domain.statement_layout import LayoutLine, nearest_ok_parents from src.domain.statement_mapping import FULL_CONFIDENCE from src.models.enums import StatementLineStatus from src.models.files import Document @@ -102,18 +101,6 @@ def _apply_item(line: FinancialStatementLine, item: LinePatch) -> bool: return False -def _recompute_parents(lines: Sequence[FinancialStatementLine]) -> None: - """Родитель всех строк `detail` — ближайшая строка `ok` выше по `row_index` (spec «Пересчёт после правки»).""" - layout = [ - LayoutLine(line.id, line.row_index, line.line_status.value, line.line_code, line.parent_row_index) - for line in lines - ] - parents = nearest_ok_parents(layout) - for line in lines: - if line.line_status is StatementLineStatus.detail: - line.parent_row_index = parents.get(line.id) - - async def apply_line_edits( # noqa: PLR0913 - контракт задачи A13: документ, шапка, элементы, пользователь, аудит session: AsyncSession, *, @@ -152,7 +139,6 @@ async def apply_line_edits( # noqa: PLR0913 - контракт задачи A13 lines = [line for line in lines if line.id != template.id] deleted.append(template.id) # T13.2: удаление пустой шаблонной строки — в аудит как `line: -> null` data_edited = any([_apply_item(by_id[item.line_id], item) for item in items]) - _recompute_parents(lines) await session.flush() for line in lines: now = _snapshot(line) diff --git a/api/src/services/statement_recompute.py b/api/src/services/statement_recompute.py new file mode 100644 index 0000000..e8752bd --- /dev/null +++ b/api/src/services/statement_recompute.py @@ -0,0 +1,275 @@ +"""Пересчёт формы ББ/ОФР после правки общим кодом `arm_statements` (часть Б, B2; spec arm-core «Пересчёт после правки»). + +Единая точка: вход пересчёта собирается из БД (`build_stored_form`), считает пакет (`engine.recompute`), результат +пишется обратно — `needs_review` строк и значений, `form_variant`, `parent_row_index` расшифровок, записи +`document_issue` видов `relation` / `soft` и проверок ядра (P3-8), статус документа. §7 хранится только здесь и +в приёме результата (B3/B4); экран и подтверждение читают его из `document_issue`. +""" + +import re +import uuid +from collections.abc import Hashable, Mapping, Sequence +from decimal import Decimal + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.common.clock import utc_now +from src.domain.statement_aggregate import CodeAggregate +from src.domain.statement_issues import IssueDraft +from src.domain.statement_lines import load_line_catalog +from src.domain.statement_status import ( + NameCheckLine, + duplicate_period_forms, + form_status_v2, + has_error_warning, + name_mismatches, +) +from src.domain.stored_form import build_stored_form +from src.models.enums import ( + DocumentCheckResult, + DocumentIssueKind, + DocumentStatus, + IssueSeverity, + IssueSource, + StatementFormVariant, + StatementLineStatus, +) +from src.models.files import Document, RecognitionJob +from src.models.statements import FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta +from src.repositories import statements as repo +from src.services.statements_engine import FailedRelation, StatementChecks, StatementsEngine + +__all__ = [ + "CODE_NOT_IN_DICTIONARY", + "CODE_NOT_IN_EDITION", + "NAME_MISMATCH", + "RECOMPUTED_CORE_CODES", + "RELATION_FAILED", + "RELATION_SOFT", + "STATEMENTS_VERSION_MISMATCH", + "code_issues", + "name_issues", + "recompute_form", + "relations_to_issues", + "version_issue", +] + +RELATION_FAILED = "RELATION_FAILED" +RELATION_SOFT = "RELATION_SOFT" +CODE_NOT_IN_DICTIONARY = "CODE_NOT_IN_DICTIONARY" +CODE_NOT_IN_EDITION = "CODE_NOT_IN_EDITION" +NAME_MISMATCH = "NAME_MISMATCH" +STATEMENTS_VERSION_MISMATCH = "STATEMENTS_VERSION_MISMATCH" +# записи `core`, которые пересчёт заменяет целиком (P3-8); прочие (`MAPPING_WARNING`, `RESULT_STATUS_INCONSISTENT`, +# `LEGACY_WARNING`) — факт приёма, их пересчёт сохраняет +RECOMPUTED_CORE_CODES = (CODE_NOT_IN_DICTIONARY, CODE_NOT_IN_EDITION, NAME_MISMATCH, STATEMENTS_VERSION_MISMATCH) +_CHECKED_CODE = re.compile(r"\d{4}", re.ASCII) # R-1 (Р-40): проверяются только четырёхзначные коды +_EMPTY = "пусто" + + +def _amount_text(value: Decimal | None) -> str: + return _EMPTY if value is None else str(value) + + +def _find_column( + columns: Sequence[FinancialStatementColumn], failure: FailedRelation +) -> FinancialStatementColumn | None: + """Колонка нарушения: по ключу `str(column_id)` (пересчёт из БД), иначе по `str(column_index)` (проверки `2.0`).""" + for column in columns: + if str(column.id) == failure.column_key: + return column + for column in columns: + if str(column.column_index) == failure.column_key: + return column + return None + + +def relations_to_issues( + checks: StatementChecks, + *, + columns: Sequence[FinancialStatementColumn], + aggregates: Mapping[str, CodeAggregate], + source: IssueSource, +) -> list[IssueDraft]: + """Нарушения пакета → черновики `document_issue`: `relations_failed` — `relation` / `RELATION_FAILED` / `error`, + `soft_relations_failed` — `soft` / `RELATION_SOFT` / `info`. `line_ids` — все строки `ok` кодов `codes`; + `expected` / `actual` могут быть `None` (цель или все члены пусты, P1-21) — в сообщении «пусто», в записи `NULL`.""" + drafts: list[IssueDraft] = [] + groups = ( + (checks.relations_failed, DocumentIssueKind.relation, RELATION_FAILED, IssueSeverity.error), + (checks.soft_relations_failed, DocumentIssueKind.soft, RELATION_SOFT, IssueSeverity.info), + ) + for failures, kind, code, severity in groups: + for failure in failures: + column = _find_column(columns, failure) + line_ids = dict.fromkeys( + line_id + for form_code in failure.codes + if form_code in aggregates + for line_id in aggregates[form_code].line_ids + ) + drafts.append( + IssueDraft( + kind=kind, + code=code, + severity=severity, + message=( + f"не сошлось {failure.relation}: " + f"{_amount_text(failure.expected)} ≠ {_amount_text(failure.actual)}" + ), + source=source, + line_ids=tuple(line_ids), + column_id=column.id if column is not None else None, + column_index=failure.column_index, + relation=failure.relation, + codes=tuple(failure.codes), + expected=failure.expected, + actual=failure.actual, + ) + ) + return drafts + + +def _core_info(code: str, message: str, line_ids: tuple[uuid.UUID, ...] = ()) -> IssueDraft: + return IssueDraft( + kind=DocumentIssueKind.warning, + code=code, + severity=IssueSeverity.info, + message=message, + source=IssueSource.core, + line_ids=line_ids, + ) + + +def code_issues( + lines: Sequence[FinancialStatementLine], + *, + in_dictionary: Mapping[str, bool], + in_edition: Mapping[str, bool] | None, +) -> list[IssueDraft]: + """`CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` — по записи на код документа (`line_ids` — все строки `ok` с + этим кодом), только для кодов из ровно четырёх цифр (R-1, Р-40). `in_dictionary` — код есть в словаре имён редакции, + `in_edition` — код из перечня редакции и варианта (`None` — редакция или вариант не определены, не проверяется).""" + by_code: dict[str, list[FinancialStatementLine]] = {} + for line in lines: + if line.line_status is StatementLineStatus.ok and line.line_code and _CHECKED_CODE.fullmatch(line.line_code): + by_code.setdefault(line.line_code, []).append(line) + drafts: list[IssueDraft] = [] + for code in sorted(by_code): + ids = tuple( + line.id for line in sorted(by_code[code], key=lambda item: (item.row_index is None, item.row_index or 0)) + ) + if not in_dictionary.get(code, True): + drafts.append(_core_info(CODE_NOT_IN_DICTIONARY, f"кода {code} нет в словаре наименований редакции", ids)) + if in_edition is not None and not in_edition.get(code, True): + drafts.append(_core_info(CODE_NOT_IN_EDITION, f"код {code} не входит в перечень кодов редакции формы", ids)) + return drafts + + +def name_issues(lines: Sequence[FinancialStatementLine], mismatched: Sequence[Hashable]) -> list[IssueDraft]: + """`NAME_MISMATCH` — по записи на строку из `name_mismatches`.""" + by_id = {line.id: line for line in lines} + drafts: list[IssueDraft] = [] + for key in mismatched: + line = by_id[key] # type: ignore[index] + drafts.append( + _core_info( + NAME_MISMATCH, + f"наименование «{line.line_name or ''}» не совпало со справочным для кода {line.line_code}", + (line.id,), + ) + ) + return drafts + + +def version_issue(job_version: str | None, engine_version: str | None) -> IssueDraft | None: + """`STATEMENTS_VERSION_MISMATCH`: версия копии пакета у задания известна и не равна версии копии ядра.""" + if job_version is None or job_version == engine_version: + return None + return _core_info( + STATEMENTS_VERSION_MISMATCH, + f"форма распознана общим кодом версии {job_version}, в ядре версия {engine_version or 'не определена'}", + ) + + +def _name_lines(lines: Sequence[FinancialStatementLine]) -> list[NameCheckLine]: + return [ + NameCheckLine( + key=line.id, + line_status=line.line_status.value, + line_code=line.line_code, + line_code_confidence=line.line_code_confidence, + line_name=line.line_name, + code_fixed=bool(line.line_code_modified) or bool(line.is_confirmed), + ) + for line in lines + ] + + +async def recompute_form( + session: AsyncSession, + *, + document: Document, + meta: FinancialStatementMeta, + engine: StatementsEngine, + job: RecognitionJob | None, +) -> None: + """Пересчёт формы по сохранённым данным (один вызов `engine.recompute`) и запись результата; статус `CONFIRMED` + не меняется. Без commit.""" + columns, loaded = await repo.load_form(session, meta.id) + stored = build_stored_form(document, meta, columns, loaded, await repo.issues(session, document.id)) + result = engine.recompute(stored) + lines = [line for line, _ in loaded] + row_of = {str(line.id): line.row_index for line in lines} + for line, values in loaded: + key = str(line.id) + line.needs_review = bool(result.line_needs_review.get(key, False)) + if line.line_status is StatementLineStatus.detail: + parent_key = result.detail_parents.get(key) + line.parent_row_index = row_of.get(parent_key) if parent_key is not None else None + for value in values: + value.needs_review = bool(result.value_needs_review.get((key, str(value.column_id)), False)) + meta.form_variant = StatementFormVariant(result.form_variant.value) if result.form_variant is not None else None + + form = document.document_type.value + catalog = load_line_catalog() + edition = result.edition.value if result.edition is not None else None + in_dictionary = { + line.line_code: catalog.reference_name(edition, form, line.line_code) is not None + for line in lines + if line.line_code is not None + } + in_edition: dict[str, bool] | None = None + if result.edition is not None and result.form_variant is not None: + order = set(engine.code_order(result.edition, stored.document_type, result.form_variant)) + in_edition = {line.line_code: line.line_code in order for line in lines if line.line_code is not None} + mismatched = name_mismatches(_name_lines(lines), catalog, form) + drafts = [ + *(code_issues(lines, in_dictionary=in_dictionary, in_edition=in_edition) if edition is not None else []), + *name_issues(lines, mismatched), + ] + mismatch = version_issue(job.statements_version if job is not None else None, engine.version) + if mismatch is not None: + drafts.append(mismatch) + _, aggregates = await repo.form_aggregate(session, meta.id) + drafts += relations_to_issues(result.checks, columns=columns, aggregates=aggregates, source=IssueSource.core) + await repo.replace_issues( + session, + document.id, + drafts, + kinds=(DocumentIssueKind.relation, DocumentIssueKind.soft), + core_codes=RECOMPUTED_CORE_CODES, + now=utc_now(), + ) + + if document.status is not DocumentStatus.CONFIRMED: + forms = await repo.file_form_periods(session, document.stored_file_id) + document.status = form_status_v2( + service_done=result.status.value == "done", + file_error_warning=has_error_warning((job.metrics or {}).get("warnings") if job is not None else None), + counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN, + period_known=document.period_year is not None and document.period_quarter is not None, + names_ok=not mismatched, + duplicate_in_file=document.id in duplicate_period_forms(forms), + ) + document.updated_at = utc_now() diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 8a2523d..c6fcd91 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -1,7 +1,7 @@ """Форма ББ/ОФР для экрана «Проверка распознавания» (spec documents-api; design D-5, D-7, D-8, D-14). Видимость — через ЮЛ-владельца (`files_repo.get_document`): чужой документ — тот же 404, что несуществующий. -Проверки §7 считаются на лету и не хранятся. +Нарушения §7 хранятся в `document_issue` и пересчитываются после каждой правки общим кодом `arm_statements`. """ import re @@ -18,32 +18,24 @@ from src.common.exceptions import ConflictError, DomainValidationError, NotFound from src.domain.osv_mapping import counterparty_check from src.domain.policy import VisibilityScope from src.domain.statement_aggregate import CodeAggregate, checks_input -from src.domain.statement_checks import ( - ChecksReport, - FailedCheck, - cross_form_check, - load_check_rules, - run_checks, - subline_parents, -) +from src.domain.statement_checks import FailedCheck, cross_form_check, subline_parents from src.domain.statement_issues import COLUMN_CLEARS, HEADER_CLEARS from src.domain.statement_lines import TemplateLine, load_line_catalog -from src.domain.statement_mapping import edition_for, quarter_of, statement_period, statement_status, value_clean +from src.domain.statement_mapping import edition_for, quarter_of, statement_period from src.models.directories import LegalEntity from src.models.enums import ( AuditAction, AuditSection, - DocumentCheckResult, DocumentIssueKind, DocumentStatus, DocumentType, FileStatus, IssueSource, - RecognitionStatus, StatementLineStatus, ) from src.models.files import Document, RecognitionJob from src.models.statements import ( + DocumentIssue, FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta, @@ -61,6 +53,8 @@ from src.services.documents import ( PERIOD_REQUIRED, ) from src.services.statement_line_edits import apply_line_edits +from src.services.statement_recompute import recompute_form +from src.services.statements_engine import PeriodKind, StatementsEngine, default_engine DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT" @@ -92,14 +86,12 @@ async def form_aggregate( async def statement_hard_failures( session: AsyncSession, document: Document, meta: FinancialStatementMeta -) -> tuple[FailedCheck, ...]: - """Нарушенные жёсткие проверки §7 по текущим суммам формы; при акцепте — пустой кортеж (D-8 Среза 3b).""" +) -> tuple[DocumentIssue, ...]: + """Нарушенные жёсткие проверки §7 — записи `document_issue` вида `relation`; при акцепте — пустой кортеж (D-8 + Среза 3b). Форма без записей (до первого пересчёта) нарушений не показывает.""" if meta.checks_accepted_at is not None: return () - columns, aggregates = await form_aggregate(session, document, meta) - column_indexes, amounts = checks_input(columns, aggregates) - report = run_checks(document.document_type.value, amounts, column_indexes, load_check_rules()) - return tuple(report.failed) + return tuple(issue for issue in await repo.issues(session, document.id) if issue.kind is DocumentIssueKind.relation) @dataclass(frozen=True) @@ -147,8 +139,8 @@ async def form_blocker(session: AsyncSession, document: Document, meta: Financia { "column_index": failure.column_index, "relation": failure.relation, - "expected": str(failure.expected), - "actual": str(failure.actual), + "expected": None if failure.expected is None else str(failure.expected), + "actual": None if failure.actual is None else str(failure.actual), } for failure in failures ] @@ -171,19 +163,14 @@ class _Edit: self.data_edited = self.data_edited or data -def _value_needs_review(value: FinancialStatementValue, line: FinancialStatementLine) -> bool: - """Правило `needs_review` значения при приёме `1.x` и без пакета: правка/подтверждение и строка `detail` снимают - сомнение, иначе — по уверенности распознавания (`value_clean`).""" - if value.is_modified or value.is_confirmed or line.line_status is StatementLineStatus.detail: - return False - return not value_clean(value.confidence, value.is_recognized) - - class StatementService: - def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None: + def __init__( + self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope, engine: StatementsEngine | None = None + ) -> None: self._session = session self._audit = audit self._scope = scope + self._engine = engine if engine is not None else default_engine() async def _visible_statement(self, document_id: uuid.UUID) -> tuple[Document, FinancialStatementMeta]: """Документ формы в пределах видимости: 404 — нет или чужой; 409 `DOCUMENT_NOT_STATEMENT` — не форма.""" @@ -216,23 +203,15 @@ class StatementService: return document, meta async def checks_report( - self, document: Document, meta: FinancialStatementMeta - ) -> tuple[ChecksReport, FailedCheck | None, dict[str, str]]: - """Проверки §7 по текущим суммам формы, мягкая межформенная проверка (D-5) и родители подстрок - (`parent_code` ответа; AURORA-1527). Всё считается по агрегату формы по коду (`form_aggregate`): родители — по - тем же кодам и в том же порядке, что и проверка «сумма подстрок = родитель» (ответ и проверка не расходятся).""" - columns, aggregates = await form_aggregate(self._session, document, meta) - return await self._report(document, columns, aggregates) - - async def _report( self, document: Document, columns: list[FinancialStatementColumn], aggregates: dict[str, CodeAggregate] - ) -> tuple[ChecksReport, FailedCheck | None, dict[str, str]]: - rules = load_check_rules() - column_indexes, amounts = checks_input(columns, aggregates) - report = run_checks(document.document_type.value, amounts, column_indexes, rules) + ) -> tuple[FailedCheck | None, dict[str, str]]: + """Мягкая межформенная проверка (D-5) и родители подстрок (`parent_code` ответа; AURORA-1527). Нарушения §7 + сюда не входят: ответ читает их из `document_issue`. Родители — по агрегату формы по коду, в том же порядке, + что и проверка «сумма подстрок = родитель» пакета.""" + _, amounts = checks_input(columns, aggregates) parents = subline_parents([code for code, _ in amounts]) balance_1370, income_2400 = await repo.pair_amounts(self._session, document) - return report, cross_form_check(balance_1370, income_2400, rules.tolerance), parents + return cross_form_check(balance_1370, income_2400, self._engine.tolerance()), parents async def get(self, document_id: uuid.UUID) -> schemas.StatementOut: document, meta = await self._visible_statement(document_id) @@ -243,7 +222,7 @@ class StatementService: ответами правки/акцепта). В БД ничего не пишет.""" columns, lines = await repo.load_form(self._session, meta.id) _, aggregates = await form_aggregate(self._session, document, meta) - report, cross, parents = await self._report(document, columns, aggregates) + cross, parents = await self.checks_report(document, columns, aggregates) edition = edition_for(document.reporting_date) catalog = load_line_catalog() return schemas.statement_out( @@ -251,7 +230,6 @@ class StatementService: meta, columns, lines, - report, cross, edition=edition, template=catalog.template_lines(edition, document.document_type.value), @@ -261,32 +239,17 @@ class StatementService: catalog=catalog, ) - async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]: + async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[DocumentIssue, ...]: """Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы).""" return await statement_hard_failures(self._session, document, meta) async def recompute(self, document: Document, meta: FinancialStatementMeta) -> None: - """Единая точка пересчёта после правки или акцепта (spec arm-core «Пересчёт после правки»; D-6); `CONFIRMED` - не трогает. До части Б `values_ok` — нет `needs_review` у значений строк не `detail`; сигнатура остаётся и в Б. - - `warnings=()`: предупреждения приёма (`MAPPING_WARNING`) хранятся как `document_issue` уровня `info` и - определяют статус только при приёме (D-6); после правки аналитика статус строится по данным формы, - проверкам, контрагенту и периоду, а эти записи остаются видимыми в `issues` и статус не удерживают. - """ + """Единая точка пересчёта после правки или акцепта (spec arm-core «Пересчёт после правки»; D-6): общий код + `arm_statements`, запись результата — `recompute_form`. `CONFIRMED` не трогает.""" if document.status is DocumentStatus.CONFIRMED: return - await repo.settle_edited_values(self._session, meta.id) job = await self._session.get(RecognitionJob, meta.recognition_job_id) - document.status = statement_status( - job_done=job is not None and job.status is RecognitionStatus.done, - checks_passed=not await self.hard_failures(document, meta), - counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN, - period_known=document.period_year is not None and document.period_quarter is not None, - values_ok=not await repo.has_review_values(self._session, meta.id), - has_lines=await repo.has_source_lines(self._session, meta.id), - warnings=(), - ) - document.updated_at = utc_now() + await recompute_form(self._session, document=document, meta=meta, engine=self._engine, job=job) async def _apply_cells( self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch, edit: _Edit @@ -344,7 +307,6 @@ class StatementService: value.amount = amount value.is_modified = value.amount != value.original_amount value.modified_by, value.modified_at = self._audit.user_id, now - value.needs_review = _value_needs_review(value, line) def _apply_confirmation( self, @@ -361,7 +323,6 @@ class StatementService: edit.record(f"value:{value.id}:confirmed", value.is_confirmed, confirmed, data=False) value.is_confirmed = confirmed value.confirmed_by, value.confirmed_at = (self._audit.user_id, now) if confirmed else (None, None) - value.needs_review = _value_needs_review(value, line) async def _resolve_cells( self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch @@ -491,6 +452,21 @@ class StatementService: meta.units, meta.units_modified = header.units, True return date_changed + async def _resolve_relative_columns(self, meta: FinancialStatementMeta, edit: _Edit) -> bool: + """Правка `header.report_date` (в том числе `null`): `period_date` колонок с `period_kind` `relative:*` + пересчитывается от новой даты отчёта до пересчёта формы; ручные (`absolute`) даты не трогаются. `True` — + даты колонок изменились (нужен пересчёт `is_current` и периода).""" + changed = False + for column in (await repo.load_form(self._session, meta.id))[0]: + if column.period_kind is None or not str(column.period_kind).startswith("relative:"): + continue + resolved = self._engine.resolve_relative(PeriodKind(str(column.period_kind)), meta.report_date) + if resolved != column.period_date: + edit.record(f"column:{column.id}", column.period_date, resolved, data=False) + column.period_date = resolved + changed = True + return changed + async def _refresh_period(self, document: Document, meta: FinancialStatementMeta, *, columns_changed: bool) -> None: """Период документа — по §9.5 п. 2 (дата отчёта, иначе самая поздняя дата колонки); `is_current` колонок — колонка с самой поздней датой (меньший `column_index` при равенстве), как при приёме.""" @@ -530,6 +506,8 @@ class StatementService: date_changed = False if header is not None: date_changed = await self._apply_header(document, meta, header, edit) + if date_changed: + columns_changed = await self._resolve_relative_columns(meta, edit) or columns_changed if not edit.after: return await self.build_out(document, meta) if columns_changed or date_changed: diff --git a/api/src/services/statements_engine.py b/api/src/services/statements_engine.py index 74b8365..1c0e035 100644 --- a/api/src/services/statements_engine.py +++ b/api/src/services/statements_engine.py @@ -14,10 +14,12 @@ from arm_statements import ( DocStatus, DocumentType, Edition, + FailedRelation, FormVariant, PeriodKind, RecomputeResult, Rules, + StatementChecks, StoredForm, choose_edition, default_rules, @@ -28,7 +30,14 @@ from arm_statements import ( status_input_from_contract, ) -__all__ = ["ArmStatementsEngine", "StatementsEngine", "default_engine"] +__all__ = [ + "ArmStatementsEngine", + "FailedRelation", + "PeriodKind", + "StatementChecks", + "StatementsEngine", + "default_engine", +] class StatementsEngine(Protocol): diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 1033560..5872c4f 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -10,9 +10,11 @@ from pathlib import Path from typing import Any import pytest +from fastapi import FastAPI from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession +from src.api.dependencies.files import get_statements_engine from src.domain.statement_checks import CROSS_FORM_RELATION from src.models.access import AppUser from src.models.audit_ex import AuditEvent @@ -29,7 +31,7 @@ from src.models.enums import ( RecognitionStatus, StatementLineStatus, ) -from src.models.files import Document, StoredFile +from src.models.files import Document, RecognitionJob, StoredFile from src.models.statements import ( DocumentIssue, FinancialStatementColumn, @@ -37,6 +39,9 @@ from src.models.statements import ( FinancialStatementMeta, FinancialStatementValue, ) +from src.services.statement_recompute import recompute_form +from src.services.statements import form_blocker +from src.services.statements_engine import default_engine from tests.conftest import AuthClientFactory from tests.factories import ( make_company_group, @@ -45,6 +50,7 @@ from tests.factories import ( make_legal_entity, make_stored_file, ) +from tests.fake_engine import FakeEngine from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot pytestmark = [pytest.mark.integration, pytest.mark.fixtures] @@ -259,6 +265,7 @@ async def test_get_statement_parent_code_matches_subline_check( document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE) await set_amount(seeded, document, "1110", Decimal("999999"), current=True) await set_amount(seeded, document, "11101", Decimal("1"), current=True) # детализация есть — проверка работает + await recompute_now(seeded, document) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() kids = sorted(line["line_code"] for line in body["lines"] if line["parent_code"] == "1110") @@ -303,6 +310,15 @@ async def _meta(session: AsyncSession, document: Document) -> FinancialStatement ).scalar_one() +async def recompute_now(session: AsyncSession, document: Document) -> None: + """Пересчёт формы общим кодом, как после первой правки: §7 хранится в `document_issue`, а не считается на лету. + До приёма B3 у загруженных маппером форм записей нарушений нет — тест, меняющий суммы в БД напрямую, зовёт это.""" + meta = await _meta(session, document) + job = await session.get(RecognitionJob, meta.recognition_job_id) + await recompute_form(session, document=document, meta=meta, engine=default_engine(), job=job) + await session.flush() + + async def drop_line(session: AsyncSession, document: Document, line_code: str) -> dict[int, Decimal | None]: """Удалить строку формы в БД теста (как будто источник её потерял); вернуть её суммы по `column_index`.""" meta = await _meta(session, document) @@ -373,6 +389,7 @@ async def test_get_completes_lost_line_without_writing( """Строка, потерянная источником (1700), достроена из справочника; статус и проверки — как без достройки.""" document = await form(seeded, fixtures_dir) await drop_line(seeded, document, "1700") + await recompute_now(seeded, document) lines_in_db = await db_line_count(seeded, document) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() @@ -391,8 +408,10 @@ async def test_get_completes_lost_line_without_writing( ) assert [v["column_index"] for v in line["values"]] == sorted(c["column_index"] for c in body["columns"]) relations = {(f["relation"], f["column_index"]) for f in body["checks"]["failed"]} - # цель 1700 не проверяется; «1600 = 1700» (цель 1600 есть, 1700 = 0) нарушено — как и без достройки - assert not any(relation.startswith("1700 =") for relation, _ in relations) + # пакет (P1-21): посчитанное соотношение с пустой целью — нарушение с `expected = null`; «1600 = 1700» (цель 1600 + # есть, 1700 пуста) нарушено — как и без достройки + lost_target = [f for f in body["checks"]["failed"] if f["relation"].startswith("1700 =")] + assert lost_target and all(f["expected"] is None for f in lost_target) assert {index for relation, index in relations if relation == "1600 = 1700"} == {0, 1, 2} assert body["document"]["status"] == document.status.value # статус пересчитывается только правкой assert await db_line_count(seeded, document) == lines_in_db @@ -456,6 +475,8 @@ async def test_cross_form_violation_is_reported_on_both_forms( await set_amount(seeded, balance, "1370", Decimal("500"), current=True) await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31)) await set_amount(seeded, income, "2400", Decimal("150"), current=True) + await recompute_now(seeded, balance) + await recompute_now(seeded, income) client = await auth_client(KA_RF) for document in (balance, income): body = (await client.get(f"/documents/{document.id}/statement")).json() @@ -1573,7 +1594,10 @@ async def test_template_line_checks_follow_filled_amounts( ) assert response.status_code == HTTPStatus.OK, response.text after_clear = response.json()["checks"]["failed"] - assert not any(f["relation"].startswith("1700 =") for f in after_clear) # очищенная 1700 снова не цель + # пакет (P1-21): пустая цель при непустых членах — нарушение с `expected = null` (прежнее «очищенная не цель» + # не действует): 1700 пуста во всех колонках — нарушение в каждой + emptied = [f for f in after_clear if f["relation"] == relation] + assert sorted((f["column_index"], f["expected"]) for f in emptied) == [(0, None), (1, None), (2, None)] assert {f["column_index"] for f in after_clear if f["relation"] == "1600 = 1700"} == {0, 1, 2} @@ -1849,6 +1873,7 @@ async def test_failed_check_has_codes_and_line_ids( ) -> None: document = await form(seeded, fixtures_dir) await set_amount(seeded, document, "1600", Decimal("1"), current=True) + await recompute_now(seeded, document) body = await _statement(await auth_client(KA_RF), document) failed = body["checks"]["failed"] assert failed, body["checks"] @@ -2046,12 +2071,36 @@ async def test_unconfirm_cell(seeded: AsyncSession, fixtures_dir: Path, auth_cli ) assert response.status_code == HTTPStatus.OK, response.text edited = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0] - assert edited["is_confirmed"] is False and edited["needs_review"] is True + # ячейка покрыта сошедшимся соотношением — пакет считает её чистой и после снятия подтверждения + assert edited["is_confirmed"] is False and edited["needs_review"] is False value = await _db_value(seeded, line["id"], cell["column_id"]) assert value.confirmed_by is None and value.confirmed_at is None assert len(await _edit_events(seeded, document)) == 1 +async def test_unconfirm_uncovered_cell( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Сценарий «Снятие подтверждения»: ячейка вне соотношений после снятия подтверждения — `needs_review`.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, document.id) + db_line = await seeded.get(FinancialStatementLine, uuid.UUID(line["id"])) + assert db_line is not None + db_line.line_code = "1999" # кода нет ни в одном соотношении + value = await _db_value(seeded, line["id"], cell["column_id"]) + value.confidence, value.is_confirmed, value.needs_review = Decimal(80), True, False + value.confirmed_by, value.confirmed_at = await _user_id(seeded), NOW + await seeded.flush() + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "confirmed": False}]}, + ) + assert response.status_code == HTTPStatus.OK, response.text + edited = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0] + assert edited["is_confirmed"] is False and edited["needs_review"] is True + + async def test_cell_amount_and_confirmed_is_422( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: @@ -2308,3 +2357,168 @@ async def test_segment_warning_stays_after_value_edit( ).status_code == HTTPStatus.OK confirm = await client.post(f"/documents/{document.id}/confirm") assert confirm.status_code == HTTPStatus.OK, confirm.text + + +# --- B2: пересчёт общим кодом, §7 из document_issue -------------------------------------------------------------- + + +async def _issues(session: AsyncSession, document: Document) -> list[DocumentIssue]: + stmt = ( + select(DocumentIssue).where(DocumentIssue.document_id == document.id).execution_options(populate_existing=True) + ) + return list((await session.execute(stmt)).scalars()) + + +async def _cell(client: Any, document: Document, code: str, column_index: int) -> tuple[str, str, str | None]: + """`(line_id, column_id, amount)` ячейки строки `ok` с кодом `code` в колонке `column_index`.""" + body = await _statement(client, document) + line = next(item for item in body["lines"] if item["line_code"] == code and item["id"] is not None) + value = next(v for v in line["values"] if v["column_index"] == column_index) + return line["id"], value["column_id"], value["amount"] + + +async def test_1x_form_after_patch_recomputed( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Сценарий «1.x-документ после PATCH»: запись `relation` по 1200 заменена, статус пересчитан, версии нет.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line_id, column_id, original = await _cell(client, document, "1230", 0) + assert original is not None + await set_amount(seeded, document, "1230", Decimal(original) + 1000, current=True) + await recompute_now(seeded, document) # до B3 запись приёма вставляется пересчётом + broken = [i for i in await _issues(seeded, document) if (i.relation or "").startswith("1200 =")] + assert broken and all(i.kind is DocumentIssueKind.relation and i.source is IssueSource.core for i in broken) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": line_id, "column_id": column_id, "amount": original}]}, + ) + assert response.status_code == HTTPStatus.OK, response.text + assert not [i for i in await _issues(seeded, document) if (i.relation or "").startswith("1200 =")] + assert not any(f["relation"].startswith("1200 =") for f in response.json()["checks"]["failed"]) + assert "STATEMENTS_VERSION_MISMATCH" not in await _issue_codes(seeded, document) + + +async def test_migrated_form_shows_violation_after_first_patch( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Сценарий «Миграция на непустой базе»: форма без записей §7 нарушений не показывает и не блокирует + подтверждение; после первой правки появляется `RELATION_FAILED`.""" + document = await form(seeded, fixtures_dir) + await set_amount(seeded, document, "1600", Decimal("1"), current=True) # 1600 ≠ 1700, записей нет + client = await auth_client(KA_RF) + assert (await _statement(client, document))["checks"]["failed"] == [] + assert await form_blocker(seeded, document, await _meta(seeded, document)) is None # CHECKS_NOT_PASSED нет + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "млн руб."}}) + assert response.status_code == HTTPStatus.OK, response.text + found = [i for i in await _issues(seeded, document) if i.code == "RELATION_FAILED" and i.codes == ["1600", "1700"]] + assert found and all(i.line_ids for i in found) + assert response.json()["checks"]["failed"] + + +async def test_statements_version_mismatch( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + meta = await _meta(seeded, document) + job = await seeded.get(RecognitionJob, meta.recognition_job_id) + assert job is not None + client = await auth_client(KA_RF) + job.statements_version = "abc1234" + await seeded.flush() + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}}) + assert response.status_code == HTTPStatus.OK, response.text + mismatch = [i for i in await _issues(seeded, document) if i.code == "STATEMENTS_VERSION_MISMATCH"] + assert len(mismatch) == 1 + assert mismatch[0].severity is IssueSeverity.info and mismatch[0].source is IssueSource.core + job.statements_version = None # у задания 1.x версии нет — запись не пишется (и прежняя заменяется) + await seeded.flush() + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "тыс. руб."}}) + assert response.status_code == HTTPStatus.OK, response.text + assert "STATEMENTS_VERSION_MISMATCH" not in await _issue_codes(seeded, document) + job.statements_version = default_engine().version # версия совпала — тоже без записи + await seeded.flush() + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "млн руб."}}) + assert response.status_code == HTTPStatus.OK, response.text + assert "STATEMENTS_VERSION_MISMATCH" not in await _issue_codes(seeded, document) + + +async def test_relative_column_follows_report_date( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + columns = await _db_columns(seeded, document) + relative, manual = columns[1], columns[2] + relative.period_kind = manual.period_kind = "relative:prev_year_end" + await seeded.flush() + client = await auth_client(KA_RF) + url = f"/documents/{document.id}/statement" + assert (await client.patch(url, json={"header": {"report_date": "2026-03-31"}})).status_code == HTTPStatus.OK + assert (await _db_columns(seeded, document))[1].period_date == datetime.date(2025, 12, 31) + # ручная дата делает колонку absolute — дальше дата отчёта её не двигает (A12) + response = await client.patch(url, json={"columns": [{"column_id": str(manual.id), "period_date": "2024-06-30"}]}) + assert response.status_code == HTTPStatus.OK, response.text + assert (await client.patch(url, json={"header": {"report_date": "2025-09-30"}})).status_code == HTTPStatus.OK + fresh = await _db_columns(seeded, document) + assert fresh[1].period_date == datetime.date(2024, 12, 31) and fresh[2].period_date == datetime.date(2024, 6, 30) + assert (await client.patch(url, json={"header": {"report_date": None}})).status_code == HTTPStatus.OK + fresh = await _db_columns(seeded, document) + assert fresh[1].period_date is None and fresh[2].period_date == datetime.date(2024, 6, 30) + + +async def test_checks_from_issues_with_codes_and_line_ids( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """`checks.failed`, `NOTHING_TO_ACCEPT` и `CHECKS_NOT_PASSED` читают запись `document_issue`, а не сумм формы.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + nothing = await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": "x"}) + assert nothing.status_code == HTTPStatus.CONFLICT and nothing.json()["detail"]["code"] == "NOTHING_TO_ACCEPT" + line_1600, column_id, _ = await _cell(client, document, "1600", 0) + seeded.add( + DocumentIssue( + document_id=document.id, + kind=DocumentIssueKind.relation, + code="RELATION_FAILED", + severity=IssueSeverity.error, + message="не сошлось 1600 = 1700: пусто ≠ 5", + line_ids=[line_1600], + column_id=uuid.UUID(column_id), + codes=["1600", "1700"], + source=IssueSource.core, + relation="1600 = 1700", + expected=None, + actual=Decimal(5), + created_at=NOW, + ) + ) + await seeded.flush() + (failed,) = (await _statement(client, document))["checks"]["failed"] + assert failed["codes"] == ["1600", "1700"] and failed["line_ids"] == [line_1600] + assert failed["relation"] == "1600 = 1700" and failed["expected"] is None and failed["actual"] == "5.00" + blocked = await client.post(f"/documents/{document.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED" + assert blocked.json()["detail"]["failures"][0]["expected"] is None + + +async def test_one_patch_one_recompute( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, app: FastAPI +) -> None: + """`PATCH` из трёх частей: один вызов пересчёта пакета и одно событие аудита.""" + fake = FakeEngine() + app.dependency_overrides[get_statements_engine] = lambda: fake + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, document.id) + line_id, column_id, amount = line["id"], cell["column_id"], cell["amount"] + response = await client.patch( + f"/documents/{document.id}/statement", + json={ + "lines": [{"line_id": line_id, "confirmed": True}], + "values": [{"line_id": line_id, "column_id": column_id, "amount": str(Decimal(amount) + 1)}], + "header": {"units": "руб."}, + }, + ) + assert response.status_code == HTTPStatus.OK, response.text + assert len(fake.forms) == 1 + assert len(await _edit_events(seeded, document)) == 1 diff --git a/api/tests/test_domain_statement_layout.py b/api/tests/test_domain_statement_layout.py index 6e824d9..0cac669 100644 --- a/api/tests/test_domain_statement_layout.py +++ b/api/tests/test_domain_statement_layout.py @@ -1,6 +1,6 @@ """Порядок строк ответа `GET …/statement` (P3-17): строки `ok` по коду, вложения источника — за своей строкой.""" -from src.domain.statement_layout import LayoutLine, nearest_ok_parents, order_lines +from src.domain.statement_layout import LayoutLine, order_lines def L( # noqa: N802 - короткая фабрика строки для таблиц тестов @@ -44,14 +44,3 @@ def test_attachments_keep_row_order() -> None: def test_empty() -> None: assert order_lines([]) == [] - - -def test_nearest_ok_parents() -> None: - lines = [ - L("a", 0, "ok", "1100", None), - L("b", 1, "detail", None, None), - L("t", None, "ok", "1250", None), - L("c", 3, "detail", None, None), - ] - assert nearest_ok_parents(lines) == {"b": 0, "c": 0} - assert nearest_ok_parents([L("d", 1, "detail", None, None), L("u", 2, "unreadable", None, None)]) == {"d": None} diff --git a/api/tests/test_form_confirmable.py b/api/tests/test_form_confirmable.py index d5df08e..a37de6a 100644 --- a/api/tests/test_form_confirmable.py +++ b/api/tests/test_form_confirmable.py @@ -10,14 +10,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.models.directories import LegalEntity from src.models.enums import DocumentStatus, DocumentType, StatementLineStatus -from src.models.files import Document +from src.models.files import Document, RecognitionJob from src.models.statements import ( FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue, ) +from src.services.statement_recompute import recompute_form from src.services.statements import form_blocker, statement_hard_failures +from src.services.statements_engine import default_engine from tests.factories import make_legal_entity from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot @@ -89,6 +91,13 @@ async def _confirmed_twin(session: AsyncSession, document: Document) -> Document return twin +async def _recompute(session: AsyncSession, document: Document, meta: FinancialStatementMeta) -> None: + """§7 хранится в `document_issue`: после прямой правки сумм в БД теста нарушения появляются пересчётом.""" + job = await session.get(RecognitionJob, meta.recognition_job_id) + await recompute_form(session, document=document, meta=meta, engine=default_engine(), job=job) + await session.flush() + + async def _break_2200(session: AsyncSession, meta: FinancialStatementMeta) -> None: values = ( await session.execute( @@ -100,6 +109,9 @@ async def _break_2200(session: AsyncSession, meta: FinancialStatementMeta) -> No for value in values: value.amount = (value.amount or 0) + 1000 await session.flush() + document = await session.get(Document, meta.document_id) + assert document is not None + await _recompute(session, document, meta) async def test_clean_form_is_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None: @@ -134,6 +146,7 @@ async def test_form_with_hard_failure_is_not_confirmable_until_accepted( for value in values: value.amount = (value.amount or 0) + 1000 await seeded.flush() + await _recompute(seeded, income, meta) assert await statement_hard_failures(seeded, income, meta) != () blocker = await form_blocker(seeded, income, meta) diff --git a/api/tests/test_statement_recompute.py b/api/tests/test_statement_recompute.py new file mode 100644 index 0000000..a58a1a4 --- /dev/null +++ b/api/tests/test_statement_recompute.py @@ -0,0 +1,183 @@ +"""Пересчёт формы после правки общим кодом (часть Б, B2): чистые преобразования и запись результата в БД +(spec arm-core «Пересчёт после правки», P3-8, R-1).""" + +import uuid +from decimal import Decimal +from http import HTTPStatus +from pathlib import Path +from typing import Any + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.statement_aggregate import CodeAggregate +from src.models.enums import DocumentIssueKind, IssueSeverity, IssueSource, StatementLineStatus +from src.models.statements import FinancialStatementColumn, FinancialStatementLine +from src.services.statement_recompute import code_issues, name_issues, relations_to_issues, version_issue +from src.services.statements_engine import FailedRelation, StatementChecks +from tests.conftest import AuthClientFactory +from tests.test_api_statements import ( + KA_RF, + _db_value, + _issues, + _statement, + form, + recompute_now, +) + + +def _failure(key: str, relation: str, codes: tuple[str, ...], expected: Decimal | None, actual: Decimal | None) -> Any: + return FailedRelation( + column_index=1, column_key=key, relation=relation, codes=codes, expected=expected, actual=actual + ) + + +def _aggregate(code: str, *line_ids: uuid.UUID) -> CodeAggregate: + return CodeAggregate(code=code, amounts={}, line_ids=line_ids, reference_name=None, has_source_line=True) + + +def test_relations_to_issues_kinds_columns_and_empty_amounts() -> None: + column = FinancialStatementColumn(id=uuid.uuid4(), column_index=1) + line_a, line_b, line_c = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + checks = StatementChecks( + relations_failed=( + _failure(str(column.id), "1600 = 1700", ("1600", "1700"), None, Decimal(5)), + _failure("1", "1100 = 1110", ("1100", "1110", "9999"), Decimal(2), Decimal(3)), # ключ — индекс (`2.0`) + ), + soft_relations_failed=(_failure("77", "1110 = 11101", ("1110", "11101"), Decimal(1), None),), + ) + aggregates = { + "1600": _aggregate("1600", line_a), + "1700": _aggregate("1700", line_b, line_c), + "1100": _aggregate("1100", line_a), + } + hard_empty, hard_by_index, soft = relations_to_issues( + checks, columns=[column], aggregates=aggregates, source=IssueSource.core + ) + assert (hard_empty.kind, hard_empty.code, hard_empty.severity) == ( + DocumentIssueKind.relation, + "RELATION_FAILED", + IssueSeverity.error, + ) + assert hard_empty.column_id == column.id and hard_empty.line_ids == (line_a, line_b, line_c) + assert hard_empty.expected is None and hard_empty.actual == Decimal(5) + assert hard_empty.message == "не сошлось 1600 = 1700: пусто ≠ 5" + assert hard_by_index.column_id == column.id # ключ `1` — индекс колонки + assert hard_by_index.line_ids == (line_a,) # кода нет в форме — вклада нет + assert (soft.kind, soft.code, soft.severity) == (DocumentIssueKind.soft, "RELATION_SOFT", IssueSeverity.info) + assert soft.column_id is None and soft.column_index == 1 and soft.message.endswith("1 ≠ пусто") + + +def _line( + code: str | None, *, row: int, status: StatementLineStatus = StatementLineStatus.ok +) -> FinancialStatementLine: + return FinancialStatementLine( + id=uuid.uuid4(), + meta_id=uuid.uuid4(), + line_code=code, + row_index=row, + line_status=StatementLineStatus.ok, + line_name=code, + ) + + +def test_code_issues_only_four_digit_codes_one_record_per_code() -> None: + """R-1 (Р-40): пятизначные коды не проверяются; одна запись на код, `line_ids` — все строки `ok` кода.""" + first, second, five, detail = _line("1999", row=1), _line("1999", row=2), _line("19999", row=3), _line(None, row=4) + drafts = code_issues( + [second, first, five, detail], + in_dictionary={"1999": False, "19999": False}, + in_edition={"1999": False, "19999": False}, + ) + assert [(d.code, d.severity, d.source) for d in drafts] == [ + ("CODE_NOT_IN_DICTIONARY", IssueSeverity.info, IssueSource.core), + ("CODE_NOT_IN_EDITION", IssueSeverity.info, IssueSource.core), + ] + assert all(d.line_ids == (first.id, second.id) for d in drafts) + assert code_issues([first], in_dictionary={"1999": False}, in_edition=None)[0].code == "CODE_NOT_IN_DICTIONARY" + + +def test_name_and_version_issues() -> None: + line = _line("1120", row=1) + (draft,) = name_issues([line], [line.id]) + assert draft.code == "NAME_MISMATCH" and draft.line_ids == (line.id,) and draft.source is IssueSource.core + assert version_issue(None, "def5678") is None and version_issue("def5678", "def5678") is None + mismatch = version_issue("abc1234", "def5678") + assert mismatch is not None and mismatch.code == "STATEMENTS_VERSION_MISMATCH" + assert mismatch.severity is IssueSeverity.info and mismatch.source is IssueSource.core + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_code_change_recomputes_core_issues( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """P3-8 (T13.4): после смены кода `CODE_NOT_IN_*` пересчитываются — только четырёхзначные (R-1).""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + body = await _statement(client, document) + ok_lines = [line for line in body["lines"] if line["id"] is not None and line["line_status"] == "ok"] + first, second = ok_lines[0], ok_lines[1] + url = f"/documents/{document.id}/statement" + response = await client.patch( + url, + json={ + "lines": [{"line_id": first["id"], "line_code": "1999"}, {"line_id": second["id"], "line_code": "19999"}] + }, + ) + assert response.status_code == HTTPStatus.OK, response.text + issues = await _issues(seeded, document) + by_code = {i.code: i for i in issues if i.code.startswith("CODE_NOT_IN_")} + assert set(by_code) == {"CODE_NOT_IN_DICTIONARY", "CODE_NOT_IN_EDITION"} + assert all(i.line_ids == [first["id"]] for i in by_code.values()) # пятизначный 19999 не проверяется + response = await client.patch(url, json={"lines": [{"line_id": first["id"], "line_code": first["line_code"]}]}) + assert response.status_code == HTTPStatus.OK, response.text + assert not [i for i in await _issues(seeded, document) if i.code.startswith("CODE_NOT_IN_")] + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_name_mismatch_follows_code_confirmation( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """P3-8: `NAME_MISMATCH` пересчитывается; подтверждённому (`code_fixed`) коду не ставится.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + body = await _statement(client, document) + target = next(line for line in body["lines"] if line["line_code"] == "1120" and line["id"] is not None) + line = await seeded.get(FinancialStatementLine, uuid.UUID(target["id"])) + assert line is not None + line.line_code_confidence, line.line_name = Decimal(50), "Нематериальные активы" + await seeded.flush() + await recompute_now(seeded, document) + mismatch = [i for i in await _issues(seeded, document) if i.code == "NAME_MISMATCH"] + assert [i.line_ids for i in mismatch] == [[target["id"]]] + response = await client.patch( + f"/documents/{document.id}/statement", json={"lines": [{"line_id": target["id"], "confirmed": True}]} + ) + assert response.status_code == HTTPStatus.OK, response.text + assert not [i for i in await _issues(seeded, document) if i.code == "NAME_MISMATCH"] + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_values_of_line_leaving_detail_are_recomputed( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """T13.3: `needs_review` значений строки, ушедшей в `detail`, не залипает на `false` — после назначения кода его + даёт пересчёт пакета.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + body = await _statement(client, document) + target = next(line for line in body["lines"] if line["id"] is not None and line["values"][0]["amount"] is not None) + column_id = target["values"][0]["column_id"] + value = await _db_value(seeded, target["id"], column_id) + value.confidence = Decimal(80) + await seeded.flush() + url = f"/documents/{document.id}/statement" + response = await client.patch(url, json={"lines": [{"line_id": target["id"], "line_status": "detail"}]}) + assert response.status_code == HTTPStatus.OK, response.text + assert (await _db_value(seeded, target["id"], column_id)).needs_review is False + response = await client.patch(url, json={"lines": [{"line_id": target["id"], "line_code": "1999"}]}) + assert response.status_code == HTTPStatus.OK, response.text + assert (await _db_value(seeded, target["id"], column_id)).needs_review is True # код вне соотношений — не покрыта diff --git a/openspec/changes/unified-normalization-core-b/design.md b/openspec/changes/unified-normalization-core-b/design.md index 01ff4b4..75eb2ba 100644 --- a/openspec/changes/unified-normalization-core-b/design.md +++ b/openspec/changes/unified-normalization-core-b/design.md @@ -36,3 +36,11 @@ Goals и Non-goals — см. `proposal.md`. | 2026-10-18 | **P3-25 (B1).** Тип формы во входе пересчёта — `document.document_type` (`BALANCE` / `INCOME` / `DISCLOSURE`), а не `meta.statement_type` (только `balance` / `income`); план писал «тип формы» без источника | `DISCLOSURE` не представим в `statement_type`; тип документа — единственный источник для всех трёх типов | форма `DISCLOSURE` собралась бы с ошибкой `KeyError` / потерей типа; правка — одна строка в `build_stored_form` | | 2026-10-18 | **P3-26 (B1, O-3 часть А).** Копия `statement-code-orders.yaml` в `api/contracts/` не заводится: перечень кодов читает пакет из своей байтовой копии `api/arm_statements/rules/statement_code_orders.yaml` (`loader._ORDERS`), ядро файла не открывает; идентичность копии пакета с `docs/contracts/statement-code-orders.yaml` проверяет `sync-statements --check`; тест идентичности контракта сверяет только `*.json`. Сейчас копия пакета побайтно равна файлу docs | второй экземпляр файла в ядре никто бы не читал и он мог бы разойтись с пакетом | нет; если sync-statements когда-либо перестанет сверять YAML — добавить сверку в `contract_identity.py` | | 2026-10-18 | **P3-27 (B1).** `edition_codes` берёт строки `ok` результата `2.0`, сортирует по `row_index` и помечает код уверенным при `line_code_confidence == 100` (ровно), `None` — неуверенный | как в разделе «Что ядро вызывает» и в `recompute` пакета (код правленый/подтверждённый там — `code_fixed`) | редакция приёма разойдётся с редакцией пересчёта; сверку делает `test_edition_codes_and_engine_edition` | +| 2026-10-18 | **P3-28 (B2).** `StatementService` получает `StatementsEngine` через зависимость `get_statements_engine` (тесты подменяют её `FakeEngine`); `FailedRelation`, `StatementChecks`, `PeriodKind` реэкспортируются из `services/statements_engine.py`, чтобы `import arm_statements` оставался только в двух файлах | единственная точка импорта пакета (common.md); подмена без патчей | нет | +| 2026-10-18 | **P3-29 (B2).** Статус после правки — `form_status_v2`: `service_done = (result.status == DONE)`, `file_error_warning` — `metrics.warnings` задания, `names_ok` — нет `NAME_MISMATCH` пересчёта, `duplicate_in_file` — `duplicate_period_forms` по формам файла (`file_form_periods`); `statement_status` D-6 с пути правки убран | один источник статуса с приёмом `2.0` (Р-16, Р-18, Р-19) | форма с повтором периода в файле после правки остаётся `NEEDS_CHECK` — как при приёме | +| 2026-10-18 | **P3-30 (B2).** Пустая цель при непустых членах — нарушение с `expected = NULL` (P1-21); тесты части А «очищенная 1700 не цель» и «цель потерянной строки не проверяется» переписаны под это; потерявшая строку 1700 форма блокирует подтверждение до правки или акцепта | следствие P1-21 пакета; спека («посчитанное соотношение») | форма, потерявшая итоговую строку, теперь требует действия аналитика — если владелец против, правится правило пакета (план 1), не ядро | +| 2026-10-18 | **P3-31 (B2).** `NOTHING_TO_ACCEPT` и `CHECKS_NOT_PASSED` читают записи `relation`; акцептованная форма не считается нарушенной (`statement_hard_failures` пуст при `checks_accepted_at`), поэтому повторный акцепт — 409 `NOTHING_TO_ACCEPT`, как в части А | сохранение поведения А; буква брифа («нет записей `relation`») допускает оба прочтения | повторный акцепт с новым комментарием станет возможен, если снять условие | +| 2026-10-18 | **P3-32 (B2).** «Эффективная дата» для `relative:*` колонок при правке `header.report_date` — новое значение `meta.report_date` (в том числе `NULL` ⇒ `period_date = NULL`), не `document.reporting_date`; изменение дат колонок идёт в аудит ключом `column:` без признака правки данных | период документа сам выводится из даты отчёта и дат колонок (§9.5 п. 2), поэтому циклическая зависимость недопустима | колонка при пустой дате отчёта теряет дату; аналитик ставит её вручную | +| 2026-10-18 | **P3-33 (B2).** Запись `relation` / `soft` несёт и `column_id` (колонка найдена по `column_key`: `str(column_id)`, затем `str(column_index)`), и `column_index` нарушения; ответ берёт индекс из колонки, иначе из записи, иначе `-1` | ключ колонки B4 для `2.0` — индекс | нет | +| 2026-10-18 | **P3-34 (B2).** `NAME_MISMATCH` пересчитывается для всех кодов (R-1 ограничивает только `CODE_NOT_IN_*`); `CODE_NOT_IN_*` не пишутся, если пакет не определил редакцию (а `CODE_NOT_IN_EDITION` — и вариант); словарь имён берётся по редакции пакета, не по дате | R-1 и спека (редакция документа — `edition` пакета) | пятизначная подстрока с уверенностью < 100 даёт `NAME_MISMATCH` (кода нет в словаре) — если поток окажется шумным, ограничить `name_mismatches` четырёхзначными | +| 2026-10-18 | **P3-35 (B2).** Временный код A12/A13 удалён: тело `StatementService.recompute`, `settle_edited_values`, `has_review_values`, `has_source_lines`, `_value_needs_review`, `nearest_ok_parents` и `_recompute_parents`; `parent_row_index` `detail` пишет только пересчёт пакета | задание B2; T13.3 (флаги detail больше не затираются навсегда) | нет | -- 2.47.2 From 02630e830f948793ab9734540fbe2caf9f516a88 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 21:03:06 +0400 Subject: [PATCH 04/13] =?UTF-8?q?feat(mapping):=20=C2=A77=20=D0=B8=20?= =?UTF-8?q?=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D1=8B=20=D0=BF=D1=80=D0=B8=20=D0=BF=D1=80=D0=B8=D1=91?= =?UTF-8?q?=D0=BC=D0=B5=201.x=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B5=D0=B9=20arm?= =?UTF-8?q?=5Fstatements=20(=D0=A0-30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checks_on_intake_1x: один recompute по вставленной форме, запись form_variant и relation/soft (core); run_checks из приёма убран. P3-36. Co-Authored-By: Claude Sonnet 5.5 --- api/src/services/result_mapping.py | 22 +++---- api/src/services/statement_recompute.py | 22 +++++++ api/tests/test_api_statements.py | 22 ++++++- api/tests/test_mapping_statements.py | 58 ++++++++++++++++++- .../unified-normalization-core-b/design.md | 1 + 5 files changed, 108 insertions(+), 17 deletions(-) diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 4b3006f..012f8ea 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -26,7 +26,6 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn import asyncio import dataclasses -import functools import uuid from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass @@ -50,11 +49,8 @@ from src.domain.osv_mapping import ( document_status, opening_matches, ) -from src.domain.statement_aggregate import checks_input -from src.domain.statement_checks import load_check_rules, run_checks from src.domain.statement_issues import mapping_warning -from src.domain.statement_lines import load_line_catalog -from src.domain.statement_mapping import StatementDraft, build_statement, edition_for, statement_status, values_ok +from src.domain.statement_mapping import StatementDraft, build_statement, statement_status, values_ok from src.domain.statement_status import duplicate_period_forms from src.models.accounting import ChartOfAccount from src.models.consolidation import Consolidation, ConsolidationVersion @@ -78,6 +74,8 @@ from src.repositories.counterparties import CounterpartyIndex from src.services import consolidation_documents from src.services.audit import AuditService from src.services.file_status import set_file_status_for_job +from src.services.statement_recompute import checks_on_intake_1x +from src.services.statements_engine import StatementsEngine, default_engine __all__ = [ "ENTRY_BATCH_SIZE", @@ -425,27 +423,21 @@ async def _insert_statements( # noqa: PLR0913 - все входы именов repeats: Mapping[uuid.UUID, str], job_done: bool, now: datetime, + engine: StatementsEngine | None = None, ) -> None: """Шапка, колонки, строки и значения каждой формы; предупреждения черновика и повтор формы — `MAPPING_WARNING` документа; статус документа по D-6 (акцепта при маппинге нет).""" if not forms: return - rules = load_check_rules() - catalog = load_line_catalog() + engine = engine or default_engine() for document, draft in forms: - edition = edition_for(document.reporting_date) - form = document.document_type.value meta = await statements_repo.insert_statement(session, document_id=document.id, job_id=job.id, draft=draft) warnings = [*draft.warnings, *([repeats[document.id]] if document.id in repeats else [])] await statements_repo.add_issues(session, document.id, [mapping_warning(text) for text in warnings], now=now) - columns, aggregates = await statements_repo.form_aggregate( - session, meta.id, functools.partial(catalog.reference_name, edition, form) - ) - column_indexes, amounts = checks_input(columns, aggregates) - report = run_checks(document.document_type.value, amounts, column_indexes, rules) + checks_passed = await checks_on_intake_1x(session, document=document, meta=meta, engine=engine) document.status = statement_status( job_done=job_done, - checks_passed=not report.failed, + checks_passed=checks_passed, counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN, period_known=document.period_year is not None and document.period_quarter is not None, values_ok=values_ok(await statements_repo.value_flags(session, meta.id)), diff --git a/api/src/services/statement_recompute.py b/api/src/services/statement_recompute.py index e8752bd..9fd5212 100644 --- a/api/src/services/statement_recompute.py +++ b/api/src/services/statement_recompute.py @@ -47,6 +47,7 @@ __all__ = [ "RELATION_FAILED", "RELATION_SOFT", "STATEMENTS_VERSION_MISMATCH", + "checks_on_intake_1x", "code_issues", "name_issues", "recompute_form", @@ -273,3 +274,24 @@ async def recompute_form( duplicate_in_file=document.id in duplicate_period_forms(forms), ) document.updated_at = utc_now() + + +async def checks_on_intake_1x( + session: AsyncSession, + *, + document: Document, + meta: FinancialStatementMeta, + engine: StatementsEngine, +) -> bool: + """§7 и вариант формы при приёме результата `1.x` (Р-30): один вызов `engine.recompute` по вставленной форме. + Пишет `meta.form_variant` и записи `relation` / `soft` (`source = core`); `needs_review` значений и строк не + трогает (у `1.x` они по правилу приёма, A9), проверки ядра уровня `info` не пишет (P3-7). Возвращает «жёстких + нарушений нет» — условие D-6 «проверки сошлись». Без commit.""" + columns, loaded = await repo.load_form(session, meta.id) + stored = build_stored_form(document, meta, columns, loaded, await repo.issues(session, document.id)) + result = engine.recompute(stored) + meta.form_variant = StatementFormVariant(result.form_variant.value) if result.form_variant is not None else None + _, aggregates = await repo.form_aggregate(session, meta.id) + drafts = relations_to_issues(result.checks, columns=columns, aggregates=aggregates, source=IssueSource.core) + await repo.add_issues(session, document.id, drafts, now=utc_now()) + return not result.checks.relations_failed diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 5872c4f..23e22f5 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -793,6 +793,26 @@ async def _break_balance(client, document_id) -> None: ) +async def test_1x_form_with_violation_not_confirmable( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, tmp_path: Path +) -> None: + """Форма `1.x` с нарушением §7 с приёма: `NEEDS_CHECK`, `POST …/confirm` без акцепта — 409 `CHECKS_NOT_PASSED`.""" + data = json.loads((fixtures_dir / MEZH_BALANCE).read_text("utf-8")) + for line in data["documents"][0]["lines"]: + if line["line_code"] == "1600": + line["values"][0]["amount"] = str(Decimal(line["values"][0]["amount"] or 0) + 1000) + path = tmp_path / "violation.json" + path.write_text(json.dumps(data, ensure_ascii=False), "utf-8") + entity = await rostov_entity(seeded, path) + (document,) = await map_golden_snapshot(seeded, entity=entity, path=path, uploaded_by=None) + assert document.status is DocumentStatus.NEEDS_CHECK + client = await auth_client(KA_RF) + blocked = await client.post(f"/documents/{document.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED" + failed = (await _statement(client, document))["checks"]["failed"] + assert any(item["codes"] == ["1600", "1700"] for item in failed) + + async def test_accept_and_reset(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) @@ -1759,7 +1779,7 @@ async def test_get_statement_v5_19_fields_on_1x_form( ) cells = [value for line in source for value in line["values"] if value["id"] is not None] assert all(value["text"] == value["string_value"] for value in cells) - assert body["document"]["form_variant"] is None + assert body["document"]["form_variant"] == "full" # Р-30: вариант определяется при приёме 1.x assert body["header"]["needs_review_fields"] == [] and body["header"]["organization_name_modified"] is False assert {column["period_kind"] for column in body["columns"]} <= {"absolute", "none"} assert body["issues"] == [] diff --git a/api/tests/test_mapping_statements.py b/api/tests/test_mapping_statements.py index 99e6abe..8b99700 100644 --- a/api/tests/test_mapping_statements.py +++ b/api/tests/test_mapping_statements.py @@ -4,6 +4,7 @@ import json import uuid from datetime import UTC, datetime +from decimal import Decimal from pathlib import Path import pytest @@ -14,11 +15,14 @@ from src.models.accounting import ChartOfAccount from src.models.directories import LegalEntity from src.models.enums import ( DocumentCheckResult, + DocumentIssueKind, DocumentStatus, DocumentType, FileStatus, + IssueSource, PeriodQuarter, RecognitionStatus, + StatementFormVariant, ) from src.models.files import Document, RecognitionJob, StoredFile from src.models.statements import ( @@ -98,6 +102,56 @@ async def test_clean_income_recognized(seeded: AsyncSession, fixtures_dir: Path) assert income.status is DocumentStatus.RECOGNIZED +def _balance_with(tmp_path: Path, source: Path, edit) -> Path: # noqa: ANN001 - правка списка строк снимка + data = json.loads(source.read_text("utf-8")) + data["documents"][0]["lines"] = edit(data["documents"][0]["lines"]) + path = tmp_path / "edited.json" + path.write_text(json.dumps(data, ensure_ascii=False), "utf-8") + return path + + +async def _core_relations(session: AsyncSession, document: Document) -> list[DocumentIssue]: + stmt = select(DocumentIssue).where( + DocumentIssue.document_id == document.id, DocumentIssue.kind == DocumentIssueKind.relation + ) + return list((await session.execute(stmt)).scalars()) + + +async def test_1x_balance_with_violation(seeded: AsyncSession, fixtures_dir: Path, tmp_path: Path) -> None: + """Форма `1.x` с нарушением: `RELATION_FAILED` источника `core` при приёме, документ `NEEDS_CHECK`.""" + source = fixtures_dir / MEZH / "1_Форма1_бух_09.json" + owner = await _owner(seeded, source) + + def edit(lines: list[dict]) -> list[dict]: + for line in lines: + if line["line_code"] == "1600": + line["values"][0]["amount"] = str(Decimal(line["values"][0]["amount"] or 0) + 1000) + return lines + + path = _balance_with(tmp_path, source, edit) + (balance,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert balance.status is DocumentStatus.NEEDS_CHECK + failed = [i for i in await _core_relations(seeded, balance) if i.code == "RELATION_FAILED"] + assert failed and all(i.source is IssueSource.core for i in failed) + assert any(i.codes == ["1600", "1700"] for i in failed) + + +async def test_1x_simplified_variant(seeded: AsyncSession, fixtures_dir: Path, tmp_path: Path) -> None: + """Вариант формы при приёме `1.x` (Р-30): ББ с 1600 и без 1100/1200 — `simplified`.""" + source = fixtures_dir / MEZH / "1_Форма1_бух_09.json" + owner = await _owner(seeded, source) + path = _balance_with(tmp_path, source, lambda lines: [x for x in lines if x["line_code"] in {"1600", "1700"}]) + (balance,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert (await _meta(seeded, balance)).form_variant is StatementFormVariant.simplified + + +async def test_1x_clean_form_has_no_relation_issues(seeded: AsyncSession, fixtures_dir: Path) -> None: + path = fixtures_dir / MEZH / "1_Форма2_бух_09.json" + owner = await _owner(seeded, path) + (income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert income.status is DocumentStatus.RECOGNIZED and await _core_relations(seeded, income) == [] + + async def test_book_with_two_forms(seeded: AsyncSession, fixtures_dir: Path) -> None: path = next((fixtures_dir / NPZ).glob("*.json")) owner = await _owner(seeded, path) @@ -290,6 +344,7 @@ async def test_repeated_form_mapping_is_idempotent(seeded: AsyncSession, fixture ) documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) before = await _statement_totals(seeded) + issues_before = (await seeded.execute(select(func.count()).select_from(DocumentIssue))).scalar_one() file_id = documents[0].stored_file_id await make_document_issue(seeded, document_id=documents[0].id) # v5.19: остаток прежнего маппинга file = await seeded.get(StoredFile, file_id) @@ -312,7 +367,8 @@ async def test_repeated_form_mapping_is_idempotent(seeded: AsyncSession, fixture ) assert await _statement_totals(seeded) == before - assert (await seeded.execute(select(func.count()).select_from(DocumentIssue))).scalar_one() == 0 + # записи прежнего маппинга (в том числе подложенная) удалены, новые §7 приёма — тот же набор (Р-30) + assert (await seeded.execute(select(func.count()).select_from(DocumentIssue))).scalar_one() == issues_before again = (await seeded.execute(select(Document).where(Document.stored_file_id == file_id))).scalars().all() assert {d.document_type for d in again} == {DocumentType.BALANCE, DocumentType.INCOME} assert {d.id for d in again}.isdisjoint({d.id for d in documents}) # документы пересозданы, не дублированы diff --git a/openspec/changes/unified-normalization-core-b/design.md b/openspec/changes/unified-normalization-core-b/design.md index 75eb2ba..5ea8ff0 100644 --- a/openspec/changes/unified-normalization-core-b/design.md +++ b/openspec/changes/unified-normalization-core-b/design.md @@ -44,3 +44,4 @@ Goals и Non-goals — см. `proposal.md`. | 2026-10-18 | **P3-33 (B2).** Запись `relation` / `soft` несёт и `column_id` (колонка найдена по `column_key`: `str(column_id)`, затем `str(column_index)`), и `column_index` нарушения; ответ берёт индекс из колонки, иначе из записи, иначе `-1` | ключ колонки B4 для `2.0` — индекс | нет | | 2026-10-18 | **P3-34 (B2).** `NAME_MISMATCH` пересчитывается для всех кодов (R-1 ограничивает только `CODE_NOT_IN_*`); `CODE_NOT_IN_*` не пишутся, если пакет не определил редакцию (а `CODE_NOT_IN_EDITION` — и вариант); словарь имён берётся по редакции пакета, не по дате | R-1 и спека (редакция документа — `edition` пакета) | пятизначная подстрока с уверенностью < 100 даёт `NAME_MISMATCH` (кода нет в словаре) — если поток окажется шумным, ограничить `name_mismatches` четырёхзначными | | 2026-10-18 | **P3-35 (B2).** Временный код A12/A13 удалён: тело `StatementService.recompute`, `settle_edited_values`, `has_review_values`, `has_source_lines`, `_value_needs_review`, `nearest_ok_parents` и `_recompute_parents`; `parent_row_index` `detail` пишет только пересчёт пакета | задание B2; T13.3 (флаги detail больше не затираются навсегда) | нет | +| 2026-10-18 | **P3-36 (B3).** `checks_on_intake_1x` живёт в `statement_recompute.py`; движок передаётся в `_insert_statements` необязательным параметром (по умолчанию `default_engine()`), `map_file` сигнатуру не меняет. Приём `1.x` пишет `meta.form_variant` и записи `relation` / `soft` (`core`), но не `CODE_NOT_IN_*` / `NAME_MISMATCH` (P3-7) и не трогает `needs_review` (A9); условие D-6 «проверки сошлись» — `not result.checks.relations_failed` (нарушение с `expected = NULL` — жёсткое) | задание B3, спека «Пересчёт при приёме»; расхождение с прежним `run_checks` (его `failed`) возможно только на формах с пустой целью, где пакет строже | форма `1.x` с пустой целью получит `NEEDS_CHECK` вместо `RECOGNIZED` | -- 2.47.2 From 028512603a1b14fd7a7ef1a5362ef9f106ee8193 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 21:15:21 +0400 Subject: [PATCH 05/13] =?UTF-8?q?fix(statements):=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20B1+B2=20?= =?UTF-8?q?=E2=80=94=20T2.1,=20T1.1,=20T1.2,=20T2.4,=20T2.6,=20T2.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2.1: аудит lines[] включает итоговую смену parent_row_index после пересчёта. T1.1: тест паритета редакции приёма и пересчёта для строки с confidence < 100 и code_fixed. T1.2: edition_codes пропускает строку ok без кода. T2.4: общий issue_column_index для ответа формы и detail отказа подтверждения. T2.6: удалено дублирование needs_review в statement_line_edits. T2.8: неизвестный relative:* не даёт 500 (адаптер stored_form и _resolve_relative_columns). Co-Authored-By: Claude Opus 5.5 --- api/src/domain/stored_form.py | 12 ++++- api/src/schemas/statements.py | 12 +++-- api/src/services/statement_line_edits.py | 39 +++++++++------ api/src/services/statements.py | 14 ++++-- api/tests/test_api_statement_lines.py | 30 ++++++++++++ api/tests/test_api_statements.py | 20 ++++++++ api/tests/test_statements_engine.py | 48 +++++++++++++++++++ .../unified-normalization-core-b/design.md | 2 + 8 files changed, 155 insertions(+), 22 deletions(-) diff --git a/api/src/domain/stored_form.py b/api/src/domain/stored_form.py index b6548b9..4bdcd32 100644 --- a/api/src/domain/stored_form.py +++ b/api/src/domain/stored_form.py @@ -38,12 +38,20 @@ def _confidence(value: object) -> float | None: return None if value is None else float(value) # type: ignore[arg-type] +def _period_kind(raw: str | None) -> PeriodKind: + """Вид периода колонки; неизвестное значение (в том числе `relative:*` вне перечня пакета) — `NONE` (T2.8).""" + try: + return PeriodKind(raw) if raw else PeriodKind.NONE + except ValueError: + return PeriodKind.NONE + + def _stored_column(column: FinancialStatementColumn) -> StoredColumn: return StoredColumn( key=str(column.id), column_index=column.column_index, period_date=column.period_date, - period_kind=PeriodKind(column.period_kind) if column.period_kind else PeriodKind.NONE, + period_kind=_period_kind(column.period_kind), ) @@ -123,4 +131,4 @@ def build_stored_form( def edition_codes(doc: StatementDocumentV2) -> list[tuple[str, bool]]: """Пары `(line_code, уверенный)` строк `ok` результата по `row_index` — вход `engine.edition`.""" ok_lines = sorted((ln for ln in doc.lines if ln.line_status == "ok"), key=lambda ln: ln.row_index) - return [(str(ln.line_code), ln.line_code_confidence == _SURE) for ln in ok_lines] + return [(ln.line_code, ln.line_code_confidence == _SURE) for ln in ok_lines if ln.line_code is not None] diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index a53b520..6441b49 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -197,11 +197,17 @@ def _failed(check: FailedCheck, aggregates: Mapping[str, CodeAggregate]) -> Fail ) +def issue_column_index(issue: DocumentIssue, column_index: Mapping[uuid.UUID, int]) -> int | None: + """Индекс колонки записи: `issue.column_index`, а при `NULL` — по `column_id` через колонки формы (T2.4); + общий для ответа формы и `detail` отказа подтверждения.""" + if issue.column_id is not None and issue.column_id in column_index: + return column_index[issue.column_id] + return issue.column_index + + def _failed_issue(issue: DocumentIssue, column_index: Mapping[uuid.UUID, int]) -> FailedCheckOut: """Нарушение §7 из записи `document_issue` (`relation` / `soft`): `codes` и `line_ids` — из записи.""" - index = issue.column_index - if issue.column_id is not None and issue.column_id in column_index: - index = column_index[issue.column_id] + index = issue_column_index(issue, column_index) codes = issue.codes if isinstance(issue.codes, list) else [] line_ids = issue.line_ids if isinstance(issue.line_ids, list) else [] return FailedCheckOut( diff --git a/api/src/services/statement_line_edits.py b/api/src/services/statement_line_edits.py index 5ba24ca..e9a7576 100644 --- a/api/src/services/statement_line_edits.py +++ b/api/src/services/statement_line_edits.py @@ -12,7 +12,6 @@ from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from src.common.exceptions import ConflictError, DomainError, DomainValidationError -from src.domain.statement_mapping import FULL_CONFIDENCE from src.models.enums import StatementLineStatus from src.models.files import Document from src.models.statements import FinancialStatementLine, FinancialStatementMeta @@ -67,16 +66,6 @@ def _check(items: Sequence[LinePatch], by_id: dict[uuid.UUID, FinancialStatement raise _bad(INVALID_LINE_PATCH, "подтверждать можно только код строки со статусом ok", item) -def _unconfirmed_needs_review(line: FinancialStatementLine) -> bool: - """`needs_review` строки без подтверждения: код есть, не назначен аналитиком и распознан неуверенно.""" - return ( - line.line_status is StatementLineStatus.ok - and not line.line_code_modified - and line.line_code_confidence is not None - and line.line_code_confidence < FULL_CONFIDENCE - ) - - def _apply_item(line: FinancialStatementLine, item: LinePatch) -> bool: """Применить действие элемента к строке; `True` — изменены данные (код, статус), а не подтверждение.""" if item.line_code is not None: @@ -84,7 +73,6 @@ def _apply_item(line: FinancialStatementLine, item: LinePatch) -> bool: return False # тот же код у строки `ok` — менять нечего line.line_code, line.line_status = item.line_code, StatementLineStatus.ok line.line_code_confidence, line.line_code_modified = ASSIGNED_CONFIDENCE, True - line.needs_review = False line.is_confirmed = False # M-4: подтверждение относилось к прежнему коду line.parent_row_index = None # M-4: у строки `ok` родителя нет (был у `detail`) return True @@ -92,15 +80,38 @@ def _apply_item(line: FinancialStatementLine, item: LinePatch) -> bool: if line.line_status is StatementLineStatus.detail: return False line.line_code, line.line_status = None, StatementLineStatus.detail - line.needs_review = False line.is_confirmed = False # M-4: у расшифровки кода нет — подтверждать нечего return True if item.confirmed is not None: line.is_confirmed = item.confirmed - line.needs_review = False if item.confirmed else _unconfirmed_needs_review(line) return False +async def line_parents(session: AsyncSession, meta: FinancialStatementMeta) -> dict[uuid.UUID, int | None]: + """`parent_row_index` строк формы — снимок до правки для `merge_parent_changes` (T2.1).""" + return {line.id: line.parent_row_index for line in await repo.form_lines(session, meta.id)} + + +async def merge_parent_changes( + session: AsyncSession, + meta: FinancialStatementMeta, + old: dict[uuid.UUID, int | None], + before: dict[str, Any], + after: dict[str, Any], +) -> None: + """После пересчёта (он назначает родителей расшифровок) довнести в `before/after` итоговую смену + `parent_row_index`: у строк правки значение заменяется итоговым, у остальных строк появляется запись + `line:` с одним ключом (T2.1).""" + for line in await repo.form_lines(session, meta.id): + if line.id not in old or old[line.id] == line.parent_row_index: + continue + key = f"line:{line.id}" + if key in after and after[key] is None: + continue + before.setdefault(key, {})["parent_row_index"] = old[line.id] + after.setdefault(key, {})["parent_row_index"] = line.parent_row_index + + async def apply_line_edits( # noqa: PLR0913 - контракт задачи A13: документ, шапка, элементы, пользователь, аудит session: AsyncSession, *, diff --git a/api/src/services/statements.py b/api/src/services/statements.py index c6fcd91..03a770f 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -52,7 +52,7 @@ from src.services.documents import ( FILE_SUPERSEDED, PERIOD_REQUIRED, ) -from src.services.statement_line_edits import apply_line_edits +from src.services.statement_line_edits import apply_line_edits, line_parents, merge_parent_changes from src.services.statement_recompute import recompute_form from src.services.statements_engine import PeriodKind, StatementsEngine, default_engine @@ -131,13 +131,14 @@ async def form_blocker(session: AsyncSession, document: Document, meta: Financia ) failures = await statement_hard_failures(session, document, meta) if failures: + by_column = {column.id: column.column_index for column in (await repo.load_form(session, meta.id))[0]} return FormBlocker( CHECKS_NOT_PASSED, "не пройдены логические проверки формы: исправьте значения или акцептуйте с комментарием", { "failures": [ { - "column_index": failure.column_index, + "column_index": schemas.issue_column_index(failure, by_column), "relation": failure.relation, "expected": None if failure.expected is None else str(failure.expected), "actual": None if failure.actual is None else str(failure.actual), @@ -460,7 +461,11 @@ class StatementService: for column in (await repo.load_form(self._session, meta.id))[0]: if column.period_kind is None or not str(column.period_kind).startswith("relative:"): continue - resolved = self._engine.resolve_relative(PeriodKind(str(column.period_kind)), meta.report_date) + try: + kind = PeriodKind(str(column.period_kind)) + except ValueError: + continue # T2.8: `relative:*` вне перечня пакета — колонка не пересчитывается (не 500) + resolved = self._engine.resolve_relative(kind, meta.report_date) if resolved != column.period_date: edit.record(f"column:{column.id}", column.period_date, resolved, data=False) column.period_date = resolved @@ -491,6 +496,7 @@ class StatementService: raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр") await self._check_columns(meta, body.columns) edit = _Edit({}, {}) + parents_before = await line_parents(self._session, meta) if body.lines else {} if await apply_line_edits( self._session, document=document, @@ -521,6 +527,8 @@ class StatementService: meta.checks_accepted_by = meta.checks_accepted_at = meta.checks_accepted_comment = None await self._session.flush() await self.recompute(document, meta) + if body.lines: # T2.1: родителей расшифровок пишет пересчёт — их итоговая смена попадает в аудит + await merge_parent_changes(self._session, meta, parents_before, edit.before, edit.after) await self._audit.record( action=AuditAction.RECOGNIZED_DATA_EDIT, section=AuditSection.RECOGNITION, diff --git a/api/tests/test_api_statement_lines.py b/api/tests/test_api_statement_lines.py index c1ce13f..a297946 100644 --- a/api/tests/test_api_statement_lines.py +++ b/api/tests/test_api_statement_lines.py @@ -286,6 +286,36 @@ async def test_parent_moves_up(seeded: AsyncSession, fixtures_dir: Path, auth_cl assert all(s.parent_row_index is None for s in stored if s) +async def test_audit_includes_parent_changes_from_recompute( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """T2.1: родителей расшифровок назначает пересчёт — итоговая смена `parent_row_index` есть в аудите.""" + document = await form(seeded, fixtures_dir) + meta = await _meta(seeded, document) + for existing in ( + await seeded.execute(select(FinancialStatementLine).where(FinancialStatementLine.meta_id == meta.id)) + ).scalars(): + existing.row_index = None if existing.row_index is None else existing.row_index + 100 + await insert_line(seeded, document, code="1140", row_index=3) + parent = await insert_line(seeded, document, code="1150", row_index=4) + details = [await insert_line(seeded, document, code=None, row_index=index, status="detail") for index in (5, 6)] + for item in details: + item.parent_row_index = 4 + client = await auth_client(KA_RF) + response = await _patch(client, document, {"lines": [{"line_id": str(parent.id), "line_status": "detail"}]}) + assert response.status_code == HTTPStatus.OK, response.text + (event,) = await _edit_events(seeded, document) + for item in details: # не правились, родитель сменился пересчётом 4 -> 3 + key = f"line:{item.id}" + assert event.before_value[key] == {"parent_row_index": 4} + assert event.after_value[key] == {"parent_row_index": 3} + assert event.after_value[f"line:{parent.id}"] == event.after_value[f"line:{details[0].id}"] | { + "line_code": None, + "line_status": "detail", + "is_confirmed": False, + } + + async def test_line_edit_audit_and_accept( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 23e22f5..a96b59f 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -2486,6 +2486,24 @@ async def test_relative_column_follows_report_date( assert fresh[1].period_date is None and fresh[2].period_date == datetime.date(2024, 6, 30) +async def test_unknown_relative_kind_is_skipped( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """T2.8: `relative:*` вне перечня пакета — правка даты отчёта не падает, колонка не пересчитывается.""" + document = await form(seeded, fixtures_dir) + columns = await _db_columns(seeded, document) + unknown, known = columns[1], columns[2] + unknown.period_kind = "relative:unknown_kind" + known.period_kind = "relative:prev_year_end" + before = unknown.period_date + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2026-03-31"}}) + assert response.status_code == HTTPStatus.OK, response.text + fresh = await _db_columns(seeded, document) + assert fresh[1].period_date == before and fresh[2].period_date == datetime.date(2025, 12, 31) + + async def test_checks_from_issues_with_codes_and_line_ids( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: @@ -2519,6 +2537,8 @@ async def test_checks_from_issues_with_codes_and_line_ids( blocked = await client.post(f"/documents/{document.id}/confirm") assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED" assert blocked.json()["detail"]["failures"][0]["expected"] is None + # T2.4: `column_index` записи пуст, колонка известна по `column_id` — индекс один и в ответе формы, и в detail + assert failed["column_index"] == 0 and blocked.json()["detail"]["failures"][0]["column_index"] == 0 async def test_one_patch_one_recompute( diff --git a/api/tests/test_statements_engine.py b/api/tests/test_statements_engine.py index a5a3723..39f6465 100644 --- a/api/tests/test_statements_engine.py +++ b/api/tests/test_statements_engine.py @@ -56,6 +56,54 @@ def test_edition_codes_and_engine_edition() -> None: assert ENGINE.recompute(form).edition == edition +@pytest.mark.parametrize(("modified", "confirmed"), [(False, False), (True, False), (False, True)]) +def test_edition_parity_for_unsure_fixed_code(modified: bool, confirmed: bool) -> None: + """T1.1: строка `ok` с `line_code_confidence < 100` и `code_fixed` (назначенная / подтверждённая) — редакция + приёма (`edition_codes` + `engine.edition`) совпадает с редакцией пересчёта из БД.""" + doc = StatementDocumentV2.model_validate(_documents("result-statement-minimal.json")[0]) + first_ok = next(ln for ln in doc.lines if ln.line_status == "ok") + doc = doc.model_copy( + update={ + "lines": [ + ln.model_copy(update={"line_code_confidence": Decimal(50)}) if ln is first_ok else ln + for ln in doc.lines + ] + } + ) + unsure = next(ln for ln in doc.lines if ln.line_status == "ok") + effective = datetime.date(2025, 9, 30) + edition = ENGINE.edition(DocumentType.BALANCE, edition_codes(doc), effective, FormVariant.FULL) + + column = make_column() + lines = [] + for ln in doc.lines: + fixed = ln is unsure + line = make_line( + ln.line_code, + row_index=ln.row_index, + status=StatementLineStatus(ln.line_status), + confidence=None if ln.line_code_confidence is None else str(ln.line_code_confidence), + modified=modified and fixed, + confirmed=confirmed and fixed, + ) + lines.append((line, [make_value(line, column, v.amount, confidence=str(v.confidence)) for v in ln.values])) + form = build_stored_form(make_document(effective), make_meta(organization_name="ООО"), [column], lines, []) + assert ENGINE.recompute(form).edition == edition + + +def test_edition_codes_skip_ok_line_without_code() -> None: + """T1.2: строка `ok` без кода не даёт пару `("None", …)`.""" + doc = StatementDocumentV2.model_validate(_documents("result-statement-minimal.json")[0]) + ok_line = next(ln for ln in doc.lines if ln.line_status == "ok") + count = len(edition_codes(doc)) + doc = doc.model_copy( + update={"lines": [ln.model_copy(update={"line_code": None}) if ln is ok_line else ln for ln in doc.lines]} + ) + pairs = edition_codes(doc) + assert len(pairs) == count - 1 + assert all(code != "None" for code, _ in pairs) + + def test_engine_scenario_recompute_after_edit() -> None: column = make_column() effective = datetime.date(2025, 9, 30) diff --git a/openspec/changes/unified-normalization-core-b/design.md b/openspec/changes/unified-normalization-core-b/design.md index 5ea8ff0..bc17ea6 100644 --- a/openspec/changes/unified-normalization-core-b/design.md +++ b/openspec/changes/unified-normalization-core-b/design.md @@ -45,3 +45,5 @@ Goals и Non-goals — см. `proposal.md`. | 2026-10-18 | **P3-34 (B2).** `NAME_MISMATCH` пересчитывается для всех кодов (R-1 ограничивает только `CODE_NOT_IN_*`); `CODE_NOT_IN_*` не пишутся, если пакет не определил редакцию (а `CODE_NOT_IN_EDITION` — и вариант); словарь имён берётся по редакции пакета, не по дате | R-1 и спека (редакция документа — `edition` пакета) | пятизначная подстрока с уверенностью < 100 даёт `NAME_MISMATCH` (кода нет в словаре) — если поток окажется шумным, ограничить `name_mismatches` четырёхзначными | | 2026-10-18 | **P3-35 (B2).** Временный код A12/A13 удалён: тело `StatementService.recompute`, `settle_edited_values`, `has_review_values`, `has_source_lines`, `_value_needs_review`, `nearest_ok_parents` и `_recompute_parents`; `parent_row_index` `detail` пишет только пересчёт пакета | задание B2; T13.3 (флаги detail больше не затираются навсегда) | нет | | 2026-10-18 | **P3-36 (B3).** `checks_on_intake_1x` живёт в `statement_recompute.py`; движок передаётся в `_insert_statements` необязательным параметром (по умолчанию `default_engine()`), `map_file` сигнатуру не меняет. Приём `1.x` пишет `meta.form_variant` и записи `relation` / `soft` (`core`), но не `CODE_NOT_IN_*` / `NAME_MISMATCH` (P3-7) и не трогает `needs_review` (A9); условие D-6 «проверки сошлись» — `not result.checks.relations_failed` (нарушение с `expected = NULL` — жёсткое) | задание B3, спека «Пересчёт при приёме»; расхождение с прежним `run_checks` (его `failed`) возможно только на формах с пустой целью, где пакет строже | форма `1.x` с пустой целью получит `NEEDS_CHECK` вместо `RECOGNIZED` | +| 2026-10-18 | **P3-37 (fix1, T2.1).** Аудит `lines[]` довносит смену `parent_row_index` после пересчёта: у правленных строк значение заменяется итоговым, у остальных появляется запись `line:` с одним ключом `parent_row_index` (`{"parent_row_index": старый}` → `{…: новый}`) | родителей пишет `recompute_form` уже после снимка строк правки; полный снимок для не правленных строк не нужен | в событии `RECOGNIZED_DATA_EDIT` у не правленных строк неполный набор ключей | +| 2026-10-18 | **P3-38 (fix1, T1.1/T1.2/T2.4/T2.6/T2.8).** Паритет редакции приёма и пересчёта для строки с `line_code_confidence < 100` и `code_fixed` подтверждён тестом (расхождения нет, код не менялся); `edition_codes` пропускает строку `ok` без кода; `column_index` нарушения — общим помощником `issue_column_index` (запись, иначе колонка по `column_id`) в ответе и в `detail` отказа; `_unconfirmed_needs_review` и присвоения `needs_review` в `_apply_item` удалены (флаг пишет только пересчёт, он всегда идёт после правки строк); неизвестный `relative:*` в `_resolve_relative_columns` пропускается без ошибки | замечания ревью B1+B2 | колонка с неизвестным `relative:*` не следует за датой отчёта | -- 2.47.2 From ff115425291cf57fa3ec6c68f2707a2d75f9b97d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 21:22:54 +0400 Subject: [PATCH 06/13] =?UTF-8?q?docs(plan):=20=D0=BA=D0=BE=D0=BF=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0=203,=20=D1=87=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20=D0=91,=20=D0=B4=D0=BB=D1=8F=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D0=BA=D0=B8=20feat/131-normalization-core-b=20(#13?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-18-normalization-core-b.md | 1371 +++++++++++++++++ 1 file changed, 1371 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-18-normalization-core-b.md diff --git a/docs/superpowers/plans/2026-10-18-normalization-core-b.md b/docs/superpowers/plans/2026-10-18-normalization-core-b.md new file mode 100644 index 0000000..09927ac --- /dev/null +++ b/docs/superpowers/plans/2026-10-18-normalization-core-b.md @@ -0,0 +1,1371 @@ +# План 3 — arm-core: хранение v5.19, правка формы, приём контракта 2.0. План реализации + +> **Для исполнителей-агентов:** обязательный навык — superpowers:subagent-driven-development (рекомендуется: в каждой +> части больше 5 задач) или superpowers:executing-plans. Шаги размечены чекбоксами (`- [ ]`). Контроллер SDD — фоновый +> сабагент на модели sonnet; исполнители — по SDD Model Selection (`~/.claude/CLAUDE.md`, «Исполнение»). + +**Goal:** ядро хранит формы ББ/ОФР по DBML v5.19 как пришли (строки без кода, повторы кодов, `needs_review`, +`document_issue`), читает их «по коду» через один агрегат, даёт аналитику разобрать строки и подтвердить ячейки и коды +через `PATCH …/statement`, принимает результаты `1.x` и `2.0` и пересчитывает форму после правки общим кодом +`arm_statements`. + +**Architecture:** две части — два PR в RSHB/arm-core. **Часть А** не зависит от пакета: схема v5.19 (одна миграция +`0007_dbml_v5_19`), скрипт очистки форм, `crm_id` ГСК/ГСЗ, модели контракта 2.0 и приём конвертов, словарь имён с +синонимами, агрегат формы по коду, новые поля API, порядок 409, `PATCH` (`lines`, `columns`, `values[].confirmed`, +`header.organization_name`), маппер `1.x` с полями v5.19 и чистые функции условия `RECOGNIZED`. До части Б §7 по-прежнему +считает ядро на лету своей копией словаря (`api/rules/statement_checks.yaml`), но уже по агрегату. **Часть Б** стартует, +когда копия пакета лежит в `api/arm_statements/` (план 2): адаптер пакета за протоколом `StatementsEngine`, маппер `2.0`, +§7 при приёме `1.x`, пересчёт после правки точкой входа пакета, чтение §7 только из `document_issue`, удаление копии +словаря §7 ядра. + +**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2 (async) + Alembic, PostgreSQL 16, pydantic v2, pytest-asyncio, ruff, +mypy; `tea` (Gitea), OpenSpec, codegraph. + +**Spec:** `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/openspec/changes/unified-statement-normalization/` +(ветка `feat/116-unified-normalization` репозитория arm-ka-docs): главное — `specs/arm-core/spec.md`; ещё +`specs/recognition-contract/spec.md` (окно перехода, конверт, ack), `specs/statement-normalization/spec.md` («Шаг 8 — +статус документа и конверта», «Точка входа пакета для пересчёта по сохранённым данным»), `proposal.md` («Порядок +выкладки», шаг 3); модель — `model/data-model-v5.19.dbml` и `model/entities-v5.19.md` (против v5.18); контракты — +`contracts/*.schema.json` (v2.0), `contracts/*.v1.schema.json`, `contracts/examples/`, `contracts/examples/v1/`; правила — +`context/05-domain-rules.md` §7, §9.3, §9.5 пп. 2–4, §11.9; гайд фронта — `dev/frontend-api-guide.md` §5.6.1. Решения — +`design.md` (Р-1…Р-30, S-1…S-74; для ядра важны Р-4, Р-8, Р-10, Р-16, Р-18…Р-20, Р-23…Р-25, Р-27, Р-28, Р-30). + +--- + +## Место в работе и порядок + +| План | Что | Связь с этим планом | +|---|---|---| +| 0 | тест идентичности копий контракта по MAJOR (arm-core, tabular-parser, scan-gateway) | слит в arm-core **до** задачи A5 | +| 1 | пакет `arm_statements` (новый репозиторий) | ядро вызывает только имена раздела «Публичный API пакета» плана 1 (сверено, раздел «Что ядро вызывает» ниже) | +| 2 | `sync-statements`: копия пакета в `api/arm_statements/`, `statements-check` в `make check-full` | PR плана 2 в arm-core слит **до** части Б | +| docs PR #117 | контракт v2.0, DBML v5.19, правила | слит **до** слияния PR части А (dbml-check, тест идентичности против `docs` main) | +| 6 | arm-web: экран по новым полям, затем `sync-web` в `arm-core/web` | выкладывается вместе с ядром: `FileOut.job.warnings` меняет тип | + +**Выкладка на контур (шаг 3 `proposal.md`) — только А и Б вместе**, после очистки форм `purge_statements.py` и вместе с +фронтом плана 6. PR части А сливается в `main`, но снимок контура с одной частью А не делается: до части Б ядро не +принимает `2.0` и не пишет `document_issue` §7 (O-9). + +## Разбивка на PR + +| | Часть А | Часть Б | +|---|---|---| +| Issue (RSHB/arm-core) | «Единая нормализация ББ/ОФР, ядро — часть А: DBML v5.19, очистка форм, агрегат по коду, правка формы (arm-ka-docs#116)» | «Единая нормализация ББ/ОФР, ядро — часть Б: приём контракта 2.0 и пересчёт общим кодом (arm-ka-docs#116)» | +| Ветка | `feat/-normalization-core-a` | `feat/131-normalization-core-b` | +| Зеркальный workspace | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-core-a/` | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-core-b/` | +| Worktree arm-core | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-core-a/arm-core` | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-core-b/arm-core` | +| Change OpenSpec arm-core | `openspec/changes/unified-normalization-core-a/` | `openspec/changes/unified-normalization-core-b/` | +| Задачи | A0–A13 (14) | B0–B6 (7) | +| Старт | сразу (A5 — после плана 0) | после слияния PR части А и PR плана 2 в arm-core | + +Зеркальный workspace — как в плане 0 (`context/00-workspace-layout.md`, «Worktree вместо веток»): в каталоге части +лежат worktree `arm-core/` (ветка фичи), **отсоединённый** worktree arm-ka-docs `docs/` (до слияния #117 — на +`origin/feat/116-unified-normalization`, после — на `origin/main`; в нём не коммитится) и симлинки `fixtures`, +`reference-old-arm`, `CLAUDE.md`, `.claude` на основной workspace. Так `../docs` из `arm-core/api` и `DBML ?= +../../docs/model/…` указывают на docs с v5.19 без переменных окружения. + +Порядок создания (`~/.claude/CLAUDE.md`, «Роли инструментов»), на примере части А: + +```bash +WS=/Users/andrey.epifancev/projects/awp-ca +mkdir -p $WS/.wt/normalization-core-a +git -C $WS/arm-core fetch origin +git -C $WS/arm-core worktree add -b tmp/normalization-core-a $WS/.wt/normalization-core-a/arm-core origin/main +git -C $WS/docs fetch origin +git -C $WS/docs worktree add --detach $WS/.wt/normalization-core-a/docs origin/feat/116-unified-normalization +for l in fixtures reference-old-arm CLAUDE.md .claude; do ln -s $WS/$l $WS/.wt/normalization-core-a/$l; done +tea issues create --login home --repo RSHB/arm-core --title "<заголовок из таблицы>" --description "<цель, ссылка на план 3>" +git -C $WS/.wt/normalization-core-a/arm-core branch -m feat/-normalization-core-a +cd $WS/.wt/normalization-core-a/arm-core && codegraph init && make venv +``` + +Все вызовы `codegraph_explore` — с `projectPath` = абсолютный путь worktree части. PR — `tea pulls create --login home +--repo RSHB/arm-core`, со ссылкой на issue и на arm-ka-docs#116; в описание — вывод `make check-full`, таблица находок и +«Rulings I made». Слияние — только по команде человека. + +## Global Constraints + +- Источник истины по схеме — `model/data-model-v5.19.dbml`; `src.models.DBML_VERSION = "5.19"`; в `Makefile` + `DBML ?= ../../docs/model/data-model-v5.19.dbml`; в `arm-core/CLAUDE.md` «Правила репозитория» — v5.19 и миграция + `0007_dbml_v5_19`. +- Одна ревизия Alembic `0007_dbml_v5_19` (`down_revision = "0006_dbml_v5_18"`); только добавляет колонки, таблицу, enum'ы, + индексы и заполняет значения по умолчанию; §7 не считает; `arm_statements` не импортирует; схема — `from + src.models.base import SCHEMA`, не литерал. +- Строка, созданная правкой: `row_index IS NULL` ⇔ `origin = template`. Признак `line_code_original_value IS NULL` для + `origin` и для «строка не из источника» нигде не используется. Маппер `1.x` и `2.0` всегда пишет `row_index`. +- `line_code_original_value` и `header_source`, `*_original_value` правками не меняются. +- Строки `detail`, `unreadable`, `missing` не входят в агрегат по коду, в §7, в межформенную проверку. +- Деньги — `Decimal` / `numeric(18,2)`, в JSON — строки; `float` в домене нет. +- Порядок блокировок: задание → файл → документы → advisory ГК → версии (по `id`); правка формы — «файл → документ», шапка + перечитывается под блокировкой с `refresh=True`. +- Ошибки — `DomainError`: 404 `NOT_FOUND`, 409 `ConflictError`, 422 `DomainValidationError`, тело `{"detail": {"code", + "message", …}}`; 422 валидации тела pydantic — массив. Коды этого плана: 409 `LINE_CODE_TAKEN` (`line_id`, + `line_code`), `LINES_UNRESOLVED` (`lines[] {line_id, row_index, line_code_text}`), `DUPLICATE_FORM` (`document_id`), + `CRM_ID_EXISTS`; 422 `LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE` (все четыре — с + `line_id`, `line_code` элемента `lines[]`, P3-24), `UNKNOWN_COLUMN`, `UNKNOWN_CELL`. Каталог + кодов в `arm-core/README.md` («Ошибки API») дополняется в той задаче, что вводит код. +- Один `PATCH …/statement` — один пересчёт и одна запись `audit_event` `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`, + `object_type = "document"`); запрос без изменений — 200 без аудита. Аудит — только через `services/audit.py`. +- Коды предупреждений и записей `document_issue` — дословно: `RELATION_FAILED` (`relation`, `error`), `RELATION_SOFT` + (`soft`, `info`), `CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH`, `RESULT_STATUS_INCONSISTENT`, + `STATEMENTS_VERSION_MISMATCH`, `MAPPING_WARNING`, `LEGACY_WARNING` (все — `info`, источник `core`); снимаемые правкой + предупреждения сервиса — `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`, `REPORT_DATE_UNCERTAIN`, `NO_ORGANIZATION_NO_INN`, + `COLUMN_WITHOUT_DATE`, `COLUMN_DATE_UNCERTAIN`; уровень файла `error` — по полю `severity`, не по списку кодов. +- `import arm_statements` — только в `api/src/services/statements_engine.py` (часть Б); копию `api/arm_statements/` не + править (её раскладывает `sync-statements`, план 2). +- Копии контракта в `api/contracts/` побайтно равны docs: основные схемы v2.0 (у запроса — v1.0), `*.v1.schema.json`, + `examples/*.json` (v2.0) и `examples/v1/*.json`; тест идентичности — правило плана 0. +- `make check` (без Docker) зелёный после каждой задачи; перед PR — `make check-full` (нужен Docker: `make up` из + `../docs/dev/`), его вывод — в описание PR. +- Язык кода-комментариев, docstring, коммитов, issue, PR — русский; идентификаторы — английские, как в DBML. Docstring + нового алгоритма называет § правил (`05-domain-rules.md`) или требование спеки `arm-core`. +- Коммиты заканчиваются строками `Co-Authored-By: Claude Opus 5.5 ` и `Claude-Session: + https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W`; описание PR — строкой `🤖 Generated with [Claude + Code](https://claude.com/claude-code)`. +- Находки и решения — с маркерами (`T.`, `C-n`/`I-n`/`M-n`, `R-n`, `O-n`, `H-n`); решения этого плана — `P3-n` + (ниже); исполнитель переносит их в `design.md` своего change'а arm-core (раздел «Decisions made without discussion»). + +## Review Focus + +1. **Две формы одного типа и периода в одном файле** (лист-продолжение, двойная печать) — оба документа хранятся с + разными `file_ordinal`, ни один не `RECOGNIZED` и не автоподтверждён (Р-18); подтверждение второго после первого — + 409 `DUPLICATE_FORM`; формы разных периодов — каждая по своим условиям; автозамена и пара межформенной проверки не + падают на двух документах с одним ключом. Тесты — A9 (маппер `1.x`), A11 (409), B4 (маппер `2.0`). +2. **Повторы кода в агрегате** — суммы строк `ok` с одним кодом складываются по колонке (`100 + 649 = 749`), пустые не + превращаются в 0 (`null`, если непустых нет), межформенная проверка берёт сумму (`500 + 300 = 800`), `parent_code` + подстроки и шаблонная достройка видят код один раз. Тесты — A7. +3. **Строка без кода при назначении кода** — строка `unreadable` с суммами становится `ok`, `row_index` и + `line_code_original_value = null` сохраняются (`origin = source`), её значения входят в агрегат и §7, родители всех + строк `detail` пересчитаны; тот же код у другой строки источника — законный повтор, без 409. Тесты — A13. +4. **Шаблонная строка с суммами** (`row_index = null`) под назначаемым кодом — 409 `LINE_CODE_TAKEN`, и **ничего** из + запроса не применено (ни другие элементы `lines`, ни `values`, ни шапка); пустая шаблонная строка удаляется, её `id` + уходит из `document_issue.line_ids`. Тесты — A13. +5. **Старые задания со строковыми `warnings`** — `metrics.warnings` из строк, смесь строк и объектов, не список, объект + без обязательных полей: `JobBrief.warnings` отдаёт структуры, строки — как `LEGACY_WARNING`, мусор пропускается, данные + в БД не переписываются; условие `RECOGNIZED` не считает строки предупреждениями `error`. Тесты — A5, A10. + +--- + +## Карта файлов + +**Часть А** + +| Файл | Ответственность | +|---|---| +| `api/src/models/statements.py` | колонки v5.19 у `financial_statement_*`, новая модель `DocumentIssue` | +| `api/src/models/files.py` | `Document.file_ordinal`, частичный уникальный индекс типа, `RecognitionJob.statements_version` | +| `api/src/models/directories.py` | `RelatedGroup.crm_id` (unique) | +| `api/src/models/enums.py`, `api/src/models/__init__.py` | enum'ы v5.19 в `ENUMS`, `DBML_VERSION = "5.19"`, экспорт `DocumentIssue` | +| `api/alembic/versions/0007_dbml_v5_19.py` | миграция (upgrade с заполнением, downgrade с отказами и журналом) | +| `api/scripts/purge_statements.py` | очистка форм перед выкладкой | +| `api/src/contract/result_v2.py`, `api/src/contract/message.py`, `api/src/contract/versions.py` | модели контракта 2.0, разбор по MAJOR | +| `api/src/domain/statement_lines.py` | словарь имён: несколько имён на код | +| `api/src/domain/statement_names.py` | совпадение наименований (§11.9) | +| `api/src/domain/statement_aggregate.py` | агрегат формы по коду | +| `api/src/domain/statement_layout.py` | порядок строк ответа, ближайший родитель `detail` (временный, до Б) | +| `api/src/domain/statement_issues.py` | черновик записи `document_issue`, правила снятия предупреждений правкой | +| `api/src/domain/statement_status.py` | условие `RECOGNIZED` (2.0, Р-18/Р-19), статус ОСВ (Р-28), проверка наименований | +| `api/src/domain/statement_mapping.py` | черновики формы `1.x` с полями v5.19 | +| `api/src/repositories/statements.py` | вставка v5.19, агрегат, `document_issue`, удаление строк | +| `api/src/services/result_mapping.py` | маппер `1.x`: `file_ordinal`, Р-10, `MAPPING_WARNING` | +| `api/src/services/recognition_exchange.py` | приём ack и конвертов `1.x`/`2.0`, `metrics.warnings` структурами | +| `api/src/services/documents.py` | `JobBrief.warnings`, `DocumentOut.issues`, порядок 409 | +| `api/src/services/statements.py`, `api/src/services/statement_line_edits.py` | `PATCH`, блокеры подтверждения, пересчёт | +| `api/src/services/related_groups.py`, `api/src/common/constraint_conflicts.py` | `crm_id` ГСК/ГСЗ | +| `api/src/schemas/statements.py`, `files.py`, `documents.py`, `directories.py` | DTO | + +**Часть Б** + +| Файл | Ответственность | +|---|---| +| `api/src/services/statements_engine.py` | протокол `StatementsEngine` и адаптер к копии `arm_statements` (единственный импорт пакета) | +| `api/src/domain/stored_form.py` | сборка входа точки пересчёта из строк БД (чистая функция) | +| `api/src/services/statement_recompute.py` | пересчёт после правки и при приёме `1.x`, запись результатов | +| `api/src/domain/statement_mapping_v2.py` | черновики формы и ОСВ из результата `2.0` | +| `api/src/services/result_mapping.py` | выбор маппера по MAJOR, маппер `2.0` | +| удаляются: `api/rules/statement_checks.yaml`, §7-часть `api/src/domain/statement_checks.py` | копия словаря §7 ядра | + +--- + +## Что ядро вызывает у пакета (часть Б) + +Источник истины по именам и типам — раздел «Публичный API пакета» плана 1 (`2026-10-18-plan-1-arm-statements.md`); +здесь они не копируются. Ядро импортирует имена верхнего уровня `arm_statements` только в +`api/src/services/statements_engine.py` и `api/src/domain/stored_form.py` (сборка входа пересчёта); остальной код +работает с протоколом ниже. + +```python +# api/src/services/statements_engine.py — единственная точка вызова функций arm_statements +from arm_statements import (DocStatus, DocumentType, Edition, FormVariant, PeriodKind, RecomputeResult, Rules, + StoredForm, choose_edition, default_rules, document_status, recompute, resolve_relative, + status_input_from_contract, statements_version) + +class StatementsEngine(Protocol): # тонкая обёртка: подмена в модульных тестах (FakeEngine), кэш правил на процесс + @property + def version(self) -> str | None: ... # statements_version(); нет файла VERSION — None + def recompute(self, form: StoredForm) -> RecomputeResult: ... # recompute(form, rules) + def contract_document_status(self, document: Mapping[str, Any]) -> DocStatus: ... + # document_status(status_input_from_contract(document)) — statementDocument результата 2.0 как dict + def edition(self, document_type: DocumentType, codes: Sequence[tuple[str, bool]], + effective_date: datetime.date | None, form_variant: FormVariant | None) -> Edition | None: ... + # choose_edition(document_type, codes, effective_date, rules, variant=form_variant) + def code_order(self, edition: Edition, document_type: DocumentType, form_variant: FormVariant) -> tuple[str, ...]: ... + # rules.code_orders.sequence(edition, document_type, form_variant); DISCLOSURE → () + def editions(self) -> tuple[Edition, ...]: ... # rules.code_orders.editions — (RAS_66N, FSBU4) + def resolve_relative(self, kind: PeriodKind, effective_date: datetime.date | None) -> datetime.date | None: ... + # ABSOLUTE / NONE → None + def tolerance(self) -> Decimal: ... # rules.checks.tolerance — Decimal("1"), для межформенной проверки ядра + +def default_engine() -> StatementsEngine: ... # ArmStatementsEngine(default_rules()), кэш на процесс +``` + +| Вызов ядра | Имя плана 1 | Где используется | +|---|---|---| +| `engine.recompute(form)` | `recompute(form: StoredForm, rules) -> RecomputeResult` | B2 (после `PATCH`), B3 (приём `1.x`) | +| `engine.contract_document_status(doc)` | `document_status(status_input_from_contract(doc))` | B4 (страховка статуса результата `2.0`) | +| `engine.edition(...)` | `choose_edition(document_type, codes, effective, rules, *, variant)` | B4 (редакция формы `2.0` для `CODE_NOT_IN_EDITION`) | +| `engine.code_order(...)` | `default_rules().code_orders.sequence(edition, form, variant) -> tuple[str, ...]` | B4, B6 | +| `engine.editions()` | `default_rules().code_orders.editions -> tuple[Edition, ...]` | B6 | +| `engine.resolve_relative(...)` | `resolve_relative(kind, effective) -> date \| None` | B2 (правка `header.report_date`) | +| `engine.tolerance()` | `default_rules().checks.tolerance -> Decimal` | B6 | +| `engine.version` | `statements_version()` | B2 (`STATEMENTS_VERSION_MISMATCH`) | +| `StoredForm`, `StoredColumn`, `StoredLine`, `StoredValue`, `StoredWarning` | одноимённые dataclass-ы пакета | B1 (`build_stored_form`) | +| `FailedRelation`, `StatementChecks` | из `RecomputeResult.checks` | B2 (`relations_to_issues`) | + +`to_contract` / `warning_to_contract` ядру не нужны: ядро читает контракт, а не пишет его. `StatusInput` ядро не собирает +— вход статуса из документа результата строит пакет (`status_input_from_contract`), поэтому страховка статуса считает +ровно то, что посчитал сервис (план 1, P1-27). + +Соглашения на границе ядра и пакета (все — в `stored_form.py` / адаптере, остальной код ядра их не видит): + +- Перечисления пакета — `StrEnum` (подкласс `str`, значения — строки контракта и БД): строка БД превращается в + `LineStatus(...)`, `DocumentType(...)`, `PeriodKind(...)`, `Severity(...)` при сборке `StoredForm`; `period_kind = NULL` + в БД → `PeriodKind.NONE`. Обратно значения пишутся в БД как `str(...)` / `.value`. +- Уверенности пакета — `float | None` (0–100), в БД — `Numeric(5,2)`: при сборке `float(confidence)`, `None` остаётся `None`. + `StoredValue.is_recognized` — `bool`: `NULL` в БД читается как `False`. +- `StoredColumn.column_index: int | None` (колонка без номера столбца допустима); ключи строки и колонки — `str(uuid)`. +- `RecomputeResult.edition` и `engine.edition(...)` — `Edition | None`; `None` при нераспознанной редакции (нет даты и + ничья): проверки по редакции (`CODE_NOT_IN_EDITION`) тогда не пишутся. Пары `codes` для `engine.edition` — строки `ok` + результата по `row_index`: `(line_code, line_code_confidence == 100)` (у результата `2.0` кодов, правленых аналитиком, + нет); пересчёт после правки берёт ту же функцию внутри `recompute` — редакция приёма и пересчёта совпадает. +- `FailedRelation` несёт `column_index: int | None` и `column_key: str`; при пересчёте из БД `column_key` — это + `StoredColumn.key`, то есть `str(column_id)`: колонка находится по ключу без обратного сопоставления по индексу. + `expected` и `actual` — `Decimal | None` (`None` — цель или все члены пусты, план 1 P1-21); `document_issue.expected` / + `actual` — `numeric(18,2)` без `NOT NULL`, `IssueDraft.expected` / `actual` — `Decimal | None`. + +Ключи хоста — `str(uuid)` строки и колонки (`key`, `column_key`, `parent_key`). + +--- + +## Решения плана без обсуждения (`P3-n`) + +| Маркер | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| P3-1 | Миграция пишет `header_source` с ключами `inn`, `report_date`, `units`, `organization_name`: `text` — из `inn_original_value`, `report_date_original_value`, `units_original_value`, `organization_name`; `confidence` — `inn_confidence` у `inn`, у остальных `null` | спека: «из `*_original_value` и `inn_confidence`»; прочих полей в v5.18 сырыми нет | подсказка у поля старой формы беднее; формы старых времён на контуре удалены очисткой | +| P3-2 | `organization_name_modified` — `false` у всех форм (миграция и оба маппера), не `NULL` | как `inn_modified` в маппере 1.x; API отдаёт `bool` | одна строка `UPDATE` | +| P3-3 | `needs_review_fields`: маппер `1.x` пишет `[]`, миграция оставляет `NULL`, чтение `NULL` = `[]` | спека миграции поле не заполняет | нет | +| P3-4 | Маппер `1.x` тоже хранит вторую форму того же типа (Р-10); формы одного типа и одной пары `period_year`, `period_quarter` получают `MAPPING_WARNING` «в файле есть другая форма <тип> за тот же период» и по D-6 — `NEEDS_CHECK` (аналог Р-18) | частичный индекс v5.19 больше не заставляет пропускать; одно правило хранения для обоих MAJOR | O-1: если владелец хочет прежний пропуск — вернуть ветку `continue` | +| P3-5 | Предупреждения маппинга листа ОСВ (повтор счёта, строки без родителя, нарастающая по данным Q1) пишутся ещё и в `document_issue` документа листа (`warning`, `MAPPING_WARNING`, `info`, `core`) при любом MAJOR; строки в `metrics.mapping_warnings` остаются | спека: `DocumentOut.issues` ОСВ — «вместе с предупреждениями маппинга ядра этого листа» | нет | +| P3-6 | `file_ordinal` объединённого документа `DISCLOSURE` / `OTHER` — индекс первого такого документа в `documents[]` | несозданный документ «номер не занимает» | нет | +| P3-7 | `CODE_NOT_IN_DICTIONARY` и `CODE_NOT_IN_EDITION` — одна запись на код документа (`line_ids` — все строки `ok` с кодом), пятизначные коды тоже проверяются; при приёме `1.x` проверки ядра уровня `info` не пишутся | спека для `1.x` перечисляет только §7 и `MAPPING_WARNING` | O-3 (поток `info` у пятизначных) | +| P3-8 | После правки записи `core` `CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH` и `STATEMENTS_VERSION_MISMATCH` пересчитываются (заменяются); `NAME_MISMATCH` не ставится строке с `code_fixed`; `MAPPING_WARNING`, `RESULT_STATUS_INCONSISTENT`, `LEGACY_WARNING` сохраняются | код строки меняется правкой; страховка статуса — факт приёма | O-4 | +| P3-9 | Результат `2.0`: значение с `column_index` вне колонок формы и повтор `column_index` колонки — первое остаётся, `MAPPING_WARNING` | FK значения на колонку; уникальность `(meta_id, column_index)` | нет | +| P3-10 | Части `PATCH` применяются в порядке `lines` → `values` → `columns` → `header`; вся адресация (422) проверяется до первой записи; `UNKNOWN_CELL` по `line_code` — по кодам строк `ok` с учётом назначений `lines` этого запроса; один `line_id` дважды в `lines` — 422 `INVALID_LINE_PATCH` | атомарность и предсказуемость | нет | +| P3-11 | `reference_name` — первое имя кода в редакции ответа (`edition` по дате); нет редакции или кода — `null` | спека: «первое имя кода в словаре»; `edition` ответа — по дате | нет (Р-29) | +| P3-12 | Очистка отказывает, только если документ формы упомянут в `ex_snapshot_item` или привязан к версии в статусе «Верифицирована» или дальше: `VERIFIED` или `ARCHIVED` с `ex_snapshot.source_version_id` = версия; привязки к версиям в работе, на согласовании, `REJECTED`, `CANCELLED` (и архивированным отменённым) снимаются, статус версий не меняется | решение владельца 18.10.2026 (Р-35): верифицированная версия read-only и заморожена в ЕХ, прочие — нет; на стенде версий дальше «в работе» нет | архивированная версия без снимка ЕХ (из `CANCELLED`) очистке не мешает — если бы мешала, владелец увидел бы лишний отказ | +| P3-13 | Коды выхода `purge_statements.py`: `0` — отчёт или выполнено, `1` — отказ (п. 2 спеки), `2` — ошибка аргументов | как у `check_dbml_vs_orm.py` | нет | +| P3-14 | До части Б §7 считает ядро на лету (`statement_checks.yaml`) по агрегату; `checks.failed[].codes` берутся из правила, `line_ids` — по агрегату | часть А должна быть самосогласованной | временный код удаляется в B6 | +| P3-15 | Для условия `RECOGNIZED` `2.0` предупреждения файла берутся из `metrics.warnings` файла результата; в `recognition_job.metrics.warnings` — из конверта (inbox) | маппер читает файл результата; по контракту наборы одинаковы | нет | +| P3-16 | Подтверждение кода (`lines[] {confirmed: true}`) ставит `is_confirmed = true` и `needs_review = false`, `line_code_confidence` и `line_code_modified` **не меняет**; назначение кода — `confidence = 100`, `line_code_modified = true` | иначе исходная уверенность теряется, а снятие подтверждения не может вернуть `needs_review` (гайд §5.6.1: «при `false` — пересчитывается»); аналогия с ячейкой (`is_modified` не ставится); подтверждено владельцем 18.10.2026 (Р-34), спека `arm-core`, §9.5 п. 9 и гайд §5.6.1 приведены к этому | нет | +| P3-17 | Порядок строк ответа: строки `ok` (с шаблонными) — по коду, затем `row_index` (`NULL` последними), затем `id`; за каждой строкой `ok` источника — её «вложения» по `row_index`: строки `detail` с этим родителем и строки `unreadable` / `missing` / `detail` без родителя, у которых она — ближайшая предыдущая по `row_index` строка `ok` источника; вложения без такой строки — в начале | спека «Ответ `GET …/statement`» | нет | +| P3-18 | Изменение `crm_id` ГСК/ГСЗ пишет одно событие `GROUP_CHANGE` с `before/after` всех изменённых полей вместо `MEMBERSHIP_CHANGE` | спека: «изменение — аудит `GROUP_CHANGE`» | O-6 | +| P3-19 | Ядро зовёт у пакета `choose_edition` (`engine.edition`) и `status_input_from_contract` (`engine.contract_document_status`) вместо сборки `StoredForm` ради редакции и своего `status_input_from_v2`; `to_contract` ядру не нужен | одно правило с сервисом и с `recompute` (план 1, P1-25, P1-27); пары `(код, уверенный)` — из строк `ok` результата по `row_index` | при ошибке сборки пар редакция приёма разойдётся с редакцией после правки — тест B1 сравнивает их | +| P3-20 | `header.source.<поле>.confidence` — строка Decimal с двумя знаками (`"61.54"`), как прочие уверенности API; в JSONB `header_source` остаётся число как пришло | фронт читает все уверенности одним способом (план 6, A8) | округление до сотых в подсказке; исходное число — в БД | +| P3-21 | `StatementHeaderOut.okei` — из `financial_statement_meta.okei` (колонка есть с v5.18), только чтение; в `PATCH header` не входит | спека `header` контракта несёт `okei`, экран показывает `issues` с `field = "okei"` у поля | нет | +| P3-22 | Справочник строк для подсказки кода — поле `StatementOut.edition_lines [{code, name}]`, а не отдельный эндпоинт: данные уже загружены (`LineCatalog`, A6) при сборке ответа, нет нового маршрута, права, кэша и запроса на фронте; около 80 пар на форму (≈ 4 КБ) против строк формы с значениями; у формы без даты — коды всех редакций, по одному | наименьший объём реализации в ядре и во фронте; закрывает пробел «у формы без даты подсказки нет» (план 6, O-3) | ответ `GET` тяжелее на ≈ 4 КБ; если понадобится справочник вне формы — отдельный эндпоинт поверх того же `LineCatalog.edition_lines` | +| P3-23 | `DocumentBrief.issue_counts {error, info}` — все записи `document_issue` документа (и `relation` / `soft`, в том числе акцептованные) по `severity`; один групповой запрос на файл | панели файлов нужен признак «есть что смотреть» без запроса на каждый документ (план 6, O-4) | акцептованное нарушение продолжает помечать документ в панели — подробности на экране документа | +| P3-24 | Ошибки элемента `lines[]` (409 `LINE_CODE_TAKEN`, 422 `LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`) несут в `detail` `line_id` и `line_code` первого неверного элемента; `LINE_CODE_REQUIRED` добавлен к четырём кодам запроса фронта | экран ставит ошибку у поля нужной строки, не угадывая (план 6, O-2); одно правило для всех ошибок элемента | при нескольких неверных элементах фронт видит только первый — как и прежде | + +## Открытые вопросы (`O-n`) + +- **O-1.** Р-10 для результатов `1.x` (P3-4): хранить вторую форму того же типа и при `1.x` или пропускать, как раньше? + План хранит. +- **O-2.** Закрыт решением владельца 18.10.2026 (Р-34): подтверждение кода уверенность не меняет (P3-16); буква спеки + `arm-core`, `05-domain-rules.md` §9.5 п. 9 и гайда §5.6.1 приведена к этому правилу. +- **O-3.** `CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` у пятизначных подстрок: каждая подстрока Excel даёт `info`. + Спека это требует (сценарий «Нестандартный код Excel»); план пишет по записи на код. Ограничить четырёхзначными? +- **O-4.** Пересчёт проверок ядра `info` после правки (P3-8) спека не описывает. +- **O-5.** Закрыт решением владельца 18.10.2026 (Р-35): отказ только при верифицированной версии (P3-12); спека `arm-core` + «Очистка форм перед выкладкой» п. 2 приведена к этому правилу, сценарий «Форма в версии на согласовании или + отклонённой» добавлен. +- **O-6.** Действие аудита при смене `crm_id` ГСК/ГСЗ (P3-18): сейчас правки ГСК/ГСЗ пишут `MEMBERSHIP_CHANGE`. +- **O-7.** Конверт с неизвестным MAJOR (`3.0`) inbox, как и сейчас, отбрасывает с предупреждением в лог (задание закроется + по сроку); сценарий спеки «файл получает `ERROR` с `mapping_error`» выполняется для файла результата. Оставить? +- **O-8.** Закрыт 18.10.2026 сведением с планом 1 (его P1-21…P1-31): (а) типы `Rules.code_orders.sequence(...) -> + tuple[str, ...]` (параметр `variant: FormVariant`, у `DISCLOSURE` — `()`), `.editions -> tuple[Edition, ...]`, + `Rules.checks.tolerance -> Decimal` записаны в API пакета; (б) редакция без пересчёта — `choose_edition` (`engine.edition`, + B4); (в) `resolve_relative(ABSOLUTE | NONE, …)` → `None`; (г) вход статуса из документа результата — + `status_input_from_contract` пакета (`engine.contract_document_status`), `status_input_from_v2` не пишется. +- **O-9.** Выкладка: PR части А в `main` без снимка контура до части Б; встроенный фронт `arm-core/web` получает новые типы + только через `sync-web` после плана 6 — выкладка ядра и фронта одним снимком. +- **O-10.** Метрики `fields_*` ядро сейчас не считает по строкам формы (копирует из конверта, `recognition_exchange.FIELD_COUNTERS`): + переводить на агрегат нечего. Подтвердить, что спека («метрики `fields_*`») не требует нового расчёта. + +--- + +## Часть А + +### Task A0: Change OpenSpec части А в arm-core + +**Files:** +- Create: `openspec/changes/unified-normalization-core-a/proposal.md`, `design.md`, `tasks.md`, + `specs/{data-model,osv-result-mapping,documents-api,primary-files,recognition-job-exchange,directories-api,statement-checks}/spec.md` + +**Interfaces:** +- Produces: change, на который ссылаются коммиты и PR части А; `design.md` с разделом `## Decisions made without + discussion` (туда переносятся `P3-n` части А и рулинги контроллера). + +- [ ] **Step 1:** `proposal.md` — ссылка на change docs (`unified-statement-normalization`, arm-ka-docs#116), перечень + требований спеки `arm-core`, которые закрывает часть А (по таблице «Покрытие спеки» ниже), и что остаётся части Б. +- [ ] **Step 2:** спеки-дельты: переносят в формулировках capability arm-core требования спеки `arm-core` docs из + колонки «А» таблицы покрытия (MODIFIED у `data-model`, `osv-result-mapping`, `documents-api`, `primary-files`, + `recognition-job-exchange`, `directories-api`; `statement-checks` — MODIFIED «§7 по агрегату по коду»). +- [ ] **Step 3:** `openspec validate unified-normalization-core-a --strict` — без ошибок. +- [ ] **Step 4: Commit** `openspec(core-a): change единой нормализации, часть А (arm-ka-docs#116)`. + +### Task A1: Схема v5.19 — модели и upgrade миграции + +**Files:** +- Modify: `api/src/models/statements.py`, `api/src/models/files.py`, `api/src/models/directories.py`, + `api/src/models/enums.py`, `api/src/models/__init__.py`, `api/src/repositories/statements.py` + (`delete_file_statements`), `api/tests/factories.py` (`make_document`), `Makefile`, `CLAUDE.md` +- Create: `api/alembic/versions/0007_dbml_v5_19.py` +- Test: `api/tests/test_migrations.py`, `api/tests/test_api_files_delete.py`, `api/tests/test_mapping_statements.py` + +**Interfaces:** +- Produces (ORM, имена колонок — как в DBML v5.19, новые колонки — в конце класса в порядке `ADD COLUMN` миграции): + - enum'ы `StatementLineStatus {ok, unreadable, missing, detail}`, `StatementFormVariant {full, simplified}`, + `DocumentIssueKind {warning, relation, soft}`, `IssueSeverity {error, info}`, `IssueSource {service, core}` — в + `ENUMS` под именами `statement_line_status`, `statement_form_variant`, `document_issue_kind`, `issue_severity`, + `issue_source`; + - `FinancialStatementLine`: `row_index: int | None`, `source_ref: str | None`, `line_status: StatementLineStatus` + (`server_default 'ok'`), `line_code_text: str | None`, `parent_row_index: int | None`, `needs_review: bool` + (`server_default false`), `is_confirmed: bool` (`server_default false`); индексы + `ix_financial_statement_line_meta_id_line_code` (не уникален) и `uq_financial_statement_line_meta_id_source_ref` + (`unique`, `postgresql_where=source_ref IS NOT NULL`); + - `FinancialStatementValue`: `source_text`, `needs_review` (`false`), `is_confirmed` (`false`), `confirmed_by` (FK + `app_user.id`), `confirmed_at`; + - `FinancialStatementColumn`: `period_kind: str | None`, `label_confidence: Decimal | None` (`Numeric(5,2)`); + - `FinancialStatementMeta`: `form_variant`, `needs_review_fields` (jsonb), `header_source` (jsonb), + `organization_name_original_value`, `organization_name_modified`; + - `Document.file_ordinal: int` (`SmallInteger`, `server_default 0`); `uq_document_file_type_account` — + `unique`, `postgresql_nulls_not_distinct=True`, `postgresql_where=document_type NOT IN ('BALANCE', 'INCOME')`; + `uq_document_stored_file_id_file_ordinal` (`stored_file_id`, `file_ordinal`, unique); + - `RecognitionJob.statements_version: str | None`; `RelatedGroup.crm_id: str | None` (`unique=True`); + - `DocumentIssue` (`document_issue`): поля DBML, `ix_document_issue_document_id`. +- Produces (репозиторий): `delete_file_statements(session, file_id)` удаляет и `document_issue` документов файла (первым). +- Produces (фабрика): `make_document(...)` без явного `file_ordinal` берёт следующий свободный номер в файле. + +- [ ] **Step 1: Write the failing test** — `test_upgrade_0007_backfills_v5_19_columns` в `test_migrations.py` (по образцу + `test_upgrade_0006_backfills_consolidation_id`): на ревизии `0006_dbml_v5_18` вставить ЮЛ, файл, `INCOME` + (`created_at` = t0) и `BALANCE` (t1 > t0), шапку ББ с `inn_original_value = "7701234567"`, `inn_confidence = 87`, + `report_date_original_value = "30.09.2025"`, `units_original_value = "тыс. руб."`, `organization_name = "ООО Ромашка"`; + колонки `c1` (`period_date = 2025-09-30`) и `c2` (`NULL`); строки `1600` и `1100` (оригинал = код) и `1250` + (`line_code_original_value = NULL`, сумма 5); значения: `(conf 100, rec true, mod false)`, `(conf 80, rec true, mod + false)`, `(conf 80, mod true)`, `(conf NULL, rec false)`, `(string_value "12 3")`; ГСК без `crm_id`. Затем `upgrade + head` и проверки: + +```python +assert rows["1100"].row_index == 0 and rows["1600"].row_index == 1 and rows["1250"].row_index is None +assert {r.line_status for r in rows.values()} == {"ok"} and not any(r.needs_review for r in rows.values()) +assert [v.needs_review for v in values] == [False, True, False, True, False] +assert value_by_text["12 3"].source_text == "12 3" +assert period_kind == {"c1": "absolute", "c2": "none"} and meta.form_variant is None +assert meta.header_source == { + "inn": {"text": "7701234567", "confidence": 87}, + "report_date": {"text": "30.09.2025", "confidence": None}, + "units": {"text": "тыс. руб.", "confidence": None}, + "organization_name": {"text": "ООО Ромашка", "confidence": None}, +} +assert meta.organization_name_original_value == "ООО Ромашка" and meta.organization_name_modified is False +assert ordinals == {"INCOME": 0, "BALANCE": 1} and related_group.crm_id is None +assert await count(conn, "document_issue") == 0 +``` + + И поведение схемы после upgrade (`test_v5_19_constraints`): вторая `BALANCE` в том же файле с `file_ordinal = 2` + вставляется; вторая `DISCLOSURE` — `IntegrityError` (`uq_document_file_type_account`); две строки с одним кодом в форме — + вставляются; две строки с одним непустым `source_ref` — `IntegrityError`; две с `source_ref = NULL` — вставляются; два + документа файла с одним `file_ordinal` — `IntegrityError`. +- [ ] **Step 2:** `make migration-cycle` — FAIL (ревизии `0007` нет). Константы теста: `HEAD = "0007_dbml_v5_19"`, + `V5_18 = "0006_dbml_v5_18"`; офлайн-SQL теста `test_offline_sql_creates_schema_before_version_table` доходит до `0007`. +- [ ] **Step 3: Implement** модели и `upgrade()` ревизии `0007_dbml_v5_19`: типы enum → колонки → заполнение → индексы + (уникальные — после заполнения `row_index`/`file_ordinal`) → таблица `document_issue`. Заполнение — SQL ревизии: + +```sql +-- row_index: строки источника по возрастанию line_code внутри формы с 0; созданные правкой — NULL +UPDATE {S}.financial_statement_line l SET row_index = n.rn FROM ( + SELECT id, row_number() OVER (PARTITION BY meta_id ORDER BY line_code, id) - 1 AS rn + FROM {S}.financial_statement_line WHERE line_code_original_value IS NOT NULL) n WHERE l.id = n.id; +UPDATE {S}.financial_statement_value SET source_text = string_value, + needs_review = NOT (is_modified OR (COALESCE(is_recognized, true) AND (confidence IS NULL OR confidence = 100))); +UPDATE {S}.financial_statement_column SET period_kind = CASE WHEN period_date IS NULL THEN 'none' ELSE 'absolute' END; +UPDATE {S}.document d SET file_ordinal = n.rn FROM ( + SELECT id, row_number() OVER (PARTITION BY stored_file_id ORDER BY created_at, id) - 1 AS rn FROM {S}.document) n + WHERE d.id = n.id; +``` + + `header_source` — `jsonb_build_object` по P3-1; `organization_name_original_value = organization_name`, + `organization_name_modified = false` (P3-2). Переключить `Makefile` (`DBML ?= ../../docs/model/data-model-v5.19.dbml`), + `DBML_VERSION = "5.19"`, `CLAUDE.md` («Правила репозитория»: v5.19, `0007_dbml_v5_19`). `make_document` — следующий + свободный `file_ordinal` в файле. `delete_file_statements` — `DELETE document_issue WHERE document_id IN (документы + файла)` первым запросом. +- [ ] **Step 4:** добавить в `test_delete_file_with_statement_form` строку `document_issue` у документа формы перед + удалением и проверку, что её нет после; в `test_repeated_form_mapping_is_idempotent` — строку `document_issue` до + повторного маппинга и проверку, что её нет после. +- [ ] **Step 5:** `make dbml-check` — пустой вывод, код 0. Если расхождение только в тексте `WHERE` из-за `\'` в note DBML — + в `scripts/dbml_parity.py::_norm_where` снимать `\` перед кавычкой, тест в `tests/test_dbml_parity.py` + (`document_type NOT IN (\'BALANCE\', \'INCOME\')` ≡ `document_type NOT IN ('BALANCE', 'INCOME')`). +- [ ] **Step 6:** `make check && make migration-cycle` — PASS (в т. ч. `test_autogenerate_finds_nothing_after_upgrade`, + `test_migrated_schema_equals_orm_create_all`). +- [ ] **Step 7: Commit** `feat(models): DBML v5.19 — колонки форм, document_issue, file_ordinal, crm_id ГСК (миграция 0007)`. + +### Task A2: Откат миграции v5.19 + +**Files:** +- Modify: `api/alembic/versions/0007_dbml_v5_19.py` (`downgrade`) +- Test: `api/tests/test_migrations.py` + +**Interfaces:** +- Consumes: ревизия A1. +- Produces: `downgrade()` — сначала проверки, потом DDL; при несовместимых данных `RuntimeError` с текстом + `откат v5.19 невозможен: <причина>: <число>[; <причина>: <число>…]`, причины дословно: `повтор кода строки в форме` + (число пар `(meta_id, line_code)` с повтором), `строки формы без кода`, `две формы одного типа в файле` (число пар + `(stored_file_id, document_type)` у `BALANCE`/`INCOME` с повтором), `crm_id ГСК/ГСЗ задан`, `наименование организации + правилось`. Журнал — `logging.getLogger("alembic.runtime.migration").warning("откат v5.19: удалены непустые значения — + %s", …)` со счётчиками `<таблица>.<колонка>: N` (булевы — число `true`, `file_ordinal` — число `<> 0`, прочие — `IS NOT + NULL`) и `document_issue: N`. + +- [ ] **Step 1: Write the failing tests** (по образцу `test_downgrade_one_step_fails_while_nu_rows_exist`): + `test_downgrade_0007_refuses_duplicate_code` (форма с двумя строками `2410`), `…_refuses_null_code` (строка + `unreadable` без кода), `…_refuses_two_forms_of_type` (две `BALANCE` в файле), `…_refuses_related_group_crm_id`, + `…_refuses_modified_organization_name`. В каждом: + +```python +with pytest.raises(AssertionError) as error: + await alembic(scratch_db_url, "downgrade", V5_18) +assert "откат v5.19 невозможен: повтор кода строки в форме: 1" in str(error.value) +assert await _scalars(conn, f"SELECT version_num FROM {SCHEMA}.alembic_version") == ["0007_dbml_v5_19"] +assert await count(conn, "financial_statement_line") == lines_before # данные не изменены +``` + + `test_downgrade_0007_drops_signals_and_logs_counts`: две ячейки `is_confirmed = true`, одна запись `document_issue`, без + несовместимых данных → откат проходит, в выводе alembic `financial_statement_value.is_confirmed: 2` и `document_issue: 1`; + после отката колонок v5.19 нет, вставка второй строки с тем же кодом в форму — `IntegrityError`; `upgrade head` снова + проходит. +- [ ] **Step 2:** `make migration-cycle` — новые тесты FAIL. +- [ ] **Step 3: Implement** `downgrade()`: пять `SELECT count(*)` до любого DDL; затем журнал; затем удаление таблицы + `document_issue`, индексов и колонок v5.19, восстановление `uq_financial_statement_line_meta_id_line_code` (unique) и + полного `uq_document_file_type_account` (`NULLS NOT DISTINCT`), удаление enum'ов. +- [ ] **Step 4:** `make migration-cycle` — PASS (полный цикл `upgrade → downgrade base → upgrade` тоже). +- [ ] **Step 5: Commit** `feat(migrations): откат v5.19 с отказом на несовместимых данных и журналом потерь`. + +### Task A3: `crm_id` ГСК/ГСЗ + +**Files:** +- Modify: `api/src/services/related_groups.py`, `api/src/schemas/directories.py`, `api/src/common/constraint_conflicts.py`, + `README.md` («Ошибки API») +- Test: `api/tests/test_api_related_group_crm_id.py` (переписать: сейчас проверяет «принимается и отбрасывается») + +**Interfaces:** +- Consumes: `RelatedGroup.crm_id` (A1). +- Produces: `RelatedGroupCreate.crm_id` / `RelatedGroupUpdate.crm_id` сохраняются (пустая строка после обрезки — `None`; + явный `null` в PATCH очищает); `RelatedGroupOut.crm_id` и `RelatedGroupDetail.crm_id` — из БД; конфликт — 409 + `CRM_ID_EXISTS` «ГСК/ГСЗ с таким crm_id уже существует» (проверка до вставки и запись `uq_related_group_crm_id` в + `CONSTRAINTS` на гонку); аудит изменения — P3-18. + +- [ ] **Step 1: Write the failing tests:** `test_create_related_group_stores_crm_id` (`POST` с `crm_id = " 51-03-0001 "` → + 201, `crm_id == "51-03-0001"`, `GET` деталей отдаёт его); `test_patch_related_group_crm_id_audited` (смена → событие + `GROUP_CHANGE`, `before_value["crm_id"] is None`, `after_value["crm_id"] == "77-02-0002"`); `test_crm_id_taken_is_409` + (вторая ГСЗ с тем же `crm_id`, в т. ч. в другой ГК → 409 `CRM_ID_EXISTS`, групп не прибавилось); + `test_patch_crm_id_null_clears`. +- [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_api_related_group_crm_id.py -v` — FAIL. +- [ ] **Step 3: Implement** — по образцу `CompanyGroupService._check_crm_id_free`; docstring схем — без «принимается и + отбрасывается». +- [ ] **Step 4:** тот же запуск — PASS; `make check` — PASS. +- [ ] **Step 5: Commit** `feat(directories): crm_id ГСК/ГСЗ сохраняется, 409 CRM_ID_EXISTS (arm-core#112)`. + +### Task A4: Очистка форм перед выкладкой `purge_statements.py` + +**Files:** +- Create: `api/scripts/purge_statements.py` +- Modify: `README.md` (раздел «Выкладка v5.19: очистка форм») +- Test: `api/tests/test_purge_statements.py` (маркер `integration`) + +**Interfaces:** +- Consumes: `files_repo.delete_file_rows`, `files_repo.expire_s3_objects`, `statements_repo.delete_file_statements` + (уже чистит `document_issue`, A1), `consolidation_documents.bound_versions` / `unbind_documents`, `AuditService.record`, + `ex_snapshot` (`source_version_id`). +- Produces: + - `@dataclass PurgePlan`: `form_only_files: list[uuid.UUID]`, `mixed_files: list[uuid.UUID]`, `form_documents: int`, + `confirmed_documents: int`, `unverified_bindings: int`, `blocking: list[BlockingVersion]` (`version_id`, `status`, + `document_id`), `superseded_with_osv: list[uuid.UUID]` (файлы, заменённые удаляемым файлом, в которых есть ОСВ); + - `async def plan_purge(session: AsyncSession, *, include_mixed: bool) -> PurgePlan` — только чтение; + - `async def apply_purge(session: AsyncSession, plan: PurgePlan, *, now: datetime) -> None`; + - `def render_report(plan: PurgePlan) -> str`; + - `async def run(argv: Sequence[str]) -> int` — `--dry-run` (по умолчанию), `--apply`, `--include-mixed`; коды P3-13; + запуск `cd api && .venv/bin/python -m scripts.purge_statements [--apply] [--include-mixed]`, одна транзакция. + +- [ ] **Step 1: Write the failing tests** (БД теста, фабрики `tests/factories.py`): + - `test_dry_run_reports_and_changes_nothing`: файл только с ББ+ОФР (ББ `CONFIRMED`, привязан к версии `INITIATED`), + смешанный файл (ОСВ + ББ), файл только с ОСВ → `run([])` → код 0; в отчёте строки `файлов только с формами: 1`, + `смешанных файлов: 1`, `документов форм: 3`, `подтверждённых документов: 1`, `привязок к неверифицированным версиям: 1`; число + строк всех таблиц не изменилось. + - `test_apply_refuses_verified_binding`: ББ привязан к версии `VERIFIED` → `run(["--apply"])` → код 1, в выводе id версии + и `VERIFIED`; база не изменена. То же для документа в `ex_snapshot_item.document_id` и для версии `ARCHIVED`, у которой + есть `ex_snapshot` с её `source_version_id`. + - `test_apply_ignores_unverified_versions` (сценарий «Форма в версии на согласовании или отклонённой», P3-12): ББ + привязан к версии `VERIFICATION_RF`, ОФР — к `REJECTED`, ещё одна форма — к `CANCELLED` и к `ARCHIVED` без + `ex_snapshot` → `run(["--apply"])` → код 0; привязки сняты, формы удалены, статусы всех версий прежние. + - `test_apply_deletes_form_only_file`: файл с ББ, ОФР, `DISCLOSURE`, комментарием к ББ, заданием; файл `old` с + `superseded_by_file_id` = удаляемый → после `--apply`: файла, документов, `financial_statement_*`, комментариев, заданий + нет; `old.superseded_by_file_id is None`, `old.status == SUPERSEDED`; `s3_object.expires_at` выставлен; привязка снята, + статус версии не изменился; событие `FILE_DELETE` с `user_id is None`, `description == "очистка форм перед v5.19"`, + `before_value` — карточка файла. + - `test_apply_mixed_keeps_osv_by_default`: смешанный файл → удалены только `BALANCE`/`INCOME` с `financial_statement_*`, + комментариями и привязками; файл, ОСВ (`osv_entry`), задания остаются; событие `FILE_DELETE` по файлу с + `after_value["deleted_documents"]` — список id; `--apply --include-mixed` — файл удалён целиком. + - `test_osv_and_directories_untouched`: число строк `osv_entry`, `consolidation*`, `correction`, `legal_entity`, + `audit_event` (кроме новых `FILE_DELETE`) не изменилось. + - `test_reupload_after_purge_not_duplicate` (сценарий «Очистка и повторная загрузка»): после `--apply` + `PrimaryFileService.find_duplicate(entity_id, sha256 удалённого файла)` — `None`. +- [ ] **Step 2:** `cd api && .venv/bin/pytest -m integration tests/test_purge_statements.py -v` — FAIL. +- [ ] **Step 3: Implement.** Отбор: документы `BALANCE`/`INCOME` у файлов любого статуса. Отказ — P3-12 и + `ex_snapshot_item`. Блокировки — порядок файл → документы → версии (`bound_versions`). Файлы только с формами — + `delete_file_rows` (без запрета `FILE_HAS_CONFIRMED_DOCUMENTS`), до него — обнуление `superseded_by_file_id` ссылающихся + файлов и `unbind_documents`; объекты S3 — `expire_s3_objects` по префиксам файла, как в `PrimaryFileService.delete`. + КФО в ядре ещё нет — пересчитывать нечего (написать в docstring). +- [ ] **Step 4:** тот же запуск — PASS; `make check` — PASS (`mypy src scripts`). +- [ ] **Step 5: Commit** `feat(scripts): purge_statements — очистка форм ББ/ОФР перед выкладкой v5.19 (Р-24, Р-27)`. + +### Task A5: Контракт 2.0 — копии, модели, приём конвертов, `JobBrief.warnings` + +**Files:** +- Modify: `api/contracts/**` (копии docs), `api/src/contract/common.py`, `api/src/contract/message.py`, + `api/src/contract/__init__.py`, `api/src/services/recognition_exchange.py`, `api/src/services/documents.py` + (`job_brief`), `api/src/schemas/files.py` +- Create: `api/src/contract/result_v2.py`, `api/src/contract/versions.py` +- Test: `api/tests/test_contract.py`, `api/tests/test_recognition_inbox.py`, `api/tests/test_api_files_read.py` + +**Interfaces:** +- Consumes: тест идентичности плана 0 (`api/tests/contract_identity.py`). +- Produces: + - `versions.major(schema_version: str) -> int`; `versions.parse_message(data: dict[str, Any]) -> + RecognitionResultMessage | RecognitionResultMessageV2` (MAJOR 1 / 2, иное — `ValidationError`); + `versions.parse_result(content: bytes) -> RecognitionResult | RecognitionResultV2` (то же); + - `result_v2`: `FileWarning {code, severity: Literal["error", "info"], message, locator?}`, `DocumentWarning {code, + severity, message, line_refs?, column_index?, field?}`, `RecognitionResultV2`, `StatementDocumentV2` + (`document_type: Literal["BALANCE", "INCOME", "DISCLOSURE"]`, `form_variant`, `status`, `warnings`, `header`, + `columns`, `lines`, `checks?`), `StatementHeaderV2` (10 полей + `<поле>_text`, `<поле>_confidence`, + `needs_review_fields`), `StatementColumnV2`, `StatementLineV2`, `StatementValueV2`, `StatementChecksV2`, + `FailedRelationV2`, `OsvDocumentV2` (поля `OsvDocument` + `status`, `warnings`), `MetricsV2` (`warnings: + list[FileWarning]`) — поля и обязательность по `recognition-result.schema.json` v2.0; + - `RecognitionResultMessageV2` — конверт v2.0 (`metrics: MetricsV2`); + - `recognition_job.metrics.warnings` пишется списком объектов `{code, severity, message, locator}`: у конверта `2.0` + — как пришли, у `1.x` — каждая строка как `{"code": "LEGACY_WARNING", "severity": "info", "message": s, "locator": + None}`; + - `schemas.files.FileWarningOut {code: str, severity: Literal["error", "info"], message: str, locator: str | None}`; + `JobBrief.warnings: list[FileWarningOut]`; `JobBrief.mapping_warnings` — по-прежнему `list[str]`; + - `documents.file_warnings(value: Any) -> list[FileWarningOut]` — строки → `LEGACY_WARNING`, объекты с `code`, + `severity ∈ {error, info}`, `message` — как есть, прочее пропускается. + +- [ ] **Step 1:** скопировать из `../docs/contracts/` (worktree `docs/` зеркала) в `api/contracts/`: основные схемы (результат + и конверт v2.0, запрос v1.0), `recognition-result.v1.schema.json`, `recognition-result-message.v1.schema.json`, + `examples/*.json`, `examples/v1/*.json`. `make check` — тест идентичности PASS, тесты примеров FAIL (модели v1). +- [ ] **Step 2: Write the failing tests:** таблица примеров `test_contract.py` разделена на v2 (`examples/*.json` → + схемы и модели v2) и v1 (`examples/v1/*.json` → схемы `.v1` и модели v1); `test_parse_message_by_major` (`"1.0"` → + `RecognitionResultMessage`, `"2.0"` → `RecognitionResultMessageV2`, `"3.0"` → `ValidationError`); + `test_inbox_ack_v2_moves_job_to_processing` (ack `processing` со `schema_version "2.0"` → задание `processing`); + `test_inbox_v1_warnings_wrapped` (конверт `1.0` с `metrics.warnings = ["sheet=ОСВ 60 строки без родителя"]` → + `job.metrics["warnings"] == [{"code": "LEGACY_WARNING", "severity": "info", "message": "sheet=ОСВ 60 строки без + родителя", "locator": None}]`); `test_inbox_v2_warnings_kept` (`JSON_FAILED`, `error`, `locator "file:a.json"` → как + пришло); `test_job_brief_converts_legacy_strings` (в БД `metrics.warnings = ["sheet=ОСВ 60 строки без родителя"]` → + `FileOut.job.warnings == [{"code": "LEGACY_WARNING", "severity": "info", "message": "sheet=ОСВ 60 строки без родителя", + "locator": None}]`, БД не изменилась); `test_job_brief_mixed_and_garbage_warnings` (`["a", {"code": "JSON_FAILED", + "severity": "error", "message": "m", "locator": "x"}, 5, {"code": "X"}]` → два элемента: `LEGACY_WARNING` «a» и + `JSON_FAILED`; `metrics.warnings = "строка"` → `[]`) — Review Focus 5. +- [ ] **Step 3:** `make check` — новые тесты FAIL. +- [ ] **Step 4: Implement.** `SCHEMA_VERSION_PATTERN` моделей v1 остаётся `^1\.\d+$`; у v2 — `^2\.\d+$`; + `RESULT_SCHEMA_VERSION_PATTERN` конверта v2 — `^\d+\.\d+$`. `recognition_exchange._parse_message` → `parse_message`; + `_apply_ack` и применение итога принимают оба класса конверта (общие поля одинаковы); `_metrics_json` оборачивает строки + `1.x`. `_load_result` в `result_mapping.py` в части А не меняется (файл `2.0` до части Б не мапится — `MappingFatal` + «результат не соответствует схеме»). +- [ ] **Step 5:** `make check` — PASS. +- [ ] **Step 6: Commit** `feat(contract): копии контракта обоих MAJOR, модели v2.0, приём конвертов 1.x/2.0, предупреждения файла структурами`. + +### Task A6: Словарь имён с синонимами и совпадение наименований + +**Files:** +- Modify: `api/src/domain/statement_lines.py`, `api/rules/statement_lines.yaml` +- Create: `api/src/domain/statement_names.py` +- Test: `api/tests/test_domain_statement_lines.py`, `api/tests/test_domain_statement_names.py` + +**Interfaces:** +- Produces: + - `LineCatalog.template_lines(edition: str | None, form: str) -> tuple[TemplateLine, ...]` — по одной строке на код, + первое имя, порядок первого появления (заменяет `lines()`; вызывающие — `services/statements.py`); + - `LineCatalog.reference_name(edition: str | None, form: str, code: str) -> str | None` — первое имя кода (P3-11); + - `LineCatalog.names(form: str, code: str) -> tuple[str, ...]` — все имена кода во **всех** редакциях; + - `LineCatalog.edition_codes(edition: str, form: str) -> frozenset[str]`; + - загрузчик не запрещает повтор кода в редакции; код — четыре цифры, как сейчас; + - `statement_names.normalize(text: str) -> str`; `statement_names.section_number(normalized: str) -> str | None`; + `statement_names.names_match(recognized: str | None, references: Iterable[str]) -> bool` (правило спеки «Совпадение + наименований»: нижний регистр, `ё` → `е`, всё кроме букв и цифр — пробел, схлопывание, `strip`; равенство, или более + короткая (≥ 5 символов) входит в более длинную, или `difflib.SequenceMatcher(None, a, b).ratio() >= 0.8`; «итого по + разделу» — сравнение номеров разделов из букв `i`, `v`, `x` (кириллические `і`, `х` → латиница), остаток слова + отбрасывается; номер не выделен — только равенство строк; пустое имя или нет справочных — `False`). + +- [ ] **Step 1: Write the failing tests:** + +```python +@pytest.mark.parametrize(("recognized", "references", "expected"), [ + ("Нематериальные активы", ["Результаты исследований и разработок"], False), + ("НДС, начисленный по отгрузке", ["Налог на добавленную стоимость по приобретенным ценностям", + "НДС, начисленный по отгрузке"], True), + ("Итого по разделу I", ["Итого по разделу II"], False), + ("Итого по разделу V1", ["Итого по разделу V"], True), + ("Итого по разделу V /", ["Итого по разделу V"], True), + ("Итого по разделу", ["Итого по разделу II"], False), + ("АКТИВ I. Внеоборотные активы Гудвил", ["Гудвил"], True), + ("Расчёты", ["Расчеты"], True), + ("", ["Запасы"], False), + ("Запасы", [], False), +]) +def test_names_match(recognized, references, expected): assert names_match(recognized, references) is expected +``` + + и `test_catalog_two_names_one_template_line` (`fsbu4`, `BALANCE`, код `1520` записан дважды: «Кредиторская + задолженность», «Обязательство по аренде» → словарь загружается, `template_lines` даёт одну `1520` с первым именем, + `names("BALANCE", "1520")` содержит оба); `test_catalog_fsbu4_additions` (в `fsbu4` есть вторые имена `1220` «НДС, + начисленный по отгрузке», `1520` «Обязательство по аренде», `2410` «Налог при упрощенной системе налогообложения» и коды + `1105` «Гудвил», `1130` «Нематериальные поисковые активы», `1140` «Материальные поисковые активы», `1530` «Доходы + будущих периодов», `2460` «Прочее»). +- [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_domain_statement_lines.py tests/test_domain_statement_names.py -v` — FAIL. +- [ ] **Step 3: Implement**; дополнения yaml — на места по `contracts/statement-code-orders.yaml` (порядок `fsbu4`), имена + — по `fixtures/ocr` и шаблонам банка; шапку yaml — «словарь имён: код → наименования по редакциям, несколько имён на код + (Р-20)». +- [ ] **Step 4:** тот же запуск и `make check` — PASS. +- [ ] **Step 5: Commit** `feat(domain): словарь имён с синонимами (Р-20) и совпадение наименований §11.9`. + +### Task A7: Агрегат формы по коду и перевод чтений «по коду» + +**Files:** +- Create: `api/src/domain/statement_aggregate.py` +- Modify: `api/src/repositories/statements.py` (`statement_amounts` → `form_aggregate`, `_line_amount`, `add_line`, + `load_form`), `api/src/services/statements.py` (`checks_report`, `statement_hard_failures`, `_resolve_cells`, + `build_out`), `api/src/services/result_mapping.py` (`_insert_statements`), `api/src/schemas/statements.py` + (`line_origin`), `api/src/domain/statement_checks.py` (`FailedCheck.codes`) +- Test: `api/tests/test_domain_statement_aggregate.py`, `api/tests/test_api_statements.py` + +**Interfaces:** +- Produces: + - `@dataclass(frozen=True) AggLine`: `id: uuid.UUID`, `row_index: int | None`, `line_status: StatementLineStatus`, + `line_code: str | None`, `amounts: Mapping[uuid.UUID, Decimal | None]` (ключ — `column_id`); + - `@dataclass(frozen=True) CodeAggregate`: `code: str`, `amounts: dict[uuid.UUID, Decimal | None]`, `line_ids: + tuple[uuid.UUID, ...]` (по `row_index`, `NULL` последними), `reference_name: str | None`, `has_source_line: bool`; + - `aggregate_by_code(lines: Iterable[AggLine], reference_name: Callable[[str], str | None] = lambda _: None) -> + dict[str, CodeAggregate]` — по возрастанию кода, только строки `ok`; сумма по колонке, `None` — если непустых нет; + - `repo.form_aggregate(session, meta_id, reference_name=...) -> tuple[list[FinancialStatementColumn], dict[str, + CodeAggregate]]`; + - `checks_input(columns, aggregates) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]` — вход + `run_checks` до части Б: коды без строки источника и без единой суммы пропускаются (правило D-5 шаблонных строк); + - `repo._line_amount` → `repo.code_amount(session, meta_id, code, *, current: bool = False, period_date=None) -> + Decimal | None` — `SUM(amount)` строк `ok` кода в колонке; + - `line_origin(line) -> "template"` ⇔ `line.row_index is None`; `repo.add_line` пишет `row_index = None`, `source_ref + = None`, `line_status = ok`, `line_code_original_value = None`; + - `FailedCheck.codes: tuple[str, ...]` (цель и члены правила, P3-14). + +- [ ] **Step 1: Write the failing tests** (домен): + +```python +def test_repeated_code_sums(): + agg = aggregate_by_code([ok("2410", r=1, c1="100"), ok("2410", r=5, c1="649")]) + assert agg["2410"].amounts[C1] == Decimal("749") and agg["2410"].line_ids == (L1, L5) +def test_empty_amounts_stay_null(): + assert aggregate_by_code([ok("1110", r=0, c1=None), ok("1110", r=3, c1=None)])["1110"].amounts[C1] is None + assert aggregate_by_code([ok("1110", r=0, c1=None), ok("1110", r=3, c1="5")])["1110"].amounts[C1] == Decimal("5") +def test_non_ok_lines_excluded(): + agg = aggregate_by_code([line("detail", None, r=2, c1="7"), line("unreadable", None, r=3, c1="9"), + line("missing", None, r=4, c1="1"), ok("1250", r=5, c1="3")]) + assert list(agg) == ["1250"] +def test_template_line_counts_as_ok(): + agg = aggregate_by_code([ok("1250", r=None, c1="10")]) + assert agg["1250"].amounts[C1] == Decimal("10") and agg["1250"].has_source_line is False +``` + + и API: `test_cross_form_uses_aggregate` (ББ: в колонке отчётной даты две строки `1370` — исходная с 500 и добавленная + строка `ok` с `row_index = 99`, 300; ОФР с `2400` = 800 − `1370(нач. года)` → `checks.cross_form is None`; при `2400` = + 500 − `1370(нач. года)` → `cross_form` с `expected`/`actual` по 800) — Review Focus 2; + `test_parent_code_with_repeated_parent` (две строки `1150`, подстрока `11501` → `parent_code == "1150"` один раз); + `test_template_not_completed_for_code_with_ok_line` и `test_template_completed_for_code_only_unreadable` (у формы нет + строки `ok` 1250, есть `unreadable` «ИЧ,250» → в ответе достроенная пустая `1250` с `id = null` и строка `unreadable` + отдельно); `test_patch_by_line_code_with_ok_line_is_unknown_cell` (код с существующей строкой `ok` → 422 `UNKNOWN_CELL`); + `test_origin_by_row_index` (строка источника с `line_code_original_value = null`, `row_index = 7` → `origin == + "source"`). +- [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_domain_statement_aggregate.py tests/test_api_statements.py -v` — FAIL. +- [ ] **Step 3: Implement**; удалить `statement_amounts` и `has_lines` — «есть строки» = есть строка с `row_index IS NOT + NULL` (`repo.has_source_lines`); `load_form` — строки по `line_code, row_index NULLS LAST, id`. +- [ ] **Step 4:** тот же запуск и `make check` — PASS (старые тесты `test_api_statements.py` про шаблонные строки зелёные). +- [ ] **Step 5: Commit** `feat(statements): агрегат формы по коду — проверки, межформенная, parent_code, достройка, правка по коду`. + +### Task A8: Ответ `GET …/statement` и `DocumentOut.issues` по v5.19 + +**Files:** +- Create: `api/src/domain/statement_layout.py` +- Modify: `api/src/schemas/statements.py`, `api/src/schemas/documents.py`, `api/src/schemas/files.py` (`DocumentBrief`), + `api/src/services/statements.py` (`build_out`), `api/src/services/documents.py` (`document_out`, `file_out` — `issue_counts` рядом с `counts` комментариев), + `api/src/services/primary_files.py` (сборка `FileOut.documents`), `api/src/repositories/statements.py` (`issues`, + `issue_counts_by_document`), `api/src/domain/statement_lines.py` (`LineCatalog.edition_lines`) +- Test: `api/tests/test_domain_statement_layout.py`, `api/tests/test_api_statements.py`, `api/tests/test_api_documents.py`, + `api/tests/test_api_files_read.py`, `api/tests/test_domain_statement_lines.py` + +**Interfaces:** +- Consumes: `CodeAggregate` (A7), `LineCatalog.reference_name` (A6). +- Produces: + - `@dataclass(frozen=True) LayoutLine`: `key: Hashable`, `row_index: int | None`, `line_status: str`, `line_code: str | + None`, `parent_row_index: int | None`; `order_lines(lines: Sequence[LayoutLine]) -> list[Hashable]` — P3-17; + - `repo.issues(session, document_id) -> list[DocumentIssue]` (по `created_at, id`); + - `IssueOut {id, kind, code, severity, message, line_ids: list[uuid.UUID], column_id, column_index, field, source, + relation, codes: list[str], expected, actual}` — общий для `StatementOut.issues` и `DocumentOut.issues`; + - `StatementDocumentOut.form_variant`; `StatementHeaderOut.needs_review_fields: list[str]`, `source: dict[str, + HeaderSourceOut]` (= `header_source` или `{}`; `HeaderSourceOut {text: str | None, confidence: Decimal | None}` — + число из JSONB приводится `Decimal(str(x)).quantize(Decimal("0.01"))` и отдаётся строкой, как прочие уверенности + API, P3-20), `organization_name_modified: bool`, `okei: str | None` (`financial_statement_meta.okei`, P3-21); + `ColumnOut.period_kind`, `label_confidence`; `LineOut` + `row_index`, `source_ref`, `line_status`, `line_code_text`, + `line_code_confidence: Decimal | None` (строка), `reference_name`, `parent_row_index`, `needs_review`, + `is_confirmed`, `line_code_modified`; `ValueOut` + `text` (`source_text`), `needs_review`, `is_confirmed`; + `FailedCheckOut` + `codes`, `line_ids`; `StatementOut.issues: list[IssueOut]`; + - `StatementOut.edition_lines: list[EditionLineOut]`, `EditionLineOut {code: str, name: str}` — справочник строк для + подсказки кода (P3-22): строки словаря имён формы `document.document_type` в редакции ответа (`edition`), по одной + на код, имя — первое (как `reference_name`), в порядке словаря; `edition is None` — строки всех редакций словаря по + порядку редакций, по одной на код (имя — из первой редакции, где код есть); `LineCatalog.edition_lines(form: str, + edition: str | None) -> list[tuple[str, str]]`; + - `DocumentOut.issues: list[IssueOut]` (у любого документа); `DocumentOut.warnings` остаётся; + - `DocumentBrief.issue_counts: IssueCountsOut {error: int, info: int}` (а значит и `DocumentOut.issue_counts`) — число + записей `document_issue` документа по `severity`, всех видов (`warning`, `relation`, `soft`), P3-23; + `repo.issue_counts_by_document(session, document_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, IssueCounts]` — один + запрос `GROUP BY document_id, severity` на список документов (как `comments_count_by_document`); документа нет в + ответе — `{error: 0, info: 0}`. + +- [ ] **Step 1: Write the failing tests:** + +```python +def test_order_lines_attachments(): + lines = [L("m0", 0, "missing", None, None), L("1230", 1, "ok", "1230", None), L("u2", 2, "unreadable", None, None), + L("1210", 3, "ok", "1210", None), L("d4", 4, "detail", None, 3), L("t1250", None, "ok", "1250", None), + L("2410b", 9, "ok", "2410", None), L("2410a", 6, "ok", "2410", None)] + assert order_lines(lines) == ["m0", "1210", "d4", "1230", "u2", "t1250", "2410a", "2410b"] +def test_detail_without_parent_follows_previous_ok(): + assert order_lines([L("1110", 0, "ok", "1110", None), L("d1", 1, "detail", None, None)]) == ["1110", "d1"] +``` + + API: `test_get_statement_v5_19_fields_on_1x_form` (форма из фикстуры `1.2`: у строк `line_status == "ok"`, + `row_index` — целые с 0, `source_ref is None`, `origin == "source"`, `needs_review is False`, `is_confirmed is False`; у + значений `text == string_value`; `document.form_variant is None`; `header.needs_review_fields == []`; + `header.organization_name_modified is False`; `columns[].period_kind ∈ {"absolute", "none"}`; `issues == []`); + `test_reference_name_first_name` (`fsbu4`, строка `1520` → `reference_name == "Кредиторская задолженность"`); + `test_issues_in_statement_and_document` (вставленная запись `document_issue` `warning` `COLUMN_SKIPPED` с `column_index = + 5`, `column_id = null` → она в `StatementOut.issues` и `DocumentOut.issues` с этими полями); + `test_line_code_confidence_and_okei` (строка с `line_code_confidence = 67.50` → `"67.50"`; `meta.okei = "384"` → + `header.okei == "384"`); `test_header_source_confidence_is_decimal_string` (`header_source = {"inn": {"text": "x", + "confidence": 61.538464}, "units": {"text": "тыс. руб.", "confidence": 100}}` → `header.source.inn.confidence == + "61.54"`, `header.source.units.confidence == "100.00"`; `confidence: null` → `null`); + `test_edition_lines` (форма `fsbu4` ББ → `edition_lines` — коды редакции `fsbu4` ББ словаря имён по одному, `1520` с + именем «Кредиторская задолженность», `1120` нет; форма без даты (`edition is None`) → коды обеих редакций, `1120` + есть, повторов кода нет); `test_issue_counts_in_file_and_document` (у ББ записи `warning` `error`, `relation` `error`, + `soft` `info`, у ОФР нет → в `GET /files/{id}` `documents[]`: ББ `issue_counts == {"error": 2, "info": 1}`, ОФР `{"error": + 0, "info": 0}`; `GET /documents/{id}` — те же числа; число SQL-запросов на `GET /files` не растёт с числом документов). +- [ ] **Step 2:** запуск трёх файлов — FAIL. +- [ ] **Step 3: Implement**; порядок `lines` ответа — `order_lines` (достроенные шаблонные — как строки `ok` без + `row_index`). +- [ ] **Step 4:** запуск и `make check` — PASS. +- [ ] **Step 5: Commit** `feat(api): StatementOut и DocumentOut по v5.19 — статусы строк, needs_review, issues, порядок строк`. + +### Task A9: Маппер `1.x` — поля v5.19, `file_ordinal`, вторая форма, `MAPPING_WARNING` + +**Files:** +- Create: `api/src/domain/statement_issues.py` +- Modify: `api/src/domain/statement_mapping.py`, `api/src/repositories/statements.py` (`insert_statement`, + `add_issues`), `api/src/services/result_mapping.py` (`map_file`, `_map_statement_docs`, `_insert_statements`) +- Test: `api/tests/test_domain_statement_mapping.py`, `api/tests/test_mapping_statements.py`, `api/tests/test_result_mapping.py` + +**Interfaces:** +- Produces: + - `@dataclass(frozen=True) IssueDraft`: `kind: DocumentIssueKind`, `code: str`, `severity: IssueSeverity`, `message: str`, + `source: IssueSource`, `line_ids: tuple[uuid.UUID, ...] = ()`, `column_id: uuid.UUID | None = None`, `column_index: int + | None = None`, `field: str | None = None`, `relation: str | None = None`, `codes: tuple[str, ...] = ()`, `expected: + Decimal | None = None`, `actual: Decimal | None = None`; `mapping_warning(message: str) -> IssueDraft`; + - `repo.add_issues(session, document_id: uuid.UUID, drafts: Sequence[IssueDraft], *, now) -> None`; + - `LineDraft` + `row_index: int` (индекс строки в `doc.lines` результата), `line_status = "ok"`, + `line_code_original_value`, `source_ref = None`, `line_code_text = None`, `needs_review = False`; `ValueDraft` + + `source_text` (= `string_value`), `needs_review` (= НЕ `value_clean(confidence, is_recognized)`); колонки + `period_kind` + (`absolute` при `period_date`, иначе `none`), `label_confidence = None`; шапка + `header_source` (10 полей шапки + результата: `text` из одноимённого поля 1.x, у `report_date` — `report_date_text`, `confidence` — где есть в 1.x, у + `okei` оба `None`), `organization_name_original_value`, `organization_name_modified = False`, `needs_review_fields = + []`, `form_variant = None`; + - `value_clean(confidence: Decimal | None, is_recognized: bool | None) -> bool` (выделено из `values_ok`); + - каждый создаваемый документ — `file_ordinal` = индекс в `result.documents` (P3-6 для `DISCLOSURE`/`OTHER`); + - предупреждения черновика формы → `MAPPING_WARNING` документа; предупреждения листа ОСВ — тоже (P3-5); Р-10 — P3-4. + +- [ ] **Step 1: Write the failing tests:** + - `test_two_osv_sheets_file_ordinals` — результат `1.x` с ОСВ 60 и 62 → `file_ordinal` 0 и 1, файл `PROCESSED`; + - `test_pdf_balance_income_1x_ordinals` — `1.2` с `BALANCE`, `INCOME` → 0 и 1; + - `test_duplicate_osv_sheet_takes_no_ordinal` — ОСВ 60, ОСВ 60, ББ → 0 и 2; + - `test_1x_line_fields` — строки 0..n: `row_index == i`, `line_status == "ok"`, `line_code_original_value == line_code`, + `source_ref is None`; значения `confidence 100, is_recognized true` → `needs_review is False`, `confidence 80` → + `True` (сценарий «Полярность needs_review при приёме 1.x»); `source_text == string_value`; + - `test_1x_duplicate_code_keeps_first` — повтор `2410` в `1.2`: хранится первая строка, `row_index` второй не занят, + `document_issue` `MAPPING_WARNING` с текстом о повторе; + - `test_1x_second_form_same_period_stored` (заменяет `test_duplicate_form_in_file_skipped`) — два `BALANCE` на 30.09.2025 + → два документа, оба `NEEDS_CHECK`, у каждого `MAPPING_WARNING` «в файле есть другая форма BALANCE за тот же период», + ни один не `CONFIRMED` — Review Focus 1; + - `test_1x_second_form_other_period` — `BALANCE` на 30.09.2025 и на 31.12.2024 → у каждого свой статус, предупреждения + о повторе нет; + - `test_osv_orphans_mapping_warning_issue` — лист с сиротами → у документа ОСВ `MAPPING_WARNING` с текстом сирот, + строка в `mapping_warnings` тоже есть; + - `test_1x_header_source` — `header_source["inn"] == {"text": inn, "confidence": inn_confidence}`, + `header_source["report_date"]["text"] == report_date_text`. +- [ ] **Step 2:** запуск трёх файлов — FAIL. +- [ ] **Step 3: Implement**; статус форм `1.x` — D-6, как сейчас (`values_ok` = нет `needs_review` у значений; + `warnings` = предупреждения черновика + повтор периода); `insert_statement` ключует строки по `row_index`, а не по коду. +- [ ] **Step 4:** запуск, `make check` и `cd api && .venv/bin/pytest -m "integration and fixtures" tests/test_mapping_statements.py tests/test_mapping_golden.py` — PASS + (golden-снимки ОСВ и все снимки форм `1.x` маппятся). +- [ ] **Step 5: Commit** `feat(mapping): маппер 1.x пишет поля v5.19, file_ordinal, хранит вторую форму, MAPPING_WARNING в document_issue`. + +### Task A10: Условие `RECOGNIZED` и статус ОСВ при приёме 2.0 — чистые функции + +**Files:** +- Create: `api/src/domain/statement_status.py` +- Test: `api/tests/test_domain_statement_status.py` + +**Interfaces:** +- Consumes: `names_match`, `LineCatalog.names` (A6). +- Produces (подключаются в B2, B4, B5): + - `has_error_warning(warnings: Any) -> bool` — есть элемент-объект с `severity == "error"`; строки и мусор — нет; + - `duplicate_period_forms(forms: Iterable[tuple[Hashable, DocumentType, int | None, PeriodQuarter | None]]) -> + set[Hashable]` — ключи форм `BALANCE`/`INCOME`, у которых в файле есть другая форма того же типа с той же парой (год, + квартал); формы без квартала не считаются; + - `@dataclass(frozen=True) NameCheckLine`: `key`, `line_status`, `line_code`, `line_code_confidence: Decimal | None`, + `line_name: str | None`, `code_fixed: bool`; `name_mismatches(lines: Iterable[NameCheckLine], catalog: LineCatalog, + form: str) -> list[Hashable]` — строки `ok` с `line_code_confidence < 100`, без `code_fixed`, чьё имя не совпало ни с + одним `catalog.names(form, code)`; + - `form_status_v2(*, service_done: bool, file_error_warning: bool, counterparty: DocumentCheckResult | None, + period_known: bool, names_ok: bool, duplicate_in_file: bool) -> DocumentStatus`; + - `osv_status_v2(*, osv_done: bool, checks_ok: bool, file_error_warning: bool, counterparty: DocumentCheckResult | + None, sheet_mapping_warnings: bool) -> DocumentStatus`. + +- [ ] **Step 1: Write the failing tests:** + +```python +OK = dict(service_done=True, file_error_warning=False, counterparty=DocumentCheckResult.MATCH, + period_known=True, names_ok=True, duplicate_in_file=False) +def test_form_recognized(): assert form_status_v2(**OK) is DocumentStatus.RECOGNIZED +@pytest.mark.parametrize("change", [{"service_done": False}, {"file_error_warning": True}, + {"counterparty": DocumentCheckResult.MISMATCH}, {"period_known": False}, {"names_ok": False}, + {"duplicate_in_file": True}]) +def test_form_needs_check(change): assert form_status_v2(**OK | change) is DocumentStatus.NEEDS_CHECK +def test_unknown_counterparty_allowed(): + assert form_status_v2(**OK | {"counterparty": None}) is DocumentStatus.RECOGNIZED +def test_duplicate_period_forms(): + forms = [(1, BAL, 2025, Q3), (2, BAL, 2025, Q3), (3, BAL, 2024, Q4), (4, INC, 2025, Q3), (5, BAL, 2025, None), + (6, BAL, 2025, None)] + assert duplicate_period_forms(forms) == {1, 2} +def test_has_error_warning(): + assert has_error_warning([{"code": "JSON_FAILED", "severity": "error", "message": "m"}]) + assert not has_error_warning([{"code": "LEGACY_WARNING", "severity": "info", "message": "m"}, "строка"]) + assert not has_error_warning(None) +``` + + `osv_status_v2`: все условия — `RECOGNIZED`; каждое нарушенное — `NEEDS_CHECK` (параметризация, как у форм). + `name_mismatches` по `fsbu4`: `1120` (50) «Нематериальные активы» → в списке; `1220` (50) «НДС, начисленный по + отгрузке» → нет; `1200` (50) «Итого по разделу I» → в списке; `1500` (56) «Итого по разделу V1» → нет; `1105` (67) + «АКТИВ I. Внеоборотные активы Гудвил» → нет; `1120` (50) с `code_fixed` → нет; `1110` (100) с чужим именем → нет; + `9999` (50) → в списке (кода нет в словаре); пустое имя (50) → в списке. +- [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_domain_statement_status.py -v` — FAIL. +- [ ] **Step 3: Implement** (docstring — §9.3 «Документ формы при приёме контракта v2.0», «Документ ОСВ при приёме контракта v2.0»). +- [ ] **Step 4:** запуск и `make check` — PASS. +- [ ] **Step 5: Commit** `feat(domain): условие RECOGNIZED для 2.0 (Р-16, Р-18, Р-19) и статус ОСВ (Р-28)`. + +### Task A11: Подтверждение формы — порядок 409 и автоподтверждение + +**Files:** +- Modify: `api/src/services/statements.py` (`form_confirmable` → `form_blocker`), `api/src/services/documents.py` + (`_check_statement`), `api/src/services/auto_confirm.py`, `api/src/repositories/statements.py`, + `api/src/repositories/files.py` (`confirmed_twin`), `README.md` +- Test: `api/tests/test_api_statements.py`, `api/tests/test_form_confirmable.py`, `api/tests/test_auto_confirm.py` + +**Interfaces:** +- Consumes: агрегат и `statement_hard_failures` (A7). +- Produces: + - `@dataclass(frozen=True) FormBlocker`: `code: str`, `message: str`, `extra: dict[str, Any]`; + - `async def form_blocker(session, document, meta) -> FormBlocker | None` — первый по порядку `PERIOD_REQUIRED` → + `LINES_UNRESOLVED` (`extra = {"lines": [{"line_id", "row_index", "line_code_text"}]}`, строки `unreadable`/`missing`, + у которых хотя бы в одной ячейке `amount` или `string_value` не `null`, по `row_index`) → `DUPLICATE_FORM` (`extra = + {"document_id": <подтверждённая форма того же файла, типа, года, квартала>}`) → `CHECKS_NOT_PASSED` (`extra = + {"failures": [...]}`, как сейчас); + - `repo.unresolved_lines(session, meta_id) -> list[tuple[uuid.UUID, int | None, str | None]]`; + - `files_repo.confirmed_twin(session, document) -> uuid.UUID | None`; + - `DocumentService._check_statement` поднимает `ConflictError(blocker.code, blocker.message, **blocker.extra)`; + `auto_confirm` подтверждает форму, только если `form_blocker(...) is None` (`form_confirmable` удаляется). + +- [ ] **Step 1: Write the failing tests** (строки вставляются ORM-фабрикой на форме из фикстуры): + - `test_confirm_unresolved_line_with_amount` — строка `missing` (`row_index = 40`) с суммой и жёсткое нарушение → + 409 `LINES_UNRESOLVED`, `detail.lines == [{"line_id": id, "row_index": 40, "line_code_text": None}]`; + - `test_confirm_unresolved_line_with_text_only` — `unreadable`, `line_code_text = "ИЧ,250"`, суммы пустые, + `string_value = "211(1 |"` → 409 `LINES_UNRESOLVED`; + - `test_unresolved_line_without_values_does_not_block` — `unreadable` без значений → подтверждение проходит (при прочих + условиях); + - `test_confirm_duplicate_form` — два ББ на 30.09.2025 в файле, первый `CONFIRMED` → второй: 409 `DUPLICATE_FORM`, + `detail.document_id == str(first.id)` — Review Focus 1; + - `test_conflict_order` — `PERIOD_REQUIRED` раньше `LINES_UNRESOLVED`; `LINES_UNRESOLVED` раньше `DUPLICATE_FORM`; + `DUPLICATE_FORM` раньше `CHECKS_NOT_PASSED`; + - `test_auto_confirm_skips_blocked_form` — форма `RECOGNIZED` со строкой `missing` с суммой → остаётся `RECOGNIZED`. +- [ ] **Step 2:** запуск трёх файлов — FAIL. +- [ ] **Step 3: Implement**; README «Ошибки API» — `LINES_UNRESOLVED`, `DUPLICATE_FORM`. +- [ ] **Step 4:** запуск и `make check` — PASS. +- [ ] **Step 5: Commit** `feat(documents): порядок 409 подтверждения формы — LINES_UNRESOLVED, DUPLICATE_FORM`. + +### Task A12: `PATCH …/statement` — подтверждение ячейки, дата колонки, шапка + +**Files:** +- Modify: `api/src/schemas/statements.py` (`CellPatch`, `HeaderPatch`, `ColumnPatch`, `StatementPatch`), + `api/src/services/statements.py` (`patch`, `_apply_cells`, `_apply_header`, `_apply_columns`, `recompute`), + `api/src/domain/statement_issues.py` (правила снятия), `api/src/repositories/statements.py` (`delete_issues`), + `README.md` +- Test: `api/tests/test_api_statements.py` + +**Interfaces:** +- Consumes: `IssueDraft`, `repo.add_issues` (A9), агрегат (A7). +- Produces: + - `CellPatch`: `line_id | line_code`, `column_id`, `amount` (необязательно, наличие — `model_fields_set`), `confirmed: + bool | None`; ровно одно из `amount`/`confirmed` (иначе 422 pydantic); `confirmed` только с `line_id` (иначе 422 + pydantic); + - `HeaderPatch.organization_name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, + max_length=1000)] | None`; + - `ColumnPatch {column_id: uuid.UUID, period_date: datetime.date}` (`null` — 422 pydantic); `StatementPatch.columns: + list[ColumnPatch]` (`max_length=100`); + - `statement_issues.HEADER_CLEARS: dict[str, frozenset[str]]` = `{"report_date": {"PERIOD_UNKNOWN", + "REPORT_DATE_UNPARSED", "REPORT_DATE_UNCERTAIN"}, "inn": {"NO_ORGANIZATION_NO_INN"}, "organization_name": + {"NO_ORGANIZATION_NO_INN"}}`, `COLUMN_CLEARS = frozenset({"COLUMN_WITHOUT_DATE", "COLUMN_DATE_UNCERTAIN"})`; + - `repo.delete_issues(session, document_id, *, codes: Collection[str], kind: DocumentIssueKind | None = None, source: + IssueSource | None = None, column_id: uuid.UUID | None = None) -> int`; + - `StatementService.recompute(document, meta) -> None` — единая точка пересчёта после правки (заменяет + `recompute_status`); до части Б: D-6 с `values_ok` = нет `needs_review` у значений строк не `detail`; в Б тело + меняется (B2), сигнатура — нет; + - сброс акцепта — только правкой данных (сумма, код, статус строки, дата колонки, поле шапки); подтверждения — нет. + +- [ ] **Step 1: Write the failing tests:** + - `test_confirm_cell_without_change` — ячейка `needs_review = true`, акцепт стоит → `values: [{line_id, column_id, + confirmed: true}]` → 200; `is_confirmed`, `confirmed_by == user`, `confirmed_at` задан, `is_modified is False`, + `needs_review is False`, сумма прежняя, одно событие `RECOGNIZED_DATA_EDIT`, `checks.accepted` не `null`; + - `test_unconfirm_cell` — снять (`confirmed: false`) с ячейки `confidence 80` → `is_confirmed is False`, + `confirmed_by is None`, `needs_review is True`, аудит; + - `test_cell_amount_and_confirmed_is_422`, `test_cell_confirmed_by_line_code_is_422` (массив), `test_confirm_missing_cell_unknown` + (строка есть, записи значения нет → 422 `UNKNOWN_CELL`); + - `test_patch_organization_name` — запись `NO_ORGANIZATION_NO_INN`, у шапки нет ИНН, имя владельца «ООО Ромашка» → + `header: {organization_name: "ООО Ромашка"}` → `organization_name_modified is True`, + `organization_name_original_value` прежнее, `counterparty_check == "MATCH"`, записи нет, аудит `before/after` по + `organization_name`, акцепт сброшен; `""` и строка из 1001 символа — 422 массивом; + - `test_confirm_report_date` — `needs_review_fields = ["report_date"]`, запись `REPORT_DATE_UNCERTAIN` → тот же + `report_date` → `needs_review_fields == []`, записи нет, аудит, акцепт сохранён; тот же `report_date` без + `needs_review_fields` → 200, событий нет; + - `test_report_date_change_clears_warnings` — записи `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`, `REPORT_DATE_UNCERTAIN`, + `SEGMENT_COLUMNS_MISMATCH` → смена даты → остаётся только `SEGMENT_COLUMNS_MISMATCH`; + - `test_inn_change_clears_no_organization`; + - `test_column_date` — колонка без даты с `COLUMN_WITHOUT_DATE` (`column_id`), `report_date = null` → `columns: + [{column_id, period_date: "2024-12-31"}]` → `period_date`, `period_kind == "absolute"`, записи нет, `is_current` + пересчитан, `document.reporting_date == 2024-12-31` и квартал `Q4` (§9.5 п. 2), акцепт сброшен; + - `test_column_date_null_is_422`, `test_unknown_column_is_422` (колонка другой формы → `UNKNOWN_COLUMN`); + - `test_confirm_column_date` — `COLUMN_DATE_UNCERTAIN`, та же дата → записи нет, `period_kind == "absolute"`, аудит, + акцепт сохранён; та же дата без предупреждения → 200 без событий; + - `test_relative_column_becomes_absolute` — колонка `relative:prev_year_end` → дата 31.12.2024 → `period_kind == + "absolute"` (вторая половина сценария — B2); + - `test_segment_warning_stays_after_value_edit` — `SEGMENT_COLUMNS_MISMATCH` остаётся после правки суммы, ручное + подтверждение документа проходит. +- [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_api_statements.py -v` — новые FAIL. +- [ ] **Step 3: Implement**; порядок частей — P3-10; README «Ошибки API» — `UNKNOWN_COLUMN`. +- [ ] **Step 4:** запуск и `make check` — PASS. +- [ ] **Step 5: Commit** `feat(statements): PATCH — подтверждение ячейки, дата колонки, наименование организации, подтверждение даты`. + +### Task A13: `PATCH …/statement` — строки (`lines`) + +**Files:** +- Create: `api/src/services/statement_line_edits.py` +- Modify: `api/src/schemas/statements.py` (`LinePatch`, `StatementPatch.lines`), `api/src/services/statements.py` + (`patch`), `api/src/domain/statement_layout.py` (`nearest_ok_parents`), `api/src/repositories/statements.py` + (`delete_line`, `remove_line_from_issues`), `README.md` +- Test: `api/tests/test_api_statement_lines.py`, `api/tests/test_domain_statement_layout.py` + +**Interfaces:** +- Consumes: `StatementService.recompute`, `_editable`, аудит (A12). +- Produces: + - `LinePatch {line_id: uuid.UUID, line_code: str | None = None, line_status: Literal["detail", "ok"] | None = None, + confirmed: bool | None = None}` (`extra="forbid"`; `line_code` до 10 символов); `StatementPatch.lines: + list[LinePatch]` (`max_length=5000`); + - `async def apply_line_edits(session, *, document, meta, items: Sequence[LinePatch], user_id, before: dict, after: + dict) -> bool` (возвращает «были правки данных» — для сброса акцепта): проверки до записи — `INVALID_LINE_PATCH` (не + ровно одно действие; один `line_id` дважды), `UNKNOWN_LINE`, `LINE_CODE_REQUIRED` (`line_status == "ok"`), + `INVALID_LINE_CODE` (не `^\d{4,5}$`), затем `LINE_CODE_TAKEN` (строка `row_index IS NULL`, `line_status = ok`, с тем + же кодом и хотя бы одной непустой `amount`; 409, ничего не изменено); у всех пяти ошибок `detail` несёт `line_id` и + `line_code` первого неверного элемента `lines[]` по порядку запроса (`line_id` — как в элементе, у `UNKNOWN_LINE` — + чужой id; `line_code` — код элемента или `null`; у `LINE_CODE_TAKEN` — строка, которой назначается код, и этот код; + P3-24); затем запись: удаление пустых шаблонных строк + назначаемых кодов (значения → строка → `id` из `line_ids` записей документа), назначение (`line_status = ok`, + `line_code`, `line_code_confidence = 100`, `line_code_modified = true`, `needs_review = false`), отметка `detail` + (`line_code = null`), подтверждение (P3-16), пересчёт `parent_row_index` всех строк `detail`; + - `nearest_ok_parents(lines: Sequence[LayoutLine]) -> dict[Hashable, int | None]` — для строк `detail`: `row_index` + ближайшей строки `ok` выше (строки без `row_index` родителями не бывают); временная, в B2 заменяется + `detail_parents` пакета; + - `repo.remove_line_from_issues(session, document_id, line_id) -> None` — `line_ids = line_ids - ''` у всех записей + документа. + +- [ ] **Step 1: Write the failing tests:** + - `test_assign_code_to_garbage_line` (AURORA-1462) — `unreadable` «ИЧ,250», `row_index = 2` → `lines: [{line_id, + line_code: "1250"}]` → `line_status == "ok"`, `line_code == "1250"`, `line_code_confidence == "100.00"`, + `line_code_modified is True`, `line_code_original_value is None` (БД), `row_index == 2`, `origin == "source"`, + `needs_review is False`; её сумма вошла в агрегат 1250 — Review Focus 3; + - `test_same_code_two_source_lines` — 1250 строке `unreadable` (`row_index 7`, без сумм), затем строке `missing` + (`row_index 2`) с суммой → обе `ok` с 1250, обе в БД, 409 нет — Review Focus 3; + - `test_code_taken_by_template_with_amount` — шаблонная `1250` (`row_index = null`) с суммой → `lines: [{unreadable, + line_code: "1250"}, {other, line_status: "detail"}]`, `values: [...]`, `header: {units: "руб."}` → 409 + `LINE_CODE_TAKEN` с `detail.line_id` строки `unreadable` и `detail.line_code == "1250"`; ни одна строка, ячейка и поле + шапки не изменились, аудита нет — Review Focus 4; + - `test_code_taken_by_empty_template` — шаблонная `1250` без сумм и запись `document_issue` с `line_ids = [template, + other]` → шаблонной строки и её значений нет, `line_ids == [other]`, строка источника `ok` 1250 — Review Focus 4; + - `test_mark_detail` — `missing` с суммой → `line_status == "detail"`, в агрегате её нет, подтверждение не даёт + `LINES_UNRESOLVED`; + - `test_status_ok_without_code` — `line_status: "ok"` → 422 `LINE_CODE_REQUIRED`, `detail.line_id == id`, + `detail.line_code is None`; + - `test_invalid_line_patch` — `{line_id}`; `{line_id, line_code, confirmed}`; один `line_id` дважды → 422 + `INVALID_LINE_PATCH`, `detail.line_id` — id элемента (у повтора — повторённый id), `detail.line_code` — код + элемента или `None`; + - `test_unknown_line` — строка другой формы → 422 `UNKNOWN_LINE`, `detail.line_id` — этот id; + - `test_invalid_line_code` — `"123"`, `"123456"`, `"12a4"` → 422 `INVALID_LINE_CODE`, `detail == {code, message, + line_id, line_code: "123"…}`; `"11101"` → 200; + - `test_confirm_code` — строка `ok` 1120, уверенность 50, `needs_review = true`, акцепт стоит → `confirmed: true` → + `is_confirmed is True`, `needs_review is False`, акцепт сохранён; `confirmed: false` → `is_confirmed is False` + (P3-16); + - `test_parent_moves_up` (сценарий «Родитель уходит в расшифровки») — `1140` (`row_index 3`), `1150` (`row_index 4`), + `detail` 5 и 6 с `parent_row_index = 4` → `1150` отмечена `detail` → у трёх строк `parent_row_index == 3`; без строк + `ok` выше — `None`; + - `test_line_edit_audit_and_accept` — одно событие, `before/after` с ключом `line:` (`line_code`, `line_status`, + `parent_row_index`, `is_confirmed`); назначение кода сбрасывает акцепт; + - домен: `nearest_ok_parents` — `[ok r0, detail r1, ok(row None), detail r3]` → `{r1: 0, r3: 0}`. +- [ ] **Step 2:** запуск двух файлов — FAIL. +- [ ] **Step 3: Implement**; README «Ошибки API» — `LINE_CODE_TAKEN`, `LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, + `INVALID_LINE_PATCH`, `UNKNOWN_LINE`. +- [ ] **Step 4:** запуск и `make check` — PASS. +- [ ] **Step 5: Commit** `feat(statements): PATCH lines — назначение кода, расшифровка, подтверждение кода, LINE_CODE_TAKEN`. + +### Завершение части А + +- [ ] `make check-full` (Docker: `make up`, затем `make migrate`) — PASS; вывод — в описание PR. +- [ ] Финальное ревью ветки (opus), таблица находок `## Findings` в ledger, PR `tea pulls create` со ссылкой на issue А + и arm-ka-docs#116; в описании — «Rulings I made», `P3-n` части А, O-1…O-10, «PR не выкладывается на контур без части Б». + +--- + +## Часть Б + +> Старт — после слияния PR части А и PR плана 2 (копия `api/arm_statements/`, `mypy src scripts arm_statements`, +> `statements-check` в `check-full`) в arm-core. Worktree части Б создаётся от `origin/main` с обоими слияниями. + +### Task B0: Change OpenSpec части Б в arm-core + +**Files:** +- Create: `openspec/changes/unified-normalization-core-b/` (`proposal.md`, `design.md`, `tasks.md`, спеки-дельты + `osv-result-mapping`, `documents-api`, `statement-checks` (REMOVED: «копия словаря §7 ядра»), `recognition-job-exchange`) + +- [ ] **Step 1:** `proposal.md` — требования спеки `arm-core` из колонки «Б» таблицы покрытия; `design.md` с разделом + «Decisions made without discussion» (P3-n части Б). +- [ ] **Step 2:** `openspec validate unified-normalization-core-b --strict` — без ошибок. +- [ ] **Step 3: Commit** `openspec(core-b): change единой нормализации, часть Б (arm-ka-docs#116)`. + +### Task B1: Адаптер пакета `StatementsEngine` и вход пересчёта из БД + +**Files:** +- Create: `api/src/services/statements_engine.py`, `api/src/domain/stored_form.py`, `api/tests/fake_engine.py` +- Test: `api/tests/test_domain_stored_form.py`, `api/tests/test_statements_engine.py` + +**Interfaces:** +- Consumes: копия `arm_statements` (имена — раздел «Публичный API пакета» плана 1; вызовы ядра — раздел «Что ядро вызывает»). +- Produces: + - `StatementsEngine` (протокол) и `default_engine()` — как в разделе «Что ядро вызывает»; `ArmStatementsEngine.version` + возвращает `statements_version()` (содержимое `api/arm_statements/VERSION`; нет файла — `None`), `recompute` / + `edition` / `code_order` / `editions` / `tolerance` берут `default_rules()` пакета, `contract_document_status` — + `document_status(status_input_from_contract(document))`; + - `build_stored_form(document: Document, meta: FinancialStatementMeta, columns: Sequence[FinancialStatementColumn], + lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], warnings: Sequence[DocumentIssue]) + -> StoredForm` (типы пакета, перечисления — `StrEnum`: `DocumentType`, `LineStatus`, `PeriodKind`, `Severity`; + уверенности — `float(...)`, `NULL` значения `is_recognized` — `False`; `period_kind = NULL` — `PeriodKind.NONE`): + `effective_date = document.reporting_date`; `has_organization_or_inn` — непустые + `meta.organization_name` или `meta.inn`; `period_known = document.reporting_date is not None`; + `needs_review_fields = tuple(meta.needs_review_fields or ())`; `accepted = meta.checks_accepted_at is not None`; + `warnings` — записи `kind = warning` (`code`, `severity`); `code_fixed = bool(line.line_code_modified) or + line.is_confirmed`; `value_fixed = value.is_modified or value.is_confirmed`; `text = value.source_text`; + `parent_key` — ключ строки с `row_index == line.parent_row_index` (у `detail`), иначе `None`; `key` строки и колонки — + `str(uuid)`; + - `edition_codes(doc: StatementDocumentV2) -> list[tuple[str, bool]]` — пары `(line_code, line_code_confidence == 100)` + строк `ok` результата по `row_index` (вход `engine.edition`); своего `StatusInput` ядро не собирает — вход статуса + из документа результата строит пакет (`status_input_from_contract`, план 1 P1-27); + - `FakeEngine` для модульных тестов: возвращает заданный `RecomputeResult`, заданные статус документа и редакцию, + запоминает вход `StoredForm`. + +- [ ] **Step 1: Write the failing tests:** `test_build_stored_form_maps_flags` (шаблонная строка → `row_index is None`; + правленая ячейка → `value_fixed`; подтверждённая ячейка → `value_fixed`; строка с назначенным кодом → `code_fixed`; + `detail` с `parent_row_index = 3` → `parent_key` строки `row_index 3`; `warnings` — только `kind = warning`); + `test_contract_document_status_matches_examples` (реальный пакет: у каждого `statementDocument` из + `contracts/examples/result-statement-minimal.json` и `result-statement-excel.json` — + `engine.contract_document_status(doc) == DocStatus(doc["status"])`; у минимального — `NEEDS_CHECK` из-за строки + `unreadable`); `test_edition_codes_and_engine_edition` (минимальный пример: пары в порядке `row_index`, строка + `unreadable` не входит; `engine.edition(DocumentType.BALANCE, pairs, date(2025, 9, 30), FormVariant.FULL)` — `Edition`; + тот же ответ, что `recompute` формы, собранной из этого результата); `test_engine_scenario_recompute_after_edit` (реальный пакет, + сценарий спеки «Пересчёт после правки по строкам БД»: ячейка 1230 `value_fixed`, код 1250 `code_fixed` у бывшей + `unreadable`, шаблонная 1260 с суммой, `detail` с уверенностью 60 → у 1230 и у значения `detail` `needs_review` `False`, + у строки 1250 `False`, соотношение `1200 = …` посчитано с 1250 и 1260; ожидаемый `RecomputeResult.checks` и + `value_needs_review` — с ключами `(str(line_id), str(column_id))`, `edition` — `Edition.FSBU4` или `RAS_66N` по дате); + `test_stored_form_types` (уверенность `Decimal("67.50")` в БД → `67.5` типа `float`; `is_recognized = NULL` → `False`; + `period_kind = NULL` → `PeriodKind.NONE`; `line_status` — `LineStatus`); `test_engine_version_reads_file` + (`engine.version == statements_version()`; `statements_version(tmp_path / "VERSION")` без файла — `None`). +- [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_domain_stored_form.py tests/test_statements_engine.py -v` — FAIL. +- [ ] **Step 3: Implement**; `ArmStatementsEngine` кэшируется на процесс (`functools.cache` у `default_engine`). +- [ ] **Step 4:** запуск и `make check` (в т. ч. `mypy src scripts arm_statements`) — PASS. +- [ ] **Step 5: Commit** `feat(statements): адаптер arm_statements за протоколом StatementsEngine и вход пересчёта из БД`. + +### Task B2: Пересчёт после правки общим кодом и §7 из `document_issue` + +**Files:** +- Create: `api/src/services/statement_recompute.py` +- Modify: `api/src/services/statements.py` (`recompute`, `checks_report`, `build_out`, `accept_checks`, + `statement_hard_failures`), `api/src/services/documents.py`, `api/src/repositories/statements.py` + (`replace_issues`), `api/src/schemas/statements.py` +- Test: `api/tests/test_api_statements.py`, `api/tests/test_api_statement_lines.py`, `api/tests/test_statement_recompute.py` + +**Interfaces:** +- Consumes: `StatementsEngine`, `build_stored_form` (B1), `name_mismatches`, `form_status_v2`, `has_error_warning`, + `duplicate_period_forms` (A10), `IssueDraft` (A9). +- Produces: + - `async def recompute_form(session, *, document, meta, engine: StatementsEngine, job: RecognitionJob | None) -> None`: + строит вход, вызывает `engine.recompute`, пишет `needs_review` строк и значений, `meta.form_variant`, + `parent_row_index` строк `detail` (ключ → `row_index`), заменяет записи `relation`/`soft` (любого источника) и записи + `core` по P3-8 (`RecomputeResult` пакета: `form_variant: FormVariant | None`, `edition: Edition | None`, + `checks`, `line_needs_review`, `value_needs_review`, `detail_parents`, `status: DocStatus`), пишет `STATEMENTS_VERSION_MISMATCH` (`info`, `core`), если `job.statements_version` не `None` и не равен + `engine.version`; статус (кроме `CONFIRMED`) — `form_status_v2(service_done = result.status == DocStatus.DONE, + file_error_warning = has_error_warning(job.metrics["warnings"]), counterparty, period_known = year и quarter, + names_ok = нет NAME_MISMATCH, duplicate_in_file)`; + - `relations_to_issues(checks: StatementChecks, *, columns, aggregates, source: IssueSource) -> list[IssueDraft]` — + `relations_failed` → `RELATION_FAILED`/`error`, `soft_relations_failed` → `RELATION_SOFT`/`info` (`FailedRelation` + плана 1); `column_id` — по `FailedRelation.column_key` (при пересчёте из БД это `str(column_id)`; при приёме `1.x` + колонки уже вставлены и ключ строится так же) — по `column_index` только для проверок сервиса `2.0` + (`column_key = str(column_index)`, B4); `line_ids` — все строки `ok` кодов `codes`; `expected`/`actual` — `Decimal | + None` (`None` — цель или все члены пусты, план 1 P1-21; в запись — `NULL`); `message` — `f"не сошлось {relation}: + {expected} ≠ {actual}"`, `None` пишется словом «пусто»; + - `StatementService.recompute` вызывает `recompute_form` (тело A12 удаляется, `nearest_ok_parents` A13 удаляется); + - `statement_hard_failures` и `checks.failed`/`soft_failed` ответа — из записей `relation`/`soft` (с `codes`, + `line_ids`); `NOTHING_TO_ACCEPT` — нет записей `relation`; правка `header.report_date` (в т. ч. `null`) пересчитывает + `period_date` колонок с `period_kind` `relative:*` через `engine.resolve_relative(PeriodKind(kind), effective_date)` до пересчёта. + +- [ ] **Step 1: Write the failing tests:** + - `test_1x_form_after_patch_recomputed` (сценарий «1.x-документ после PATCH») — форма `1.2` с несошедшимся `1200 = 1210 + + … + 1260` (запись `relation` по 1200 есть после приёма, B3; до B3 — вставить запись вручную) → правка 1230 так, что + сходится → записи `relation` по 1200 нет, статус пересчитан, `STATEMENTS_VERSION_MISMATCH` нет; + - `test_migrated_form_shows_violation_after_first_patch` (сценарий «Миграция на непустой базе») — форма без записей + `document_issue` с несошедшимся `1600 = 1700`: `GET` — `checks.failed == []`, `POST …/confirm` — без + `CHECKS_NOT_PASSED`; после любого `PATCH` — запись `RELATION_FAILED` с `codes` `["1600", "1700"]`; + - `test_unconfirm_uncovered_cell` (сценарий «Снятие подтверждения») — ячейка, не покрытая соотношением → `needs_review + is True` после пересчёта; + - `test_statements_version_mismatch` — `job.statements_version = "abc1234"`, `engine.version = "def5678"` → запись + `STATEMENTS_VERSION_MISMATCH` после `PATCH` (`FakeEngine.version` / реальный `statements_version()`); `job.statements_version = None` — нет; + - `test_relative_column_follows_report_date` — колонка `relative:prev_year_end`, `PATCH header.report_date = + 2025-09-30` → `period_date == 2024-12-31`; после ручной даты (A12) — не меняется; `report_date = null` → `period_date + is None` у относительных; + - `test_checks_from_issues_with_codes_and_line_ids` — `checks.failed[0].codes` и `line_ids` из записи; + - `test_one_patch_one_recompute` — `FakeEngine` вызван один раз за `PATCH` из трёх частей, событие одно. +- [ ] **Step 2:** запуск трёх файлов — FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4:** запуск и `make check` — PASS (тесты A12/A13 зелёные на общем коде). +- [ ] **Step 5: Commit** `feat(statements): пересчёт после правки точкой входа arm_statements, §7 только из document_issue`. + +### Task B3: Маппер `1.x` — §7 при приёме и вариант формы (Р-30) + +**Files:** +- Modify: `api/src/services/result_mapping.py` (`_insert_statements`), `api/src/services/statement_recompute.py` +- Test: `api/tests/test_mapping_statements.py`, `api/tests/test_api_statements.py` + +**Interfaces:** +- Consumes: `engine.recompute` (`RecomputeResult.form_variant`, `.checks`; `.status` здесь не используется), `build_stored_form`, `relations_to_issues` (B1, B2). +- Produces: `async def checks_on_intake_1x(session, *, document, meta, engine) -> bool` — один вызов + `engine.recompute` по вставленной форме; пишет `meta.form_variant` и записи `relation`/`soft` (`source = core`); возвращает + «жёстких нарушений нет» — условие D-6 «проверки сошлись»; `needs_review` значений и строк **не** перезаписывает (у `1.x` + они по правилу приёма, A9). + +- [ ] **Step 1: Write the failing tests:** `test_1x_balance_with_violation` (сценарий «Форма 1.x с нарушением»: `1.2`, ББ + с несошедшимся `1600 = 1700` → запись `relation` `RELATION_FAILED`, `codes == ["1600", "1700"]`, `source == "core"`, + документ `NEEDS_CHECK`; `POST …/confirm` без акцепта — 409 `CHECKS_NOT_PASSED`); `test_1x_simplified_variant` (ББ `1.2` + с 1600 и без 1100/1200 → `form_variant == "simplified"`); `test_1x_clean_income_recognized` (прежний + `test_clean_income_recognized` зелёный: `RECOGNIZED` и `CONFIRMED`). +- [ ] **Step 2:** запуск — FAIL. +- [ ] **Step 3: Implement**; `run_checks` ядра из `_insert_statements` убрать. +- [ ] **Step 4:** запуск, `make check`, `cd api && .venv/bin/pytest -m "integration and fixtures" tests/test_mapping_statements.py` — PASS. +- [ ] **Step 5: Commit** `feat(mapping): §7 и вариант формы при приёме 1.x копией arm_statements (Р-30)`. + +### Task B4: Маппер `2.0` — формы + +**Files:** +- Create: `api/src/domain/statement_mapping_v2.py`, `api/tests/factories_v2.py` +- Modify: `api/src/services/result_mapping.py` (`map_file`, выбор маппера), `api/src/repositories/statements.py` + (`insert_statement` — черновик v2) +- Test: `api/tests/test_domain_statement_mapping_v2.py`, `api/tests/test_mapping_statements_v2.py` + +**Interfaces:** +- Consumes: модели `result_v2` (A5), `IssueDraft`/`add_issues` (A9), `form_status_v2`, `duplicate_period_forms`, + `name_mismatches`, `has_error_warning` (A10), `engine.contract_document_status`, `engine.edition`, `engine.code_order`, + `edition_codes` (B1). +- Produces: + - `build_statement_v2(doc: StatementDocumentV2) -> StatementDraftV2` — строки как пришли (`row_index`, `source_ref = + ref`, `line_status`, `line_code`, `line_code_original_value = line_code`, `line_code_text`, `line_code_confidence`, + `line_name`, `line_name_confidence`, `parent_row_index` — `row_index` строки с `ref == parent_ref`, `needs_review`, + `is_confirmed = False`); значение на каждую пару «строка × колонка» (`amount`, `string_value`, `source_text = text`, + `confidence`, `is_recognized`, `needs_review`; нет в результате — пустая запись с `needs_review = False`); колонки с + `period_kind`, `label_confidence`; шапка: разобранные поля, `header_source` (`{поле: {"text": <поле>_text, + "confidence": <поле>_confidence}}` для 10 полей), `inn_original_value = inn`, `report_date_original_value = + report_date_text`, `units_original_value = units_text`, `organization_name_original_value = organization_name`, + `inn_confidence`, `needs_review_fields`, `form_variant`; P3-9; + - `service_issues(doc, *, line_ids_by_ref, column_ids_by_index, ok_line_ids_by_code) -> list[IssueDraft]` — + `doc.warnings` (`warning`, `service`; `line_refs` → `line_ids`; `column_index` → `column_id`, если колонка есть, иначе + `column_index`) и `doc.checks` (`relation`/`soft`, `service`; `column_id` — по `column_index` колонки: у сервиса `FailedRelation.column_key = str(column_index)`); + - `core_info_issues(*, lines, edition: Edition | None, form_variant: FormVariant | None, catalog, engine) -> list[IssueDraft]` — `CODE_NOT_IN_DICTIONARY`, + `CODE_NOT_IN_EDITION` (P3-7), `NAME_MISMATCH` (строка); `edition is None` или `form_variant is None` — проверки по редакции не пишутся; + `edition = engine.edition(DocumentType(doc.document_type), edition_codes(doc), document.reporting_date, + FormVariant(doc.form_variant) if doc.form_variant else None)` — без сборки `StoredForm` и пересчёта; + - статус формы: `service_done = doc.status == DocStatus.DONE and engine.contract_document_status(doc.model_dump(mode="json")) == DocStatus.DONE`; + `expected`/`actual` нарушений результата — `Decimal | None` (контракт: `null` — цели нет); + `doc.status == "done"` при несогласии — запись `RESULT_STATUS_INCONSISTENT` (`info`, `core`, у документа); + `file_error_warning = has_error_warning(result.metrics.warnings)` (P3-15); `RECOGNIZED` → автоподтверждение (как сейчас); + - `job.statements_version = result.service.statements_version`; `DISCLOSURE` — один документ на файл (P3-6), + предупреждения всех объединённых — в его `document_issue`. + +- [ ] **Step 1: Write the failing tests** (`factories_v2.statement_result_v2(...)` собирает результат `2.0` из базового + словаря с переопределениями; реальный пакет): + - golden на примерах docs: `result-statement-minimal.json` — строка `unreadable` с `line_code is None`, + `line_code_text` как в примере, документ `NEEDS_CHECK`; `result-statement-excel.json` — `BALANCE` с записью + `RELATION_SOFT`, `INCOME` со строкой `detail` и `parent_row_index` строки-родителя, один `DISCLOSURE` без строк; + - `test_repeated_code_kept` — две строки `ok` 2410 (пустая и `(649)`) → две строки с разными `row_index` и `source_ref`, + у каждой свои значения; + - `test_line_without_code` — `unreadable`, `line_code_text = "ИЧ,250"`, сумма → `line_code is None`, + `line_code_original_value is None`, `line_status == "unreadable"`, `row_index` из результата; + - `test_two_balances_same_period` — два `BALANCE` 2025 Q3 → разные `file_ordinal`, оба `NEEDS_CHECK`, ни один не + `CONFIRMED` — Review Focus 1; `test_two_balances_other_periods` — каждый по своим условиям; + - `test_neighbor_needs_check` — ББ `done`, ОФР `needs_check`, ошибок файла нет, ИНН совпал, 30.09.2025 → ББ `CONFIRMED` + (автоподтверждён), ОФР `NEEDS_CHECK`; + - `test_json_failed_blocks_all` — все `done`, `metrics.warnings` с `JSON_FAILED` (`error`) → все формы `NEEDS_CHECK`; + то же для `TABLE_PARSE_ERROR`; + - `test_name_mismatch_neighbor_code` — 1120 (50) «Нематериальные активы» → `NEEDS_CHECK`, `NAME_MISMATCH` у строки; + `test_legal_repeat_name` — 1220 (50) «НДС, начисленный по отгрузке» → `NAME_MISMATCH` нет; `test_section_total_swap` + — 1200 (50) «Итого по разделу I» → `NAME_MISMATCH`; `test_section_ocr_tail` — 1500 (56) «Итого по разделу V1» → нет; + - `test_done_with_unreadable_without_values` и `test_done_with_unreadable_with_amount` — `NEEDS_CHECK`, + `RESULT_STATUS_INCONSISTENT` у документа, не `CONFIRMED`; + - `test_report_date_not_quarter_end` — 15.08.2025 → `NEEDS_CHECK`; `POST …/confirm` → 409 `PERIOD_REQUIRED`; + - `test_66n_blank_in_2025` — Межениновская 3 кв. 2025 на бланке 66н с 1120 → `CODE_NOT_IN_EDITION` у 1120 нет; + - `test_column_skipped_issue` — `COLUMN_SKIPPED`, `column_index = 5`, колонки 5 нет → запись `column_index == 5`, + `column_id is None`; + - `test_header_source_hint` — `organization_name_text = "ООО «Ромашкa»"`, `organization_name_confidence = 67` → + `header_source["organization_name"] == {"text": "ООО «Ромашкa»", "confidence": 67}` и то же в `GET` + (`header.source`); + - `test_statements_version_and_ordinals` — `service.statements_version = "abc1234"` → у задания `"abc1234"`; `BALANCE`, + `INCOME` → `file_ordinal` 0, 1. +- [ ] **Step 2:** запуск двух файлов — FAIL. +- [ ] **Step 3: Implement**; `map_file` получает `RecognitionResult | RecognitionResultV2` и выбирает маппер по типу. +- [ ] **Step 4:** запуск и `make check` — PASS. +- [ ] **Step 5: Commit** `feat(mapping): маппер результата 2.0 для форм — хранение как пришло, document_issue, условие RECOGNIZED`. + +### Task B5: Маппер `2.0` — ОСВ, выбор по MAJOR, приём файла результата + +**Files:** +- Modify: `api/src/services/result_mapping.py` (`_load_result`, ОСВ-часть `map_file`), `api/src/domain/osv_mapping.py` + (статус по Р-28) +- Test: `api/tests/test_result_mapping.py`, `api/tests/test_mapping_statements_v2.py`, `api/tests/test_api_documents.py` + +**Interfaces:** +- Consumes: `parse_result` (A5), `osv_status_v2`, `has_error_warning` (A10), `IssueDraft` (A9). +- Produces: `_load_result` → `versions.parse_result` (MAJOR 1 — маппер `1.x`, MAJOR 2 — маппер `2.0`, иное — + `MappingFatal` с итогом проверки схемы в `metrics.mapping_error`, файл `ERROR`, без повтора); документ ОСВ `2.0` — + статус `osv_status_v2(osv_done = sheet.status == "done", checks_ok, file_error_warning, counterparty, + sheet_mapping_warnings)`; `sheet.warnings` → `document_issue` (`warning`, `service`, коды `OSV_*`, `info`). + +- [ ] **Step 1: Write the failing tests:** `test_osv_in_book_with_needs_check_income` (сценарий спеки: ОСВ `done` с + сошедшимися контрольными суммами + ОФР `needs_check`, конверт `needs_check`, ошибок файла нет → ОСВ `CONFIRMED`, её + `OSV_*` — в `DocumentOut.issues`); `test_osv_v2_examples` (`result-osv-minimal.json`, `result-osv-needs-check.json` — + статусы по примерам); `test_two_osv_v2_ordinals` (ОСВ 60, 62 → 0, 1); `test_result_major_3_is_fatal` (`schema_version + "3.0"` → файл `ERROR`, `metrics.mapping_error` начинается с «результат не соответствует схеме»); + `test_result_1x_still_mapped` (`1.2` — маппер `1.x`). +- [ ] **Step 2:** запуск — FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4:** запуск, `make check`, `cd api && .venv/bin/pytest -m "integration and fixtures" tests/test_mapping_golden.py tests/test_mapping_statements.py` — PASS. +- [ ] **Step 5: Commit** `feat(mapping): ОСВ по контракту 2.0 (Р-28) и выбор маппера по MAJOR`. + +### Task B6: Удаление копии словаря §7 ядра, редакции из перечня пакета + +**Files:** +- Delete: `api/rules/statement_checks.yaml` +- Modify: `api/src/domain/statement_checks.py` (остаются `cross_form_check` и `subline_parents`; `RelationRule`, + `CheckRules`, `load_check_rules`, `run_checks`, `FailedCheck.codes` удаляются), `api/src/services/statements.py` + (`checks_report`: допуск — `engine.tolerance()` = `default_rules().checks.tolerance`), `api/src/domain/statement_lines.py` (порядок и состав редакций — + `engine.editions()` = `default_rules().code_orders.editions` (`Edition`)), `CLAUDE.md` (раздел «Формы ББ/ОФР»: §7 — копия `arm_statements`, проверки хранятся в + `document_issue`), `README.md` +- Test: `api/tests/test_domain_statement_checks.py`, `api/tests/test_domain_statement_lines.py` + +**Interfaces:** +- Consumes: `engine.tolerance()`, `engine.editions()`, `engine.code_order(edition, document_type, form_variant)` (B1; `Rules.code_orders.sequence` плана 1). +- Produces: `cross_form_check(balance_1370, income_2400, tolerance: Decimal) -> FailedCheck | None` без изменений + сигнатуры; `test_name_dictionary_codes_in_code_orders` — каждый код `statement_lines.yaml` редакции есть в перечне + этой редакции копии пакета (любой вариант формы). + +- [ ] **Step 1: Write the failing tests:** `test_name_dictionary_codes_in_code_orders`; `test_no_core_check_rules` + (`not (API_DIR / "rules" / "statement_checks.yaml").exists()` и в `src/` нет `load_check_rules`); тесты + `run_checks` в `test_domain_statement_checks.py` удаляются, тесты `cross_form_check` и `subline_parents` остаются. +- [ ] **Step 2:** запуск — FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4:** `make check` — PASS; `grep -rn "statement_checks.yaml\|run_checks\|load_check_rules" api/src api/tests` — + пусто. +- [ ] **Step 5: Commit** `refactor(statements): удалена копия словаря §7 ядра — проверки копией arm_statements`. + +### Завершение части Б + +- [ ] `make check-full` — PASS, `statements-check` — код `0` или `10` (предупреждение «копия отстала» — в описание PR). +- [ ] Финальное ревью ветки (opus), `## Findings` в ledger, PR со ссылкой на issue Б и arm-ka-docs#116; в описании — + «Rulings I made», `P3-n` части Б, открытые `O-n`, напоминание о порядке выкладки (очистка → миграция → ядро А+Б → фронт; + сервисы на `2.0` — после). + +--- + +## Покрытие спеки `arm-core` + +| Требование спеки | Часть А | Часть Б | +|---|---|---| +| Хранение результата формы v2.0 (DBML v5.19) | схема A1; `file_ordinal` и Р-10 у `1.x` A9 | маппер `2.0` B4, B5 | +| Признак строки, созданной правкой | A7, A13 | — | +| Шапка формы — сырой текст и уверенность | схема A1; `1.x` A9; `header.source` A8 | `2.0` B4 | +| Агрегат формы по коду | A7 | — | +| Условие `RECOGNIZED` при приёме 2.0 | чистые функции A10; словарь A6 | B4 (страховка — `engine.contract_document_status`) | +| Предупреждения и нарушения документа — `document_issue` | таблица A1; снятие правкой A12; `line_ids` A13; `issues` A8 | `relation`/`soft`, `core` B2–B4 | +| Формы результата 1.x — §7 при приёме | поля и `MAPPING_WARNING` A9; `LEGACY_WARNING` A5 | B3 | +| Пересчёт после правки | временный пересчёт A12 | B2 | +| PATCH формы — строки, колонки, подтверждение, наименование | A12, A13 | относительные даты B2 | +| Подтверждение формы — порядок 409 | A11 | `CHECKS_NOT_PASSED` из `document_issue` B2 | +| Словари ядра и достройка шаблонных строк | словарь имён A6; достройка A7 | удаление копии §7, редакции из перечня B6 | +| Ответ `GET …/statement` по v2.0 | A8 | `checks` из `document_issue` B2 | +| Миграция DBML v5.19 | A1, A2 | сценарий «после PATCH … `RELATION_FAILED`» — B2 | +| Очистка форм перед выкладкой | A4 | — | +| Предупреждения файла и ОСВ в API, статус ОСВ при приёме 2.0 | A5, A8, A9 | B5 | +| `crm_id` ГСК/ГСЗ | A3 | — | +| `recognition-contract`: «сначала ядро», ack 2.0, копии обоих MAJOR | A5 | B5 | -- 2.47.2 From 21e0720fb07b4e97fd5d2db67fde432a1e69cc45 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 21:42:04 +0400 Subject: [PATCH 07/13] =?UTF-8?q?feat(mapping):=20=D0=BC=D0=B0=D0=BF=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=202.0=20=D0=B4=D0=BB=D1=8F=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=20=E2=80=94=20=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BA=D0=B0=D0=BA=20=D0=BF=D1=80=D0=B8=D1=88=D0=BB?= =?UTF-8?q?=D0=BE,=20document=5Fissue,=20=D1=83=D1=81=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D0=B5=20RECOGNIZED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/domain/statement_issues.py | 181 +++++++- api/src/domain/statement_mapping.py | 7 +- api/src/domain/statement_mapping_v2.py | 299 ++++++++++++ api/src/repositories/statements.py | 1 + api/src/services/result_mapping.py | 103 ++++- api/src/services/statement_intake_v2.py | 205 +++++++++ api/src/services/statement_recompute.py | 151 ++---- api/src/services/statements_engine.py | 4 + api/tests/factories_v2.py | 195 ++++++++ api/tests/test_domain_statement_mapping_v2.py | 249 ++++++++++ api/tests/test_mapping_statements_v2.py | 432 ++++++++++++++++++ .../unified-normalization-core-b/design.md | 7 + 12 files changed, 1694 insertions(+), 140 deletions(-) create mode 100644 api/src/domain/statement_mapping_v2.py create mode 100644 api/src/services/statement_intake_v2.py create mode 100644 api/tests/factories_v2.py create mode 100644 api/tests/test_domain_statement_mapping_v2.py create mode 100644 api/tests/test_mapping_statements_v2.py diff --git a/api/src/domain/statement_issues.py b/api/src/domain/statement_issues.py index 9d2d644..2634c82 100644 --- a/api/src/domain/statement_issues.py +++ b/api/src/domain/statement_issues.py @@ -1,18 +1,60 @@ -"""Черновик записи `document_issue` (DBML v5.19) — чистые данные без ORM. +"""Черновик записи `document_issue` (DBML v5.19) — чистые данные без ORM — и проверки ядра уровня `info`. Маппер `1.x` пишет только `MAPPING_WARNING` (P3-5, P3-7): предупреждения черновика формы, листа ОСВ и повтор формы -в файле; проверки §7 и прочие коды ядра в `1.x` не пишутся. Запись в БД — `repositories/statements.add_issues`. +в файле; проверки §7 и прочие коды ядра в `1.x` не пишутся. Маппер `2.0` и пересчёт после правки пишут проверки ядра +`CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH`, `STATEMENTS_VERSION_MISMATCH` (`core_info_issues`, +`version_issue`) — одним кодом (P3-39). Запись в БД — `repositories/statements.add_issues`. """ +import re import uuid +from collections.abc import Collection, Hashable, Mapping, Sequence from dataclasses import dataclass from decimal import Decimal +from typing import NamedTuple -from src.models.enums import DocumentIssueKind, IssueSeverity, IssueSource +from src.domain.statement_lines import LineCatalog +from src.domain.statement_status import NameCheckLine, name_mismatches +from src.models.enums import DocumentIssueKind, IssueSeverity, IssueSource, StatementLineStatus +from src.models.statements import FinancialStatementLine -__all__ = ["COLUMN_CLEARS", "HEADER_CLEARS", "MAPPING_WARNING_CODE", "IssueDraft", "mapping_warning"] +__all__ = [ + "CODE_NOT_IN_DICTIONARY", + "CODE_NOT_IN_EDITION", + "COLUMN_CLEARS", + "HEADER_CLEARS", + "MAPPING_WARNING_CODE", + "NAME_MISMATCH", + "RECOMPUTED_CORE_CODES", + "RELATION_FAILED", + "RELATION_SOFT", + "RESULT_STATUS_INCONSISTENT", + "STATEMENTS_VERSION_MISMATCH", + "CoreInfo", + "IssueDraft", + "code_issues", + "core_info_issues", + "mapping_warning", + "name_check_lines", + "name_issues", + "relation_message", + "result_status_inconsistent", + "version_issue", +] MAPPING_WARNING_CODE = "MAPPING_WARNING" +RELATION_FAILED = "RELATION_FAILED" +RELATION_SOFT = "RELATION_SOFT" +CODE_NOT_IN_DICTIONARY = "CODE_NOT_IN_DICTIONARY" +CODE_NOT_IN_EDITION = "CODE_NOT_IN_EDITION" +NAME_MISMATCH = "NAME_MISMATCH" +STATEMENTS_VERSION_MISMATCH = "STATEMENTS_VERSION_MISMATCH" +RESULT_STATUS_INCONSISTENT = "RESULT_STATUS_INCONSISTENT" +# записи `core`, которые пересчёт заменяет целиком (P3-8); прочие (`MAPPING_WARNING`, `RESULT_STATUS_INCONSISTENT`, +# `LEGACY_WARNING`) — факт приёма, их пересчёт сохраняет +RECOMPUTED_CORE_CODES = (CODE_NOT_IN_DICTIONARY, CODE_NOT_IN_EDITION, NAME_MISMATCH, STATEMENTS_VERSION_MISMATCH) +_CHECKED_CODE = re.compile(r"\d{4}", re.ASCII) # R-1 (Р-40): проверяются только четырёхзначные коды +_EMPTY = "пусто" # предупреждения сервиса, которые снимает правка поля шапки (в том числе подтверждение даты тем же значением) — # spec arm-core «Записи document_issue» @@ -53,3 +95,134 @@ def mapping_warning(message: str) -> IssueDraft: message=message, source=IssueSource.core, ) + + +def _core_info(code: str, message: str, line_ids: tuple[uuid.UUID, ...] = ()) -> IssueDraft: + return IssueDraft( + kind=DocumentIssueKind.warning, + code=code, + severity=IssueSeverity.info, + message=message, + source=IssueSource.core, + line_ids=line_ids, + ) + + +def relation_message(relation: str, expected: Decimal | None, actual: Decimal | None) -> str: + """Сообщение нарушения §7; `None` (цель или все члены пусты, P1-21) — «пусто».""" + left = _EMPTY if expected is None else str(expected) + right = _EMPTY if actual is None else str(actual) + return f"не сошлось {relation}: {left} ≠ {right}" + + +def result_status_inconsistent(service_status: str) -> IssueDraft: + """`RESULT_STATUS_INCONSISTENT` (`info`, `core`, у документа): сервис прислал `done`, общая функция статуса пакета + по содержимому документа даёт иное — документ считается `needs_check`.""" + return _core_info( + RESULT_STATUS_INCONSISTENT, + f"сервис прислал статус {service_status}, а по содержимому документа он needs_check", + ) + + +def code_issues( + lines: Sequence[FinancialStatementLine], + *, + in_dictionary: Mapping[str, bool], + in_edition: Mapping[str, bool] | None, +) -> list[IssueDraft]: + """`CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` — по записи на код документа (`line_ids` — все строки `ok` с + этим кодом), только для кодов из ровно четырёх цифр (R-1, Р-40). `in_dictionary` — код есть в словаре имён редакции, + `in_edition` — код из перечня редакции и варианта (`None` — редакция или вариант не определены, не проверяется).""" + by_code: dict[str, list[FinancialStatementLine]] = {} + for line in lines: + if line.line_status is StatementLineStatus.ok and line.line_code and _CHECKED_CODE.fullmatch(line.line_code): + by_code.setdefault(line.line_code, []).append(line) + drafts: list[IssueDraft] = [] + for code in sorted(by_code): + ids = tuple( + line.id for line in sorted(by_code[code], key=lambda item: (item.row_index is None, item.row_index or 0)) + ) + if not in_dictionary.get(code, True): + drafts.append(_core_info(CODE_NOT_IN_DICTIONARY, f"кода {code} нет в словаре наименований редакции", ids)) + if in_edition is not None and not in_edition.get(code, True): + drafts.append(_core_info(CODE_NOT_IN_EDITION, f"код {code} не входит в перечень кодов редакции формы", ids)) + return drafts + + +def name_issues(lines: Sequence[FinancialStatementLine], mismatched: Sequence[Hashable]) -> list[IssueDraft]: + """`NAME_MISMATCH` — по записи на строку из `name_mismatches`.""" + by_id = {line.id: line for line in lines} + drafts: list[IssueDraft] = [] + for key in mismatched: + line = by_id[key] # type: ignore[index] + drafts.append( + _core_info( + NAME_MISMATCH, + f"наименование «{line.line_name or ''}» не совпало со справочным для кода {line.line_code}", + (line.id,), + ) + ) + return drafts + + +def version_issue(job_version: str | None, engine_version: str | None) -> IssueDraft | None: + """`STATEMENTS_VERSION_MISMATCH`: версия копии пакета у задания известна и не равна версии копии ядра.""" + if job_version is None or job_version == engine_version: + return None + return _core_info( + STATEMENTS_VERSION_MISMATCH, + f"форма распознана общим кодом версии {job_version}, в ядре версия {engine_version or 'не определена'}", + ) + + +def name_check_lines(lines: Sequence[FinancialStatementLine]) -> list[NameCheckLine]: + """Вход проверки наименований из строк БД: закреплённый код (`code_fixed`) — правленый или подтверждённый.""" + return [ + NameCheckLine( + key=line.id, + line_status=line.line_status.value, + line_code=line.line_code, + line_code_confidence=line.line_code_confidence, + line_name=line.line_name, + code_fixed=bool(line.line_code_modified) or bool(line.is_confirmed), + ) + for line in lines + ] + + +class CoreInfo(NamedTuple): + """Проверки ядра уровня `info` по строкам формы: записи и ключи строк с несовпавшим наименованием.""" + + drafts: list[IssueDraft] + mismatched: list[Hashable] + + +def core_info_issues( + lines: Sequence[FinancialStatementLine], + *, + form: str, + edition: str | None, + order: Collection[str] | None, + catalog: LineCatalog, +) -> CoreInfo: + """Проверки ядра уровня `info` (приём `2.0` и пересчёт после правки — одно правило, P3-39). + + `edition` — редакция точки входа пакета (`"66n"` / `"fsbu4"`, `None` — не определена: `CODE_NOT_IN_*` не пишутся); + `order` — перечень кодов редакции и варианта формы (`None` — редакция или вариант не определены: + `CODE_NOT_IN_EDITION` не пишется). `NAME_MISMATCH` не зависит от редакции (словарь имён — любая редакция, P3-34). + Домен пакет не импортирует: редакцию и перечень считает сервис через `StatementsEngine`.""" + in_dictionary = { + line.line_code: catalog.reference_name(edition, form, line.line_code) is not None + for line in lines + if line.line_code is not None + } + in_edition: dict[str, bool] | None = None + if order is not None: + allowed = set(order) + in_edition = {line.line_code: line.line_code in allowed for line in lines if line.line_code is not None} + mismatched = name_mismatches(name_check_lines(lines), catalog, form) + drafts = [ + *(code_issues(lines, in_dictionary=in_dictionary, in_edition=in_edition) if edition is not None else []), + *name_issues(lines, mismatched), + ] + return CoreInfo(drafts, mismatched) diff --git a/api/src/domain/statement_mapping.py b/api/src/domain/statement_mapping.py index 00a9bc1..a73159a 100644 --- a/api/src/domain/statement_mapping.py +++ b/api/src/domain/statement_mapping.py @@ -49,7 +49,7 @@ class ColumnDraft: @dataclass class ValueDraft: - line_code: str + line_code: str | None # `None` у строк `unreadable` / `missing` / `detail` результата 2.0 column_index: int amount: Decimal | None string_value: str | None @@ -62,9 +62,9 @@ class ValueDraft: @dataclass class LineDraft: - line_code: str + line_code: str | None # `None` у строк `unreadable` / `missing` / `detail` результата 2.0 line_code_confidence: Decimal | None - line_name: str + line_name: str | None line_name_confidence: Decimal | None row_index: int = 0 # индекс строки в `doc.lines` результата — ключ строки формы; повтор кода — свой индекс line_status: str = "ok" @@ -72,6 +72,7 @@ class LineDraft: source_ref: str | None = None line_code_text: str | None = None needs_review: bool = False + parent_row_index: int | None = None # `row_index` родителя строки `detail` (2.0, по `parent_ref`) @dataclass diff --git a/api/src/domain/statement_mapping_v2.py b/api/src/domain/statement_mapping_v2.py new file mode 100644 index 0000000..619c390 --- /dev/null +++ b/api/src/domain/statement_mapping_v2.py @@ -0,0 +1,299 @@ +"""Чистые функции маппинга формы ББ/ОФР контракта `2.0` (`StatementDocumentV2`) в черновики `financial_statement_*` +(spec arm-core «Хранение результата формы v2.0», «Шапка формы — сырой текст и уверенность», «Предупреждения и +нарушения документа — document_issue»; P3-9, P3-39…). + +Форма хранится как пришла: строки с их `row_index` и `ref`, повтор кода не склеивается, значение на каждую пару +«строка × колонка». Статус документа и проверки ядра `info` здесь не считаются: им нужны id строк после вставки +(`services/statement_intake_v2.py`). +""" + +import uuid +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +from src.contract.result_v2 import DocumentWarning, StatementColumnV2, StatementDocumentV2, StatementValueV2 +from src.domain.statement_issues import RELATION_FAILED, RELATION_SOFT, IssueDraft, relation_message +from src.domain.statement_mapping import ( + FULL_CONFIDENCE, + ColumnDraft, + LineDraft, + StatementDraft, + ValueDraft, + quarter_of, + statement_period, +) +from src.models.enums import ( + DocumentIssueKind, + DocumentType, + IssueSeverity, + IssueSource, + StatementFormVariant, + StatementType, +) + +__all__ = ["HEADER_SOURCE_FIELDS", "StatementDraftV2", "build_statement_v2", "service_issues"] + +HEADER_SOURCE_FIELDS = ( + "organization_name", + "inn", + "report_date", + "units", + "okud", + "okpo", + "okopf", + "okfs", + "okei", + "address", +) +_STATEMENT_TYPES = {DocumentType.BALANCE: StatementType.balance, DocumentType.INCOME: StatementType.income} + + +@dataclass +class StatementDraftV2(StatementDraft): + """Черновик формы `2.0`: то же, что у `1.x`, плюс вариант формы (он же в `header["form_variant"]`).""" + + form_variant: StatementFormVariant | None = None + + +def _decimal(value: float | None) -> Decimal | None: + return Decimal(str(value)) if value is not None else None + + +def _header_meta(doc: StatementDocumentV2, form_variant: StatementFormVariant | None) -> dict[str, Any]: + header = doc.header + inn_confidence = _decimal(header.inn_confidence) + header_source = { + name: {"text": getattr(header, f"{name}_text"), "confidence": getattr(header, f"{name}_confidence")} + for name in HEADER_SOURCE_FIELDS + } + return { + "inn": header.inn, + "inn_original_value": header.inn, + "inn_confidence": inn_confidence, + "inn_verified": inn_confidence == FULL_CONFIDENCE if header.inn is not None else None, + "inn_modified": False, + "organization_name": header.organization_name, + "organization_name_original_value": header.organization_name, + "organization_name_modified": False, + "report_date": header.report_date, + "report_date_original_value": header.report_date_text, + "report_date_verified": header.report_date is not None, + "report_date_modified": False, + "units": header.units, + "units_original_value": header.units_text, + "units_verified": header.units is not None, + "units_modified": False, + "okud": header.okud, + "okpo": header.okpo, + "okopf": header.okopf, + "okfs": header.okfs, + "okei": header.okei, + "address": header.address, + "header_source": header_source, + "needs_review_fields": list(header.needs_review_fields), + "form_variant": form_variant, + } + + +def _columns(doc: StatementDocumentV2, warnings: list[str]) -> list[StatementColumnV2]: + """Колонки результата; повтор `column_index` — остаётся первая, о повторе предупреждение (P3-9).""" + seen: set[int] = set() + result: list[StatementColumnV2] = [] + for column in doc.columns: + if column.column_index in seen: + warnings.append(f"{doc.locator}: колонка {column.column_index} повторяется — оставлена первая") + continue + seen.add(column.column_index) + result.append(column) + return result + + +def _column_drafts(source_columns: Sequence[StatementColumnV2]) -> list[ColumnDraft]: + """`is_current` — у колонки с самой поздней датой (при равенстве — с меньшим `column_index`), как у `1.x` (§9.5).""" + latest = max((c.period_date for c in source_columns if c.period_date is not None), default=None) + first_latest = ( + min(c.column_index for c in source_columns if c.period_date == latest) if latest is not None else None + ) + return [ + ColumnDraft( + column.column_index, + column.label, + column.period_date, + None if latest is None else column.period_date == latest and column.column_index == first_latest, + period_kind=column.period_kind, + label_confidence=_decimal(column.label_confidence), + ) + for column in source_columns + ] + + +def _lines_and_values( + doc: StatementDocumentV2, source_columns: Sequence[StatementColumnV2], warnings: list[str] +) -> tuple[list[LineDraft], list[ValueDraft]]: + known_columns = {column.column_index for column in source_columns} + row_by_ref = {line.ref: line.row_index for line in doc.lines} + lines: list[LineDraft] = [] + values: list[ValueDraft] = [] + seen_rows: set[int] = set() + seen_refs: set[str] = set() + for line in doc.lines: + if line.row_index in seen_rows or line.ref in seen_refs: + warnings.append( + f"{doc.locator}: строка row_index={line.row_index}, ref={line.ref} повторяет прежнюю — пропущена" + ) + continue + seen_rows.add(line.row_index) + seen_refs.add(line.ref) + parent: int | None = None + if line.line_status == "detail" and line.parent_ref is not None: + parent = row_by_ref.get(line.parent_ref) + if parent is None: + warnings.append(f"{doc.locator}: строка {line.ref}: родитель {line.parent_ref} не найден") + lines.append( + LineDraft( + line.line_code, + _decimal(line.line_code_confidence), + line.line_name, + _decimal(line.line_name_confidence), + row_index=line.row_index, + line_status=line.line_status, + line_code_original_value=line.line_code, + source_ref=line.ref, + line_code_text=line.line_code_text, + needs_review=line.needs_review, + parent_row_index=parent, + ) + ) + by_column: dict[int, StatementValueV2] = {} + for cell in line.values: + if cell.column_index not in known_columns: + warnings.append( + f"{doc.locator}: строка {line.ref}: значение колонки {cell.column_index}" + " вне колонок формы пропущено" + ) + continue + if cell.column_index in by_column: + warnings.append( + f"{doc.locator}: строка {line.ref}: значение колонки {cell.column_index}" + " повторяется — оставлено первое" + ) + continue + by_column[cell.column_index] = cell + for column in source_columns: + cell_value = by_column.get(column.column_index) + values.append( + ValueDraft( + line.line_code, + column.column_index, + cell_value.amount if cell_value is not None else None, + cell_value.string_value if cell_value is not None else None, + _decimal(cell_value.confidence) if cell_value is not None else None, + cell_value.is_recognized if cell_value is not None else None, + row_index=line.row_index, + source_text=cell_value.text if cell_value is not None else None, + needs_review=cell_value.needs_review if cell_value is not None else False, + ) + ) + return lines, values + + +def build_statement_v2(doc: StatementDocumentV2) -> StatementDraftV2: + """Черновик формы `BALANCE` / `INCOME` результата `2.0`: всё, как пришло (спека «Хранение результата формы v2.0»). + + Период — как у `1.x` (§9.5 п. 2): `header.report_date`, иначе самая поздняя дата колонок; квартал — только при конце + квартала. Предупреждения черновика (повтор колонки, значения вне колонок, повтор ячейки, повтор строки, потерянный + родитель) — строки для `MAPPING_WARNING`, P3-9.""" + document_type = DocumentType(doc.document_type) + warnings: list[str] = [] + source_columns = _columns(doc, warnings) + period_date = statement_period(doc.header.report_date, [column.period_date for column in source_columns]) + period_year = period_date.year if period_date is not None else None + period_quarter = quarter_of(period_date) if period_date is not None else None + if period_date is None: + warnings.append(f"{doc.locator}: период формы не определён (нет даты отчёта и дат колонок)") + elif period_quarter is None: + warnings.append(f"{doc.locator}: дата {period_date.isoformat()} не конец квартала — квартал не определён") + lines, values = _lines_and_values(doc, source_columns, warnings) + form_variant = StatementFormVariant(doc.form_variant) if doc.form_variant is not None else None + return StatementDraftV2( + document_type=document_type, + statement_type=_STATEMENT_TYPES[document_type], + locator=doc.locator, + period_date=period_date, + period_year=period_year, + period_quarter=period_quarter, + header=_header_meta(doc, form_variant), + columns=_column_drafts(source_columns), + lines=lines, + values=values, + warnings=warnings, + form_variant=form_variant, + ) + + +def _warning_issue( + warning: DocumentWarning, + *, + line_ids_by_ref: Mapping[str, uuid.UUID], + column_ids_by_index: Mapping[int, uuid.UUID], +) -> IssueDraft: + line_ids = tuple(line_ids_by_ref[ref] for ref in (warning.line_refs or ()) if ref in line_ids_by_ref) + column_id = column_ids_by_index.get(warning.column_index) if warning.column_index is not None else None + return IssueDraft( + kind=DocumentIssueKind.warning, + code=warning.code, + severity=IssueSeverity(warning.severity), + message=warning.message, + source=IssueSource.service, + line_ids=line_ids, + column_id=column_id, + column_index=warning.column_index if column_id is None else None, + field=warning.field, + ) + + +def service_issues( + doc: StatementDocumentV2, + *, + line_ids_by_ref: Mapping[str, uuid.UUID], + column_ids_by_index: Mapping[int, uuid.UUID], + ok_line_ids_by_code: Mapping[str, Sequence[uuid.UUID]], +) -> list[IssueDraft]: + """Записи `document_issue` источника `service`: `warnings[]` документа (`line_refs` → `line_ids`; `column_index` → + `column_id`, если такая колонка есть в форме, иначе остаётся `column_index`) и `checks` (`relations_failed` — + `relation` / `RELATION_FAILED` / `error`, `soft_relations_failed` — `soft` / `RELATION_SOFT` / `info`; `line_ids` — + все строки `ok` кодов `codes`; `column_id` — по `column_index` нарушения, ключ колонки сервиса — её номер).""" + drafts = [ + _warning_issue(warning, line_ids_by_ref=line_ids_by_ref, column_ids_by_index=column_ids_by_index) + for warning in doc.warnings + ] + if doc.checks is None: + return drafts + groups = ( + (doc.checks.relations_failed, DocumentIssueKind.relation, RELATION_FAILED, IssueSeverity.error), + (doc.checks.soft_relations_failed, DocumentIssueKind.soft, RELATION_SOFT, IssueSeverity.info), + ) + for failures, kind, code, severity in groups: + for failure in failures: + line_ids = dict.fromkeys( + line_id for form_code in failure.codes for line_id in ok_line_ids_by_code.get(form_code, ()) + ) + drafts.append( + IssueDraft( + kind=kind, + code=code, + severity=severity, + message=relation_message(failure.relation, failure.expected, failure.actual), + source=IssueSource.service, + line_ids=tuple(line_ids), + column_id=column_ids_by_index.get(failure.column_index), + column_index=failure.column_index, + relation=failure.relation, + codes=tuple(failure.codes), + expected=failure.expected, + actual=failure.actual, + ) + ) + return drafts diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index 7d39e37..4e3ef9e 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -118,6 +118,7 @@ async def insert_statement( "line_code_original_value": line.line_code_original_value, "line_code_confidence": line.line_code_confidence, "line_code_modified": False, + "parent_row_index": line.parent_row_index, "needs_review": line.needs_review, "line_name": line.line_name, "line_name_original_value": line.line_name, diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 012f8ea..e5d4971 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -39,6 +39,7 @@ from sqlalchemy import Integer, Select, delete, func, insert, select, update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.contract.result import OsvDocument, RecognitionResult, StatementDocument +from src.contract.result_v2 import RecognitionResultV2, StatementDocumentV2 from src.core.config import Settings from src.core.storage import ObjectNotFoundError, ObjectStorage from src.domain.osv_mapping import ( @@ -74,6 +75,7 @@ from src.repositories.counterparties import CounterpartyIndex from src.services import consolidation_documents from src.services.audit import AuditService from src.services.file_status import set_file_status_for_job +from src.services.statement_intake_v2 import FormV2, OtherV2, insert_forms_v2, map_statement_docs_v2 from src.services.statement_recompute import checks_on_intake_1x from src.services.statements_engine import StatementsEngine, default_engine @@ -198,7 +200,7 @@ def _schema_error_summary(exc: ValidationError) -> str: return f"результат не соответствует схеме recognition-result: ошибок {exc.error_count()}, первая: {where} {kind}" -def _osv_documents(result: RecognitionResult) -> list[OsvDocument]: +def _osv_documents(result: RecognitionResult | RecognitionResultV2) -> list[OsvDocument]: return [document for document in result.documents if isinstance(document, OsvDocument)] @@ -446,18 +448,98 @@ async def _insert_statements( # noqa: PLR0913 - все входы именов ) +@dataclass +class _FormsPlan: + """Документы форм/раскрытий результата до вставки: общие поля и черновики по MAJOR.""" + + documents: list[Document] + warnings: list[str] + repeats: dict[uuid.UUID, str] + forms_v1: list[tuple[Document, StatementDraft]] + forms_v2: list[FormV2] + others_v2: list[OtherV2] + + +def _plan_forms( # noqa: PLR0913 - все входы именованные + result: RecognitionResult | RecognitionResultV2, + *, + job: RecognitionJob, + ordinals: Mapping[int, int], + file: StoredFile, + owner: LegalEntity, + owner_names: frozenset[str], + now: datetime, +) -> _FormsPlan: + """Выбор маппера форм по типу результата: `2.0` (и `job.statements_version` сервиса) или `1.x`.""" + if isinstance(result, RecognitionResultV2): + job.statements_version = result.service.statements_version + forms_v2, others_v2, warnings, repeats = map_statement_docs_v2( + [doc for doc in result.documents if isinstance(doc, StatementDocumentV2)], + ordinals=ordinals, + file=file, + owner=owner, + owner_names=owner_names, + now=now, + ) + documents = [*(other.document for other in others_v2), *(form.document for form in forms_v2)] + return _FormsPlan(documents, warnings, repeats, [], forms_v2, others_v2) + forms_v1, other_documents, warnings, repeats = _map_statement_docs( + [doc for doc in result.documents if isinstance(doc, StatementDocument)], + ordinals=ordinals, + file=file, + owner=owner, + owner_names=owner_names, + now=now, + ) + documents = [*other_documents, *(document for document, _ in forms_v1)] + return _FormsPlan(documents, warnings, repeats, forms_v1, [], []) + + +async def _insert_forms( # noqa: PLR0913 - все входы именованные + session: AsyncSession, + plan: _FormsPlan, + *, + job: RecognitionJob, + result: RecognitionResult | RecognitionResultV2, + job_done: bool, + now: datetime, + engine: StatementsEngine | None, +) -> None: + """Вставка форм: `2.0` — как пришли (`insert_forms_v2`), `1.x` — `_insert_statements`.""" + if isinstance(result, RecognitionResultV2): + await insert_forms_v2( + session, + job=job, + result=result, + forms=plan.forms_v2, + others=plan.others_v2, + repeats=plan.repeats, + engine=engine or default_engine(), + now=now, + ) + return + await _insert_statements( + session, job=job, forms=plan.forms_v1, repeats=plan.repeats, job_done=job_done, now=now, engine=engine + ) + + async def map_file( # noqa: PLR0913 - все входы маппинга файла именованные session: AsyncSession, *, job: RecognitionJob, file: StoredFile, - result: RecognitionResult, + result: RecognitionResult | RecognitionResultV2, chart: Mapping[str, uuid.UUID], index: CounterpartyIndex, now: datetime, + engine: StatementsEngine | None = None, ) -> list[str]: """Маппинг результата в транзакции вызывающего (D-3, D-6…D-10); возвращает предупреждения маппинга файла. + Маппер выбирается по типу результата: `RecognitionResultV2` — формы по контракту `2.0` + (`services/statement_intake_v2`, `job.statements_version` = `service.statements_version`), `RecognitionResult` — + `1.x` (`_map_statement_docs`). + Очистка прежних документов/строк файла → документ на каждый лист ОСВ (повтор счёта — предупреждение) и на каждую форму ББ/ОФР (`_map_statement_docs`) → строки ОСВ пакетами по `ENTRY_BATCH_SIZE` → вставка форм и их статус → `job.group_schema`, `metrics.mapping_warnings` → файл `PROCESSED` (только из @@ -467,7 +549,6 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай if not result.documents: raise MappingFatal(NO_DOCUMENTS) sheets = _osv_documents(result) - statement_docs = [doc for doc in result.documents if isinstance(doc, StatementDocument)] owner = await session.get(LegalEntity, file.legal_entity_id) if owner is None: raise MappingFatal(f"ЮЛ-владелец файла не найден: {file.legal_entity_id}") @@ -545,12 +626,9 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай for entry in mapped.entries ) - forms, other_documents, form_warnings, repeats = _map_statement_docs( - statement_docs, ordinals=ordinals, file=file, owner=owner, owner_names=owner_names, now=now - ) - warnings.extend(form_warnings) - documents.extend(other_documents) - documents.extend(document for document, _ in forms) + plan = _plan_forms(result, job=job, ordinals=ordinals, file=file, owner=owner, owner_names=owner_names, now=now) + documents.extend(plan.documents) + warnings.extend(plan.warnings) session.add_all(documents) await session.flush() @@ -562,7 +640,7 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай for start in range(0, len(entries), ENTRY_BATCH_SIZE): await session.execute(insert(OsvEntry).values(entries[start : start + ENTRY_BATCH_SIZE])) - await _insert_statements(session, job=job, forms=forms, repeats=repeats, job_done=job_done, now=now) + await _insert_forms(session, plan, job=job, result=result, job_done=job_done, now=now, engine=engine) job.group_schema = [_group_schema(sheet) for sheet in sheets] job.metrics = {**_metrics(job), MAPPING_WARNINGS_KEY: warnings} @@ -899,7 +977,10 @@ async def _register_failure( async def _map_candidate( - session_factory: async_sessionmaker[AsyncSession], candidate: Candidate, result: RecognitionResult, clock: Clock + session_factory: async_sessionmaker[AsyncSession], + candidate: Candidate, + result: RecognitionResult | RecognitionResultV2, + clock: Clock, ) -> None: """Шаг 3: транзакция маппинга. Задание и файл перечитываются под блокировкой (порядок «задание → файл»); файл уже не ждёт результата этого задания (его опередили, задание не текущее — Review Focus 1) — ничего не меняется.""" diff --git a/api/src/services/statement_intake_v2.py b/api/src/services/statement_intake_v2.py new file mode 100644 index 0000000..e44e668 --- /dev/null +++ b/api/src/services/statement_intake_v2.py @@ -0,0 +1,205 @@ +"""Приём документов форм ББ/ОФР/раскрытий из результата `2.0` (spec arm-core «Хранение результата формы v2.0», +«Условие RECOGNIZED при приёме 2.0», «Предупреждения и нарушения документа — document_issue»; Р-10, Р-16, Р-18, +Р-19, P3-6, P3-9, P3-15, P3-19, P3-39…). + +`map_statement_docs_v2` создаёт документы и черновики (до вставки), `insert_forms_v2` пишет форму, записи +`document_issue` и считает статус. Пересчётов §7, `needs_review` и предупреждений сервиса при приёме нет — они уже в +результате; ядро добавляет только проверки уровня `info` (`core_info_issues`) и страховку статуса. +""" + +import uuid +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.contract.result_v2 import RecognitionResultV2, StatementDocumentV2 +from src.domain.osv_mapping import counterparty_check +from src.domain.statement_issues import ( + IssueDraft, + core_info_issues, + mapping_warning, + result_status_inconsistent, + version_issue, +) +from src.domain.statement_lines import load_line_catalog +from src.domain.statement_mapping_v2 import StatementDraftV2, build_statement_v2, service_issues +from src.domain.statement_status import duplicate_period_forms, form_status_v2, has_error_warning +from src.domain.stored_form import edition_codes +from src.models.directories import LegalEntity +from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType +from src.models.files import Document, RecognitionJob, StoredFile +from src.repositories import statements as statements_repo +from src.services.statements_engine import DocStatus, FormVariant, StatementsEngine +from src.services.statements_engine import DocumentType as PackageDocumentType + +__all__ = ["FormV2", "OtherV2", "insert_forms_v2", "map_statement_docs_v2"] + + +@dataclass(frozen=True) +class FormV2: + """Форма `BALANCE` / `INCOME` результата: документ БД, черновик и исходный документ результата.""" + + document: Document + draft: StatementDraftV2 + source: StatementDocumentV2 + + +@dataclass(frozen=True) +class OtherV2: + """Объединённый документ `DISCLOSURE` файла (P3-6) и документы результата, из которых он собран.""" + + document: Document + sources: tuple[StatementDocumentV2, ...] + + +def map_statement_docs_v2( # noqa: PLR0913 - все входы именованные + statement_docs: Sequence[StatementDocumentV2], + *, + ordinals: Mapping[int, int], + file: StoredFile, + owner: LegalEntity, + owner_names: frozenset[str], + now: datetime, +) -> tuple[list[FormV2], list[OtherV2], list[str], dict[uuid.UUID, str]]: + """Документы форм и `DISCLOSURE` результата `2.0`. Возвращает формы, объединённые `DISCLOSURE` (один документ на + файл, `locator` через запятую, `file_ordinal` — индекс первого, статус `RECOGNIZED`, P3-6), предупреждения маппинга + для `metrics.mapping_warnings` и текст «другая форма» по `id` документов форм одного типа и периода (Р-10, Р-18: + хранятся все, каждый `NEEDS_CHECK`). `ordinals` — `id(документ результата)` → индекс в `result.documents`.""" + forms: list[FormV2] = [] + disclosures: list[StatementDocumentV2] = [] + warnings: list[str] = [] + for doc in statement_docs: + doc_type = DocumentType(doc.document_type) + if doc_type is DocumentType.DISCLOSURE: + disclosures.append(doc) + continue + draft = build_statement_v2(doc) + warnings.extend(draft.warnings) + document = Document( + id=uuid.uuid4(), + stored_file_id=file.id, + legal_entity_id=file.legal_entity_id, + document_type=doc_type, + file_ordinal=ordinals[id(doc)], + osv_account_code=None, + period_year=draft.period_year, + period_quarter=draft.period_quarter, + is_cumulative=None, + reporting_date=draft.period_date, + locator=doc.locator, + status=DocumentStatus.NEEDS_CHECK, # уточняется после вставки формы + period_check=DocumentCheckResult.UNKNOWN, + counterparty_check=counterparty_check(doc.header.inn, doc.header.organization_name, owner.inn, owner_names), + created_at=now, + updated_at=now, + ) + forms.append(FormV2(document, draft, doc)) + duplicated = duplicate_period_forms( + (form.document.id, form.document.document_type, form.document.period_year, form.document.period_quarter) + for form in forms + ) + repeats: dict[uuid.UUID, str] = {} + for form in forms: + if form.document.id in duplicated: + text = f"в файле есть другая форма {form.document.document_type.value} за тот же период" + repeats[form.document.id] = text + warnings.append(f"{form.document.locator}: {text}") + others: list[OtherV2] = [] + if disclosures: + others.append( + OtherV2( + Document( + id=uuid.uuid4(), + stored_file_id=file.id, + legal_entity_id=file.legal_entity_id, + document_type=DocumentType.DISCLOSURE, + file_ordinal=ordinals[id(disclosures[0])], + locator=",".join(doc.locator for doc in disclosures), + status=DocumentStatus.RECOGNIZED, + period_check=DocumentCheckResult.UNKNOWN, + created_at=now, + updated_at=now, + ), + tuple(disclosures), + ) + ) + return forms, others, warnings, repeats + + +def _file_warnings(result: RecognitionResultV2) -> list[dict[str, object]]: + """`metrics.warnings` файла результата структурами (P3-15).""" + if result.metrics is None or result.metrics.warnings is None: + return [] + return [warning.model_dump(mode="json") for warning in result.metrics.warnings] + + +async def insert_forms_v2( # noqa: PLR0913 - все входы именованные + session: AsyncSession, + *, + job: RecognitionJob, + result: RecognitionResultV2, + forms: Sequence[FormV2], + others: Sequence[OtherV2], + repeats: Mapping[uuid.UUID, str], + engine: StatementsEngine, + now: datetime, +) -> None: + """Форма как пришла, `document_issue` и статус каждого документа формы; предупреждения `DISCLOSURE` — в + `document_issue` его единственного документа. Без commit.""" + file_error = has_error_warning(_file_warnings(result)) + catalog = load_line_catalog() + for other in others: + drafts: list[IssueDraft] = [] + for source in other.sources: + drafts += service_issues(source, line_ids_by_ref={}, column_ids_by_index={}, ok_line_ids_by_code={}) + await statements_repo.add_issues(session, other.document.id, drafts, now=now) + for form in forms: + document, source = form.document, form.source + meta = await statements_repo.insert_statement(session, document_id=document.id, job_id=job.id, draft=form.draft) + columns, aggregates = await statements_repo.form_aggregate(session, meta.id) + lines = await statements_repo.form_lines(session, meta.id) + + package_type = PackageDocumentType(source.document_type) + variant = FormVariant(source.form_variant) if source.form_variant is not None else None + edition = engine.edition(package_type, edition_codes(source), document.reporting_date, variant) + order = ( + engine.code_order(edition, package_type, variant) if edition is not None and variant is not None else None + ) + info = core_info_issues( + lines, + form=document.document_type.value, + edition=edition.value if edition is not None else None, + order=order, + catalog=catalog, + ) + engine_done = engine.contract_document_status(source.model_dump(mode="json")) is DocStatus.DONE + inconsistent = source.status == "done" and not engine_done + + warnings = [*form.draft.warnings, *([repeats[document.id]] if document.id in repeats else [])] + drafts = [ + *(mapping_warning(text) for text in warnings), + *service_issues( + source, + line_ids_by_ref={line.source_ref: line.id for line in lines if line.source_ref is not None}, + column_ids_by_index={column.column_index: column.id for column in columns}, + ok_line_ids_by_code={code: aggregate.line_ids for code, aggregate in aggregates.items()}, + ), + *info.drafts, + ] + mismatch = version_issue(job.statements_version, engine.version) + if mismatch is not None: + drafts.append(mismatch) + if inconsistent: + drafts.append(result_status_inconsistent(source.status)) + await statements_repo.add_issues(session, document.id, drafts, now=now) + + document.status = form_status_v2( + service_done=source.status == "done" and engine_done, + file_error_warning=file_error, + counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN, + period_known=document.period_year is not None and document.period_quarter is not None, + names_ok=not info.mismatched, + duplicate_in_file=document.id in repeats, + ) diff --git a/api/src/services/statement_recompute.py b/api/src/services/statement_recompute.py index 9fd5212..17b7aeb 100644 --- a/api/src/services/statement_recompute.py +++ b/api/src/services/statement_recompute.py @@ -6,24 +6,29 @@ в приёме результата (B3/B4); экран и подтверждение читают его из `document_issue`. """ -import re -import uuid -from collections.abc import Hashable, Mapping, Sequence -from decimal import Decimal +from collections.abc import Mapping, Sequence from sqlalchemy.ext.asyncio import AsyncSession from src.common.clock import utc_now from src.domain.statement_aggregate import CodeAggregate -from src.domain.statement_issues import IssueDraft -from src.domain.statement_lines import load_line_catalog -from src.domain.statement_status import ( - NameCheckLine, - duplicate_period_forms, - form_status_v2, - has_error_warning, - name_mismatches, +from src.domain.statement_issues import ( + CODE_NOT_IN_DICTIONARY, + CODE_NOT_IN_EDITION, + NAME_MISMATCH, + RECOMPUTED_CORE_CODES, + RELATION_FAILED, + RELATION_SOFT, + STATEMENTS_VERSION_MISMATCH, + IssueDraft, + code_issues, + core_info_issues, + name_issues, + relation_message, + version_issue, ) +from src.domain.statement_lines import load_line_catalog +from src.domain.statement_status import duplicate_period_forms, form_status_v2, has_error_warning from src.domain.stored_form import build_stored_form from src.models.enums import ( DocumentCheckResult, @@ -35,7 +40,7 @@ from src.models.enums import ( StatementLineStatus, ) from src.models.files import Document, RecognitionJob -from src.models.statements import FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta +from src.models.statements import FinancialStatementColumn, FinancialStatementMeta from src.repositories import statements as repo from src.services.statements_engine import FailedRelation, StatementChecks, StatementsEngine @@ -49,27 +54,14 @@ __all__ = [ "STATEMENTS_VERSION_MISMATCH", "checks_on_intake_1x", "code_issues", + "core_info_issues", "name_issues", "recompute_form", "relations_to_issues", "version_issue", ] -RELATION_FAILED = "RELATION_FAILED" -RELATION_SOFT = "RELATION_SOFT" -CODE_NOT_IN_DICTIONARY = "CODE_NOT_IN_DICTIONARY" -CODE_NOT_IN_EDITION = "CODE_NOT_IN_EDITION" -NAME_MISMATCH = "NAME_MISMATCH" -STATEMENTS_VERSION_MISMATCH = "STATEMENTS_VERSION_MISMATCH" -# записи `core`, которые пересчёт заменяет целиком (P3-8); прочие (`MAPPING_WARNING`, `RESULT_STATUS_INCONSISTENT`, -# `LEGACY_WARNING`) — факт приёма, их пересчёт сохраняет -RECOMPUTED_CORE_CODES = (CODE_NOT_IN_DICTIONARY, CODE_NOT_IN_EDITION, NAME_MISMATCH, STATEMENTS_VERSION_MISMATCH) -_CHECKED_CODE = re.compile(r"\d{4}", re.ASCII) # R-1 (Р-40): проверяются только четырёхзначные коды -_EMPTY = "пусто" - - -def _amount_text(value: Decimal | None) -> str: - return _EMPTY if value is None else str(value) +# коды и проверки ядра `info` живут в `domain/statement_issues` (P3-39); здесь они реэкспортируются (`__all__`) def _find_column( @@ -114,10 +106,7 @@ def relations_to_issues( kind=kind, code=code, severity=severity, - message=( - f"не сошлось {failure.relation}: " - f"{_amount_text(failure.expected)} ≠ {_amount_text(failure.actual)}" - ), + message=relation_message(failure.relation, failure.expected, failure.actual), source=source, line_ids=tuple(line_ids), column_id=column.id if column is not None else None, @@ -131,82 +120,6 @@ def relations_to_issues( return drafts -def _core_info(code: str, message: str, line_ids: tuple[uuid.UUID, ...] = ()) -> IssueDraft: - return IssueDraft( - kind=DocumentIssueKind.warning, - code=code, - severity=IssueSeverity.info, - message=message, - source=IssueSource.core, - line_ids=line_ids, - ) - - -def code_issues( - lines: Sequence[FinancialStatementLine], - *, - in_dictionary: Mapping[str, bool], - in_edition: Mapping[str, bool] | None, -) -> list[IssueDraft]: - """`CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` — по записи на код документа (`line_ids` — все строки `ok` с - этим кодом), только для кодов из ровно четырёх цифр (R-1, Р-40). `in_dictionary` — код есть в словаре имён редакции, - `in_edition` — код из перечня редакции и варианта (`None` — редакция или вариант не определены, не проверяется).""" - by_code: dict[str, list[FinancialStatementLine]] = {} - for line in lines: - if line.line_status is StatementLineStatus.ok and line.line_code and _CHECKED_CODE.fullmatch(line.line_code): - by_code.setdefault(line.line_code, []).append(line) - drafts: list[IssueDraft] = [] - for code in sorted(by_code): - ids = tuple( - line.id for line in sorted(by_code[code], key=lambda item: (item.row_index is None, item.row_index or 0)) - ) - if not in_dictionary.get(code, True): - drafts.append(_core_info(CODE_NOT_IN_DICTIONARY, f"кода {code} нет в словаре наименований редакции", ids)) - if in_edition is not None and not in_edition.get(code, True): - drafts.append(_core_info(CODE_NOT_IN_EDITION, f"код {code} не входит в перечень кодов редакции формы", ids)) - return drafts - - -def name_issues(lines: Sequence[FinancialStatementLine], mismatched: Sequence[Hashable]) -> list[IssueDraft]: - """`NAME_MISMATCH` — по записи на строку из `name_mismatches`.""" - by_id = {line.id: line for line in lines} - drafts: list[IssueDraft] = [] - for key in mismatched: - line = by_id[key] # type: ignore[index] - drafts.append( - _core_info( - NAME_MISMATCH, - f"наименование «{line.line_name or ''}» не совпало со справочным для кода {line.line_code}", - (line.id,), - ) - ) - return drafts - - -def version_issue(job_version: str | None, engine_version: str | None) -> IssueDraft | None: - """`STATEMENTS_VERSION_MISMATCH`: версия копии пакета у задания известна и не равна версии копии ядра.""" - if job_version is None or job_version == engine_version: - return None - return _core_info( - STATEMENTS_VERSION_MISMATCH, - f"форма распознана общим кодом версии {job_version}, в ядре версия {engine_version or 'не определена'}", - ) - - -def _name_lines(lines: Sequence[FinancialStatementLine]) -> list[NameCheckLine]: - return [ - NameCheckLine( - key=line.id, - line_status=line.line_status.value, - line_code=line.line_code, - line_code_confidence=line.line_code_confidence, - line_name=line.line_name, - code_fixed=bool(line.line_code_modified) or bool(line.is_confirmed), - ) - for line in lines - ] - - async def recompute_form( session: AsyncSession, *, @@ -235,20 +148,14 @@ async def recompute_form( form = document.document_type.value catalog = load_line_catalog() edition = result.edition.value if result.edition is not None else None - in_dictionary = { - line.line_code: catalog.reference_name(edition, form, line.line_code) is not None - for line in lines - if line.line_code is not None - } - in_edition: dict[str, bool] | None = None - if result.edition is not None and result.form_variant is not None: - order = set(engine.code_order(result.edition, stored.document_type, result.form_variant)) - in_edition = {line.line_code: line.line_code in order for line in lines if line.line_code is not None} - mismatched = name_mismatches(_name_lines(lines), catalog, form) - drafts = [ - *(code_issues(lines, in_dictionary=in_dictionary, in_edition=in_edition) if edition is not None else []), - *name_issues(lines, mismatched), - ] + order = ( + engine.code_order(result.edition, stored.document_type, result.form_variant) + if result.edition is not None and result.form_variant is not None + else None + ) + info = core_info_issues(lines, form=form, edition=edition, order=order, catalog=catalog) + mismatched = info.mismatched + drafts = list(info.drafts) mismatch = version_issue(job.statements_version if job is not None else None, engine.version) if mismatch is not None: drafts.append(mismatch) diff --git a/api/src/services/statements_engine.py b/api/src/services/statements_engine.py index 1c0e035..0ca01bd 100644 --- a/api/src/services/statements_engine.py +++ b/api/src/services/statements_engine.py @@ -32,7 +32,11 @@ from arm_statements import ( __all__ = [ "ArmStatementsEngine", + "DocStatus", + "DocumentType", + "Edition", "FailedRelation", + "FormVariant", "PeriodKind", "StatementChecks", "StatementsEngine", diff --git a/api/tests/factories_v2.py b/api/tests/factories_v2.py new file mode 100644 index 0000000..8d6aeeb --- /dev/null +++ b/api/tests/factories_v2.py @@ -0,0 +1,195 @@ +"""Фабрики результата распознавания контракта `2.0` для тестов маппера форм (B4): базовый документ ББ/ОФР, строки, +значения, колонки; `statement_result_v2` собирает `RecognitionResultV2` с переопределениями. Реальный пакет +`arm_statements` считает статус и редакцию, поэтому базовая форма «чистая»: уверенности 100, `needs_review = false`. +""" + +import copy +import uuid +from collections.abc import Callable, Mapping, Sequence +from pathlib import Path +from typing import Any + +from src.contract import RecognitionResultV2 + +__all__ = [ + "BALANCE_DATE", + "balance_lines", + "column_v2", + "header_v2", + "line_v2", + "result_builder", + "statement_doc_v2", + "statement_result_v2", + "value_v2", +] + +BALANCE_DATE = "2025-09-30" +OWNER_INN = "7700000000" +_NO_CHECKS: dict[str, Any] = {"relations_failed": [], "soft_relations_failed": []} + + +def header_v2(**overrides: Any) -> dict[str, Any]: + """Шапка с ИНН владельца по умолчанию (`counterparty_check = MATCH`), датой 30.09.2025 и тыс. руб.""" + header: dict[str, Any] = { + "form_name": "Бухгалтерский баланс", + "organization_name": "ООО Ромашка", + "organization_name_text": "ООО Ромашка", + "organization_name_confidence": 100, + "inn": OWNER_INN, + "inn_text": OWNER_INN, + "inn_confidence": 100, + "report_date": BALANCE_DATE, + "report_date_text": "30 сентября 2025 г.", + "report_date_confidence": 100, + "units": "тыс. руб.", + "units_text": "тыс. руб.", + "units_confidence": 100, + "needs_review_fields": [], + } + return {**header, **overrides} + + +def column_v2( + index: int = 2, *, date: str | None = BALANCE_DATE, kind: str = "absolute", label: str | None = None +) -> dict[str, Any]: + return { + "column_index": index, + "label": label or f"На {date}", + "label_confidence": 100, + "period_date": date, + "period_kind": kind, + } + + +def value_v2( # noqa: PLR0913 - все поля значения контракта + column_index: int = 2, + amount: str | None = "100", + *, + text: str | None = None, + confidence: float = 100, + is_recognized: bool = True, + needs_review: bool = False, +) -> dict[str, Any]: + return { + "column_index": column_index, + "amount": amount, + "text": text if text is not None else amount, + "confidence": confidence, + "is_recognized": is_recognized, + "needs_review": needs_review, + } + + +def line_v2( # noqa: PLR0913 - все поля строки контракта + row_index: int, + code: str | None, + name: str | None = "Строка", + *, + amount: str | None = "100", + status: str = "ok", + ref: str | None = None, + code_confidence: float | None = 100, + name_confidence: float | None = 100, + code_text: str | None = None, + parent_ref: str | None = None, + needs_review: bool = False, + values: Sequence[Mapping[str, Any]] | None = None, +) -> dict[str, Any]: + """Строка результата; без `values` — одно значение `amount` в колонке 2 (`amount=None` — значений нет).""" + if values is None: + values = [] if amount is None else [value_v2(2, amount)] + line: dict[str, Any] = { + "row_index": row_index, + "ref": ref or f"row:{row_index}", + "line_status": status, + "line_code": code, + "line_code_text": code_text if code_text is not None else code, + "line_code_confidence": code_confidence, + "line_name": name, + "line_name_confidence": name_confidence, + "needs_review": needs_review, + "values": [dict(value) for value in values], + } + if parent_ref is not None: + line["parent_ref"] = parent_ref + return line + + +def balance_lines(amount: str = "100") -> list[dict[str, Any]]: + """Минимальный чистый баланс (ключевые коды, имена как в словаре `fsbu4`).""" + names = [ + ("1210", "Запасы"), + ("1200", "Итого по разделу II"), + ("1600", "БАЛАНС"), + ("1300", "Итого по разделу III"), + ("1700", "БАЛАНС"), + ] + return [line_v2(index, code, name, amount=amount) for index, (code, name) in enumerate(names)] + + +def statement_doc_v2( # noqa: PLR0913 - все поля документа контракта + document_type: str = "BALANCE", + *, + lines: Sequence[Mapping[str, Any]] | None = None, + columns: Sequence[Mapping[str, Any]] | None = None, + header: Mapping[str, Any] | None = None, + status: str = "done", + form_variant: str | None = "full", + warnings: Sequence[Mapping[str, Any]] = (), + checks: Mapping[str, Any] | None = None, + locator: str | None = None, +) -> dict[str, Any]: + """Документ формы `2.0`; `checks` по умолчанию пустые (форма со строками, иначе поле не допускается).""" + doc_lines = [dict(line) for line in (balance_lines() if lines is None else lines)] + doc: dict[str, Any] = { + "document_type": document_type, + "locator": locator or f"file:test.json;table:{document_type}", + "status": status, + "form_variant": form_variant, + "warnings": [dict(warning) for warning in warnings], + "header": dict(header_v2() if header is None else header), + "columns": [dict(column) for column in ([column_v2()] if columns is None else columns)], + "lines": doc_lines, + } + if doc_lines and document_type != "DISCLOSURE": + doc["checks"] = copy.deepcopy(dict(_NO_CHECKS if checks is None else checks)) + elif checks is not None: + doc["checks"] = copy.deepcopy(dict(checks)) + return doc + + +def statement_result_v2( # noqa: PLR0913 - все поля результата + documents: Sequence[Mapping[str, Any]] | None = None, + *, + job_id: uuid.UUID | None = None, + file_id: uuid.UUID | None = None, + status: str = "done", + statements_version: str | None = "abc1234", + file_warnings: Sequence[Mapping[str, Any]] = (), + overrides: Mapping[str, Any] | None = None, +) -> RecognitionResultV2: + """Результат `2.0`: базовый словарь + переопределения верхнего уровня (`overrides`).""" + payload: dict[str, Any] = { + "schema_version": "2.0", + "job_id": str(job_id or uuid.uuid4()), + "file_id": str(file_id or uuid.uuid4()), + "service": {"name": "tabular-parser", "version": "test"}, + "status": status, + "metrics": {"warnings": [dict(warning) for warning in file_warnings]}, + "documents": [dict(doc) for doc in (documents if documents is not None else [statement_doc_v2()])], + } + if statements_version is not None: + payload["service"]["statements_version"] = statements_version + payload.update(overrides or {}) + return RecognitionResultV2.model_validate(payload) + + +def result_builder( + documents: Sequence[Mapping[str, Any]] | None = None, **kwargs: Any +) -> Callable[..., RecognitionResultV2]: + """`result_builder` для `map_golden_snapshot`: путь игнорируется, `job_id`/`file_id` подставляются.""" + + def build(_path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> RecognitionResultV2: + return statement_result_v2(documents, job_id=job_id, file_id=file_id, **kwargs) + + return build diff --git a/api/tests/test_domain_statement_mapping_v2.py b/api/tests/test_domain_statement_mapping_v2.py new file mode 100644 index 0000000..cd7004c --- /dev/null +++ b/api/tests/test_domain_statement_mapping_v2.py @@ -0,0 +1,249 @@ +"""Чистый маппинг формы контракта 2.0 в черновики (spec arm-core «Хранение результата формы v2.0», «Шапка формы», +«Предупреждения и нарушения документа»; P3-9, P3-39): `build_statement_v2`, `service_issues`, `core_info_issues`.""" + +import json +import uuid +from decimal import Decimal +from pathlib import Path + +from src.contract import RecognitionResultV2, StatementDocumentV2 +from src.domain.statement_issues import CODE_NOT_IN_DICTIONARY, CODE_NOT_IN_EDITION, NAME_MISMATCH, core_info_issues +from src.domain.statement_lines import load_line_catalog +from src.domain.statement_mapping_v2 import HEADER_SOURCE_FIELDS, build_statement_v2, service_issues +from src.models.enums import ( + DocumentIssueKind, + IssueSeverity, + IssueSource, + PeriodQuarter, + StatementFormVariant, + StatementLineStatus, +) +from src.models.statements import FinancialStatementLine +from tests.factories_v2 import column_v2, header_v2, line_v2, statement_doc_v2, statement_result_v2, value_v2 + +EXAMPLES = Path(__file__).resolve().parents[1] / "contracts" / "examples" +HEADER_FIELDS_COUNT = 10 + + +def _doc(**kwargs: object) -> StatementDocumentV2: + doc = statement_result_v2([statement_doc_v2(**kwargs)]).documents[0] # type: ignore[arg-type] + assert isinstance(doc, StatementDocumentV2) + return doc + + +def _example(name: str) -> RecognitionResultV2: + return RecognitionResultV2.model_validate(json.loads((EXAMPLES / name).read_text("utf-8"))) + + +def test_repeated_code_kept() -> None: + """Две строки `ok` 2410 (пустая и `(649)`) — две строки со своими row_index, ref и значениями.""" + lines = [ + line_v2(0, "2410", "Налог на прибыль", amount=None, ref="row:7"), + line_v2(1, "2410", "Налог на прибыль", amount="-649", ref="row:9"), + ] + draft = build_statement_v2(_doc(document_type="INCOME", lines=lines)) + assert [(line.row_index, line.source_ref, line.line_code) for line in draft.lines] == [ + (0, "row:7", "2410"), + (1, "row:9", "2410"), + ] + assert [(v.row_index, v.amount) for v in draft.values] == [(0, None), (1, Decimal("-649"))] + assert [line.line_code_original_value for line in draft.lines] == ["2410", "2410"] + assert not draft.warnings + + +def test_line_without_code() -> None: + """`unreadable` с текстом кода: код и его исходное значение `None`, статус, row_index и текст — как в результате.""" + line = line_v2(7, None, "Финансовые вложения", status="unreadable", code_text="ИЧ,250", code_confidence=25) + draft = build_statement_v2(_doc(lines=[line])) + (stored,) = draft.lines + assert stored.line_code is None and stored.line_code_original_value is None + assert (stored.line_status, stored.row_index, stored.line_code_text) == ("unreadable", 7, "ИЧ,250") + assert stored.line_code_confidence == Decimal(25) + assert draft.values[0].amount == Decimal(100) and draft.values[0].line_code is None + + +def test_detail_parent_row_index() -> None: + """`parent_row_index` — `row_index` строки с `ref == parent_ref`; потерянный родитель — `None` и предупреждение.""" + lines = [ + line_v2(0, "2110", "Выручка", ref="r:a"), + line_v2(1, None, "в том числе", status="detail", ref="r:b", parent_ref="r:a", code_confidence=None), + line_v2(2, None, "сирота", status="detail", ref="r:c", parent_ref="r:missing", code_confidence=None), + ] + draft = build_statement_v2(_doc(document_type="INCOME", lines=lines)) + assert [line.parent_row_index for line in draft.lines] == [None, 0, None] + assert [line.line_status for line in draft.lines] == ["ok", "detail", "detail"] + assert any("r:missing" in warning for warning in draft.warnings) + + +def test_value_for_every_cell_and_empty_record() -> None: + """Значение на каждую пару «строка × колонка»; отсутствующее — пустая запись с `needs_review = false`.""" + columns = [column_v2(2), column_v2(3, date="2024-12-31")] + lines = [line_v2(0, "1210", "Запасы", values=[value_v2(2, "100", text="1 00", needs_review=True)])] + draft = build_statement_v2(_doc(lines=lines, columns=columns)) + first, empty = draft.values + assert (first.column_index, first.amount, first.source_text, first.needs_review) == (2, Decimal(100), "1 00", True) + assert (empty.column_index, empty.amount, empty.source_text, empty.confidence) == (3, None, None, None) + assert empty.needs_review is False and empty.is_recognized is None + + +def test_columns_carry_period_kind_and_current_flag() -> None: + columns = [ + column_v2(2, kind="relative:current"), + column_v2(3, date="2024-12-31", kind="relative:prev_year_end"), + column_v2(4, date=None, kind="none", label="Примечание"), + ] + draft = build_statement_v2(_doc(columns=columns, lines=[line_v2(0, "1210")])) + assert [(c.column_index, c.period_kind, c.is_current) for c in draft.columns] == [ + (2, "relative:current", True), + (3, "relative:prev_year_end", False), + (4, "none", False), + ] + assert all(c.label_confidence == Decimal(100) for c in draft.columns) + + +def test_duplicate_column_and_cells_first_kept_with_warning() -> None: + """P3-9: повтор `column_index` колонки и ячейки, значение вне колонок — первое остаётся, предупреждения.""" + columns = [column_v2(2), column_v2(2, date="2024-12-31")] + values = [value_v2(2, "100"), value_v2(2, "999"), value_v2(9, "5")] + draft = build_statement_v2(_doc(columns=columns, lines=[line_v2(0, "1210", values=values)])) + assert len(draft.columns) == 1 and draft.columns[0].period_date is not None + assert [v.amount for v in draft.values] == [Decimal(100)] + assert len(draft.warnings) == 3 # noqa: PLR2004 - колонка, повтор ячейки, ячейка вне колонок + + +def test_header_source_and_original_values() -> None: + """`header_source` — `{text, confidence}` для 10 полей; `*_original_value` — сырой текст / значение как пришло.""" + header = header_v2( + organization_name="ООО «Ромашка»", + organization_name_text="ООО «Ромашкa»", + organization_name_confidence=67, + inn_text="77 00000000", + inn_confidence=80, + report_date_text="30 сентября 2025 г.", + units_text="тыс.руб", + needs_review_fields=["organization_name"], + okei="384", + okei_text="384", + ) + draft = build_statement_v2(_doc(header=header)) + meta = draft.header + source = meta["header_source"] + assert list(source) == list(HEADER_SOURCE_FIELDS) and len(source) == HEADER_FIELDS_COUNT + assert source["organization_name"] == {"text": "ООО «Ромашкa»", "confidence": 67} + assert source["inn"] == {"text": "77 00000000", "confidence": 80} + assert meta["inn_original_value"] == "7700000000" and meta["inn_confidence"] == Decimal(80) + assert meta["inn_verified"] is False + assert meta["report_date_original_value"] == "30 сентября 2025 г." + assert meta["units_original_value"] == "тыс.руб" and meta["units"] == "тыс. руб." + assert meta["organization_name_original_value"] == "ООО «Ромашка»" and meta["organization_name_modified"] is False + assert meta["needs_review_fields"] == ["organization_name"] and meta["okei"] == "384" + assert meta["form_variant"] is StatementFormVariant.full and draft.form_variant is StatementFormVariant.full + + +def test_period_from_report_date() -> None: + draft = build_statement_v2(_doc()) + assert (draft.period_year, draft.period_quarter) == (2025, PeriodQuarter.Q3) + mid = build_statement_v2(_doc(header=header_v2(report_date="2025-08-15"), columns=[column_v2(2, date=None)])) + assert (mid.period_year, mid.period_quarter) == (2025, None) + assert any("не конец квартала" in warning for warning in mid.warnings) + + +def test_example_minimal() -> None: + """Пример docs `result-statement-minimal.json`: `unreadable` без кода, текст кода и документ `needs_check`.""" + result = _example("result-statement-minimal.json") + doc = result.documents[0] + assert isinstance(doc, StatementDocumentV2) and doc.status == "needs_check" + draft = build_statement_v2(doc) + by_row = {line.row_index: line for line in draft.lines} + assert by_row[2].line_status == "unreadable" and by_row[2].line_code is None + assert by_row[2].line_code_text == "ИЧ,250" and by_row[2].source_ref == "row:3" + assert [line.source_ref for line in draft.lines][5:7] == ["row:14#1", "row:14#2"] + assert not draft.warnings + + +def test_example_excel() -> None: + """Пример docs `result-statement-excel.json`: `detail` с родителем, `DISCLOSURE` без строк.""" + result = _example("result-statement-excel.json") + balance, income, disclosure = result.documents + assert isinstance(income, StatementDocumentV2) and isinstance(balance, StatementDocumentV2) + draft = build_statement_v2(income) + detail = next(line for line in draft.lines if line.line_status == "detail") + assert detail.parent_row_index == 0 and detail.line_code is None + assert isinstance(disclosure, StatementDocumentV2) and not disclosure.lines + + +def test_service_issues_warnings_and_checks() -> None: + """`line_refs` → `line_ids`; `column_index` → `column_id`; `relation` / `soft` с `codes`, `expected`, `actual`.""" + ids = {"r0": uuid.uuid4(), "r1": uuid.uuid4()} + column = uuid.uuid4() + checks = { + "relations_failed": [ + { + "column_index": 2, + "relation": "1600 = 1700", + "codes": ["1600", "1700"], + "expected": None, + "actual": "5.00", + } + ], + "soft_relations_failed": [ + {"column_index": 2, "relation": "1110 = 11101", "codes": ["1110"], "expected": "9.00", "actual": "8.00"} + ], + } + warnings = [ + {"code": "ROW_SPLIT", "severity": "info", "message": "разлеплена", "line_refs": ["r0", "nope", "r1"]}, + {"code": "COLUMN_DATE_UNCERTAIN", "severity": "error", "message": "дата", "column_index": 2}, + {"code": "COLUMN_SKIPPED", "severity": "info", "message": "пропущена", "column_index": 5}, + {"code": "ORGANIZATION_UNCERTAIN", "severity": "info", "message": "орг", "field": "organization_name"}, + ] + doc = _doc(lines=[line_v2(0, "1600", ref="r0")], warnings=warnings, checks=checks) + ok_ids = {"1600": (ids["r0"],), "1700": (ids["r1"],)} + drafts = service_issues(doc, line_ids_by_ref=ids, column_ids_by_index={2: column}, ok_line_ids_by_code=ok_ids) + split, uncertain, skipped, org, hard, soft = drafts + assert all(d.source is IssueSource.service for d in drafts) + assert split.line_ids == (ids["r0"], ids["r1"]) and split.kind is DocumentIssueKind.warning + assert (uncertain.column_id, uncertain.column_index, uncertain.severity) == (column, None, IssueSeverity.error) + assert (skipped.column_id, skipped.column_index) == (None, 5) + assert org.field == "organization_name" + assert (hard.kind, hard.code, hard.severity) == (DocumentIssueKind.relation, "RELATION_FAILED", IssueSeverity.error) + assert hard.expected is None and hard.actual == Decimal("5.00") and hard.codes == ("1600", "1700") + assert hard.line_ids == (ids["r0"], ids["r1"]) and hard.column_id == column and hard.column_index == 2 # noqa: PLR2004 + assert (soft.kind, soft.code, soft.severity) == (DocumentIssueKind.soft, "RELATION_SOFT", IssueSeverity.info) + assert "пусто ≠ 5.00" in hard.message + + +def _db_line(code: str, name: str, confidence: int | None, row: int) -> FinancialStatementLine: + return FinancialStatementLine( + id=uuid.uuid4(), + meta_id=uuid.uuid4(), + line_code=code, + line_name=name, + line_code_confidence=None if confidence is None else Decimal(confidence), + line_code_modified=False, + is_confirmed=False, + row_index=row, + line_status=StatementLineStatus.ok, + ) + + +def test_core_info_issues_rules() -> None: + """Четырёхзначные коды вне словаря/перечня — по записи на код; пятизначные не проверяются (R-1); без редакции + `CODE_NOT_IN_*` нет, `NAME_MISMATCH` остаётся; без перечня нет `CODE_NOT_IN_EDITION`.""" + catalog = load_line_catalog() + lines = [ + _db_line("1999", "Чужая строка", 100, 0), + _db_line("1999", "Чужая строка ещё", 100, 1), + _db_line("11101", "Расшифровка", 100, 2), + _db_line("1210", "Нематериальные активы", 50, 3), + ] + info = core_info_issues(lines, form="BALANCE", edition="fsbu4", order=("1110", "1210"), catalog=catalog) + by_code = {d.code: d for d in info.drafts} + assert by_code[CODE_NOT_IN_DICTIONARY].line_ids == (lines[0].id, lines[1].id) + assert by_code[CODE_NOT_IN_EDITION].line_ids == (lines[0].id, lines[1].id) + assert [d.code for d in info.drafts].count(CODE_NOT_IN_DICTIONARY) == 1 # пятизначный 11101 не проверяется + assert by_code[NAME_MISMATCH].line_ids == (lines[3].id,) and info.mismatched == [lines[3].id] + + no_edition = core_info_issues(lines, form="BALANCE", edition=None, order=None, catalog=catalog) + assert {d.code for d in no_edition.drafts} == {NAME_MISMATCH} + no_order = core_info_issues(lines, form="BALANCE", edition="fsbu4", order=None, catalog=catalog) + assert {d.code for d in no_order.drafts} == {CODE_NOT_IN_DICTIONARY, NAME_MISMATCH} diff --git a/api/tests/test_mapping_statements_v2.py b/api/tests/test_mapping_statements_v2.py new file mode 100644 index 0000000..465fa7f --- /dev/null +++ b/api/tests/test_mapping_statements_v2.py @@ -0,0 +1,432 @@ +"""Маппер результата 2.0 для форм ББ/ОФР/раскрытий (spec arm-core «Хранение результата формы v2.0», «Условие +RECOGNIZED при приёме 2.0», «Предупреждения и нарушения документа — document_issue»; Р-10, Р-16, Р-18, Р-19, P3-6, P3-9, +P3-15, P3-39): реальный пакет `arm_statements`, `map_file` на БД теста.""" + +import json +import uuid +from collections.abc import Sequence +from decimal import Decimal +from http import HTTPStatus +from pathlib import Path +from typing import Any + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.common.clock import utc_now +from src.contract import RecognitionResultV2 +from src.models.directories import LegalEntity, RegionalOffice +from src.models.enums import DocumentIssueKind, DocumentStatus, DocumentType, IssueSource, PeriodQuarter +from src.models.files import Document, RecognitionJob, StoredFile +from src.models.statements import ( + DocumentIssue, + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) +from src.services.auto_confirm import auto_confirm +from src.services.statements_engine import DocumentType as DocumentType_ +from src.services.statements_engine import Edition, FormVariant, default_engine +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_legal_entity +from tests.factories_v2 import ( + OWNER_INN, + balance_lines, + column_v2, + header_v2, + line_v2, + result_builder, + statement_doc_v2, + value_v2, +) +from tests.fixtures_helpers import map_golden_snapshot + +pytestmark = pytest.mark.integration + +EXAMPLES = Path(__file__).resolve().parents[1] / "contracts" / "examples" +KA_RF = "ka_rf@armka.local" +ROSTOV = "Ростовский региональный филиал" +V2_PATH = Path("result-v2.json") +HEADER_FIELDS_COUNT = 10 +ERROR_JSON_FAILED = {"code": "JSON_FAILED", "severity": "error", "message": "потерян json"} +ERROR_TABLE_PARSE = {"code": "TABLE_PARSE_ERROR", "severity": "error", "message": "таблица не разобрана"} +INCOME_LINES = [line_v2(0, "2110", "Выручка"), line_v2(1, "2100", "Валовая прибыль (убыток)")] + + +async def _entity(session: AsyncSession, *, inn: str = OWNER_INN, rostov: bool = False) -> LegalEntity: + if not rostov: + return await make_legal_entity(session, name="ООО Владелец", inn=inn) + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group = await make_company_group(session, name="ГК Владелец", office=office) + return await make_legal_entity(session, name="ООО Владелец", inn=inn, group=group) + + +async def _map( + session: AsyncSession, entity: LegalEntity, documents: Sequence[dict[str, Any]], **kwargs: Any +) -> list[Document]: + """Документы файла по `file_ordinal`.""" + await map_golden_snapshot( + session, entity=entity, path=V2_PATH, uploaded_by=None, result_builder=result_builder(documents, **kwargs) + ) + stored = (await session.execute(select(StoredFile).where(StoredFile.legal_entity_id == entity.id))).scalars().all() + rows = await session.execute( + select(Document) + .where(Document.stored_file_id.in_([file.id for file in stored])) + .order_by(Document.file_ordinal) + ) + return list(rows.scalars()) + + +async def _issues(session: AsyncSession, document: Document) -> list[DocumentIssue]: + stmt = select(DocumentIssue).where(DocumentIssue.document_id == document.id).order_by(DocumentIssue.created_at) + return list((await session.execute(stmt)).scalars()) + + +async def _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta: + stmt = select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id) + return (await session.execute(stmt)).scalar_one() + + +async def _lines(session: AsyncSession, document: Document) -> list[FinancialStatementLine]: + meta = await _meta(session, document) + stmt = ( + select(FinancialStatementLine) + .where(FinancialStatementLine.meta_id == meta.id) + .order_by(FinancialStatementLine.row_index) + ) + return list((await session.execute(stmt)).scalars()) + + +async def _confirm_clean(session: AsyncSession, documents: Sequence[Document]) -> None: + file = await session.get(StoredFile, documents[0].stored_file_id) + assert file is not None + await auto_confirm(session, file=file, documents=documents, now=utc_now()) + + +def _example_builder(name: str): # noqa: ANN202 - фабрика `result_builder` golden-хелпера + def build(_path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> RecognitionResultV2: + payload = json.loads((EXAMPLES / name).read_text("utf-8")) + return RecognitionResultV2.model_validate({**payload, "job_id": str(job_id), "file_id": str(file_id)}) + + return build + + +async def test_example_minimal(seeded: AsyncSession) -> None: + """Пример docs `result-statement-minimal.json`: `unreadable` без кода, `ROW_SPLIT` и `ORGANIZATION_UNCERTAIN`.""" + entity = await _entity(seeded) + await map_golden_snapshot( + seeded, + entity=entity, + path=V2_PATH, + uploaded_by=None, + result_builder=_example_builder("result-statement-minimal.json"), + ) + (balance,) = (await seeded.execute(select(Document).where(Document.legal_entity_id == entity.id))).scalars() + assert balance.status is DocumentStatus.NEEDS_CHECK and balance.file_ordinal == 0 + lines = await _lines(seeded, balance) + unreadable = next(line for line in lines if line.line_status.value == "unreadable") + assert unreadable.line_code is None and unreadable.line_code_original_value is None + assert unreadable.line_code_text == "ИЧ,250" and unreadable.row_index == 2 # noqa: PLR2004 + issues = {issue.code: issue for issue in await _issues(seeded, balance)} + split = issues["ROW_SPLIT"] + split_refs = {line.id for line in lines if line.source_ref in ("row:14#1", "row:14#2")} + assert set(split.line_ids or ()) == {str(line_id) for line_id in split_refs} and len(split_refs) == 2 # noqa: PLR2004 + assert issues["ORGANIZATION_UNCERTAIN"].field == "organization_name" + assert all(issue.source is IssueSource.service for issue in (split, issues["ORGANIZATION_UNCERTAIN"])) + + +async def test_example_excel(seeded: AsyncSession) -> None: + """Пример docs `result-statement-excel.json`: `BALANCE` с `RELATION_SOFT`, `INCOME` с `detail` и пустой целью, + один `DISCLOSURE` без строк.""" + entity = await _entity(seeded) + await map_golden_snapshot( + seeded, + entity=entity, + path=V2_PATH, + uploaded_by=None, + result_builder=_example_builder("result-statement-excel.json"), + ) + documents = list( + (await seeded.execute(select(Document).where(Document.legal_entity_id == entity.id).order_by("file_ordinal"))) + .scalars() + .all() + ) + balance, income, disclosure = documents + assert [d.document_type for d in documents] == [DocumentType.BALANCE, DocumentType.INCOME, DocumentType.DISCLOSURE] + soft = next(issue for issue in await _issues(seeded, balance) if issue.kind is DocumentIssueKind.soft) + assert (soft.code, soft.relation, soft.codes) == ("RELATION_SOFT", "1110 = 11101", ["1110", "11101"]) + assert soft.expected == Decimal("905786.99") and soft.actual == Decimal("900000.00") and soft.column_id is not None + hard = next(issue for issue in await _issues(seeded, income) if issue.kind is DocumentIssueKind.relation) + assert hard.expected is None and hard.actual == Decimal("400.00") + lines = await _lines(seeded, income) + detail = next(line for line in lines if line.line_status.value == "detail") + assert detail.line_code is None and detail.parent_row_index == lines[0].row_index == 0 + assert disclosure.status is DocumentStatus.RECOGNIZED and disclosure.file_ordinal == 2 # noqa: PLR2004 + assert not await _lines_count(seeded, disclosure) + + +async def _lines_count(session: AsyncSession, document: Document) -> int: + meta = ( + await session.execute( + select(FinancialStatementMeta.id).where(FinancialStatementMeta.document_id == document.id) + ) + ).scalar_one_or_none() + return 0 if meta is None else len(await _lines(session, document)) + + +async def test_repeated_code_kept(seeded: AsyncSession) -> None: + """Две строки `ok` 2410 (пустая и `(649)`) — две строки со своими `row_index`, `source_ref` и значениями.""" + lines = [ + line_v2(0, "2410", "Налог на прибыль", amount=None, ref="row:7"), + line_v2(1, "2410", "Налог на прибыль", amount="-649", ref="row:9"), + ] + entity = await _entity(seeded) + (income,) = await _map(seeded, entity, [statement_doc_v2("INCOME", lines=lines)]) + stored = await _lines(seeded, income) + assert [(line.row_index, line.source_ref, line.line_code) for line in stored] == [ + (0, "row:7", "2410"), + (1, "row:9", "2410"), + ] + amounts = { + value.line_id: value.amount + for value in (await seeded.execute(select(FinancialStatementValue))).scalars() + if value.line_id in {line.id for line in stored} + } + assert amounts[stored[0].id] is None and amounts[stored[1].id] == Decimal(-649) + + +async def test_line_without_code(seeded: AsyncSession) -> None: + line = line_v2(5, None, "Финансовые вложения", status="unreadable", code_text="ИЧ,250", code_confidence=25) + (balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2(lines=[line])]) + (stored,) = await _lines(seeded, balance) + assert stored.line_code is None and stored.line_code_original_value is None + assert stored.line_status.value == "unreadable" and stored.row_index == 5 # noqa: PLR2004 + assert stored.line_code_text == "ИЧ,250" and stored.is_confirmed is False + + +async def test_two_balances_same_period(seeded: AsyncSession) -> None: + """Review Focus 1: два `BALANCE` одного периода — разные `file_ordinal`, оба `NEEDS_CHECK`, не `CONFIRMED`.""" + entity = await _entity(seeded) + first, second = await _map(seeded, entity, [statement_doc_v2(), statement_doc_v2()]) + assert (first.file_ordinal, second.file_ordinal) == (0, 1) + assert first.status is second.status is DocumentStatus.NEEDS_CHECK + await _confirm_clean(seeded, [first, second]) + assert first.status is second.status is DocumentStatus.NEEDS_CHECK + warning = [i.message for i in await _issues(seeded, first) if i.code == "MAPPING_WARNING"] + assert any("другая форма BALANCE за тот же период" in message for message in warning) + + +async def test_two_balances_other_periods(seeded: AsyncSession) -> None: + """Баланс на 30.09.2025 и на 31.12.2024 — правило Р-18 их не касается, каждый по своим условиям.""" + entity = await _entity(seeded) + docs = [ + statement_doc_v2(), + statement_doc_v2(header=header_v2(report_date="2024-12-31"), columns=[column_v2(2, date="2024-12-31")]), + ] + first, second = await _map(seeded, entity, docs) + assert first.status is second.status is DocumentStatus.RECOGNIZED + assert (first.period_quarter, second.period_quarter) == (PeriodQuarter.Q3, PeriodQuarter.Q4) + + +async def test_neighbor_needs_check(seeded: AsyncSession) -> None: + """Р-19: `needs_check` соседнего документа не мешает: ББ `done` автоподтверждён, ОФР `needs_check` — нет.""" + entity = await _entity(seeded) + docs = [statement_doc_v2("BALANCE"), statement_doc_v2("INCOME", lines=INCOME_LINES, status="needs_check")] + balance, income = await _map(seeded, entity, docs) + assert (balance.status, income.status) == (DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK) + await _confirm_clean(seeded, [balance, income]) + assert (balance.status, income.status) == (DocumentStatus.CONFIRMED, DocumentStatus.NEEDS_CHECK) + + +@pytest.mark.parametrize("warning", [ERROR_JSON_FAILED, ERROR_TABLE_PARSE]) +async def test_file_error_warning_blocks_all(seeded: AsyncSession, warning: dict[str, str]) -> None: + """P3-15: предупреждение файла уровня `error` в `metrics.warnings` результата — все формы файла `NEEDS_CHECK`.""" + entity = await _entity(seeded) + docs = [statement_doc_v2("BALANCE"), statement_doc_v2("INCOME", lines=INCOME_LINES)] + balance, income = await _map(seeded, entity, docs, file_warnings=[warning]) + assert balance.status is income.status is DocumentStatus.NEEDS_CHECK + + +async def test_info_file_warning_does_not_block(seeded: AsyncSession) -> None: + info = {"code": "SOMETHING", "severity": "info", "message": "к сведению"} + (balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2()], file_warnings=[info]) + assert balance.status is DocumentStatus.RECOGNIZED + + +NAME_CASES = [ + ("1120", "Нематериальные активы", 50, True), # подмена кода на соседа + ("1220", "НДС, начисленный по отгрузке", 50, False), # законный повтор с другим именем (fsbu4) + ("1200", "Итого по разделу I", 50, True), # подмена итога раздела + ("1500", "Итого по разделу V1", 56, False), # хвост OCR после номера раздела + ("1120", "Нематериальные активы", 100, False), # уверенный код не проверяется +] + + +@pytest.mark.parametrize(("code", "name", "confidence", "mismatch"), NAME_CASES) +async def test_name_mismatch(seeded: AsyncSession, code: str, name: str, confidence: int, mismatch: bool) -> None: + lines = [*balance_lines(), line_v2(10, code, name, code_confidence=confidence)] + (balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2(lines=lines)]) + flagged = [i for i in await _issues(seeded, balance) if i.code == "NAME_MISMATCH"] + stored = await _lines(seeded, balance) + if mismatch: + assert [i.line_ids for i in flagged] == [[str(stored[-1].id)]] + assert flagged[0].source is IssueSource.core and balance.status is DocumentStatus.NEEDS_CHECK + else: + assert not flagged + + +async def test_done_with_unreadable_without_values(seeded: AsyncSession) -> None: + """Сервис прислал `done`, но есть `unreadable` без значений — функция статуса пакета не согласна.""" + lines = [*balance_lines(), line_v2(9, None, "Строка", status="unreadable", amount=None, code_text="???")] + (balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2(lines=lines, status="done")]) + assert balance.status is DocumentStatus.NEEDS_CHECK + inconsistent = [i for i in await _issues(seeded, balance) if i.code == "RESULT_STATUS_INCONSISTENT"] + assert len(inconsistent) == 1 and inconsistent[0].source is IssueSource.core + assert inconsistent[0].line_ids is None and inconsistent[0].kind is DocumentIssueKind.warning + await _confirm_clean(seeded, [balance]) + assert balance.status is DocumentStatus.NEEDS_CHECK + + +async def test_done_with_unreadable_with_amount(seeded: AsyncSession) -> None: + lines = [*balance_lines(), line_v2(9, None, "Строка", status="unreadable", amount="50", code_text="???")] + (balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2(lines=lines, status="done")]) + assert balance.status is DocumentStatus.NEEDS_CHECK + assert [i.code for i in await _issues(seeded, balance) if i.code == "RESULT_STATUS_INCONSISTENT"] + await _confirm_clean(seeded, [balance]) + assert balance.status is DocumentStatus.NEEDS_CHECK + + +async def test_needs_check_without_inconsistency_record(seeded: AsyncSession) -> None: + """`needs_check` сервиса: документ `NEEDS_CHECK`, `RESULT_STATUS_INCONSISTENT` не пишется.""" + (balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2(status="needs_check")]) + assert balance.status is DocumentStatus.NEEDS_CHECK + assert not [i for i in await _issues(seeded, balance) if i.code == "RESULT_STATUS_INCONSISTENT"] + + +async def test_report_date_not_quarter_end(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Дата 15.08.2025: квартал не определён, `NEEDS_CHECK`, подтверждение — 409 `PERIOD_REQUIRED`.""" + entity = await _entity(seeded, rostov=True) + docs = [statement_doc_v2(header=header_v2(report_date="2025-08-15"), columns=[column_v2(2, date="2025-08-15")])] + (balance,) = await _map(seeded, entity, docs) + assert balance.status is DocumentStatus.NEEDS_CHECK + assert (balance.period_year, balance.period_quarter) == (2025, None) + await _confirm_clean(seeded, [balance]) + assert balance.status is DocumentStatus.NEEDS_CHECK + client = await auth_client(KA_RF) + blocked = await client.post(f"/documents/{balance.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "PERIOD_REQUIRED" + + +async def test_66n_blank_in_2025(seeded: AsyncSession) -> None: + """Бланк 66н, сданный в 2025 году: редакция документа — `66n` по подпоследовательности кодов, `CODE_NOT_IN_EDITION` + у 1120 нет (в перечне `fsbu4` его нет).""" + names = {"1110": "Нематериальные активы", "1120": "Результаты исследований и разработок"} + codes = ["1110", "1120", "1130", "1140", "1150", "1160", "1170", "1180"] + lines = [line_v2(index, code, names.get(code, "Строка")) for index, code in enumerate(codes)] + (balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2(lines=lines)]) + stored = {line.line_code: line.id for line in await _lines(seeded, balance)} + pairs = [(code, True) for code in codes] + edition = default_engine().edition(DocumentType_.BALANCE, pairs, balance.reporting_date, FormVariant.FULL) + assert edition is Edition.RAS_66N + info = [i for i in await _issues(seeded, balance) if i.code == "CODE_NOT_IN_EDITION"] + assert str(stored["1120"]) not in {line_id for issue in info for line_id in issue.line_ids or ()} + + +async def test_code_not_in_edition_for_fsbu4_form(seeded: AsyncSession) -> None: + """Контроль к 66н: код вне перечня выбранной редакции даёт одну запись на код документа (R-1: только 4 цифры).""" + lines = [*balance_lines(), line_v2(10, "9999", "Чужая"), line_v2(11, "99999", "Подстрока Excel")] + (balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2(lines=lines)]) + stored = {line.line_code: line.id for line in await _lines(seeded, balance)} + core = [issue for issue in await _issues(seeded, balance) if issue.source is IssueSource.core] + by_code = {(issue.code, tuple(issue.line_ids or ())) for issue in core} + assert ("CODE_NOT_IN_DICTIONARY", (str(stored["9999"]),)) in by_code + assert ("CODE_NOT_IN_EDITION", (str(stored["9999"]),)) in by_code + assert not any(str(stored["99999"]) in line_ids for _, line_ids in by_code) + + +async def test_column_skipped_issue(seeded: AsyncSession) -> None: + """`COLUMN_SKIPPED` с `column_index = 5`, колонки 5 нет — `column_index == 5`, `column_id` пуст; для имеющейся + колонки — наоборот.""" + warnings = [ + {"code": "COLUMN_SKIPPED", "severity": "info", "message": "колонка пропущена", "column_index": 5}, + {"code": "COLUMN_DATE_UNCERTAIN", "severity": "info", "message": "дата", "column_index": 2}, + ] + (balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2(warnings=warnings)]) + issues = {i.code: i for i in await _issues(seeded, balance)} + assert (issues["COLUMN_SKIPPED"].column_index, issues["COLUMN_SKIPPED"].column_id) == (5, None) + column = (await seeded.execute(select(FinancialStatementColumn))).scalars().one() + uncertain = issues["COLUMN_DATE_UNCERTAIN"] + assert (uncertain.column_id, uncertain.column_index) == (column.id, None) + + +async def test_header_source_hint(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """`organization_name_text` / `_confidence` — в `header_source` и в `GET …/statement` (`header.source`).""" + header = header_v2(organization_name_text="ООО «Ромашкa»", organization_name_confidence=67) + entity = await _entity(seeded, rostov=True) + (balance,) = await _map(seeded, entity, [statement_doc_v2(header=header)]) + meta = await _meta(seeded, balance) + assert len(meta.header_source) == HEADER_FIELDS_COUNT + assert meta.header_source["organization_name"] == {"text": "ООО «Ромашкa»", "confidence": 67} + assert meta.inn_original_value == OWNER_INN and meta.organization_name_original_value == "ООО Ромашка" + assert meta.form_variant is not None and meta.form_variant.value == "full" + body = (await (await auth_client(KA_RF)).get(f"/documents/{balance.id}/statement")).json() + assert body["header"]["source"]["organization_name"] == {"text": "ООО «Ромашкa»", "confidence": "67.00"} + + +async def test_statements_version_and_ordinals(seeded: AsyncSession) -> None: + """`recognition_job.statements_version` из `service.statements_version`; `BALANCE`, `INCOME` — ordinal 0, 1; + отличие от версии копии ядра — `STATEMENTS_VERSION_MISMATCH` (`info`, `core`).""" + entity = await _entity(seeded) + docs = [statement_doc_v2("BALANCE"), statement_doc_v2("INCOME", lines=INCOME_LINES)] + balance, income = await _map(seeded, entity, docs, statements_version="abc1234") + assert (balance.file_ordinal, income.file_ordinal) == (0, 1) + job = ( + await seeded.execute(select(RecognitionJob).where(RecognitionJob.file_id == balance.stored_file_id)) + ).scalar_one() + assert job.statements_version == "abc1234" + mismatch = [i for i in await _issues(seeded, balance) if i.code == "STATEMENTS_VERSION_MISMATCH"] + assert len(mismatch) == 1 and mismatch[0].source is IssueSource.core + + +async def test_disclosure_one_document_with_all_warnings(seeded: AsyncSession) -> None: + """P3-6: несколько `DISCLOSURE` — один документ (ordinal первого, `locator` через запятую); предупреждения всех — в + его `document_issue`; он `RECOGNIZED`.""" + first = statement_doc_v2("DISCLOSURE", lines=[], columns=[], form_variant=None, locator="loc:1") + first["warnings"] = [{"code": "FIRST_W", "severity": "info", "message": "первое"}] + second = statement_doc_v2("DISCLOSURE", lines=[], columns=[], form_variant=None, locator="loc:2") + second["warnings"] = [{"code": "SECOND_W", "severity": "info", "message": "второе"}] + entity = await _entity(seeded) + balance, disclosure = await _map(seeded, entity, [statement_doc_v2(), first, second]) + assert balance.file_ordinal == 0 and disclosure.file_ordinal == 1 + assert disclosure.document_type is DocumentType.DISCLOSURE and disclosure.locator == "loc:1,loc:2" + assert disclosure.status is DocumentStatus.RECOGNIZED + assert {i.code for i in await _issues(seeded, disclosure)} == {"FIRST_W", "SECOND_W"} + documents = (await seeded.execute(select(Document).where(Document.stored_file_id == balance.stored_file_id))).all() + assert len(documents) == 2 # noqa: PLR2004 + + +async def test_value_cells_and_empty_records(seeded: AsyncSession) -> None: + """Значение на каждую пару «строка × колонка»; сырой текст, `needs_review` и пустая запись без `needs_review`.""" + columns = [column_v2(2), column_v2(3, date="2024-12-31")] + lines = [ + line_v2(0, "1210", "Запасы", values=[value_v2(2, "428311", text="42831 1", is_recognized=False)]), + line_v2(1, "1600", "БАЛАНС", values=[value_v2(2, "5", needs_review=True), value_v2(3, "4")]), + ] + (balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2(lines=lines, columns=columns)]) + stored = await _lines(seeded, balance) + rows = ( + await seeded.execute( + select(FinancialStatementValue, FinancialStatementColumn.column_index) + .join(FinancialStatementColumn, FinancialStatementColumn.id == FinancialStatementValue.column_id) + .where(FinancialStatementValue.line_id.in_([line.id for line in stored])) + ) + ).all() + cells = {(value.line_id, index): value for value, index in rows} + assert len(cells) == 4 # noqa: PLR2004 + typed = cells[(stored[0].id, 2)] + assert (typed.amount, typed.source_text, typed.is_recognized) == (Decimal(428311), "42831 1", False) + empty = cells[(stored[0].id, 3)] + assert (empty.amount, empty.source_text, empty.needs_review) == (None, None, False) + assert cells[(stored[1].id, 2)].needs_review is True diff --git a/openspec/changes/unified-normalization-core-b/design.md b/openspec/changes/unified-normalization-core-b/design.md index bc17ea6..9c232ae 100644 --- a/openspec/changes/unified-normalization-core-b/design.md +++ b/openspec/changes/unified-normalization-core-b/design.md @@ -47,3 +47,10 @@ Goals и Non-goals — см. `proposal.md`. | 2026-10-18 | **P3-36 (B3).** `checks_on_intake_1x` живёт в `statement_recompute.py`; движок передаётся в `_insert_statements` необязательным параметром (по умолчанию `default_engine()`), `map_file` сигнатуру не меняет. Приём `1.x` пишет `meta.form_variant` и записи `relation` / `soft` (`core`), но не `CODE_NOT_IN_*` / `NAME_MISMATCH` (P3-7) и не трогает `needs_review` (A9); условие D-6 «проверки сошлись» — `not result.checks.relations_failed` (нарушение с `expected = NULL` — жёсткое) | задание B3, спека «Пересчёт при приёме»; расхождение с прежним `run_checks` (его `failed`) возможно только на формах с пустой целью, где пакет строже | форма `1.x` с пустой целью получит `NEEDS_CHECK` вместо `RECOGNIZED` | | 2026-10-18 | **P3-37 (fix1, T2.1).** Аудит `lines[]` довносит смену `parent_row_index` после пересчёта: у правленных строк значение заменяется итоговым, у остальных появляется запись `line:` с одним ключом `parent_row_index` (`{"parent_row_index": старый}` → `{…: новый}`) | родителей пишет `recompute_form` уже после снимка строк правки; полный снимок для не правленных строк не нужен | в событии `RECOGNIZED_DATA_EDIT` у не правленных строк неполный набор ключей | | 2026-10-18 | **P3-38 (fix1, T1.1/T1.2/T2.4/T2.6/T2.8).** Паритет редакции приёма и пересчёта для строки с `line_code_confidence < 100` и `code_fixed` подтверждён тестом (расхождения нет, код не менялся); `edition_codes` пропускает строку `ok` без кода; `column_index` нарушения — общим помощником `issue_column_index` (запись, иначе колонка по `column_id`) в ответе и в `detail` отказа; `_unconfirmed_needs_review` и присвоения `needs_review` в `_apply_item` удалены (флаг пишет только пересчёт, он всегда идёт после правки строк); неизвестный `relative:*` в `_resolve_relative_columns` пропускается без ошибки | замечания ревью B1+B2 | колонка с неизвестным `relative:*` не следует за датой отчёта | +| 2026-10-18 | **P3-39 (B4).** Проверки ядра `info` (`CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH`, `STATEMENTS_VERSION_MISMATCH`, `RESULT_STATUS_INCONSISTENT`, `code_issues`, `name_issues`, `version_issue`, `name_check_lines`) вынесены из `services/statement_recompute.py` в `domain/statement_issues.py`; единая функция `core_info_issues(lines, *, form, edition: str \| None, order: Collection[str] \| None, catalog) -> CoreInfo(drafts, mismatched)` используется приёмом `2.0` и `recompute_form` (поведение пересчёта не изменилось, P3-34). Домен пакет не импортирует: редакцию (`engine.edition`) и перечень кодов (`engine.code_order`) считает сервис; `statement_recompute` реэкспортирует прежние имена. Вместо сигнатуры плана `core_info_issues(*, lines, edition, form_variant, catalog, engine)` | домен не должен знать `StatementsEngine`/`arm_statements` (D-1); одно правило для приёма и пересчёта | нет; импортёры прежних имён из `statement_recompute` работают | +| 2026-10-18 | **P3-40 (B4).** Приём форм `2.0` — отдельный сервис `services/statement_intake_v2.py` (`map_statement_docs_v2`, `insert_forms_v2`); `map_file` выбирает маппер по типу результата через `_plan_forms` / `_insert_forms` (иначе функция превышала лимит ruff по операторам) и получил необязательный параметр `engine`. `_load_result` и тип в `process_once` остаются под `1.x` до B5 | размер `result_mapping.py` и лимиты линтера; граница B4/B5 из брифов | нет | +| 2026-10-18 | **P3-41 (B4).** `DISCLOSURE` в `2.0` — по-прежнему один документ на файл со статусом `RECOGNIZED` (D-3), `file_ordinal` первого; предупреждения всех объединённых документов результата пишутся в его `document_issue` источником `service`. Статус сервиса и предупреждения файла уровня `error` на него не влияют | спека условие `RECOGNIZED` относится к формам ББ/ОФР; у `DISCLOSURE` нет данных | `DISCLOSURE` с `needs_check` сервиса автоподтверждается (данных нет — подтверждать нечего) | +| 2026-10-18 | **P3-42 (B4).** `STATEMENTS_VERSION_MISMATCH` пишется и при приёме `2.0` (`version_issue(job.statements_version, engine.version)`), а не только при пересчёте после правки | иначе запись появлялась бы только после первой правки; один набор записей `core` у приёма и пересчёта | лишний `info` на формах, принятых копией иной версии | +| 2026-10-18 | **P3-43 (B4).** Результат с повтором `row_index` или `ref` у строк: вторая и далее строка пропускается с `MAPPING_WARNING`; `detail` с `parent_ref`, которого нет, хранится с `parent_row_index = null` и тем же предупреждением | уникальность `(meta_id, source_ref)` и ключа строки при вставке иначе роняет приём (файл `ERROR` после попыток); схема уникальности `ref`/`row_index` не проверяет | потерянная строка дубля видна только в предупреждении | +| 2026-10-18 | **P3-44 (B4).** Запись `relation` / `soft` из `checks` результата несёт `column_id` (колонка с таким `column_index`, если есть) и всегда `column_index` (как P3-33); запись `warning` с `column_index` — либо `column_id` (колонка есть), либо `column_index` (колонки нет) | спека «Пропущенная колонка» и ответ API, читающий индекс из записи | нет | +| 2026-10-18 | **P3-45 (B4).** `is_current` колонок `2.0` — как у `1.x` (колонка с самой поздней датой, при равенстве меньший `column_index`; нет дат — `NULL`), в том числе у колонок `relative:*` с датой; значение, которого нет в результате, пишется с `is_recognized = NULL`, остальными полями пустыми и `needs_review = false` | §9.5 п. 2; спека «Хранение результата формы v2.0» | колонка `relative:*` без даты не станет текущей | -- 2.47.2 From 5e45a6ca7e1885ee22c912eb0f5470d5dd32720b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 21:42:42 +0400 Subject: [PATCH 08/13] =?UTF-8?q?chore(statements):=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BF?= =?UTF-8?q?=D0=B8=D0=B8=20arm=5Fstatements=20=D0=B4=D0=BE=203390f92=20(?= =?UTF-8?q?=D0=A0-44,=20NUMBERS=5FOUTSIDE=5FTABLE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/arm_statements/VERSION | 2 +- api/arm_statements/columns.py | 78 ++++++++++++++++++- api/arm_statements/issues.py | 1 + .../rules/statement_warnings.yaml | 1 + api/arm_statements/segments.py | 48 +++++++++++- api/arm_statements/tests/test_columns.py | 77 ++++++++++++++++-- api/arm_statements/tests/test_loader.py | 1 + api/arm_statements/tests/test_pipeline.py | 16 ++++ api/arm_statements/tests/test_segments.py | 28 +++++++ 9 files changed, 242 insertions(+), 10 deletions(-) diff --git a/api/arm_statements/VERSION b/api/arm_statements/VERSION index e7dc345..efc6084 100644 --- a/api/arm_statements/VERSION +++ b/api/arm_statements/VERSION @@ -1 +1 @@ -52857a0491c352c4997f8d2cc7011f36b57defb1 +3390f92daddbfd4919d7f799ad2959cc35bfbd92 diff --git a/api/arm_statements/columns.py b/api/arm_statements/columns.py index 75ae3ff..e3ab9d6 100644 --- a/api/arm_statements/columns.py +++ b/api/arm_statements/columns.py @@ -14,6 +14,7 @@ from arm_statements.loader import Rules from arm_statements.model import Cell, PeriodKind, Source, StatementColumn, StatementHeader, StatementWarning _FULL_CONFIDENCE = 100.0 +_MAX_REFS = 10 @dataclass(frozen=True, slots=True) @@ -94,7 +95,10 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана """Колонки значений сегмента и предупреждения о пропущенных/недатированных столбцах.""" drafts: list[ColumnDraft] = [] warnings: list[StatementWarning] = [] - for col in range(code_col + 1, _width(rows, [*header_rows, *data_rows])): + columns = range(code_col + 1, _width(rows, [*header_rows, *data_rows])) + last_dated = _last_dated_column(rows, header_rows, data_rows, columns, code_col, source, rules) + outside: list[int] = [] + for col in columns: numbers = has_numbers(rows, data_rows, col, code_col, source, rules) if not header_rows: if numbers: @@ -107,14 +111,86 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана warnings.append(make_warning(WarningCode.COLUMN_SKIPPED, message, rules, column_index=col)) elif cell is not None and parsed.kind is not PeriodKind.NONE: drafts.append(ColumnDraft(col, cell.text, cell.confidence, parsed.kind, parsed.date)) + elif numbers and cell is None and last_dated is not None and col > last_dated: + outside.append(col) # числа правее последней колонки с подписью периода — не колонка сумм elif numbers: label, conf = (cell.text, cell.confidence) if cell is not None else ("", None) message = f"у столбца {col} с числами нет разобранной подписи периода" warnings.append(make_warning(WarningCode.COLUMN_WITHOUT_DATE, message, rules, column_index=col)) drafts.append(ColumnDraft(col, label, conf, PeriodKind.NONE, None)) + if outside: + warnings.append(_outside_table_warning(rows, data_rows, outside, code_col, source, rules)) return drafts, warnings +def _last_dated_column( # noqa: PLR0913, PLR0917 - внутренняя сборка границы таблицы + rows: Grid, + header_rows: tuple[int, ...], + data_rows: Sequence[int], + columns: range, + code_col: int, + source: Source, + rules: Rules, +) -> int | None: + """Правый столбец сегмента с подписью периода и числами в строках с кодами; без шапки — `None`. + + Подписанный столбец без чисел (потерянная подпись объединённой ячейки) границей таблицы не служит. + """ + dated = [ + col + for col in columns + if header_rows + and (cell := _label(rows, header_rows, col)) is not None + and not (parsed := parse_period_label(cell, rules)).excluded + and parsed.kind is not PeriodKind.NONE + and has_numbers(rows, data_rows, col, code_col, source, rules) + ] + return max(dated, default=None) + + +def _outside_table_warning( # noqa: PLR0913, PLR0917 - внутренняя сборка предупреждения + rows: Grid, data_rows: Sequence[int], outside: Sequence[int], code_col: int, source: Source, rules: Rules +) -> StatementWarning: + refs = [ + cell.ref + for r in _code_rows(rows, data_rows, code_col, rules) + for col in outside + if (cell := cell_at(rows, r, col)) is not None and _is_number(cell, source, rules) + ] + return outside_table_warning(refs, rules) + + +def outside_table_warning(refs: Sequence[str], rules: Rules) -> StatementWarning: + """`NUMBERS_OUTSIDE_TABLE` (info): адреса ячеек правее таблицы (`locator` — все, через перевод строки).""" + shown = ", ".join(refs[:_MAX_REFS]) + (f" и ещё {len(refs) - _MAX_REFS}" if len(refs) > _MAX_REFS else "") + message = f"числа правее таблицы формы не учитываются: {shown}" + return make_warning(WarningCode.NUMBERS_OUTSIDE_TABLE, message, rules, locator="\n".join(refs)) + + +def merge_outside_table(warnings: Sequence[StatementWarning], rules: Rules) -> list[StatementWarning]: + """Предупреждения `NUMBERS_OUTSIDE_TABLE` сегментов — в одно (адреса без повторов).""" + refs: dict[str, None] = {} + rest: list[StatementWarning] = [] + for warning in warnings: + if warning.code == WarningCode.NUMBERS_OUTSIDE_TABLE: + refs.update(dict.fromkeys((warning.locator or "").split("\n"))) + else: + rest.append(warning) + return [*rest, outside_table_warning(list(refs), rules)] if refs else rest + + +def trailing_outside_refs( # noqa: PLR0913, PLR0917 - внутренняя сборка + rows: Grid, data_rows: Sequence[int], cols: Sequence[int], code_col: int, source: Source, rules: Rules +) -> list[str]: + """Адреса чисел в строках с кодами по столбцам `cols` (хвост сегмента правее колонок документа).""" + return [ + cell.ref + for r in _code_rows(rows, data_rows, code_col, rules) + for col in cols + if (cell := cell_at(rows, r, col)) is not None and _is_number(cell, source, rules) + ] + + def _latest(drafts: Sequence[ColumnDraft], *, confident: bool) -> date | None: days = [ d.date diff --git a/api/arm_statements/issues.py b/api/arm_statements/issues.py index 4f54512..0efc9c4 100644 --- a/api/arm_statements/issues.py +++ b/api/arm_statements/issues.py @@ -45,6 +45,7 @@ class WarningCode(StrEnum): VALUE_OUT_OF_RANGE = "VALUE_OUT_OF_RANGE" SHEET_OTHER = "SHEET_OTHER" ROW_SPLIT = "ROW_SPLIT" + NUMBERS_OUTSIDE_TABLE = "NUMBERS_OUTSIDE_TABLE" def make_warning( # noqa: PLR0913 -- сигнатура задана публичным API пакета diff --git a/api/arm_statements/rules/statement_warnings.yaml b/api/arm_statements/rules/statement_warnings.yaml index d81a271..03699c9 100644 --- a/api/arm_statements/rules/statement_warnings.yaml +++ b/api/arm_statements/rules/statement_warnings.yaml @@ -34,3 +34,4 @@ warnings: VALUE_OUT_OF_RANGE: {severity: info, scope: document} SHEET_OTHER: {severity: info, scope: file} ROW_SPLIT: {severity: info, scope: document} + NUMBERS_OUTSIDE_TABLE: {severity: info, scope: document} diff --git a/api/arm_statements/segments.py b/api/arm_statements/segments.py index 9cbedec..9bdce13 100644 --- a/api/arm_statements/segments.py +++ b/api/arm_statements/segments.py @@ -1,11 +1,20 @@ """Шаг 2: сегменты сетки, повторные шапки, границы листов и сопоставление колонок с первым сегментом.""" from collections.abc import Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import NamedTuple from arm_statements.codes import code_parts, is_code_cell, majority_section, parse_code -from arm_statements.columns import ColumnDraft, DocumentDates, document_dates, finalize_columns, segment_columns +from arm_statements.columns import ( + ColumnDraft, + DocumentDates, + document_dates, + finalize_columns, + merge_outside_table, + outside_table_warning, + segment_columns, + trailing_outside_refs, +) from arm_statements.issues import WarningCode, make_warning from arm_statements.layout import ( Grid, @@ -20,6 +29,7 @@ from arm_statements.layout import ( from arm_statements.loader import CodeSection, Rules from arm_statements.model import ( FormCandidate, + PeriodKind, RowRole, StatementColumn, StatementHeader, @@ -212,6 +222,22 @@ def _map_segment( return layout, warning +def _trim_outside(seg: _Analysed, width: int, candidate: FormCandidate, rules: Rules) -> _Analysed: + """Сегмент без подписанных колонок: колонки сверх `width` документа — числа правее таблицы, не колонки.""" + if seg.code_col is None or len(seg.drafts) <= width or any(d.kind is not PeriodKind.NONE for d in seg.drafts): + return seg + tail = seg.drafts[width:] + if any(d.label for d in tail): # столбец с текстом подписи — не формула аналитика, остаётся расхождением + return seg + data = [r for r, role in zip(range(seg.first, seg.last + 1), seg.roles, strict=True) if role is RowRole.DATA] + refs = trailing_outside_refs( + candidate.rows, data, [d.grid_col for d in tail], seg.code_col, candidate.source, rules + ) + return replace( + seg, drafts=seg.drafts[:width], draft_warnings=(*seg.draft_warnings, outside_table_warning(refs, rules)) + ) + + def find_layout(candidate: FormCandidate, header: StatementHeader, rules: Rules) -> Layout: """Раскладка кандидата: сегменты, колонки документа, роли всех строк и предупреждения шага 2.""" segments, section = _split_and_analyse(candidate, rules) @@ -223,7 +249,23 @@ def find_layout(candidate: FormCandidate, header: StatementHeader, rules: Rules) if defining is not None: dates = document_dates(defining.drafts, header) columns, finalize_warnings = finalize_columns(defining.drafts, dates, rules) - warnings.extend(defining.draft_warnings) + # числа правее таблицы — по всем сегментам (формула аналитика может стоять на любой странице формы); + # сегмент без подписанных колонок (лист-продолжение) сверх колонок документа тоже «правее таблицы» + if any(d.kind is not PeriodKind.NONE for d in defining.drafts): + segments = [ + seg if seg is defining else _trim_outside(seg, len(columns), candidate, rules) for seg in segments + ] + warnings.extend( + merge_outside_table( + [ + w + for seg in segments + for w in seg.draft_warnings + if seg is defining or w.code == WarningCode.NUMBERS_OUTSIDE_TABLE + ], + rules, + ) + ) warnings.extend(finalize_warnings) if not any(s.header_rows for s in coded): warnings.append( diff --git a/api/arm_statements/tests/test_columns.py b/api/arm_statements/tests/test_columns.py index 2a12edc..c516d4d 100644 --- a/api/arm_statements/tests/test_columns.py +++ b/api/arm_statements/tests/test_columns.py @@ -68,18 +68,63 @@ def test_junk_label_over_numbers_is_kept(rules: Rules) -> None: assert [(w.code, w.severity, w.column_index) for w in warnings] == [("COLUMN_WITHOUT_DATE", Severity.ERROR, 3)] -def test_empty_label_over_numbers_is_kept(rules: Rules) -> None: +def test_empty_label_right_of_dated_columns_is_outside_table(rules: Rules) -> None: rows: list[list[Any]] = [ ["Наименование", "Код", "На 30.09.2025", ""], ["Запасы", "1210", "5", "4"], ["Итого", "1200", "5", "4"], ] columns, warnings = run(rows, OCR, rules) - assert [(c.column_index, c.label, c.label_confidence, c.period_kind) for c in columns] == [ - (2, "На 30.09.2025", 100.0, PeriodKind.ABSOLUTE), - (3, "", None, PeriodKind.NONE), + assert [(c.column_index, c.period_kind) for c in columns] == [(2, PeriodKind.ABSOLUTE)] + assert [(w.code, w.severity, w.column_index) for w in warnings] == [("NUMBERS_OUTSIDE_TABLE", Severity.INFO, None)] + assert warnings[0].locator == "row:1\nrow:2" + + +def test_numbers_right_of_last_dated_column_are_not_amounts(rules: Rules) -> None: + rows: list[list[Any]] = [ + ["Наименование", "", "Код", "На 30.09.2025", "На 31.12.2024", None, None], + ["Запасы", "", "1210", D("5"), D("4"), None, D("9")], + ["Баланс", "", "1600", D("5"), D("4"), D("7"), None], + ["Итого", "", "1700", D("5"), D("4"), None, None], ] - assert [(w.code, w.column_index) for w in warnings] == [("COLUMN_WITHOUT_DATE", 3)] + columns, warnings = run(rows, EXCEL, rules, data_rows=(1, 2, 3)) + assert [c.column_index for c in columns] == [3, 4] + assert [(w.code, w.severity) for w in warnings] == [("NUMBERS_OUTSIDE_TABLE", Severity.INFO)] + assert warnings[0].locator == "Лист1!G2\nЛист1!F3" + assert "G2" in warnings[0].message and "F3" in warnings[0].message + + +def test_numbers_outside_table_do_not_depend_on_cell_count(rules: Rules) -> None: + rows: list[list[Any]] = [["Наименование", "", "Код", "На 30.09.2025", None]] + rows += [["Строка", "", str(1200 + i), D("1"), D("2")] for i in range(12)] + cand = excel([("Лист1", rows)]) + _, warnings = segment_columns(cand.rows, (0,), tuple(range(1, 13)), 2, EXCEL, rules) + assert len(warnings) == 1 + assert warnings[0].message.endswith("и ещё 2") + assert warnings[0].message.count("Лист1!E") == 10 # noqa: PLR2004 + assert len((warnings[0].locator or "").split("\n")) == 12 # noqa: PLR2004 + + +def test_no_dated_columns_keeps_old_behaviour(rules: Rules) -> None: + rows: list[list[Any]] = [ + ["Наименование", "", "Код", "", ""], + ["Запасы", "", "1210", D("5"), D("4")], + ["Итого", "", "1200", D("5"), D("4")], + ] + columns, warnings = run(rows, EXCEL, rules) + assert [c.column_index for c in columns] == [3, 4] + assert [(w.code, w.column_index) for w in warnings] == [("COLUMN_WITHOUT_DATE", 3), ("COLUMN_WITHOUT_DATE", 4)] + + +def test_undated_column_between_dated_keeps_old_behaviour(rules: Rules) -> None: + rows: list[list[Any]] = [ + ["Наименование", "", "Код", "На 30.09.2025", None, "На 31.12.2024"], + ["Запасы", "", "1210", D("5"), D("9"), D("4")], + ["Итого", "", "1200", D("5"), D("9"), D("4")], + ] + columns, warnings = run(rows, EXCEL, rules) + assert [c.column_index for c in columns] == [3, 4, 5] + assert [(w.code, w.severity, w.column_index) for w in warnings] == [("COLUMN_WITHOUT_DATE", Severity.ERROR, 4)] def test_two_row_header_is_joined_with_min_confidence(rules: Rules) -> None: @@ -144,3 +189,25 @@ def test_public_amount_columns(rules: Rules) -> None: ] assert amount_columns(excel([("Лист1", rows)]).rows, 2, rules) == (3, 4) assert amount_columns(ocr([["Запасы", "1210", "5", "", "4"]]).rows, 1, rules, source=OCR) == (2, 4) + + +def test_dated_column_without_numbers_is_not_the_table_edge(rules: Rules) -> None: + rows: list[list[Any]] = [ + ["Наименование", "", "Код", "На 30.09.2025", "", "На 31.12.2024", ""], + ["Запасы", "", "1210", None, D("5"), None, D("4")], + ["Итого", "", "1200", None, D("5"), None, D("4")], + ] # подпись объединённой ячейки стоит левее значений: границы таблицы нет, колонки остаются + columns, warnings = run(rows, EXCEL, rules) + assert [c.column_index for c in columns] == [3, 4, 5, 6][:0] or [w.code for w in warnings].count( + "NUMBERS_OUTSIDE_TABLE" + ) == 0 + + +def test_sheet_name_with_comma_keeps_addresses(rules: Rules) -> None: + rows: list[list[Any]] = [ + ["Наименование", "", "Код", "На 30.09.2025", None], + ["Запасы", "", "1210", D("5"), D("9")], + ] + cand = excel([("Баланс, 2025", rows)]) + _, warnings = segment_columns(cand.rows, (0,), (1,), 2, EXCEL, rules) + assert (warnings[0].locator or "").split("\n") == ["'Баланс, 2025'!E2"] diff --git a/api/arm_statements/tests/test_loader.py b/api/arm_statements/tests/test_loader.py index f5df35c..fe1e0f9 100644 --- a/api/arm_statements/tests/test_loader.py +++ b/api/arm_statements/tests/test_loader.py @@ -43,6 +43,7 @@ SPEC_WARNINGS = { "VALUE_OUT_OF_RANGE": ("info", "document"), "SHEET_OTHER": ("info", "file"), "ROW_SPLIT": ("info", "document"), + "NUMBERS_OUTSIDE_TABLE": ("info", "document"), "XLS_NO_CODEPAGE": ("info", "file"), } diff --git a/api/arm_statements/tests/test_pipeline.py b/api/arm_statements/tests/test_pipeline.py index 45af8d9..4c5ad8a 100644 --- a/api/arm_statements/tests/test_pipeline.py +++ b/api/arm_statements/tests/test_pipeline.py @@ -194,3 +194,19 @@ def test_document_warnings_order(rules: Rules) -> None: ] assert [c for c in codes if c in expected] == [c for c in expected if c in codes] assert codes[0] == "NUMBER_FORMAT_UNPARSED" and codes.index("TYPE_CONFLICT") < codes.index("NO_ORGANIZATION_NO_INN") + + +# --- числа правее таблицы формы (решение владельца 18.10.2026): не колонки сумм, статус не портят --- + + +def test_numbers_right_of_table_do_not_break_status_in_repeated_segments(rules: Rules) -> None: + rows: list[list[str | None]] = [[*r, None] for r in bal_rows()] + rows[6][3] = "7" # формула аналитика правее таблицы, в строке БАЛАНС (1600) + rows[9][3] = "8" + rows.insert(7, [*HEAD, None]) # повторная шапка второго сегмента (страница 2 формы) + cand = ocr(rows, header=FULL_HEADER, type_hint=DocumentType.BALANCE) + doc = normalize_candidate(cand, rules).document + assert doc is not None and doc.status is DocStatus.DONE + assert [c.column_index for c in doc.columns] == [2] + assert [(w.code, w.severity) for w in doc.warnings] == [("NUMBERS_OUTSIDE_TABLE", Severity.INFO)] + assert all(len(line.values) == 1 for line in doc.lines) diff --git a/api/arm_statements/tests/test_segments.py b/api/arm_statements/tests/test_segments.py index d636409..3f18448 100644 --- a/api/arm_statements/tests/test_segments.py +++ b/api/arm_statements/tests/test_segments.py @@ -187,3 +187,31 @@ def test_continuation_sheet_with_duplicate_date_maps_by_order(rules: Rules) -> N cand = excel(_npz_sheets(duplicate)) layout = find_layout(cand, parse_header(hdr(report_date="30.09.2025"), rules), rules) assert layout.segments[1].value_cols == ((8, 16), (9, 21), (10, 24)) + + +def test_numbers_right_of_table_in_all_segments_give_one_warning(rules: Rules) -> None: + head: list[str | D | None] = ["Наименование", "Код", "На 30.09.2025", "На 31.12.2024", None] + rows: list[list[str | D | None]] = [ + head, + ["Запасы", "1210", D("5"), D("4"), D("9")], + head, + ["Итого", "1200", D("5"), D("4"), D("8")], + ] + layout = find_layout(excel([("Лист1", rows)]), parse_header(hdr(), rules), rules) + assert [w.code for w in layout.warnings] == ["NUMBERS_OUTSIDE_TABLE"] + assert layout.warnings[0].locator == "Лист1!E2\nЛист1!E4" + assert layout.segments[1].value_cols == ((2, 2), (3, 3)) + + +def test_headerless_continuation_sheet_with_outside_numbers(rules: Rules) -> None: + first: list[list[str | D | None]] = [ + ["Наименование", "", "Код", "На 30.09.2025", "На 31.12.2024", None], + ["Запасы", "", "1210", D("5"), D("4"), D("9")], + ] + second: list[list[str | D | None]] = [["Итого", "", "1200", D("5"), D("4"), D("8")]] + cand = excel([("Лист1", first), ("Лист2", second)]) + layout = find_layout(cand, parse_header(hdr(), rules), rules) + assert [w.code for w in layout.warnings] == ["NUMBERS_OUTSIDE_TABLE"] + assert layout.warnings[0].locator == "Лист1!F2\nЛист2!F1" + assert layout.segments[1].value_cols == ((3, 3), (4, 4)) + assert layout.segments[1].extra_cols == () -- 2.47.2 From 2b8fafc3b3472154caeec6f315478fe258df2c2b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 21:57:38 +0400 Subject: [PATCH 09/13] =?UTF-8?q?feat(mapping):=20=D0=9E=D0=A1=D0=92=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D1=83=202.0=20(=D0=A0-28)=20=D0=B8=20=D0=B2=D1=8B=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B5=D1=80=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=20MAJOR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Приём файла результата выбирает модель по MAJOR (parse_result); нечитаемый JSON и неизвестный MAJOR — неповторяемая ошибка схемы (T5.2). Статус ОСВ 2.0 — osv_status_v2, предупреждения листа (OSV_*) — в document_issue. Решения P3-46…P3-49. Co-Authored-By: Claude Opus 5.5 --- api/src/domain/osv_mapping.py | 6 +- api/src/services/result_mapping.py | 90 +++++++++++++---- api/src/services/statement_intake_v2.py | 6 +- api/tests/test_api_documents.py | 25 +++++ api/tests/test_mapping_statements_v2.py | 97 +++++++++++++++++++ api/tests/test_result_mapping.py | 53 ++++++++++ .../unified-normalization-core-b/design.md | 4 + 7 files changed, 258 insertions(+), 23 deletions(-) diff --git a/api/src/domain/osv_mapping.py b/api/src/domain/osv_mapping.py index f367562..e9fc062 100644 --- a/api/src/domain/osv_mapping.py +++ b/api/src/domain/osv_mapping.py @@ -35,6 +35,7 @@ __all__ = [ "account_root", "account_sort_key", "build_entries", + "checks_clean", "counterparty_check", "document_fields", "document_status", @@ -227,7 +228,8 @@ def counterparty_check( return DocumentCheckResult.MATCH if normalized in owner_names_normalized else DocumentCheckResult.MISMATCH -def _checks_clean(checks: OsvChecks | None) -> bool: +def checks_clean(checks: OsvChecks | None) -> bool: + """Контрольные суммы листа (§1.4) без нарушений; `checks` нет — «не проверялось», нарушений нет.""" if checks is None: return True return ( @@ -246,7 +248,7 @@ def document_status( ) -> DocumentStatus: """§9.3, D-7 (OQ-05): `RECOGNIZED` только при задании `done`, `checks` без нарушений, контрагенте шапки не `MISMATCH` и без предупреждений маппинга; иначе `NEEDS_CHECK`.""" - recognized = job_done and _checks_clean(checks) and counterparty != DocumentCheckResult.MISMATCH and not warnings + recognized = job_done and checks_clean(checks) and counterparty != DocumentCheckResult.MISMATCH and not warnings return DocumentStatus.RECOGNIZED if recognized else DocumentStatus.NEEDS_CHECK diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index e5d4971..4ee7802 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -7,7 +7,8 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn 1. короткая транзакция: `pick_candidate` (`FOR UPDATE OF recognition_job SKIP LOCKED`) и проверка ключа результата (бакет хранилища, папка `results/{file_id}/{job_id}/`, OQ-70); -2. БЕЗ транзакции: чтение объекта из S3 и валидация `RecognitionResult` v1.x — блокировки не держатся на время сети; +2. БЕЗ транзакции: чтение объекта из S3 и валидация результата по MAJOR (`1.x` или `2.0`) — блокировки не + держатся на время сети; 3. транзакция маппинга: задание и файл перечитываются под блокировкой (порядок «задание → файл», как у inbox, вахтёра и публикатора, — без взаимоблокировок); файл уже не `PROCESSING` или задание уже не текущее (результат старого задания, Review Focus 1) — ничего не меняется. Иначе `map_file`: очистка документов и строк файла, @@ -39,20 +40,22 @@ from sqlalchemy import Integer, Select, delete, func, insert, select, update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.contract.result import OsvDocument, RecognitionResult, StatementDocument -from src.contract.result_v2 import RecognitionResultV2, StatementDocumentV2 +from src.contract.result_v2 import DocumentWarning, OsvDocumentV2, RecognitionResultV2, StatementDocumentV2 +from src.contract.versions import parse_result from src.core.config import Settings from src.core.storage import ObjectNotFoundError, ObjectStorage from src.domain.osv_mapping import ( EntryDraft, build_entries, + checks_clean, counterparty_check, document_fields, document_status, opening_matches, ) -from src.domain.statement_issues import mapping_warning +from src.domain.statement_issues import IssueDraft, mapping_warning from src.domain.statement_mapping import StatementDraft, build_statement, statement_status, values_ok -from src.domain.statement_status import duplicate_period_forms +from src.domain.statement_status import duplicate_period_forms, has_error_warning, osv_status_v2 from src.models.accounting import ChartOfAccount from src.models.consolidation import Consolidation, ConsolidationVersion from src.models.directories import CompanyGroup, LegalEntity @@ -60,9 +63,12 @@ from src.models.enums import ( AuditAction, AuditSection, DocumentCheckResult, + DocumentIssueKind, DocumentStatus, DocumentType, FileStatus, + IssueSeverity, + IssueSource, OsvDataType, OsvGroupType, PeriodQuarter, @@ -75,7 +81,7 @@ from src.repositories.counterparties import CounterpartyIndex from src.services import consolidation_documents from src.services.audit import AuditService from src.services.file_status import set_file_status_for_job -from src.services.statement_intake_v2 import FormV2, OtherV2, insert_forms_v2, map_statement_docs_v2 +from src.services.statement_intake_v2 import FormV2, OtherV2, file_warnings, insert_forms_v2, map_statement_docs_v2 from src.services.statement_recompute import checks_on_intake_1x from src.services.statements_engine import StatementsEngine, default_engine @@ -204,16 +210,22 @@ def _osv_documents(result: RecognitionResult | RecognitionResultV2) -> list[OsvD return [document for document in result.documents if isinstance(document, OsvDocument)] -async def _load_result(storage: ObjectStorage, candidate: Candidate) -> RecognitionResult: - """Объект результата из S3 и его валидация по контракту; неповторяемые ошибки — `MappingFatal`.""" +async def _load_result(storage: ObjectStorage, candidate: Candidate) -> RecognitionResult | RecognitionResultV2: + """Объект результата из S3 и его валидация по контракту; неповторяемые ошибки — `MappingFatal`. + + Модель выбирается по MAJOR `schema_version` (`contract.versions.parse_result`): 1 — `RecognitionResult` (маппер + `1.x`), 2 — `RecognitionResultV2` (маппер `2.0`); иной MAJOR — итог проверки схемы (P3-46). Нечитаемый JSON и + не-JSON байты — тоже неповторяемая ошибка схемы (T5.2), без текста исключения в `mapping_error`.""" try: content = await storage.get_bytes(candidate.key) except ObjectNotFoundError as exc: raise MappingFatal(f"файл результата не найден в S3: {candidate.bucket}/{candidate.key}") from exc try: - result = RecognitionResult.model_validate_json(content) + result = parse_result(content) except ValidationError as exc: raise MappingFatal(_schema_error_summary(exc)) from exc + except ValueError as exc: # JSONDecodeError, UnicodeDecodeError: pydantic ValidationError перехвачен выше + raise MappingFatal("результат не соответствует схеме recognition-result: файл не является JSON") from exc if result.job_id != candidate.job_id or result.file_id != candidate.file_id: raise MappingFatal( f"файл результата относится к другому заданию: job_id={result.job_id}, file_id={result.file_id}" @@ -316,6 +328,44 @@ async def _q1_root_opening( return (root.opening_debit, root.opening_credit) if root is not None else None +def _osv_warning_issue(warning: DocumentWarning) -> IssueDraft: + """Предупреждение листа ОСВ `2.0` (`OSV_*`, `info`) → `document_issue` вида `warning`, источник `service`. + У ОСВ нет строк и колонок формы: `line_refs` не сохраняются, `column_index` и `field` — как пришли.""" + return IssueDraft( + kind=DocumentIssueKind.warning, + code=warning.code, + severity=IssueSeverity(warning.severity), + message=warning.message, + source=IssueSource.service, + column_index=warning.column_index, + field=warning.field, + ) + + +def _osv_status( + sheet: OsvDocument, + *, + job_done: bool, + file_error: bool, + counterparty: DocumentCheckResult, + mapping_warnings: Sequence[str], +) -> tuple[DocumentStatus, list[IssueDraft]]: + """Статус документа ОСВ и записи `document_issue` сервиса: `2.0` — по Р-28 (`osv_status_v2`, статус листа и + предупреждения листа), `1.x` — по §9.3 (`document_status`, статус задания); у `1.x` записей сервиса нет.""" + if isinstance(sheet, OsvDocumentV2): + status = osv_status_v2( + osv_done=sheet.status == "done", + checks_ok=checks_clean(sheet.checks), + file_error_warning=file_error, + counterparty=counterparty, + sheet_mapping_warnings=bool(mapping_warnings), + ) + return status, [_osv_warning_issue(warning) for warning in sheet.warnings] + return document_status( + job_done=job_done, checks=sheet.checks, counterparty=counterparty, warnings=mapping_warnings + ), [] + + def _group_schema(sheet: OsvDocument) -> dict[str, Any]: return { "locator": sheet.locator, @@ -559,7 +609,8 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай await _clear_file(session, file.id) kept, duplicate_warnings, warnings = _split_duplicates(sheets) documents: list[Document] = [] - sheet_issues: list[tuple[Document, list[str]]] = [] + sheet_issues: list[tuple[Document, list[IssueDraft]]] = [] + file_error = isinstance(result, RecognitionResultV2) and has_error_warning(file_warnings(result)) entries: list[dict[str, Any]] = [] for sheet in kept: fields = document_fields(sheet) @@ -591,6 +642,14 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай check = counterparty_check( sheet.header.organization_inn, sheet.header.organization_name, owner.inn, owner_names ) + mapping_warnings = [*duplicate_warnings.get(sheet.locator, ()), *sheet_warnings] + status, service_drafts = _osv_status( + sheet, + job_done=job_done, + file_error=file_error, + counterparty=check, + mapping_warnings=mapping_warnings, + ) document = Document( id=uuid.uuid4(), stored_file_id=file.id, @@ -602,19 +661,14 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай period_quarter=fields.period_quarter, is_cumulative=is_cumulative, locator=fields.locator, - status=document_status( - job_done=job_done, - checks=sheet.checks, - counterparty=check, - warnings=[*duplicate_warnings.get(sheet.locator, ()), *sheet_warnings], - ), + status=status, period_check=DocumentCheckResult.UNKNOWN, # до версии консолидации (Срез 4) counterparty_check=check, created_at=now, updated_at=now, ) documents.append(document) - sheet_issues.append((document, [*duplicate_warnings.get(sheet.locator, ()), *sheet_warnings])) + sheet_issues.append((document, [*service_drafts, *(mapping_warning(text) for text in mapping_warnings)])) warnings.extend(sheet_warnings) entries.extend( _entry_values( @@ -632,8 +686,8 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай session.add_all(documents) await session.flush() - for document, messages in sheet_issues: # P3-5: предупреждения листа ОСВ — и в `document_issue` - await statements_repo.add_issues(session, document.id, [mapping_warning(text) for text in messages], now=now) + for document, drafts in sheet_issues: # P3-5: предупреждения листа ОСВ (сервис 2.0 и маппинг) — в `document_issue` + await statements_repo.add_issues(session, document.id, drafts, now=now) # T7.2: родитель раньше потомка на любой границе пачки (self-FK `parent_entry_id`) — не полагаемся на порядок строк # результата; сортировка устойчивая, внутри уровня порядок строк сохраняется entries.sort(key=lambda values: values["level"]) diff --git a/api/src/services/statement_intake_v2.py b/api/src/services/statement_intake_v2.py index e44e668..f623e51 100644 --- a/api/src/services/statement_intake_v2.py +++ b/api/src/services/statement_intake_v2.py @@ -34,7 +34,7 @@ from src.repositories import statements as statements_repo from src.services.statements_engine import DocStatus, FormVariant, StatementsEngine from src.services.statements_engine import DocumentType as PackageDocumentType -__all__ = ["FormV2", "OtherV2", "insert_forms_v2", "map_statement_docs_v2"] +__all__ = ["FormV2", "OtherV2", "file_warnings", "insert_forms_v2", "map_statement_docs_v2"] @dataclass(frozen=True) @@ -128,7 +128,7 @@ def map_statement_docs_v2( # noqa: PLR0913 - все входы именован return forms, others, warnings, repeats -def _file_warnings(result: RecognitionResultV2) -> list[dict[str, object]]: +def file_warnings(result: RecognitionResultV2) -> list[dict[str, object]]: """`metrics.warnings` файла результата структурами (P3-15).""" if result.metrics is None or result.metrics.warnings is None: return [] @@ -148,7 +148,7 @@ async def insert_forms_v2( # noqa: PLR0913 - все входы именован ) -> None: """Форма как пришла, `document_issue` и статус каждого документа формы; предупреждения `DISCLOSURE` — в `document_issue` его единственного документа. Без commit.""" - file_error = has_error_warning(_file_warnings(result)) + file_error = has_error_warning(file_warnings(result)) catalog = load_line_catalog() for other in others: drafts: list[IssueDraft] = [] diff --git a/api/tests/test_api_documents.py b/api/tests/test_api_documents.py index cb868f8..899eed2 100644 --- a/api/tests/test_api_documents.py +++ b/api/tests/test_api_documents.py @@ -483,3 +483,28 @@ async def test_period_quarter_enum_in_card(seeded: AsyncSession, auth_client: Au body = (await client.get(f"/documents/{document.id}")).json() assert (body["period_quarter"], body["is_cumulative"]) == ("Q3", False) + + +async def test_osv_v2_warnings_in_card_issues(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Предупреждения листа ОСВ результата `2.0` (`OSV_*`) — в `DocumentOut.issues` (источник `service`, `info`).""" + # ленивые импорты: `tests.fixtures_helpers` тяжёлый и нужен одному тесту + from src.contract import RecognitionResultV2 # noqa: PLC0415 + from tests.fixtures_helpers import map_golden_snapshot # noqa: PLC0415 + + def build(_path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> RecognitionResultV2: + example = Path(__file__).resolve().parents[1] / "contracts" / "examples" / "result-osv-needs-check.json" + payload = json.loads(example.read_text("utf-8")) + return RecognitionResultV2.model_validate({**payload, "job_id": str(job_id), "file_id": str(file_id)}) + + entity = await _rostov_entity(seeded) + (document,) = await map_golden_snapshot( + seeded, entity=entity, path=Path("result-v2.json"), uploaded_by=None, result_builder=build + ) + client = await auth_client(KA_RF) + + body = (await client.get(f"/documents/{document.id}")).json() + + service = [issue for issue in body["issues"] if issue["source"] == "service"] + assert {issue["code"] for issue in service} == {"OSV_HIERARCHY_BY_OUTLINE", "OSV_BALANCE_IDENTITY"} + assert all(issue["kind"] == "warning" and issue["severity"] == "info" for issue in service) + assert body["status"] == "NEEDS_CHECK" diff --git a/api/tests/test_mapping_statements_v2.py b/api/tests/test_mapping_statements_v2.py index 465fa7f..c331325 100644 --- a/api/tests/test_mapping_statements_v2.py +++ b/api/tests/test_mapping_statements_v2.py @@ -430,3 +430,100 @@ async def test_value_cells_and_empty_records(seeded: AsyncSession) -> None: empty = cells[(stored[0].id, 3)] assert (empty.amount, empty.source_text, empty.needs_review) == (None, None, False) assert cells[(stored[1].id, 2)].needs_review is True + + +# --- ОСВ по контракту 2.0 (B5, Р-28) -------------------------------------------------------------------------------- + + +def _osv_doc( + example: str = "result-osv-minimal.json", *, account: str = "60", locator: str | None = None +) -> dict[str, Any]: + """Лист ОСВ из примера docs; `account` — счёт шапки, `locator` — уникальный `locator` листа.""" + doc = json.loads((EXAMPLES / example).read_text("utf-8"))["documents"][0] + doc["header"]["account_code_raw"] = account + if locator is not None: + doc["locator"] = locator + return doc + + +async def _osv_issues(session: AsyncSession, document: Document) -> dict[str, DocumentIssue]: + return {issue.code: issue for issue in await _issues(session, document)} + + +async def test_osv_in_book_with_needs_check_income(seeded: AsyncSession) -> None: + """Сценарий спеки: ОСВ `done` с сошедшимися суммами + ОФР `needs_check`, конверт `needs_check`, ошибок файла нет → + ОСВ `CONFIRMED` (автоподтверждение), её `OSV_*` — в `document_issue`.""" + entity = await _entity(seeded) + osv = _osv_doc() + osv["warnings"] = [{"code": "OSV_HIERARCHY_BY_OUTLINE", "severity": "info", "message": "иерархия по outline"}] + income = statement_doc_v2("INCOME", status="needs_check", lines=INCOME_LINES) + documents = await _map(seeded, entity, [osv, income], status="needs_check") + osv_document, income_document = documents + assert osv_document.document_type is DocumentType.OSV and income_document.status is DocumentStatus.NEEDS_CHECK + assert osv_document.status is DocumentStatus.RECOGNIZED + issue = (await _osv_issues(seeded, osv_document))["OSV_HIERARCHY_BY_OUTLINE"] + assert issue.kind is DocumentIssueKind.warning and issue.source is IssueSource.service + assert issue.severity.value == "info" and issue.message == "иерархия по outline" + await _confirm_clean(seeded, [osv_document]) + assert osv_document.status is DocumentStatus.CONFIRMED + + +async def test_osv_v2_examples(seeded: AsyncSession) -> None: + """Примеры docs: `result-osv-minimal.json` — `RECOGNIZED`; `result-osv-needs-check.json` (`status` листа + `needs_check`, нарушение тождества) — `NEEDS_CHECK`, оба `OSV_*` в issues.""" + entity = await _entity(seeded) + await map_golden_snapshot( + seeded, + entity=entity, + path=V2_PATH, + uploaded_by=None, + result_builder=_example_builder("result-osv-minimal.json"), + ) + await map_golden_snapshot( + seeded, + entity=entity, + path=V2_PATH, + uploaded_by=None, + result_builder=_example_builder("result-osv-needs-check.json"), + ) + rows = (await seeded.execute(select(Document).where(Document.legal_entity_id == entity.id))).scalars().all() + by_status = {document.status: document for document in rows} + assert set(by_status) == {DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK} + assert not await _issues(seeded, by_status[DocumentStatus.RECOGNIZED]) + codes = set(await _osv_issues(seeded, by_status[DocumentStatus.NEEDS_CHECK])) + assert {"OSV_HIERARCHY_BY_OUTLINE", "OSV_BALANCE_IDENTITY"} <= codes + + +async def test_two_osv_v2_ordinals(seeded: AsyncSession) -> None: + """Два листа ОСВ (60 и 62) в одном файле: `file_ordinal` 0 и 1.""" + entity = await _entity(seeded) + documents = await _map( + seeded, entity, [_osv_doc(account="60", locator="sheet:A"), _osv_doc(account="62", locator="sheet:B")] + ) + assert [(d.osv_account_code, d.file_ordinal) for d in documents] == [("60", 0), ("62", 1)] + + +async def test_osv_v2_error_file_warning_blocks_recognized(seeded: AsyncSession) -> None: + """Предупреждение файла `error` (Р-28) — ОСВ `NEEDS_CHECK`, хотя лист `done` и суммы сошлись.""" + entity = await _entity(seeded) + (document,) = await _map(seeded, entity, [_osv_doc()], file_warnings=[ERROR_JSON_FAILED]) + assert document.status is DocumentStatus.NEEDS_CHECK + + +async def test_osv_v2_mapping_warning_blocks_recognized(seeded: AsyncSession) -> None: + """Повтор счёта в файле — предупреждение маппинга по листу: первый лист `NEEDS_CHECK`, запись в issues + (источник `core`, `MAPPING_WARNING`).""" + entity = await _entity(seeded) + (document,) = await _map(seeded, entity, [_osv_doc(locator="sheet:A"), _osv_doc(locator="sheet:B")]) + assert document.status is DocumentStatus.NEEDS_CHECK + issue = (await _osv_issues(seeded, document))["MAPPING_WARNING"] + assert issue.source is IssueSource.core and "sheet:B" in issue.message + + +async def test_osv_v2_sheet_needs_check_blocks_recognized(seeded: AsyncSession) -> None: + """`osvDocument.status = needs_check` при чистых суммах — `NEEDS_CHECK` (статус конверта не важен).""" + entity = await _entity(seeded) + osv = _osv_doc() + osv["status"] = "needs_check" + (document,) = await _map(seeded, entity, [osv]) + assert document.status is DocumentStatus.NEEDS_CHECK diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index d0c7615..d05c3a0 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -1981,3 +1981,56 @@ async def test_osv_orphans_mapping_warning_issue(seeded: AsyncSession) -> None: assert (orphans.kind.value, orphans.severity.value, orphans.source.value) == ("warning", "info", "core") job = await seeded.get(RecognitionJob, file.current_recognition_job_id) assert job is not None and any("без родителя" in w for w in job.metrics[MAPPING_WARNINGS_KEY]) + + +# --- приём файла результата: выбор по MAJOR, нечитаемый JSON (B5) --------------------------------------------------- + + +def _result_key(file_id: uuid.UUID, job_id: uuid.UUID) -> str: + return f"results/{file_id}/{job_id}/result.json" + + +async def test_result_major_3_is_fatal(committed_sessionmaker: async_sessionmaker[AsyncSession]) -> None: + """P3-46: неизвестный MAJOR (`3.0`) — неповторяемая ошибка схемы: файл `ERROR`, без документов и повтора.""" + maker, storage = committed_sessionmaker, FakeStorage() + file_id, job_id, _ = await _prepare(maker, storage, _minimal() | {"schema_version": "3.0"}) + + assert await process_once(maker, storage, _settings()) is True + + assert (await _file(maker, file_id)).status is FileStatus.ERROR + job = await _job(maker, job_id) + assert job.metrics[MAPPING_ERROR_KEY].startswith("результат не соответствует схеме") + assert MAPPING_ATTEMPTS_KEY not in job.metrics + assert await _documents(maker, file_id) == [] + assert await process_once(maker, storage, _settings()) is False + + +async def test_result_1x_still_mapped(committed_sessionmaker: async_sessionmaker[AsyncSession]) -> None: + """MAJOR 1 (`1.2`) — маппер `1.x`: документ ОСВ создан, файл `PROCESSED`.""" + maker, storage = committed_sessionmaker, FakeStorage() + file_id, _, _ = await _prepare(maker, storage, _minimal() | {"schema_version": "1.2"}, owner_inn="7700000000") + + assert await process_once(maker, storage, _settings()) is True + + assert (await _file(maker, file_id)).status is FileStatus.PROCESSED + assert len(await _documents(maker, file_id)) == 1 + + +@pytest.mark.parametrize("content", [b"{not json", b"", b"\xff\xfe\x00", b"[1, 2]", b"null"]) +async def test_unreadable_result_json_is_fatal( + committed_sessionmaker: async_sessionmaker[AsyncSession], content: bytes +) -> None: + """T5.2: невалидный JSON / не объект — `MappingFatal` («результат не соответствует схеме…»): файл `ERROR`, + без повтора, без текста исключения парсера в `mapping_error`.""" + maker, storage = committed_sessionmaker, FakeStorage() + file_id, job_id, _ = await _prepare(maker, storage, _minimal()) + storage.objects[_result_key(file_id, job_id)] = content + + assert await process_once(maker, storage, _settings()) is True + + assert (await _file(maker, file_id)).status is FileStatus.ERROR + job = await _job(maker, job_id) + assert job.metrics[MAPPING_ERROR_KEY].startswith("результат не соответствует схеме recognition-result") + assert "Expecting" not in job.metrics[MAPPING_ERROR_KEY] + assert MAPPING_ATTEMPTS_KEY not in job.metrics + assert await process_once(maker, storage, _settings()) is False diff --git a/openspec/changes/unified-normalization-core-b/design.md b/openspec/changes/unified-normalization-core-b/design.md index 9c232ae..35cfd2d 100644 --- a/openspec/changes/unified-normalization-core-b/design.md +++ b/openspec/changes/unified-normalization-core-b/design.md @@ -54,3 +54,7 @@ Goals и Non-goals — см. `proposal.md`. | 2026-10-18 | **P3-43 (B4).** Результат с повтором `row_index` или `ref` у строк: вторая и далее строка пропускается с `MAPPING_WARNING`; `detail` с `parent_ref`, которого нет, хранится с `parent_row_index = null` и тем же предупреждением | уникальность `(meta_id, source_ref)` и ключа строки при вставке иначе роняет приём (файл `ERROR` после попыток); схема уникальности `ref`/`row_index` не проверяет | потерянная строка дубля видна только в предупреждении | | 2026-10-18 | **P3-44 (B4).** Запись `relation` / `soft` из `checks` результата несёт `column_id` (колонка с таким `column_index`, если есть) и всегда `column_index` (как P3-33); запись `warning` с `column_index` — либо `column_id` (колонка есть), либо `column_index` (колонки нет) | спека «Пропущенная колонка» и ответ API, читающий индекс из записи | нет | | 2026-10-18 | **P3-45 (B4).** `is_current` колонок `2.0` — как у `1.x` (колонка с самой поздней датой, при равенстве меньший `column_index`; нет дат — `NULL`), в том числе у колонок `relative:*` с датой; значение, которого нет в результате, пишется с `is_recognized = NULL`, остальными полями пустыми и `needs_review = false` | §9.5 п. 2; спека «Хранение результата формы v2.0» | колонка `relative:*` без даты не станет текущей | +| 2026-10-18 | **P3-46 (B5).** `_load_result` выбирает модель по MAJOR через `contract.versions.parse_result`; неизвестный MAJOR, неверный формат версии и любое нарушение схемы — `MappingFatal` с итогом проверки схемы (`результат не соответствует схеме recognition-result: ошибок N, первая: …`), файл `ERROR`, без повтора | спека «Приём файла результата»; D-2 | файл с MAJOR 3 не мапится, а не обрабатывается молча как `1.x` | +| 2026-10-18 | **P3-47 (B5, T5.2 части А).** Нечитаемый JSON (`JSONDecodeError`, не UTF-8) в `_load_result` — тоже `MappingFatal` с текстом «результат не соответствует схеме recognition-result: файл не является JSON» (текст исключения парсера пользователю не показывается; M-1); не-объект (`[]`, `null`) отсекается моделью как нарушение схемы | иначе `ValueError` из `parse_result` уходил бы в повторяемые ошибки и файл `ERROR` получал бы только после `MAPPING_MAX_ATTEMPTS` попыток | ошибка JSON не отличается в тексте от прочих ошибок схемы | +| 2026-10-18 | **P3-48 (B5).** Статус ОСВ `2.0` — `osv_status_v2(osv_done = sheet.status == "done", checks_ok, file_error_warning, counterparty, sheet_mapping_warnings)`; «предупреждения маппинга листа» = повтор счёта + сироты + «нарастающая по данным Q1» (вход `document_status` `1.x` тот же); у `1.x` статус как прежде (§9.3, статус задания). Предупреждение уровня `error` ищется в `metrics.warnings` результата (`file_warnings`), а не в `recognition_job.metrics` | Р-28; единый источник с формами | ОСВ с `error` в метриках файла — `NEEDS_CHECK` | +| 2026-10-18 | **P3-49 (B5).** `osvDocument.warnings` пишутся в `document_issue` как `warning` / `service` с `severity` из предупреждения (по контракту `info`, `error` не отбрасывается); `line_refs` у ОСВ не сохраняются (строк формы нет), `column_index` и `field` — как пришли. Записи сервиса идут раньше записей маппинга ядра. Дубль листа (повтор счёта) своих предупреждений/статуса не вносит — документом остаётся первый лист | у ОСВ нет `line_ids`/колонок формы; повтор — D-7 | `error`-предупреждение листа ОСВ в `issues` не влияет на статус само по себе (статус — по `status` листа) | -- 2.47.2 From 570e82b5d35ab3e01b44330990b3f3983e47c406 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 22:08:27 +0400 Subject: [PATCH 10/13] =?UTF-8?q?refactor(statements):=20=D1=83=D0=B4?= =?UTF-8?q?=D0=B0=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BA=D0=BE=D0=BF=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D1=8F=20=C2=A77=20?= =?UTF-8?q?=D1=8F=D0=B4=D1=80=D0=B0=20=E2=80=94=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B8=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B5=D0=B9?= =?UTF-8?q?=20arm=5Fstatements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Удалены api/rules/statement_checks.yaml, load_check_rules, run_checks и правила соотношений; в domain/statement_checks.py остались межформенная проверка 1370/2400 и subline_parents. Тест test_name_dictionary_codes_in_code_orders сверяет справочник имён с перечнями редакций пакета. CLAUDE.md и README.md: §7 — копия arm_statements, нарушения хранятся в document_issue. Решения P3-50…P3-52. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 14 +- README.md | 14 +- api/rules/statement_checks.yaml | 29 --- api/src/contract/result.py | 2 +- api/src/domain/statement_aggregate.py | 3 +- api/src/domain/statement_checks.py | 192 +------------- api/tests/test_domain_statement_checks.py | 237 ++---------------- api/tests/test_domain_statement_lines.py | 14 ++ .../unified-normalization-core-b/design.md | 3 + 9 files changed, 65 insertions(+), 443 deletions(-) delete mode 100644 api/rules/statement_checks.yaml diff --git a/CLAUDE.md b/CLAUDE.md index eefb45c..094aecf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ ОСВ (§1.3, §1.5, §9.3); Срез 4 — `consolidation_periods.py` (выбор источников и приведение периодов, §2, Q1-эквивалентность ОСВ, R-14), `consolidated_osv.py` (чистая сборка свода: склейка кварталов, пробелы, дорезолв, фильтры, §2–§3), `checklist.py` (чек-лист комплекта, ячейки `CellStatus`); Срез 3b — `statement_mapping.py` (формы ББ/ОФР: период, черновик, статус D-6), - `statement_checks.py` (проверки §7 по словарю `api/rules/statement_checks.yaml`), `checklist_csv.py` (CSV чек-листа); + `statement_checks.py` (остатки §7 в ядре: межформенная 1370↔2400 и `subline_parents`; соотношения §7 считает `arm_statements`), `checklist_csv.py` (CSV чек-листа); `policy.py` дополнен `IN_WORK_STATUSES`/`ensure_version_editable`, §10; для шахматки и КФО — Срезы 5–6), `contract/` (модели контракта обмена), `api/scripts/` (`check_dbml_vs_orm.py`, `seed.py` + `seed_lib/`, `check_infra.py`), данные seed — `api/seed/`. @@ -89,9 +89,12 @@ функции) + `repositories/statements.py` (`financial_statement_meta/column/line/value`); форма создаётся с тем, что есть в результате (колонки могут отсутствовать); второй `BALANCE`/`INCOME` того же файла пропускается с предупреждением (OQ-77), `DISCLOSURE`/`OTHER` — один документ без данных на тип (`RECOGNIZED`, D-3); документ `BALANCE`/`INCOME` без `meta` (создан до Среза 3b) подтверждается без условий D-9, `…/statement` по нему — 409 - `DOCUMENT_NOT_STATEMENT` (решение №16). Проверки §7 — `domain/statement_checks.py` по словарю `api/rules/statement_checks.yaml`; **файл — - копия словаря tabular-parser, при изменении правится в обоих репозиториях** (расхождение молчаливо меняет результат проверок). - Проверки считаются на лету и не хранятся; хранится только акцепт (`checks_accepted_by/at/comment` в шапке). Правка формы и акцепт — + `DOCUMENT_NOT_STATEMENT` (решение №16). Проверки §7 — словарь и расчёт в пакете `arm_statements` (`api/arm_statements/`, копия, напрямую не править — + `../docs/scripts/sync-statements.sh`); **копии словаря §7 в ядре нет** (удалены `api/rules/statement_checks.yaml`, + `load_check_rules`, `run_checks`). Нарушения при приёме результата и после правки пишет пакет через + `services/statements_engine.py` и они **хранятся в `document_issue`** (`relation` / `soft`, `codes`, `line_ids`); ответ + формы читает их оттуда. В `domain/statement_checks.py` остались только межформенная проверка 1370↔2400 (на лету, допуск — + `engine.tolerance()`) и `subline_parents` (`parent_code` ответа). Акцепт — `checks_accepted_by/at/comment` в шапке. Правка формы и акцепт — `services/statements.py` (`PATCH …/statement`, `POST …/statement/checks/accept`): **только до подтверждения** (`CONFIRMED` → `DOCUMENT_READONLY`), аудит `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`, `object_type=document`; один запрос — одно событие), первичка рядом (`original_amount`, `*_original_value`, `is_modified`/`*_modified`); любая правка сбрасывает акцепт и пересчитывает статус @@ -99,7 +102,8 @@ остаётся устаревший акцепт. Подтверждение формы (`services/documents._check_statement`) требует период (`PERIOD_REQUIRED`) и пройденные/акцептованные жёсткие проверки (`CHECKS_NOT_PASSED`, `detail.failures`); мягкие (в т. ч. межформенная 1370↔2400) не блокируют. Полный состав формы — `domain/statement_lines.py` по справочнику `api/rules/statement_lines.yaml` (редакции `66n`/`fsbu4` - из шаблонов банка; копия словаря scan-gateway с ним не совпадает): `GET …/statement` достраивает строки и ячейки с + из шаблонов банка; это справочник имён и состава экрана — перечни кодов редакций живут в `arm_statements` + (`Rules.code_orders`, `engine.editions()`/`engine.code_order(...)`), согласованность проверяет `test_name_dictionary_codes_in_code_orders`): `GET …/statement` достраивает строки и ячейки с `id = null`, `PATCH` по `line_code` создаёт строку (`line_code_original_value = NULL` — «не из источника»); пустые созданные строки не участвуют в §7 (`statement_amounts`). Решения — `openspec/changes/statement-template-lines/design.md`. Комментарии к документу — `services/documents.py` (`comments`/`add_comment`, `comment(object_type='document')`, без аудита; `comments_count` в карточках). `GET /files/{id}/content` — `services/primary_files.content` + diff --git a/README.md b/README.md index 63a44eb..2ab2f94 100644 --- a/README.md +++ b/README.md @@ -563,9 +563,10 @@ S3 (storage); без S3 — предупреждение в логе, цикл `modified_by/at`; у полей шапки — `*_modified`, `*_original_value`). Период формы — по дате отчёта, а при её отсутствии по самой поздней дате колонок; ББ/ОФР всегда нарастающие. Статус документа формы — `RECOGNIZED` только если задание `done`, нет нарушений проверок §7 (или они акцептованы), контрагент не `MISMATCH`, период определён, значения разобраны, есть строки и нет -предупреждений; иначе `NEEDS_CHECK` (правило D-6 спеки). Проверки §7 (`domain/statement_checks.py`) считаются на лету по -текущим суммам формы и не хранятся; словарь — `api/rules/statement_checks.yaml` (копия словаря tabular-parser — правится -в обоих репозиториях). +предупреждений; иначе `NEEDS_CHECK` (правило D-6 спеки). Проверки §7 считает пакет `arm_statements` (копия в `api/arm_statements/`, +обновляется `sync-statements`) при приёме результата и после каждой правки; нарушения хранятся в `document_issue` +(`relation` / `soft`). Копии словаря §7 в ядре нет; в `domain/statement_checks.py` остались межформенная проверка 1370↔2400 +(на лету, допуск — из правил пакета) и `subline_parents`. - **`GET /documents/{id}/statement`** — `{document, edition, header, columns[], lines[], checks, issues[], edition_lines[]}`: `header` — `inn`, `report_date`, `units` вида `{value, original_value, confidence, verified, modified}` (`confidence` есть у ИНН, у даты и единиц — `null`) и справочные поля формы @@ -619,7 +620,8 @@ S3 (storage); без S3 — предупреждение в логе, цикл Комментарии аудит не пишут; при удалении файла комментарии его документов удаляются вместе с документами (полиморфная связь без FK; решение №18 закрыто). -Расчёт — `src/domain/statement_mapping.py` и `src/domain/statement_checks.py` (чистые функции), доступ к данным — +Расчёт — `src/domain/statement_mapping.py`, `src/domain/statement_checks.py` (межформенная проверка) и пакет +`arm_statements` через `src/services/statements_engine.py` (чистые функции), доступ к данным — `src/repositories/statements.py`, правка и акцепт — `src/services/statements.py`. ## Удаление и замена @@ -1120,7 +1122,7 @@ make migration-cycle # проверить цикл upgr api/ run_server.py точка входа (uvicorn; хост и порт — из настроек) alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16, 0005_dbml_v5_17.py — v5.17, 0006_dbml_v5_18.py — v5.18, 0007_dbml_v5_19.py — v5.19) - rules/ statement_checks.yaml — словарь проверок §7 формы ББ/ОФР (копия словаря tabular-parser; меняется в обоих репозиториях) + rules/ statement_lines.yaml — справочник имён строк форм ББ/ОФР по редакциям (словарь проверок §7 — в пакете arm_statements) seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист) seed/dev/ dev-данные: филиалы и пользователи по ролям scripts/ check_dbml_vs_orm.py (ORM <-> DBML), seed.py и seed_lib/ (загрузчик справочников) @@ -1143,7 +1145,7 @@ api/ common/ доменные ошибки (`{detail: {code, message}}`), часы domain/ чистые функции по §§ правил, без БД: policy (видимость), directory_import (§13), aliases (§3), inn, file_types (тип файла по расширению и сигнатуре), osv_mapping (результат парсера → документы/строки, §1.3, §1.5, §9.3); - Срез 3b — statement_mapping (формы ББ/ОФР: период, статус, §9.3, §9.5), statement_checks (проверки §7), + Срез 3b — statement_mapping (формы ББ/ОФР: период, статус, §9.3, §9.5), statement_checks (межформенная проверка §7, родители подстрок), checklist_csv (CSV чек-листа); Срез 4 — consolidation_periods (выбор источников, §2), consolidated_osv (свод), checklist (чек-лист), policy (`ensure_version_editable`, §10) diff --git a/api/rules/statement_checks.yaml b/api/rules/statement_checks.yaml deleted file mode 100644 index 317466c..0000000 --- a/api/rules/statement_checks.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# КОПИЯ tabular-parser/api/rules/statement_checks.yaml (Срез 3b, design D-4): меняется в обоих репозиториях одновременно. -# Межрепозиторного теста идентичности нет сознательно (решение №10); расхождение копий — известный риск (03-architecture). -# Соотношения §7 (05-domain-rules.md), ББ и ОФР. Данные, не код: новое соотношение — строка здесь. -# relation: «<код> = <код> [+|-] <код> …», только четырёхзначные коды; strict: нарушение переводит документ в needs_check -# (false — только предупреждение, ASSUMPTION(OQ-12)); abs_terms: коды, которые 1С выгружает со знаком минус, а 66н — в -# скобках: правая часть считается и как записано, и с этими членами по модулю (design D-7). -tolerance: 1 # допуск в единицах формы (тыс. руб. округляются построчно) -# OQ-138 (решение владельца 06.10.2026): единицы шапки верны, расхождение до 0,001 % итоговой строки — округление. -# Жёсткое соотношение с расхождением больше `tolerance`, но не больше этой доли от наибольшего по модулю числа -# соотношения (итог или слагаемое: итог бывает малой разностью больших слагаемых), не портит статус: -# уходит в soft_relations_failed с предупреждением «в пределах округления». -rounding_tolerance_percent: 0.001 -subline_check: true # «сумма пятизначных подстрок = родитель» — мягкая проверка, генерируется по коду -relations: - - {form: BALANCE, relation: "1100 = 1105 + 1110 + 1120 + 1130 + 1140 + 1150 + 1160 + 1170 + 1180 + 1190", strict: true} - - {form: BALANCE, relation: "1200 = 1210 + 1215 + 1220 + 1230 + 1240 + 1250 + 1260", strict: true} - - {form: BALANCE, relation: "1300 = 1310 + 1320 + 1340 + 1350 + 1360 + 1370", strict: true} - - {form: BALANCE, relation: "1400 = 1410 + 1420 + 1430 + 1450", strict: true} - - {form: BALANCE, relation: "1500 = 1510 + 1520 + 1530 + 1540 + 1550", strict: true} - - {form: BALANCE, relation: "1600 = 1100 + 1200", strict: true} - - {form: BALANCE, relation: "1700 = 1300 + 1400 + 1500", strict: true} - - {form: BALANCE, relation: "1600 = 1700", strict: true} - - {form: INCOME, relation: "2100 = 2110 - 2120", strict: true, abs_terms: ["2120"]} - - {form: INCOME, relation: "2200 = 2100 - 2210 - 2220", strict: true, abs_terms: ["2210", "2220"]} - - {form: INCOME, relation: "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350", strict: true, abs_terms: ["2330", "2350"]} - - {form: INCOME, relation: "2400 = 2300 - 2410 + 2420", strict: false, abs_terms: ["2410"]} - - {form: INCOME, relation: "2400 = 2300 + 2410 + 2460", strict: false} - - {form: INCOME, relation: "2410 = 2411 + 2412", strict: false} - - {form: INCOME, relation: "2500 = 2400 + 2510 + 2520 + 2530", strict: false} diff --git a/api/src/contract/result.py b/api/src/contract/result.py index 1af260b..867d90d 100644 --- a/api/src/contract/result.py +++ b/api/src/contract/result.py @@ -172,7 +172,7 @@ class FailedRelation(ContractModel): """ column_index: IntLike - relation: str # «1600 = 1100 + 1200» — текст из словаря statement_checks.yaml + relation: str # «1600 = 1100 + 1200» — текст из словаря §7 пакета arm_statements expected: Money actual: Money diff --git a/api/src/domain/statement_aggregate.py b/api/src/domain/statement_aggregate.py index 590280f..3e01a9e 100644 --- a/api/src/domain/statement_aggregate.py +++ b/api/src/domain/statement_aggregate.py @@ -74,7 +74,8 @@ class _Column(Protocol): def checks_input( columns: Sequence[_Column], aggregates: Mapping[str, CodeAggregate] ) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]: - """Вход `run_checks`: индексы колонок (по возрастанию) и `(код, {column_index: сумма})` по возрастанию кода. + """Коды формы для `subline_parents` и межформенной проверки: индексы колонок (по возрастанию) и + `(код, {column_index: сумма})` по возрастанию кода. `columns` — колонки формы (`id`, `column_index`). Код без строки источника и без единой суммы (пустая шаблонная строка) пропускается (design D-5 шаблонных строк).""" diff --git a/api/src/domain/statement_checks.py b/api/src/domain/statement_checks.py index ea50d21..25bd049 100644 --- a/api/src/domain/statement_checks.py +++ b/api/src/domain/statement_checks.py @@ -1,152 +1,46 @@ -"""Логические проверки форм ББ/ОФР §7 в ядре (spec statement-checks; design D-4, D-5). +"""Остатки проверок §7 в ядре: межформенная проверка и родители подстрок (spec statement-checks; design D-5). -Перенос `tabular-parser/api/src/parser/statements/checks.py` по семантике: соотношение по каждой колонке, только если -в форме есть строка его цели (пустая сумма `None` = 0: оба производителя отдают значение на каждую колонку каждой -строки); отсутствующий код и пустая сумма в правой части — 0; правая часть считается как записано и с `abs_terms` по -модулю, выполнено, если хоть один вариант в пределах `tolerance`; жёсткое нарушение не больше -`rounding_tolerance_percent` от наибольшего по модулю числа соотношения — мягкое; «сумма пятизначных подстрок = -четырёхзначный родитель» — мягкая и только если в колонке есть хоть одна непустая подстрока (AURORA-1527). -Результат не хранится (DBML). +Соотношения §7 по колонкам формы (в т. ч. «сумма пятизначных подстрок = четырёхзначный родитель») считает пакет +`arm_statements` при приёме и пересчёте; нарушения хранятся в `document_issue`. Копии словаря в ядре нет (часть Б, B6). +Здесь остались то, чего пакет не знает: межформенная проверка 1370 (ББ) ↔ 2400 (ОФР) — она по двум документам — и +привязка пятизначных подстрок к родителям для поля `parent_code` ответа формы (AURORA-1527). """ -import functools -import re -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from dataclasses import dataclass -from decimal import Decimal, InvalidOperation -from pathlib import Path -from typing import Literal, cast - -import yaml +from decimal import Decimal __all__ = [ "CROSS_FORM_RELATION", - "CheckRules", - "ChecksReport", "FailedCheck", - "RelationRule", "cross_form_check", - "load_check_rules", - "run_checks", "subline_parents", ] -Form = Literal["BALANCE", "INCOME"] -RULES_PATH = Path(__file__).resolve().parents[2] / "rules" / "statement_checks.yaml" CROSS_FORM_RELATION = "1370 − 1370(начало года) = 2400" -# диапазоны четырёхзначных кодов форм для генерации подстрок (§12 п. 2; в tabular-parser — -# statement_headers.yaml) -CODE_RANGES: dict[str, tuple[int, int]] = {"BALANCE": (1100, 1799), "INCOME": (2100, 2999)} -_ZERO = Decimal("0") -_HUNDRED = Decimal("100") _PARENT_LEN = 4 _GROUP_LEN = 3 -_RELATION = re.compile(r"^\s*(?P\d{4})\s*=\s*(?P\d{4,5}(\s*[+\-]\s*\d{4,5})*)\s*$") -_TERM = re.compile(r"([+\-])?\s*(\d{4,5})") - - -@dataclass(frozen=True) -class RelationRule: - form: Form - relation: str - target: str - terms: tuple[tuple[int, str], ...] - strict: bool - abs_terms: frozenset[str] = frozenset() - - -@dataclass(frozen=True) -class CheckRules: - tolerance: Decimal - rounding_tolerance_percent: Decimal - subline_check: bool - relations: tuple[RelationRule, ...] - - def relations_for(self, form: str) -> tuple[RelationRule, ...]: - return tuple(rule for rule in self.relations if rule.form == form) @dataclass(frozen=True) class FailedCheck: + """Межформенное нарушение (`column_index = -1`); `codes` — коды для подсветки строк ответа (1370 и 2400).""" + column_index: int relation: str expected: Decimal actual: Decimal - codes: tuple[str, ...] = () # цель и члены правила (P3-14); у межформенной — 1370 и 2400 - - -@dataclass(frozen=True) -class ChecksReport: - failed: tuple[FailedCheck, ...] - soft_failed: tuple[FailedCheck, ...] - - -def _parse_relation(form: Form, relation: str, strict: bool, abs_terms: Sequence[str]) -> RelationRule: - found = _RELATION.match(relation) - if found is None: - raise ValueError(f"соотношение «{relation}»: ожидается «<код> = <код> [+|-] <код> …»") - terms = tuple((-1 if sign == "-" else 1, code) for sign, code in _TERM.findall(found.group("rhs"))) - unknown = set(abs_terms) - {code for _, code in terms} - if unknown: - raise ValueError(f"abs_terms {sorted(unknown)} нет в правой части «{relation}»") - return RelationRule(form, relation, found.group("target"), terms, strict, frozenset(abs_terms)) - - -@functools.lru_cache(maxsize=4) -def load_check_rules(path: Path | None = None) -> CheckRules: - """Словарь §7 из yaml; любая ошибка — `ValueError` с именем файла.""" - source = path or RULES_PATH - try: - data = yaml.safe_load(source.read_text(encoding="utf-8")) - relations = tuple( - _parse_relation(item["form"], item["relation"], bool(item["strict"]), item.get("abs_terms", ())) - for item in data["relations"] - ) - for rule in relations: - if rule.form not in CODE_RANGES: - raise ValueError(f"форма {rule.form!r}: ожидается BALANCE или INCOME") - return CheckRules( - tolerance=Decimal(str(data["tolerance"])), - rounding_tolerance_percent=Decimal(str(data.get("rounding_tolerance_percent", 0))), - subline_check=bool(data.get("subline_check", True)), - relations=relations, - ) - except (OSError, KeyError, TypeError, ValueError, InvalidOperation, yaml.YAMLError) as exc: - raise ValueError(f"словарь проверок {source.name}: {exc}") from exc - - -def _rhs(amounts: Mapping[str, Decimal], rule: RelationRule, *, absolute: bool) -> Decimal: - total = _ZERO - for sign, code in rule.terms: - value = amounts.get(code, _ZERO) - if absolute and code in rule.abs_terms: - value = abs(value) - total += sign * value - return total - - -def _holds(amounts: Mapping[str, Decimal], rule: RelationRule, tolerance: Decimal) -> tuple[bool, Decimal, Decimal]: - expected = amounts.get(rule.target, _ZERO) - plain = _rhs(amounts, rule, absolute=False) - if abs(expected - plain) <= tolerance: - return True, expected, plain - if rule.abs_terms: - with_abs = _rhs(amounts, rule, absolute=True) - if abs(expected - with_abs) <= tolerance: - return True, expected, with_abs - if abs(expected - with_abs) < abs(expected - plain): - return False, expected, with_abs - return False, expected, plain + codes: tuple[str, ...] = () def subline_parents(codes: Sequence[str]) -> dict[str, str]: - """Родитель каждой пятизначной подстроки: `{код подстроки: код четырёхзначной строки}` (единое правило §7 для - проверки «сумма подстрок = родитель» и для поля `parent_code` ответа формы; AURORA-1527). + """Родитель каждой пятизначной подстроки: `{код подстроки: код четырёхзначной строки}` (поле `parent_code` ответа + формы; AURORA-1527). Подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами, иначе — к строке с кодом из её первых четырёх цифр, если такая есть; без родителя в результат не попадает. Порядок строк в БД не хранится (DBML порядка не знает): «выше» — порядок `codes`, и вызывающий передаёт его тот же, в каком `checks_input` - отдаёт коды проверке (по возрастанию кода; M-9 2b). Порядок результата — порядок подстрок в `codes`.""" + отдаёт коды (по возрастанию кода; M-9 2b). Порядок результата — порядок подстрок в `codes`.""" present = {code for code in codes if len(code) == _PARENT_LEN} result: dict[str, str] = {} last_parent: str | None = None @@ -163,66 +57,6 @@ def subline_parents(codes: Sequence[str]) -> dict[str, str]: return result -def _subline_rules(codes: Sequence[str], form: str) -> list[RelationRule]: - """«1110 = 11101 + …» для четырёхзначных кодов формы с пятизначными подстроками; родители — `subline_parents`.""" - low, high = CODE_RANGES[form] - children: dict[str, list[str]] = {} - for kid, parent in subline_parents(codes).items(): - children.setdefault(parent, []).append(kid) - rules: list[RelationRule] = [] - for parent, kids in children.items(): - if parent.isdigit() and low <= int(parent) <= high: - relation = f"{parent} = {' + '.join(kids)}" - rules.append( - RelationRule( - cast(Form, form), - relation, - parent, - tuple((1, kid) for kid in kids), - False, - ) - ) - return rules - - -def run_checks( - form: str, - lines: Sequence[tuple[str, Mapping[int, Decimal | None]]], - column_indexes: Sequence[int], - rules: CheckRules, -) -> ChecksReport: - """Проверки §7 формы `form` по колонкам `column_indexes`; `lines` — `(код, {колонка: сумма})` в порядке кодов - (порядок строк формы в БД не хранится; от него зависит только привязка пятизначных подстрок).""" - explicit = list(rules.relations_for(form)) - sublines = _subline_rules([code for code, _ in lines], form) if rules.subline_check else [] - hard: list[FailedCheck] = [] - soft: list[FailedCheck] = [] - for column_index in column_indexes: - # присутствие цели = наличие строки формы; пустая сумма (None) = 0, как `_amounts` в tabular-parser: оба - # производителя отдают значение на каждую колонку каждой кодированной строки (пустая ячейка — amount=None) - amounts = {code: (values.get(column_index) or _ZERO) for code, values in lines} - filled = {code: values.get(column_index) is not None for code, values in lines} - for rule in [*explicit, *sublines]: - if rule not in sublines and rule.target not in amounts: - continue - # AURORA-1527: детализации нет (все подстроки родителя в колонке пусты, `None`) — «родитель = Σ подстрок» - # по колонке не проверяется; ноль — значение. Правило кода, словарь не меняется. - if rule in sublines and all(filled.get(code) is not True for _, code in rule.terms): - continue - holds, expected, actual = _holds(amounts, rule, rules.tolerance) - if holds: - continue - codes = (rule.target, *(code for _, code in rule.terms)) - failed = FailedCheck(column_index, rule.relation, expected, actual, codes) - scale = max([abs(expected), *(abs(amounts.get(code, _ZERO)) for _, code in rule.terms)]) - rounding = scale * rules.rounding_tolerance_percent / _HUNDRED - if rule.strict and abs(expected - actual) > rounding: - hard.append(failed) - else: - soft.append(failed) - return ChecksReport(failed=tuple(hard), soft_failed=tuple(soft)) - - def cross_form_check( balance_1370: tuple[Decimal | None, Decimal | None], income_2400: Decimal | None, diff --git a/api/tests/test_domain_statement_checks.py b/api/tests/test_domain_statement_checks.py index 586082e..255caa0 100644 --- a/api/tests/test_domain_statement_checks.py +++ b/api/tests/test_domain_statement_checks.py @@ -1,175 +1,27 @@ -"""Проверки §7 ядра (spec statement-checks; design D-4, D-5): перенос проверок tabular-parser.""" +"""Остатки проверок §7 в ядре: межформенная проверка и родители подстрок. + +Остальные проверки §7 считает пакет `arm_statements` (тесты пакета и `test_statements_recompute*`); копии словаря +в ядре нет (часть Б, B6).""" from decimal import Decimal +from pathlib import Path import pytest -from src.domain.statement_checks import ( - ChecksReport, - FailedCheck, - cross_form_check, - load_check_rules, - run_checks, - subline_parents, -) +from src.domain.statement_checks import FailedCheck, cross_form_check, subline_parents D = Decimal -RULES = load_check_rules() +API_DIR = Path(__file__).resolve().parents[1] -def _lines(*items: tuple[str, dict[int, str | None]]) -> list[tuple[str, dict[int, Decimal | None]]]: - return [(code, {i: (D(v) if v is not None else None) for i, v in values.items()}) for code, values in items] - - -def test_rules_loaded_from_copy() -> None: - assert RULES.tolerance == D("1") - assert RULES.rounding_tolerance_percent == D("0.001") - relations = [rule.relation for rule in RULES.relations_for("BALANCE")] - assert "1600 = 1100 + 1200" in relations and "1600 = 1700" in relations - income = {rule.relation: rule for rule in RULES.relations_for("INCOME")} - assert income["2100 = 2110 - 2120"].abs_terms == frozenset({"2120"}) - assert income["2400 = 2300 - 2410 + 2420"].strict is False - - -def test_hard_violation() -> None: - report = run_checks( - "BALANCE", _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "1000"})), [0], RULES - ) - assert FailedCheck(0, "1600 = 1100 + 1200", D("1000"), D("900"), ("1600", "1100", "1200")) in report.failed - - -def test_relation_skipped_without_target() -> None: - report = run_checks("BALANCE", _lines(("1110", {0: "5"}), ("1150", {0: "7"})), [0], RULES) - assert report == ChecksReport(failed=(), soft_failed=()) - - -def test_tolerance_one_unit() -> None: - report = run_checks("BALANCE", _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "901"})), [0], RULES) - assert all(item.relation != "1600 = 1100 + 1200" for item in report.failed) - - -def test_abs_terms_minus_sign() -> None: - report = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "-60"}), ("2100", {0: "40"})), [0], RULES) - assert all(item.relation != "2100 = 2110 - 2120" for item in report.failed) - - -def test_rounding_goes_soft() -> None: - # расхождение 5 при числах ~1 000 000: 5 ≤ 1 000 000 × 0,001 % = 10 → мягкое «в пределах округления» - report = run_checks( - "BALANCE", _lines(("1100", {0: "500000"}), ("1200", {0: "500000"}), ("1600", {0: "1000005"})), [0], RULES - ) - assert all(item.relation != "1600 = 1100 + 1200" for item in report.failed) - assert any(item.relation == "1600 = 1100 + 1200" for item in report.soft_failed) - - -def test_soft_relation_not_hard() -> None: - report = run_checks("INCOME", _lines(("2300", {0: "100"}), ("2410", {0: "20"}), ("2400", {0: "50"})), [0], RULES) - assert all(item.relation != "2400 = 2300 - 2410 + 2420" for item in report.failed) - assert any(item.relation == "2400 = 2300 - 2410 + 2420" for item in report.soft_failed) - - -def test_sublines_soft() -> None: - report = run_checks("BALANCE", _lines(("1150", {0: "10"}), ("11501", {0: "6"}), ("11502", {0: "2"})), [0], RULES) - assert any(item.relation == "1150 = 11501 + 11502" for item in report.soft_failed) - assert report.failed == () - - -def test_target_present_no_terms_hard_failure() -> None: - # Спека: отсутствующий код = 0; если цель присутствует но членов нет, это нарушение - # Правило 1600 = 1100 + 1200, в колонке только 1600=1000 → 1600 ≠ 0, жёсткое нарушение - report = run_checks("BALANCE", _lines(("1600", {0: "1000"})), [0], RULES) - assert any( - item.relation == "1600 = 1100 + 1200" and item.expected == D("1000") and item.actual == D("0") - for item in report.failed - ) - - -def test_target_without_self_sum_hard_failure() -> None: - # Спека: пропуск только если цель отсутствует; если цель есть, правило проверяется - # Правило 1600 = 1700, в колонке только 1600=1000 (нет 1300, 1400, 1500) → нарушение - report = run_checks("BALANCE", _lines(("1600", {0: "1000"})), [0], RULES) - assert any( - item.relation == "1600 = 1700" and item.expected == D("1000") and item.actual == D("0") - for item in report.failed - ) - - -def _by_relation(items: tuple[FailedCheck, ...], relation: str) -> list[FailedCheck]: - return [item for item in items if item.relation == relation] - - -def test_relation_checked_per_column_and_none_is_zero() -> None: - # колонка 0: 900 = 300 + 600; колонка 1: 1000 ≠ 900; колонка 2: 300 = 300 + None(=0) - lines = _lines( - ("1100", {0: "300", 1: "300", 2: "300"}), - ("1200", {0: "600", 1: "600", 2: None}), - ("1600", {0: "900", 1: "1000", 2: "300"}), - ) - report = run_checks("BALANCE", lines, [0, 1, 2], RULES) - assert _by_relation(report.failed, "1600 = 1100 + 1200") == [ - FailedCheck(1, "1600 = 1100 + 1200", D("1000"), D("900"), ("1600", "1100", "1200")) - ] - assert _by_relation(report.soft_failed, "1600 = 1100 + 1200") == [] - - -def test_tolerance_exceeded_by_one_unit_is_violation() -> None: - # допуск 1: 902 при 300 + 600 — расхождение 2 > 1, число мало, округление (0,001 %) не спасает - lines = _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "902"})) - report = run_checks("BALANCE", lines, [0], RULES) - assert _by_relation(report.failed, "1600 = 1100 + 1200") == [ - FailedCheck(0, "1600 = 1100 + 1200", D("902"), D("900"), ("1600", "1100", "1200")) - ] - - -def test_tolerance_boundary_is_inclusive() -> None: - lines = _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "901"})) - report = run_checks("BALANCE", lines, [0], RULES) - assert _by_relation(report.failed, "1600 = 1100 + 1200") == [] - assert _by_relation(report.soft_failed, "1600 = 1100 + 1200") == [] - - -def test_abs_terms_positive_expense_holds_and_mismatch_fails() -> None: - # 66н пишет расход в скобках, 1С — со знаком минус; здесь положительный 2120: 100 − 60 = 40 выполнено по модулю - holds = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "60"}), ("2100", {0: "40"})), [0], RULES) - assert _by_relation(holds.failed, "2100 = 2110 - 2120") == [] - broken = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "60"}), ("2100", {0: "50"})), [0], RULES) - failed = _by_relation(broken.failed, "2100 = 2110 - 2120") - assert len(failed) == 1 - assert (failed[0].column_index, failed[0].expected, failed[0].actual) == (0, D("50"), D("40")) - - -def test_subline_attached_to_last_parent_by_row_order() -> None: - # M-9: 12310 и 12320 идут под строкой 1230, хотя префикса 1231/1232 среди четырёхзначных кодов нет - lines = _lines(("1230", {0: "10"}), ("12310", {0: "6"}), ("12320", {0: "2"})) - report = run_checks("BALANCE", lines, [0], RULES) - assert [item.relation for item in _by_relation(report.soft_failed, "1230 = 12310 + 12320")] == [ - "1230 = 12310 + 12320" - ] - - -def test_subline_prefers_last_parent_over_prefix() -> None: - # 12310 стоит под 1230 (ближайшая строка выше с теми же тремя цифрами), а не под 1231, хотя префикс 1231 есть - lines = _lines(("1231", {0: "5"}), ("1230", {0: "10"}), ("12310", {0: "6"}), ("12320", {0: "2"})) - report = run_checks("BALANCE", lines, [0], RULES) - relations = {item.relation for item in report.soft_failed} - assert "1230 = 12310 + 12320" in relations - assert not any(relation.startswith("1231 =") for relation in relations) - - -def test_rounding_threshold_boundary() -> None: - # порог = 0,001 % наибольшего по модулю числа соотношения; при итоге 10 000 000 он ровно 100 - assert D("10000000") * RULES.rounding_tolerance_percent / D("100") == D("100") - halves = {0: "4999950"} - at_threshold = run_checks( - "BALANCE", _lines(("1100", halves), ("1200", halves), ("1600", {0: "10000000"})), [0], RULES - ) - assert _by_relation(at_threshold.failed, "1600 = 1100 + 1200") == [] - assert [item.expected - item.actual for item in _by_relation(at_threshold.soft_failed, "1600 = 1100 + 1200")] == [ - D("100") - ] - above = run_checks("BALANCE", _lines(("1100", halves), ("1200", halves), ("1600", {0: "10000001"})), [0], RULES) - assert [item.expected - item.actual for item in _by_relation(above.failed, "1600 = 1100 + 1200")] == [D("101")] - assert _by_relation(above.soft_failed, "1600 = 1100 + 1200") == [] +def test_no_core_check_rules() -> None: + """Копии словаря §7 и загрузчика/исполнителя правил в ядре нет (B6).""" + assert not (API_DIR / "rules" / "statement_checks.yaml").exists() + forbidden = ("load_check_rules", "run_checks", "statement_checks.yaml") + for source in (API_DIR / "src").rglob("*.py"): + text = source.read_text(encoding="utf-8") + for name in forbidden: + assert name not in text, f"{source.relative_to(API_DIR)}: {name}" @pytest.mark.parametrize( @@ -191,26 +43,6 @@ def test_cross_form( assert cross_form_check(b1370, i2400, D("1")) == expected -def test_broken_rules_file(tmp_path) -> None: - bad = tmp_path / "statement_checks.yaml" - bad.write_text("tolerance: 1\nrelations:\n - {form: BALANCE, relation: '16A0 = 1100', strict: true}\n", "utf-8") - with pytest.raises(ValueError, match="statement_checks.yaml"): - load_check_rules(bad) - - -def test_target_with_empty_cell_counts_as_zero_and_is_checked() -> None: - """M-1 (откат): цель присутствует, если строка есть в форме; пустая ячейка (`None`) = 0 — как в tabular-parser - (`_amounts`, null -> 0): оба производителя отдают значение на каждую колонку каждой кодированной строки, пустая - или нераспознанная ячейка итога при ненулевых слагаемых — жёсткое нарушение (а не пропуск правила).""" - terms = (("1100", {0: "300", 1: "300"}), ("1200", {0: "600", 1: "600"})) - report = run_checks("BALANCE", _lines(*terms, ("1600", {0: None, 1: "0"})), [0, 1], RULES) - failed = _by_relation(report.failed, "1600 = 1100 + 1200") - assert [(item.column_index, item.expected, item.actual) for item in failed] == [ - (0, D("0"), D("900")), - (1, D("0"), D("900")), - ] - - def test_subline_parents_nearest_parent_above_with_same_group() -> None: # 12310 и 12320 относятся к 1230 (ближайшая выше с теми же тремя цифрами), а не к 1231 codes = ["1231", "1230", "12310", "12320"] @@ -232,42 +64,3 @@ def test_subline_parents_follow_given_order() -> None: # порядок задаёт вызывающий (порядок кодов `statement_amounts`): «ближайшая выше» считается по нему assert subline_parents(["1230", "1231", "12310"]) == {"12310": "1231"} assert subline_parents(["1231", "1230", "12310"]) == {"12310": "1230"} - - -def test_run_checks_and_subline_parents_agree() -> None: - # проверка «сумма подстрок = родитель» строится по тем же родителям, что и `subline_parents` - codes = ["1231", "1230", "12310", "12320", "1110", "11101"] - amounts = {"11101": "5", "1230": "10"} - report = run_checks("BALANCE", _lines(*((code, {0: amounts.get(code, "1")}) for code in codes)), [0], RULES) - parents = subline_parents(codes) - expected = { - f"{parent} = {' + '.join(kid for kid, owner in parents.items() if owner == parent)}" - for parent in set(parents.values()) - } - assert {item.relation for item in report.soft_failed} == expected - - -def test_subline_check_skipped_when_all_sublines_empty_in_column() -> None: - # AURORA-1527: детализации нет (все подстроки пусты) — «1150 = 11501 + …» по колонке не проверяется - lines = _lines(("1150", {0: "90", 1: "90"}), ("11501", {0: None, 1: None}), ("11502", {0: None, 1: None})) - assert _by_relation(run_checks("BALANCE", lines, [0, 1], RULES).soft_failed, "1150 = 11501 + 11502") == [] - - -def test_subline_check_is_per_column() -> None: - # в колонке 0 подстроки пусты (не проверяется), в колонке 1 заполнены и не сходятся (нарушение) - lines = _lines(("1150", {0: "90", 1: "90"}), ("11501", {0: None, 1: "10"}), ("11502", {0: None, 1: None})) - failed = _by_relation(run_checks("BALANCE", lines, [0, 1], RULES).soft_failed, "1150 = 11501 + 11502") - assert [(f.column_index, f.expected, f.actual) for f in failed] == [(1, D("90"), D("10"))] - - -def test_subline_zero_is_a_value() -> None: - # ноль — значение: подстроки с нулями проверяются как раньше - lines = _lines(("1150", {0: "90"}), ("11501", {0: "0"}), ("11502", {0: None})) - assert len(_by_relation(run_checks("BALANCE", lines, [0], RULES).soft_failed, "1150 = 11501 + 11502")) == 1 - - -def test_empty_sublines_do_not_affect_other_relations() -> None: - # прочие соотношения §7 не затронуты: пустая цель 1200 при ненулевых слагаемых — по-прежнему жёсткое нарушение - lines = _lines(("1210", {0: "5"}), ("12101", {0: None}), ("1200", {0: None}), ("1600", {0: "5"})) - report = run_checks("BALANCE", lines, [0], RULES) - assert [f.relation for f in report.failed if f.relation.startswith("1200 =")] != [] diff --git a/api/tests/test_domain_statement_lines.py b/api/tests/test_domain_statement_lines.py index 2c2ada7..94a4235 100644 --- a/api/tests/test_domain_statement_lines.py +++ b/api/tests/test_domain_statement_lines.py @@ -5,10 +5,24 @@ from pathlib import Path import pytest from src.domain.statement_lines import TemplateLine, load_line_catalog +from src.services.statements_engine import DocumentType, FormVariant, default_engine CATALOG = load_line_catalog() +def test_name_dictionary_codes_in_code_orders() -> None: + """Редакции справочника имён — ровно редакции пакета, каждый код имени есть в перечне этой редакции копии пакета + (любой вариант формы, B6; перечни — `Rules.code_orders`).""" + engine = default_engine() + editions = engine.editions() + assert tuple(CATALOG.editions) == tuple(edition.value for edition in editions) + for edition in editions: + for form, document_type in (("BALANCE", DocumentType.BALANCE), ("INCOME", DocumentType.INCOME)): + in_orders = {code for variant in FormVariant for code in engine.code_order(edition, document_type, variant)} + missing = CATALOG.edition_codes(edition.value, form) - in_orders + assert not missing, f"{edition.value}/{form}: коды справочника имён вне перечня пакета: {sorted(missing)}" + + @pytest.mark.parametrize( ("edition", "form", "count", "first", "last"), [ diff --git a/openspec/changes/unified-normalization-core-b/design.md b/openspec/changes/unified-normalization-core-b/design.md index 35cfd2d..5f0901d 100644 --- a/openspec/changes/unified-normalization-core-b/design.md +++ b/openspec/changes/unified-normalization-core-b/design.md @@ -58,3 +58,6 @@ Goals и Non-goals — см. `proposal.md`. | 2026-10-18 | **P3-47 (B5, T5.2 части А).** Нечитаемый JSON (`JSONDecodeError`, не UTF-8) в `_load_result` — тоже `MappingFatal` с текстом «результат не соответствует схеме recognition-result: файл не является JSON» (текст исключения парсера пользователю не показывается; M-1); не-объект (`[]`, `null`) отсекается моделью как нарушение схемы | иначе `ValueError` из `parse_result` уходил бы в повторяемые ошибки и файл `ERROR` получал бы только после `MAPPING_MAX_ATTEMPTS` попыток | ошибка JSON не отличается в тексте от прочих ошибок схемы | | 2026-10-18 | **P3-48 (B5).** Статус ОСВ `2.0` — `osv_status_v2(osv_done = sheet.status == "done", checks_ok, file_error_warning, counterparty, sheet_mapping_warnings)`; «предупреждения маппинга листа» = повтор счёта + сироты + «нарастающая по данным Q1» (вход `document_status` `1.x` тот же); у `1.x` статус как прежде (§9.3, статус задания). Предупреждение уровня `error` ищется в `metrics.warnings` результата (`file_warnings`), а не в `recognition_job.metrics` | Р-28; единый источник с формами | ОСВ с `error` в метриках файла — `NEEDS_CHECK` | | 2026-10-18 | **P3-49 (B5).** `osvDocument.warnings` пишутся в `document_issue` как `warning` / `service` с `severity` из предупреждения (по контракту `info`, `error` не отбрасывается); `line_refs` у ОСВ не сохраняются (строк формы нет), `column_index` и `field` — как пришли. Записи сервиса идут раньше записей маппинга ядра. Дубль листа (повтор счёта) своих предупреждений/статуса не вносит — документом остаётся первый лист | у ОСВ нет `line_ids`/колонок формы; повтор — D-7 | `error`-предупреждение листа ОСВ в `issues` не влияет на статус само по себе (статус — по `status` листа) | +| 2026-10-18 | **P3-50 (B6).** `FailedCheck.codes` в ядре остаётся (вопреки плану, который удалял поле): оно нужно межформенной проверке 1370↔2400 (`codes = ("1370", "2400")`) для `codes` / `line_ids` в `checks.cross_form` ответа; нарушения §7 по колонкам читаются из `document_issue`, где коды хранятся сами | межформенную проверку пакет не считает (она по двум документам), без `codes` подсветка строк в ответе пропала бы | у `cross_form` не было бы `codes`/`line_ids`, фронт не подсвечивал бы строки 1370 и 2400 | +| 2026-10-18 | **P3-51 (B6).** `checks_report` уже брал допуск у `engine.tolerance()` (с B2), поэтому правки сервиса в B6 нет; константа допуска ядра удалена вместе с `load_check_rules`. Состав и порядок редакций справочника имён `statement_lines.yaml` не переписывается на `engine.editions()` в коде: согласованность проверяет тест `test_name_dictionary_codes_in_code_orders` (редакции справочника = редакции пакета в том же порядке; каждый код справочника есть в перечне редакции пакета, любой вариант формы) | справочник остаётся данными имён и состава экрана; перечни кодов — единственный источник в пакете, расхождение ловит тест, а не молчаливая подмена в рантайме | при добавлении редакции в пакет без справочника имён тест красный; в рантайме новая редакция дала бы форму без шаблонных строк | +| 2026-10-18 | **P3-52 (B6).** `edition_for` (граница 66n/fsbu4 по дате для шаблона экрана) в `domain/statement_mapping.py` не тронут: переход на `engine.edition(...)` требует кодов формы и варианта и относится к ответу формы (B7/доработка), а не к удалению копии словаря | не расширять B6 за рамки брифа | ответ формы и приём могут расходиться в редакции на формах без даты; существующее поведение сохранено | -- 2.47.2 From e944565e558d6ac28a09203628383913ff753d47 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 22:25:11 +0400 Subject: [PATCH 11/13] =?UTF-8?q?=D0=A1=D0=BF=D0=B5=D0=BA=D0=B0=20=D1=87?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D0=B8=20=D0=91:=20=D0=B4=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=8B=20=D0=BF=D0=BE=D0=B4=20=D0=B0=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D1=86=D0=B8=D1=8E,=20null=20=D0=B2=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=80=D1=83=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=D1=85=20=C2=A77,=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../unified-normalization-core-b/design.md | 2 + .../unified-normalization-core-b/proposal.md | 5 +- .../specs/documents-api/spec.md | 156 +++++++++++++++--- .../specs/osv-result-mapping/spec.md | 134 +++++++++++---- .../specs/statement-checks/spec.md | 30 ++-- .../unified-normalization-core-b/tasks.md | 14 +- 6 files changed, 266 insertions(+), 75 deletions(-) diff --git a/openspec/changes/unified-normalization-core-b/design.md b/openspec/changes/unified-normalization-core-b/design.md index 5f0901d..b460c91 100644 --- a/openspec/changes/unified-normalization-core-b/design.md +++ b/openspec/changes/unified-normalization-core-b/design.md @@ -61,3 +61,5 @@ Goals и Non-goals — см. `proposal.md`. | 2026-10-18 | **P3-50 (B6).** `FailedCheck.codes` в ядре остаётся (вопреки плану, который удалял поле): оно нужно межформенной проверке 1370↔2400 (`codes = ("1370", "2400")`) для `codes` / `line_ids` в `checks.cross_form` ответа; нарушения §7 по колонкам читаются из `document_issue`, где коды хранятся сами | межформенную проверку пакет не считает (она по двум документам), без `codes` подсветка строк в ответе пропала бы | у `cross_form` не было бы `codes`/`line_ids`, фронт не подсвечивал бы строки 1370 и 2400 | | 2026-10-18 | **P3-51 (B6).** `checks_report` уже брал допуск у `engine.tolerance()` (с B2), поэтому правки сервиса в B6 нет; константа допуска ядра удалена вместе с `load_check_rules`. Состав и порядок редакций справочника имён `statement_lines.yaml` не переписывается на `engine.editions()` в коде: согласованность проверяет тест `test_name_dictionary_codes_in_code_orders` (редакции справочника = редакции пакета в том же порядке; каждый код справочника есть в перечне редакции пакета, любой вариант формы) | справочник остаётся данными имён и состава экрана; перечни кодов — единственный источник в пакете, расхождение ловит тест, а не молчаливая подмена в рантайме | при добавлении редакции в пакет без справочника имён тест красный; в рантайме новая редакция дала бы форму без шаблонных строк | | 2026-10-18 | **P3-52 (B6).** `edition_for` (граница 66n/fsbu4 по дате для шаблона экрана) в `domain/statement_mapping.py` не тронут: переход на `engine.edition(...)` требует кодов формы и варианта и относится к ответу формы (B7/доработка), а не к удалению копии словаря | не расширять B6 за рамки брифа | ответ формы и приём могут расходиться в редакции на формах без даты; существующее поведение сохранено | +| 2026-10-18 | **P3-60 (fix-волна, I-5).** В ответах нарушение §7 может иметь `expected` / `actual` = `null` (пустая цель при непустых членах, P1-21). Индекс колонки при её отсутствии различается: `checks.failed[]` ответа `GET …/statement` отдаёт `-1` (`FailedCheckOut.column_index: int`, межформенная `cross_form` всегда `-1`), а `detail.failures[]` отказа 409 `CHECKS_NOT_PASSED` — `null` (`issue_column_index`). Спека описывает поведение как есть; код не правился. **Вопрос на выравнивание (`-1` или `null`) — план 6 / фронт (arm-web)** | фиксация фактического контракта до решения владельца; тип поля у фронта (arm-web) нужно сверить | фронт, ожидающий число, получит `null` в `detail.failures[].column_index` (только в 409, когда у записи нет ни `column_id`, ни `column_index`) | +| 2026-10-18 | **P3-61 (fix-волна, C-1, M-1).** Дельты B переписаны под `openspec archive`: MODIFIED «Пересчёт после правки», «Словари ядра и достройка шаблонных строк», «Формы результата 1.x — §7 при приёме» теперь несут полный текст требования части А со всеми сценариями плюс уточнения Б (иначе архивация падает: «scenario(s) not present in the modified block»); добавлен MODIFIED «Хранение результата формы v2.0 (DBML v5.19)» и «Форма ББ/ОФР для экрана проверки» (сценарий «Строка, потерянная источником» приведён к P3-30). ADDED «Маппер результата 2.0 для форм» заменён MODIFIED «Хранение…» (устранено противоречие «первое остаётся» vs «без повторной дедупликации») и ADDED «Проверки ядра при приёме результата 2.0»; ADDED «`CHECKS_NOT_PASSED` из `document_issue`» и «Блок `checks`…» оставлены только с тем, чего нет в А (форма элемента нарушения, `null`), дубль порядка 409 убран. Проверено архивацией А, затем Б на копии `openspec/`. **Дополняет R-3 и D-4** | архивация обязана проходить; дубли и противоречия между А и Б вводят в заблуждение | если архивация А и Б падает — править заголовки и сценарии дельт Б; при будущей правке требований А в самой А тексты MODIFIED Б нужно обновить синхронно | diff --git a/openspec/changes/unified-normalization-core-b/proposal.md b/openspec/changes/unified-normalization-core-b/proposal.md index c2e4992..2362dcc 100644 --- a/openspec/changes/unified-normalization-core-b/proposal.md +++ b/openspec/changes/unified-normalization-core-b/proposal.md @@ -37,8 +37,9 @@ ## Impact -`api/src/domain/{stored_form,statement_mapping_v2,statement_checks,statement_lines}.py`, -`api/src/services/{statements_engine,statement_recompute,statements,documents,result_mapping}.py`, +`api/src/domain/{stored_form,statement_mapping_v2,statement_checks,statement_issues,statement_mapping,statement_aggregate,statement_layout,osv_mapping}.py`, +`api/src/services/{statements_engine,statement_recompute,statement_intake_v2,statement_line_edits,statements,result_mapping}.py`, +`api/src/api/dependencies/files.py`, `api/src/contract/result.py`, `api/src/repositories/statements.py`, `api/src/schemas/statements.py`, `api/rules/statement_checks.yaml` (удаляется), `CLAUDE.md`, `README.md`. `import arm_statements` — только в `statements_engine.py` и `stored_form.py`. diff --git a/openspec/changes/unified-normalization-core-b/specs/documents-api/spec.md b/openspec/changes/unified-normalization-core-b/specs/documents-api/spec.md index f50334f..d32c419 100644 --- a/openspec/changes/unified-normalization-core-b/specs/documents-api/spec.md +++ b/openspec/changes/unified-normalization-core-b/specs/documents-api/spec.md @@ -1,20 +1,105 @@ ## MODIFIED Requirements +### Requirement: Форма ББ/ОФР для экрана проверки +`GET /api/v1/documents/{document_id}/statement` (`DOCUMENT_VIEW`, policy по ЮЛ) SHALL отдавать для документа +`BALANCE`/`INCOME`: `document` (`id`, `document_type`, `status`, `period_year`, `period_quarter`, `reporting_date`, +`counterparty_check`, `locator`, `file_id`); `edition` (`66n` при дате периода ≤ 2024-12-31, `fsbu4` — позже, `null` без +даты); `header` — для `inn`, `report_date`, `units`: `value`, `original_value`, `confidence` (у ИНН), `verified`, +`modified`; плюс `organization_name`, `okud`, `okpo`, `okopf`, `okfs`, `address`; `columns[]` (`id`, `column_index`, +`label`, `period_date`, `is_current`); `lines[]` в порядке, описанном ниже (`id`, `origin`, `line_code`, `line_code_confidence`, +`line_name`, `line_name_confidence`, `values[]` по `column_index`: `id`, `column_id`, `amount`, `string_value`, +`confidence`, `is_recognized`, `is_modified`, `original_amount`, `modified_by`, `modified_at`); `checks` (`failed`, +`soft_failed`, `cross_form`, `accepted`: `by`, `at`, `comment` или `null`). Элемент нарушения в `checks` — `{column_index, +relation, expected, actual, codes, line_ids}`; `expected` и `actual` SHALL быть `null`, когда сторона соотношения пуста +(пустая цель при непустых членах — `expected = null`, пакет `arm_statements`, P1-21), см. требование «Блок `checks` +ответа формы из `document_issue`». Документ другого типа — 409 `DOCUMENT_NOT_STATEMENT`; чужой или несуществующий — 404. + +**Состав ответа v2.0.** `StatementOut` SHALL отдавать: у документа `form_variant`; у шапки `needs_review_fields`, `source` (`header_source`; +`confidence` — строкой Decimal, как прочие уверенности API), `organization_name_modified` и `okei`; у колонок `period_kind`, `label_confidence`; у строк `id`, `origin` (`template` ⇔ +`row_index = null`), `row_index`, `source_ref`, `line_status`, `line_code`, `line_code_text`, `line_code_confidence`, +`line_name` (из файла), `reference_name` (справочное имя по коду — первое имя кода в словаре — или `null`), +`parent_row_index`, `parent_code`, `needs_review`, `is_confirmed`, `line_code_modified`; у значений `text` +(`source_text`), `needs_review`, `is_confirmed` и прежние поля; `issues[]` — записи `document_issue` с `line_ids`, +`column_id`, `column_index`, `field`, `codes`; `checks` — нарушения из `document_issue` с `codes` и `line_ids`; +`edition_lines[] {code, name}` — справочник строк для подсказки кода: коды словаря имён формы в редакции ответа по одному +(имя — первое), у формы без редакции — коды всех редакций по одному. `DocumentBrief` (`FileOut.documents[]`) и +`DocumentOut` SHALL отдавать `issue_counts {error, info}` — число записей `document_issue` документа по уровню. Порядок +`lines` — по возрастанию кода, как сейчас (`schemas/statements.py`), одинаковые коды — по `row_index`; строки +`unreadable` / `missing` — после ближайшей предыдущей по `row_index` строки, размещённой по коду, без такой — в начале; +`detail` — после родителя. Разделы и размещение итогов на экране («как на бумаге», Р-14) строит экран. + +Форма SHALL достраиваться в ответе по справочнику строк редакции `edition` (`api/rules/statement_lines.yaml`, свой +список кодов и наименований для `BALANCE` и `INCOME` каждой редакции): строки справочника, кода которых нет в форме, +добавляются с `id = null`, `origin = "template"`, наименованием из справочника и `*_confidence = null`; у каждой строки +SHALL быть значение по каждой колонке формы — недостающее добавляется с `id = null`, `amount = null`, +`string_value = null`, `confidence = null`, `is_recognized = null`, `is_modified = false`, `original_amount = null`, +`modified_by = null`, `modified_at = null`. При `edition = null` строки не достраиваются, недостающие значения — +достраиваются. Строка из БД имеет `origin = "template"` ⇔ `row_index IS NULL` (создана правкой аналитика), иначе `"source"`. Строки источника, которых нет в справочнике, отдаются как есть. +Достроенные строки и значения MUST NOT записываться в БД при чтении и MUST NOT участвовать в проверках §7. + +#### Scenario: Форма для экрана +- **WHEN** запрошена форма `BALANCE` из `mezheninovskaya/2025-Q3/1_Форма1_бух_09` +- **THEN** `edition = fsbu4`, все 37 строк файла — `origin = source` с `id`, каждый код справочника `fsbu4` / `BALANCE` + есть среди `lines`, у каждой строки значения по всем колонкам, `checks.failed` пуст + +#### Scenario: Строка, потерянная источником +- **WHEN** у формы `BALANCE` за 2025 Q1 в БД нет строки 1700 (OCR склеил «1500\n1700» до OQ-156) +- **THEN** в `lines` есть 1700: `id = null`, `origin = template`, `line_name = "БАЛАНС"`, значения по всем колонкам + `id = null`, `amount = null`, `is_recognized = null`; достройка при чтении не меняет статус документа и проверки: + `checks.failed` содержит соотношения с пустой целью 1700 (`1700 = 1300 + 1400 + 1500`, `expected = null`, `actual` — + сумма членов) и `1600 = 1700` (цель 1600 заполнена, 1700 пуста) — такие же, как без достройки; подтверждение формы + требует правки строки 1700 или акцепта проверок + +#### Scenario: Недостающая ячейка +- **WHEN** у строки 1150 в БД есть значение только для колонки 0, а у формы две колонки +- **THEN** у 1150 два значения; значение колонки 1 — `id = null`, `amount = null` + +#### Scenario: Нет даты периода +- **WHEN** у формы OCR не определена дата периода (`edition = null`) +- **THEN** `lines` — только строки из БД, у каждой строки значения по всем колонкам + +#### Scenario: Не форма +- **WHEN** запрошена форма документа `OSV` +- **THEN** 409 `DOCUMENT_NOT_STATEMENT` + +#### Scenario: Документ, принятый по 1.x +- **WHEN** форма загружена до перехода сервиса (результат 1.2) +- **THEN** у строк `line_status = ok`, `row_index` по порядку, `source_ref = null`, `origin = source`, у значений `text = + string_value`, `issues` содержат нарушения §7, посчитанные ядром, и `MAPPING_WARNING`; экран работает + ### Requirement: Пересчёт после правки После каждого PATCH формы ядро SHALL пересчитывать `checks`, `needs_review`, `form_variant`, родителей строк `detail` -и статус **точкой входа пакета `arm_statements` для пересчёта** по сохранённым данным (конвенция знака — по -`source_text`); временного пересчёта ядра MUST NOT быть. Правила: `is_modified` или `is_confirmed` значения ⇒ -`needs_review = false`; `line_code_modified` или `is_confirmed` строки ⇒ `needs_review` строки = `false`; значения строк -`detail` — `needs_review = false`; акцепт ⇒ жёсткие нарушения не учитываются; правленые ячейки в голосование знака не -входят. Результат пишется: `needs_review` строк и значений, `form_variant`, `parent_row_index` строк `detail`, записи -`document_issue` видов `relation` / `soft` (заменяются целиком), статус. Относительные даты колонок -(`period_kind`, `statement-normalization`) пересчитываются от даты отчёта после правки `header.report_date` и +и статус **точкой входа пакета `arm_statements` для пересчёта** по сохранённым данным (`statement-normalization`; +конвенция знака — по `source_text`); временного пересчёта ядра MUST NOT быть. Правила: `is_modified` или `is_confirmed` +значения ⇒ `needs_review = false`; `line_code_modified` или `is_confirmed` строки ⇒ `needs_review` строки = `false`; +значения строк `detail` — `needs_review = false` (Р-23), у строки, выведенной из `detail` назначением кода, +`needs_review` её значений считается по общему правилу; акцепт ⇒ жёсткие нарушения не учитываются; правленые ячейки в +голосование знака не входят. Результат пишется: `needs_review` строк и значений, `form_variant`, `parent_row_index` всех +строк `detail` (ближайшая строка `ok` выше по `row_index`; нет такой — `null`, строка остаётся `detail`), записи +`document_issue` видов `relation` / `soft` (заменяются целиком независимо от источника), статус. Относительные даты +колонок (`period_kind`, `statement-normalization`) пересчитываются от даты отчёта после правки `header.report_date` и `columns[].period_date`. Отличие `statements_version` задания от версии копии ядра — `document_issue` `STATEMENTS_VERSION_MISMATCH` (`info`); у задания без версии (`1.x`) запись не пишется. Записи `core` `CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION` (только четырёхзначные коды), `NAME_MISMATCH` и `STATEMENTS_VERSION_MISMATCH` пересчитываются заменой; `NAME_MISMATCH` не ставится строке с назначенным аналитиком кодом; `MAPPING_WARNING`, `RESULT_STATUS_INCONSISTENT`, `LEGACY_WARNING` сохраняются. Один PATCH — один пересчёт и одна запись -аудита `RECOGNIZED_DATA_EDIT`. +аудита `RECOGNIZED_DATA_EDIT`. Акцепт SHALL сбрасываться правкой данных — суммы, кода или статуса строки, даты +колонки, поля шапки; подтверждение или снятие подтверждения (`confirmed`) и подтверждение даты отчёта или даты колонки +тем же значением акцепт MUST NOT сбрасывать (Р-25, S-47). + +#### Scenario: Подтверждение ячейки без правки +- **WHEN** аналитик подтверждает ячейку с `needs_review = true` без изменения суммы +- **THEN** `is_confirmed = true`, `confirmed_by/at`, `is_modified` не ставится, `needs_review = false`, аудит; акцепт + проверок, если был, сохраняется + +#### Scenario: Снятие подтверждения +- **WHEN** аналитик снимает подтверждение с ячейки (`confirmed: false`), которая не покрыта соотношением +- **THEN** `is_confirmed = false`, `needs_review = true` после пересчёта, аудит + +#### Scenario: Родитель уходит в расшифровки +- **WHEN** у строки 1150 две расшифровки (`detail`, `parent_row_index` = 1150), аналитик отмечает саму 1150 расшифровкой +- **THEN** после пересчёта родитель всех трёх строк `detail` — ближайшая строка `ok` выше 1150; строк над ними нет — `null` + (на экране — в начале раздела) #### Scenario: Правка исправляет нарушение - **WHEN** форма принята с `RELATION_FAILED` по `1200 = 1210 + … + 1260`, аналитик правит сумму 1230 так, что @@ -29,11 +114,17 @@ ### Requirement: Словари ядра и достройка шаблонных строк Копия словаря §7 ядра (`api/rules/statement_checks.yaml`) SHALL быть удалена: проверки ядро считает копией пакета `arm_statements`. `api/rules/statement_lines.yaml` SHALL оставаться только словарём имён (код → наименования по -редакциям; у кода может быть несколько имён, Р-20); порядок и состав редакций — из копии перечня кодов пакета -(`code_orders.editions`); код словаря имён, которого нет в перечне своей редакции, — ошибка проверки. В редакции -`fsbu4` словарь дополнен вторыми именами законных повторов 1220, 1520, 2410 и кодами печатной формы 1С 1105, 1130, 1140, -1530, 2460. Шаблонные строки достраиваются по словарю имён (одна на код, первое имя); код, у которого есть хотя бы одна -строка `ok` из файла, шаблоном MUST NOT достраиваться; строки `unreadable` / `missing` не заменяются шаблонной молча. +редакциям): у кода может быть несколько имён (синонимы и законные повторы кода в бланке, Р-20), загрузчик повтор кода в +редакции MUST NOT запрещать; сверка наименования — с любым именем кода; шаблонная строка берёт первое имя. Порядок и +состав редакций — из копии перечня кодов пакета (`code_orders.editions`); код словаря имён, которого нет в перечне своей +редакции, — ошибка проверки (тест согласованности, `sync-statements --check`). В редакции `fsbu4` словарь имён SHALL быть +дополнен вторыми именами законных повторов 1220 «НДС, начисленный по отгрузке», 1520 «Обязательство по аренде», 2410 +«Налог при упрощенной системе налогообложения» (Р-20) и кодами печатной формы 1С 1105 «Гудвил», 1130 «Нематериальные +поисковые активы», 1140 «Материальные поисковые активы», 1530 «Доходы будущих периодов», 2460 «Прочее» (имена — по +`fixtures/ocr` и шаблонам банка); без этого законные повторы РБ, Счастливочной и aep_6 и любой неуверенный 1105 дают +`NAME_MISMATCH`. Шаблонные строки достраиваются по словарю имён (одна на код, первое имя); код, у которого есть хотя бы +одна строка `ok` из файла, шаблоном MUST NOT достраиваться; строки `unreadable` / `missing` не заменяются шаблонной строкой +молча. #### Scenario: Код словаря вне перечня - **WHEN** в `statement_lines.yaml` у редакции есть код, которого нет в перечне этой редакции копии пакета @@ -43,25 +134,46 @@ - **WHEN** у формы нет строки `ok` с кодом 1250, но есть строка `unreadable` «ИЧ,250» - **THEN** шаблонная строка 1250 достраивается пустой, строка `unreadable` показывается отдельно со статусом +#### Scenario: Два имени у кода +- **WHEN** в редакции `fsbu4` словаря имён код 1520 записан дважды — «Кредиторская задолженность» и «Обязательство по + аренде» +- **THEN** словарь загружается; шаблонная строка 1520 одна, с именем «Кредиторская задолженность» + +#### Scenario: Гудвил с заголовком раздела в имени +- **WHEN** строка 1105 (уверенность 67) с наименованием «АКТИВ I. Внеоборотные активы Гудвил» +- **THEN** справочное «Гудвил» (≥ 5 символов) входит в него — совпало, `NAME_MISMATCH` нет + ## ADDED Requirements ### Requirement: `CHECKS_NOT_PASSED` из `document_issue` -`POST /documents/{id}/confirm` для формы ББ/ОФР SHALL проверять 409 в порядке (требование «Подтверждение документа»): `FILE_SUPERSEDED` → `ALREADY_CONFIRMED` → -`DOCUMENT_NOT_CONFIRMABLE` → `PERIOD_REQUIRED` → `LINES_UNRESOLVED` (есть строки `unreadable` / `missing`, у которых хотя -бы в одной ячейке `amount` или `string_value` не `null`; `detail.lines[] {line_id, row_index, line_code_text}`) → -`DUPLICATE_FORM` (в файле уже подтверждена другая форма того же типа, `period_year` и `period_quarter`; -`detail.document_id`) → `CHECKS_NOT_PASSED`. Нарушения для `CHECKS_NOT_PASSED` SHALL читаться **только из записей -`document_issue` вида `relation`** без акцепта; пересчёта §7 в запросе подтверждения нет. +Нарушения для 409 `CHECKS_NOT_PASSED` при `POST /documents/{id}/confirm` формы ББ/ОФР (порядок проверок и условия — +«Подтверждение документа») SHALL читаться **только из записей `document_issue` вида `relation`** без акцепта +(акцептованная форма нарушенной не считается); пересчёта §7 в запросе подтверждения нет. `detail.failures[]` — +`{column_index, relation, expected, actual}`: `expected` и `actual` — строки Decimal или `null` (пустая цель при +непустых членах — `expected = null`); `column_index` — индекс колонки записи, а если колонку определить нельзя +(у записи нет ни `column_id`, ни `column_index`) — `null`. #### Scenario: Жёсткое нарушение из document_issue - **WHEN** у формы есть запись `relation` без акцепта и нет строк `unreadable` / `missing` со значениями -- **THEN** 409 `CHECKS_NOT_PASSED` +- **THEN** 409 `CHECKS_NOT_PASSED`, `detail.failures[]` — по записи на нарушение + +#### Scenario: Нарушение с пустой целью +- **WHEN** у формы нет строки 1700 (потеряна источником), 1300 + 1400 + 1500 заполнены +- **THEN** 409 `CHECKS_NOT_PASSED`; в `detail.failures[]` нарушение `1700 = 1300 + 1400 + 1500` с `expected = null` ### Requirement: Блок `checks` ответа формы из `document_issue` Блок `checks` ответа `GET …/statement` SHALL строиться из записей `document_issue` видов `relation` (`failed`) и `soft` -(`soft_failed`) с `codes`, `expected`, `actual`, `line_ids`; собственного расчёта §7 при чтении нет. Межформенная -проверка `cross_form` считается по агрегату формы по коду. +(`soft_failed`) с `codes`, `expected`, `actual`, `line_ids`; собственного расчёта §7 при чтении нет. Элемент нарушения — +`{column_index, relation, expected, actual, codes, line_ids}`: `expected` и `actual` — `Decimal` или `null` +(`expected = null` — цель соотношения пуста при непустых членах); `column_index` — индекс колонки формы по `column_id` +записи, иначе `column_index` записи, иначе `-1` (колонки нет: межформенная проверка `cross_form` всегда `-1`). +Фактическое различие с `detail.failures[]` 409 (там отсутствующий индекс — `null`, а не `-1`) сохраняется как есть +(P3-60). Межформенная проверка `cross_form` считается по агрегату формы по коду. #### Scenario: checks из document_issue - **WHEN** у формы две записи `relation` и одна `soft` - **THEN** `checks.failed` содержит две записи, `checks.soft_failed` — одну; расчёт §7 при запросе не выполняется + +#### Scenario: Нарушение с пустой целью в ответе +- **WHEN** у формы запись `relation` по `1700 = 1300 + 1400 + 1500` с `expected = NULL` +- **THEN** элемент `checks.failed` имеет `expected = null`, `actual` — сумма членов diff --git a/openspec/changes/unified-normalization-core-b/specs/osv-result-mapping/spec.md b/openspec/changes/unified-normalization-core-b/specs/osv-result-mapping/spec.md index 611a7f4..8f5c740 100644 --- a/openspec/changes/unified-normalization-core-b/specs/osv-result-mapping/spec.md +++ b/openspec/changes/unified-normalization-core-b/specs/osv-result-mapping/spec.md @@ -1,25 +1,105 @@ -## ADDED Requirements +## MODIFIED Requirements -### Requirement: Маппер результата 2.0 для форм ББ/ОФР -Для результата `2.0` ядро SHALL создавать документ формы, храня строки, значения, колонки и шапку **как пришли** -(DBML v5.19): строка — `row_index`, `source_ref = ref`, `line_status`, `line_code`, `line_code_original_value`, -`line_code_text`, `line_code_confidence`, `line_name`, `parent_row_index` (по `parent_ref`), `needs_review`, -`is_confirmed = false`; значение — на каждую пару «строка × колонка» (нет в результате — пустая запись с -`needs_review = false`); колонка — с `period_kind`, `label_confidence`; шапка — разобранные поля, `header_source` -(`{поле: {text, confidence}}`), `*_original_value`, `needs_review_fields`, `form_variant`. Значение с `column_index` вне -колонок формы и повтор `column_index` колонки — первое остаётся, остальное — `document_issue` `MAPPING_WARNING`. -Предупреждения и `checks` результата SHALL писаться в `document_issue` (источник `service`); проверки ядра — источник -`core`: `CODE_NOT_IN_DICTIONARY` и `CODE_NOT_IN_EDITION` (редакция — `edition` точки входа пакета, а не по дате; одна -запись на код документа, `line_ids` — все строки `ok` кода; **только для кодов из ровно четырёх цифр** — пятизначные -подстроки Excel не проверяются), `NAME_MISMATCH`, `RESULT_STATUS_INCONSISTENT`, `STATEMENTS_VERSION_MISMATCH`. Статус -документа — по требованию «Условие `RECOGNIZED` при приёме 2.0»; согласованность `done` — вызовом -`engine.contract_document_status` пакета. Проверки §7, `needs_review` и предупреждения сервиса MUST NOT пересчитываться -при загрузке. +### Requirement: Хранение результата формы v2.0 (DBML v5.19) +Маппер результата `2.0` SHALL писать документ формы как пришёл: строки, коды и значения не сливаются и не +дедуплицируются (повтор кода — отдельные строки), каждая строка результата — `financial_statement_line` (`row_index`, +`source_ref` = `ref`, `line_status`, `line_code`, `line_code_original_value` = `line_code` результата, `line_code_text`, +`line_code_confidence`, `line_name`, `line_name_confidence`, `parent_row_index` — `row_index` строки с `ref = parent_ref`, +`needs_review`, `is_confirmed = false`); каждая пара «строка × колонка» — одна `financial_statement_value` (`amount`, +`string_value`, `source_text` = `values[].text`, `confidence`, `is_recognized`, `needs_review`; значения, которого нет в +результате, — пустая запись с `needs_review = false`); колонки — `financial_statement_column` с `period_kind`, +`label_confidence`; шапка — `financial_statement_meta` с `form_variant`, `needs_review_fields` и `header_source` +(раздел «Шапка формы»); предупреждения и нарушения документа — `document_issue` (источник `service`); версия общего кода — +`recognition_job.statements_version`. Единственные случаи, когда элемент результата не записывается, — нарушение +уникальности ключей хранения, и каждый из них MUST фиксироваться `document_issue` `MAPPING_WARNING` (`info`, источник +`core`): значение с `column_index` вне колонок формы; повтор `column_index` колонки — остаётся первая колонка, остальные +пропускаются; повтор `row_index` или `ref` у строк — остаётся первая строка, остальные пропускаются; `detail` с `parent_ref`, +которого нет, хранится с `parent_row_index = null`. Уникальность `(meta_id, line_code)` снята; `(meta_id, source_ref)` +уникален там, где `source_ref` не `null`. Каждый `BALANCE` / `INCOME` результата SHALL становиться отдельным `document`; +вторая форма того же типа в файле хранится (Р-10); ОСВ по счёту, `DISCLOSURE` и `OTHER` — по-прежнему один документ на тип +(и счёт) на файл. **Каждый** создаваемый `document` (ОСВ, формы, `DISCLOSURE`, `OTHER`) при приёме результата любого +`MAJOR` (мапперы `1.x` и `2.0`) SHALL получать `file_ordinal` = индекс документа в `documents[]` результата (с 0); +документ, который не создаётся (второй лист ОСВ с тем же счётом, второй `DISCLOSURE`), номер не занимает, но и не +сдвигает остальные — номера уникальны в файле и могут идти с пропусками. + +#### Scenario: Книга из двух ОСВ +- **WHEN** результат содержит две ОСВ по счетам 60 и 62 (`documents[0]`, `documents[1]`), принятый маппером `1.x` или `2.0` +- **THEN** два документа с `file_ordinal` 0 и 1; уникальность `(stored_file_id, file_ordinal)` не нарушена, файл не `ERROR` + +#### Scenario: PDF с ББ и ОФР по 1.x +- **WHEN** результат `1.2` содержит `BALANCE` и `INCOME` +- **THEN** документы с `file_ordinal` 0 и 1 + +#### Scenario: Повтор кода сохраняется +- **WHEN** в результате две строки `ok` с кодом 2410 (пустая и с `(649)`) +- **THEN** в БД две строки 2410 с разными `row_index` и `source_ref`, обе со своими значениями #### Scenario: Форма 2.0 хранится как пришла - **WHEN** результат `2.0` содержит две строки с кодом 1230 и строку без кода (`ref` с `parent_ref`) - **THEN** создано три строки с их `row_index`, `source_ref` и `parent_row_index`; повтор кода не сливается +#### Scenario: Строка без кода +- **WHEN** в результате строка `unreadable` с `line_code_text = "ИЧ,250"` и суммой +- **THEN** строка хранится с `line_code = null`, `line_code_original_value = null`, `line_status = unreadable`, + `line_code_text = "ИЧ,250"` и `row_index` из результата + +#### Scenario: Два баланса одного периода в одном файле +- **WHEN** результат содержит два `BALANCE` с одной парой `period_year`, `period_quarter` (лист-продолжение другого + типа или двойная печать) +- **THEN** создаются два документа с разными `file_ordinal`; ни один не автоподтверждается (Р-18) + +#### Scenario: Два баланса разных периодов в одном файле +- **WHEN** результат содержит `BALANCE` на 30.09.2025 и `BALANCE` на 31.12.2024 +- **THEN** два документа с разными `file_ordinal`; каждый получает статус по своим условиям, правило Р-18 их не касается + +#### Scenario: Повтор column_index колонки +- **WHEN** в `columns[]` результата два элемента с `column_index = 1` +- **THEN** сохраняется первая колонка, вторая пропускается, у документа `MAPPING_WARNING` + +#### Scenario: Повтор ref строки +- **WHEN** в результате две строки с одним `ref` +- **THEN** сохраняется первая, вторая пропускается с `MAPPING_WARNING`; приём не падает + +### Requirement: Формы результата 1.x — §7 при приёме +Для формы, принятой по результату `1.x` (окно перехода после выкладки ядра; `checks` контракта 2.0 у неё нет), ядро +SHALL **один раз при приёме** посчитать §7 точкой входа пакета для пересчёта (`statement-normalization`; вариант формы — +по структуре кодов, как у 2.0) по суммам результата и записать нарушения в `document_issue` (источник `core`, виды +`relation` / `soft`, как в требовании о `document_issue`) и `form_variant` в `financial_statement_meta`; собственного +пересчёта §7 ядро MUST NOT выполнять. Условие D-6 «проверки сошлись» для `1.x` SHALL браться из этого расчёта; +`needs_review` значения при приёме `1.x` = **НЕ** `values_ok` (`values_ok` — `is_recognized` не `false` и `confidence` +равна 100 или пуста), `needs_review` строки — `false`. Предупреждения маппера `1.x` по документу (повтор кода, значение +вне колонок) — `document_issue` `MAPPING_WARNING` (`info`, источник `core`); строки `metrics.warnings` результата `1.x` — +в `recognition_job.metrics.warnings` структурами `{code: "LEGACY_WARNING", severity: "info", message}`. После правки такая +форма пересчитывается общим кодом, как форма 2.0; `MAPPING_WARNING` и `LEGACY_WARNING` статус не держат. Формы, +существовавшие в БД до миграции v5.19, §7 не пересчитываются (требования «Миграция DBML v5.19» и «Очистка форм перед +выкладкой»). + +#### Scenario: Полярность needs_review при приёме 1.x +- **WHEN** в результате `1.2` у значения `confidence = 100`, `is_recognized = true`, у другого — `confidence = 80` +- **THEN** у первого `needs_review = false`, у второго — `true` + +#### Scenario: Форма 1.x с нарушением +- **WHEN** результат `1.2` содержит ББ с несошедшимся `1600 = 1700` +- **THEN** у формы запись `document_issue` `relation` `RELATION_FAILED` с `codes = ["1600", "1700"]` (источник `core`), + документ `NEEDS_CHECK`; `POST …/confirm` без акцепта — 409 `CHECKS_NOT_PASSED` + +#### Scenario: 1.x-документ после PATCH +- **WHEN** форма принята по результату `1.2`, аналитик правит сумму 1230 так, что `1200 = 1210 + … + 1260` сходится +- **THEN** ядро пересчитывает §7 и статус общим кодом, запись `relation` по 1200 заменена (её больше нет), статус + пересчитан; `STATEMENTS_VERSION_MISMATCH` не пишется (у задания 1.x версии нет) + +## ADDED Requirements + +### Requirement: Проверки ядра при приёме результата 2.0 +При приёме результата `2.0` ядро SHALL писать в `document_issue` формы предупреждения и `checks` результата (источник +`service`) и проверки ядра (источник `core`): `CODE_NOT_IN_DICTIONARY` и `CODE_NOT_IN_EDITION` (редакция — `edition` точки +входа пакета, а не по дате; одна запись на код документа, `line_ids` — все строки `ok` кода; **только для кодов из ровно +четырёх цифр** — пятизначные подстроки Excel не проверяются; запись не пишется, если пакет не определил редакцию), +`NAME_MISMATCH`, `RESULT_STATUS_INCONSISTENT` (согласованность `done` — вызовом `engine.contract_document_status` +пакета) и `STATEMENTS_VERSION_MISMATCH` (при отличии `statements_version` задания от версии копии ядра; у `1.x` не +пишется). Проверки §7, `needs_review` и предупреждения сервиса MUST NOT пересчитываться при загрузке. Статус документа +формы — по требованию «Условие `RECOGNIZED` при приёме 2.0». + #### Scenario: Пятизначный код не даёт info - **WHEN** в ББ строка с кодом `12301` (подстрока Excel), которого нет в словаре - **THEN** `CODE_NOT_IN_DICTIONARY` и `CODE_NOT_IN_EDITION` для него не пишутся @@ -28,6 +108,10 @@ - **WHEN** в ББ строка 1120 на бланке `fsbu4`, где такого кода нет - **THEN** запись `CODE_NOT_IN_EDITION` (`info`, `core`) с `line_ids` строк `ok` этого кода +#### Scenario: Версия копии отличается +- **WHEN** результат `2.0` принят с `statements_version`, отличной от версии копии ядра +- **THEN** запись `STATEMENTS_VERSION_MISMATCH` (`info`, `core`) уже при приёме + ### Requirement: Ведомость ОСВ по контракту 2.0 Для листа ОСВ результата `2.0` ядро SHALL создавать документ ОСВ по `osvDocument`, предупреждения листа (`osvDocument.warnings`, `OSV_*`) писать в `document_issue` (вид `warning`, источник `service`), а статус документа @@ -37,21 +121,3 @@ #### Scenario: ОСВ в книге с ОФР, требующим проверки - **WHEN** книга из ОСВ (`status = done`, контрольные суммы сошлись) и ОФР (`needs_check`) - **THEN** документ ОСВ `RECOGNIZED`, документ ОФР `NEEDS_CHECK` - -## MODIFIED Requirements - -### Requirement: Формы результата 1.x — §7 при приёме -Для формы, принятой по результату `1.x` (окно перехода после выкладки ядра; `checks` контракта 2.0 у неё нет), ядро -SHALL **один раз при приёме** посчитать §7 точкой входа пакета для пересчёта (`statement-normalization`; вариант формы — -по структуре кодов, как у 2.0) по суммам результата и записать нарушения в `document_issue` (источник `core`, виды -`relation` / `soft`) и `form_variant` в `financial_statement_meta`; собственного пересчёта §7 ядро MUST NOT выполнять. -Условие D-6 «проверки сошлись» для `1.x` SHALL браться из этого расчёта; `needs_review` значения при приёме `1.x` = -**НЕ** `values_ok` (`values_ok` — `is_recognized` не `false` и `confidence` равна 100 или пуста), `needs_review` строки — -`false`. Предупреждения маппера `1.x` по документу — `document_issue` `MAPPING_WARNING` (`info`, источник `core`); -строки `metrics.warnings` результата `1.x` — в `recognition_job.metrics.warnings` как `LEGACY_WARNING`. Формы, -существовавшие в БД до миграции v5.19, §7 не пересчитываются. - -#### Scenario: Форма 1.x с нарушением -- **WHEN** результат `1.2` содержит ББ с несошедшимся `1600 = 1700` -- **THEN** у формы запись `document_issue` `relation` `RELATION_FAILED` с `codes = ["1600", "1700"]` (источник `core`), - документ `NEEDS_CHECK`; `POST …/confirm` без акцепта — 409 `CHECKS_NOT_PASSED` diff --git a/openspec/changes/unified-normalization-core-b/specs/statement-checks/spec.md b/openspec/changes/unified-normalization-core-b/specs/statement-checks/spec.md index e3e47bd..fc05517 100644 --- a/openspec/changes/unified-normalization-core-b/specs/statement-checks/spec.md +++ b/openspec/changes/unified-normalization-core-b/specs/statement-checks/spec.md @@ -2,15 +2,18 @@ ### Requirement: Соотношения §7 по колонкам Проверки SHALL считаться чистой функцией по текущим `financial_statement_value.amount` документа для каждой колонки -отдельно по словарю §7 копии пакета `arm_statements` (`statement-normalization`; своей копии словаря ядро не держит): соотношение проверяется, только -если присутствует его левая часть; отсутствующий код и пустая сумма — 0; выполнено, если `|левая − правая| ≤ tolerance` +отдельно по словарю §7 копии пакета `arm_statements` (`statement-normalization`; своей копии словаря ядро не держит): +соотношение **посчитано**, если непуста цель (левая часть) или хотя бы один член (есть строка `ok` с кодом и непустой +суммой); если пусто всё — соотношение пропускается. В посчитанном соотношении отсутствующий код и пустая сумма в +арифметике — 0, но пустая цель при непустых членах и непустая цель при пустых членах — нарушение (пустая цель даёт +`expected = null`, P1-21 пакета). Выполнено, если `|левая − правая| ≤ tolerance` (1, в единицах формы) хотя бы в одном варианте правой части — как записано или с кодами `abs_terms` по модулю. Нарушение жёсткого (`strict`) соотношения, не превышающее `rounding_tolerance_percent` (0,001 %) от наибольшего по модулю числа соотношения, SHALL считаться мягким («в пределах округления»). Мягкие: соотношения `strict: false` и «сумма пятизначных подстрок = четырёхзначный родитель». Результат — `{failed: [...], soft_failed: [...]}`, элемент — `{column_index, -relation, expected, actual}`; не хранится как есть: нарушения при приёме и пересчёте записываются в `document_issue`. +relation, expected, actual}`, где `expected` и `actual` могут быть `null` (`expected = null` — пустая цель); не хранится как есть: нарушения при приёме и пересчёте записываются в `document_issue`. -Присутствующими SHALL считаться суммы **агрегата формы по коду** (требование «Агрегат формы по коду»): строки `ok` формы из БД, кроме строк, созданных правкой аналитика (`row_index IS NULL`), у которых все суммы пустые; строки `detail`, `unreadable`, `missing` и строки, достроенные в ответе по справочнику (`documents-api`, `id = null`), в проверках MUST NOT участвовать. Созданная правкой строка с хотя бы одной непустой суммой проверяется по всем колонкам, пустые суммы — 0. Повтор кода в форме суммируется. Результат нарушения несёт `codes` (коды цели и членов соотношения) и `line_ids` (все строки `ok` этих кодов). Соотношения считает точка входа пакета для пересчёта по агрегату формы; запись в БД — только в виде `document_issue` при приёме и пересчёте, `api/rules/statement_checks.yaml` и код его загрузки в ядре MUST NOT существовать. +Присутствующими SHALL считаться суммы **агрегата формы по коду** (требование «Агрегат формы по коду»): строки `ok` формы из БД, кроме строк, созданных правкой аналитика (`row_index IS NULL`), у которых все суммы пустые; строки `detail`, `unreadable`, `missing` и строки, достроенные в ответе по справочнику (`documents-api`, `id = null`), в проверках MUST NOT участвовать. Созданная правкой строка с хотя бы одной непустой суммой проверяется по всем колонкам; в колонке, где её сумма пуста, она — пустая цель (или член с суммой 0). Повтор кода в форме суммируется. Результат нарушения несёт `codes` (коды цели и членов соотношения) и `line_ids` (все строки `ok` этих кодов). Соотношения считает точка входа пакета для пересчёта по агрегату формы; запись в БД — только в виде `document_issue` при приёме и пересчёте, `api/rules/statement_checks.yaml` и код его загрузки в ядре MUST NOT существовать. #### Scenario: Сходящийся баланс - **WHEN** проверяется форма `mezheninovskaya/2025-Q3/1_Форма1_бух_09` @@ -29,18 +32,25 @@ relation, expected, actual}`; не хранится как есть: наруш - **THEN** проверка по текущим суммам: `failed` пуст #### Scenario: Пустая шаблонная строка не проверяется -- **WHEN** в форме нет строки 1700 (в ответе она достроена пустой), 1300 + 1400 + 1500 = 2 371 333 в колонке 0 -- **THEN** соотношения с целью 1700 (`1700 = 1300 + 1400 + 1500`) не проверяются; `1600 = 1700` (цель 1600 есть в - форме, 1700 = 0) нарушено — как и без достройки: потерянная строка «БАЛАНС» сама по себе даёт `NEEDS_CHECK` +- **WHEN** в форме нет строки 1700 (в ответе она достроена пустой и как значение в проверках не участвует), 1300 + + 1400 + 1500 = 2 371 333 в колонке 0 +- **THEN** сама строка 1700 не проверяется, но соотношение `1700 = 1300 + 1400 + 1500` посчитано и нарушено: `expected = null`, `actual = 2371333`; + `1600 = 1700` (цель 1600 есть в форме, 1700 пуста) тоже нарушено; потерявшая итоговую строку форма блокирует + подтверждение (`CHECKS_NOT_PASSED`) до правки строки или акцепта (P3-30) + +#### Scenario: Все стороны пусты +- **WHEN** в колонке нет ни цели, ни членов соотношения +- **THEN** соотношение пропускается, нарушения нет #### Scenario: Заполненная шаблонная строка проверяется - **WHEN** аналитик заполнил 1700 = 2 371 333 только в колонке 0, а в колонке 1 1300 + 1400 + 1500 = 2 000 000 -- **THEN** в колонке 0 соотношение выполнено; в колонке 1 `failed` содержит `1700 = 1300 + 1400 + 1500` с `expected 0`, - `actual 2000000` +- **THEN** в колонке 0 соотношение выполнено; в колонке 1 `failed` содержит `1700 = 1300 + 1400 + 1500` с + `expected = null` (1700 в колонке 1 пуста), `actual = 2000000` #### Scenario: Очищенная шаблонная строка - **WHEN** затем аналитик очищает 1700 в колонке 0 (`amount = null`) -- **THEN** строка 1700 снова не участвует в проверках +- **THEN** строка 1700 снова не участвует в проверках как значение: пустая цель при непустых членах — нарушение + `expected = null` в каждой колонке (прежнее «очищенная строка не цель» не действует, P3-30) #### Scenario: Повтор кода суммируется - **WHEN** в ББ две строки 1230 со значениями 100 и 50 в колонке 0 diff --git a/openspec/changes/unified-normalization-core-b/tasks.md b/openspec/changes/unified-normalization-core-b/tasks.md index 7f1b2f0..1c676f2 100644 --- a/openspec/changes/unified-normalization-core-b/tasks.md +++ b/openspec/changes/unified-normalization-core-b/tasks.md @@ -1,9 +1,9 @@ ## Задачи (SDD-план `docs/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-3-arm-core.md`, часть Б) -- [ ] B0. Change OpenSpec части Б в arm-core -- [ ] B1. Адаптер пакета `StatementsEngine` и вход пересчёта из БД -- [ ] B2. Пересчёт после правки общим кодом и §7 из `document_issue` -- [ ] B3. Маппер `1.x` — §7 при приёме и вариант формы (Р-30) -- [ ] B4. Маппер `2.0` — формы -- [ ] B5. Маппер `2.0` — ОСВ, выбор по MAJOR, приём файла результата -- [ ] B6. Удаление копии словаря §7 ядра, редакции из перечня пакета +- [x] B0. Change OpenSpec части Б в arm-core +- [x] B1. Адаптер пакета `StatementsEngine` и вход пересчёта из БД +- [x] B2. Пересчёт после правки общим кодом и §7 из `document_issue` +- [x] B3. Маппер `1.x` — §7 при приёме и вариант формы (Р-30) +- [x] B4. Маппер `2.0` — формы +- [x] B5. Маппер `2.0` — ОСВ, выбор по MAJOR, приём файла результата +- [x] B6. Удаление копии словаря §7 ядра, редакции из перечня пакета -- 2.47.2 From e6292a24df299a99b7b54ea838f2a35ff3dd73af Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 22:38:24 +0400 Subject: [PATCH 12/13] =?UTF-8?q?fix(statements):=20fix-=D0=B2=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=87=D0=B0=D1=81=D1=82=D0=B8=20=D0=91=20?= =?UTF-8?q?=E2=80=94=20is=5Frecognized=20NULL,=20=D0=B5=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=D0=B0,=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BA=D0=B8=20=D1=84=D0=BE=D1=80=D0=BC=202.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-2: NULL у is_recognized не сомнение (P3-54); I-3: metrics.file_warnings при приёме 2.0 — единый источник для пересчёта (P3-53, R-11); I-4/T4.4/T6.1: тесты правки, акцепта и веток приёма 2.0; T4.2: now в checks_on_intake_1x; M-4: row_by_ref по принятым строкам; документация (M-5, M-6, M-7). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 19 +- README.md | 7 +- api/src/domain/statement_mapping_v2.py | 9 +- api/src/domain/stored_form.py | 6 +- api/src/services/result_mapping.py | 24 +- api/src/services/statement_intake_v2.py | 24 +- api/src/services/statement_recompute.py | 9 +- api/tests/test_domain_statement_mapping_v2.py | 29 ++ api/tests/test_domain_stored_form.py | 10 +- api/tests/test_statements_v2_edit.py | 295 ++++++++++++++++++ 10 files changed, 415 insertions(+), 17 deletions(-) create mode 100644 api/tests/test_statements_v2_edit.py diff --git a/CLAUDE.md b/CLAUDE.md index 094aecf..e9c53a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,15 +97,26 @@ `engine.tolerance()`) и `subline_parents` (`parent_code` ответа). Акцепт — `checks_accepted_by/at/comment` в шапке. Правка формы и акцепт — `services/statements.py` (`PATCH …/statement`, `POST …/statement/checks/accept`): **только до подтверждения** (`CONFIRMED` → `DOCUMENT_READONLY`), аудит `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`, `object_type=document`; один запрос — одно событие), первичка - рядом (`original_amount`, `*_original_value`, `is_modified`/`*_modified`); любая правка сбрасывает акцепт и пересчитывает статус - документа. **Порядок блокировок «файл → документ»**, шапка (`meta`) читается ПОД блокировкой с `refresh=True` — иначе в сессии + рядом (`original_amount`, `*_original_value`, `is_modified`/`*_modified`); акцепт сбрасывает только правка данных (подтверждения + ячеек, кодов и дат — нет, Р-25), статус документа пересчитывается после каждой правки. **Порядок блокировок «файл → документ»**, шапка (`meta`) читается ПОД блокировкой с `refresh=True` — иначе в сессии остаётся устаревший акцепт. Подтверждение формы (`services/documents._check_statement`) требует период (`PERIOD_REQUIRED`) и пройденные/акцептованные жёсткие проверки (`CHECKS_NOT_PASSED`, `detail.failures`); мягкие (в т. ч. межформенная 1370↔2400) не блокируют. Полный состав формы — `domain/statement_lines.py` по справочнику `api/rules/statement_lines.yaml` (редакции `66n`/`fsbu4` из шаблонов банка; это справочник имён и состава экрана — перечни кодов редакций живут в `arm_statements` (`Rules.code_orders`, `engine.editions()`/`engine.code_order(...)`), согласованность проверяет `test_name_dictionary_codes_in_code_orders`): `GET …/statement` достраивает строки и ячейки с - `id = null`, `PATCH` по `line_code` создаёт строку (`line_code_original_value = NULL` — «не из источника»); пустые - созданные строки не участвуют в §7 (`statement_amounts`). Решения — `openspec/changes/statement-template-lines/design.md`. Комментарии к документу — `services/documents.py` (`comments`/`add_comment`, `comment(object_type='document')`, + `id = null`, `PATCH` по `line_code` создаёт строку (`line_code_original_value = NULL` — «не из источника»); пустая созданная + строка вклада в §7 не даёт, а пустая цель соотношения при заполненных членах — нарушение с `expected = NULL` (P3-30, P1-21; + потерявшая итоговую строку форма не подтверждается до правки или акцепта). **Приём результата `2.0` (часть Б)** — `_load_result` + выбирает маппер по MAJOR `schema_version` (`contract.versions.parse_result`: 1 — `RecognitionResult`, 2 — `RecognitionResultV2`; иной + MAJOR и битый/не-JSON файл — `MappingFatal` с итогом проверки схемы). Формы `2.0` (`services/statement_intake_v2`) пишутся как пришли: + §7, `needs_review` и предупреждения берутся из результата, ядро добавляет только проверки `info` (`core_info_issues`) и страховку + статуса (`engine.contract_document_status`, `RESULT_STATUS_INCONSISTENT`); предупреждения и нарушения сервиса — `document_issue` + с `source = service`, первый же пересчёт после правки заменяет `relation` / `soft` записями `core`, прочее сохраняет. ОСВ `2.0` — по + Р-28: статус листа из `osvDocument.status`, сошедшихся сумм, ошибки файла и предупреждений маппинга; предупреждения листа + (`OSV_*`) — в `document_issue` документа ОСВ и на статус не влияют (P3-49). **Ошибка файла — единый источник (P3-53, R-11):** + `metrics.warnings` результата `2.0` сохраняются в `recognition_job.metrics.file_warnings`, пересчёт читает их оттуда (без ключа — + `metrics.warnings` конверта, `1.x`). `recompute_form`/`checks_on_intake_1x` — `services/statement_recompute.py`, вход пакету + собирает `domain/stored_form.py` (`is_recognized = NULL` — не сомнение, P3-54). Решения — `openspec/changes/statement-template-lines/design.md`. Комментарии к документу — `services/documents.py` (`comments`/`add_comment`, `comment(object_type='document')`, без аудита; `comments_count` в карточках). `GET /files/{id}/content` — `services/primary_files.content` + `ObjectStorage.stream` (поток из S3, первая порция до заголовков → `FILE_CONTENT_NOT_FOUND`; сессию тело потока не держит; имя — RFC 5987, `X-Content-Type-Options: nosniff`; освобождение потока — `common/streaming.py`: `ClosingStreamingResponse` + diff --git a/README.md b/README.md index 2ab2f94..47f00ed 100644 --- a/README.md +++ b/README.md @@ -568,6 +568,11 @@ S3 (storage); без S3 — предупреждение в логе, цикл (`relation` / `soft`). Копии словаря §7 в ядре нет; в `domain/statement_checks.py` остались межформенная проверка 1370↔2400 (на лету, допуск — из правил пакета) и `subline_parents`. +**Формы, принятые кодом части А (M-5).** У форм ББ/ОФР, загруженных до выкладки части Б, записей §7 в `document_issue` нет +(нарушения считались на лету). После выкладки они не проверяются на `CHECKS_NOT_PASSED` при подтверждении, пока не пройдут +первую правку (она запишет `relation` / `soft`) или не будут пересозданы повторной загрузкой; на стендах формы очищает +`scripts/purge_statements.py` (раздел «Выкладка v5.19: очистка форм»). + - **`GET /documents/{id}/statement`** — `{document, edition, header, columns[], lines[], checks, issues[], edition_lines[]}`: `header` — `inn`, `report_date`, `units` вида `{value, original_value, confidence, verified, modified}` (`confidence` есть у ИНН, у даты и единиц — `null`) и справочные поля формы (`organization_name`, `okud`, `okpo`, …); `lines[].values[]` — суммы строкой, `is_modified`, `original_amount`; `lines[].parent_code` — у пятизначной подстроки код её четырёхзначной строки-родителя (правило проверки «сумма подстрок = родитель», `domain/statement_checks.subline_parents`: ближайшая выше строка с теми же тремя цифрами, иначе строка с кодом из первых четырёх цифр; родитель считается по строкам, участвующим в проверках, — достроенные из справочника пустые строки родителями не бывают), у остальных строк `null`; поле вычисляется на лету, в БД не хранится; мягкая проверка «сумма подстрок = родитель» по колонке выполняется, только если хотя бы одна подстрока родителя в ней непустая (`amount` не `null`; ноль — значение), иначе не проверяется; `checks` — @@ -606,7 +611,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл (в том числе подтверждение ячейки, которой нет в БД), колонка не этой формы — 422 `UNKNOWN_COLUMN`, одна колонка дважды — 422 `DUPLICATE_COLUMN`, одна ячейка дважды — 422 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно событие `RECOGNIZED_DATA_EDIT` с `before/after`; возврат ячейки к исходному значению снимает `is_modified`; правка ИНН - пересчитывает `counterparty_check`, даты отчёта — период документа; любая правка сбрасывает акцепт и пересчитывает статус. + пересчитывает `counterparty_check`, даты отчёта — период документа; акцепт сбрасывает только правка данных (подтверждения ячеек и дат — нет, Р-25), статус пересчитывается после каждой правки. `lines[] {line_id, line_code | line_status: "detail" | confirmed}` (ровно одно действие) — назначить или сменить код, отметить расшифровкой, подтвердить код (порядок частей: `lines` → `values` → `columns` → `header`). Все ошибки элемента проверяются до первой записи; первая неверная несёт в `detail` `line_id` и `line_code`; `LINE_CODE_TAKEN` — ничего не применено. `values[]` адресует ячейку `line_id` или `line_code` (ровно одно): ячейка, которой нет, создаётся при первой непустой правке; строка по `line_code` — только код справочника редакции, которого нет в форме. Запрос без изменений — 200 без аудита. Ответ — та же форма, что у `GET`. diff --git a/api/src/domain/statement_mapping_v2.py b/api/src/domain/statement_mapping_v2.py index 619c390..227d7aa 100644 --- a/api/src/domain/statement_mapping_v2.py +++ b/api/src/domain/statement_mapping_v2.py @@ -133,7 +133,14 @@ def _lines_and_values( doc: StatementDocumentV2, source_columns: Sequence[StatementColumnV2], warnings: list[str] ) -> tuple[list[LineDraft], list[ValueDraft]]: known_columns = {column.column_index for column in source_columns} - row_by_ref = {line.ref: line.row_index for line in doc.lines} + # M-4: `ref` → `row_index` только по принятым строкам (первый вход побеждает) — родитель, на который указывает + # `parent_ref` повторённого `ref`, — первая принятая строка, а не пропущенный дубль; ссылка вперёд допустима + row_by_ref: dict[str, int] = {} + accepted_rows: set[int] = set() + for line in doc.lines: + if line.row_index not in accepted_rows and line.ref not in row_by_ref: + accepted_rows.add(line.row_index) + row_by_ref[line.ref] = line.row_index lines: list[LineDraft] = [] values: list[ValueDraft] = [] seen_rows: set[int] = set() diff --git a/api/src/domain/stored_form.py b/api/src/domain/stored_form.py index 4bdcd32..ff7806f 100644 --- a/api/src/domain/stored_form.py +++ b/api/src/domain/stored_form.py @@ -1,8 +1,8 @@ """Вход пересчёта формы ББ/ОФР из строк БД: сборка `StoredForm` пакета `arm_statements` (часть Б, B1). Единственное место ядра (вместе с `services/statements_engine.py`), где импортируется пакет. Здесь же все соглашения -границы: перечисления пакета — `StrEnum`, уверенности — `float | None`, `NULL` у `is_recognized` — `False`, -`period_kind = NULL` — `PeriodKind.NONE`, ключи строки и колонки — `str(uuid)`. +границы: перечисления пакета — `StrEnum`, уверенности — `float | None`, `NULL` у `is_recognized` — `True` (I-2: +сомнение только при `false`), `period_kind = NULL` — `PeriodKind.NONE`, ключи строки и колонки — `str(uuid)`. """ from collections.abc import Sequence @@ -61,7 +61,7 @@ def _stored_value(value: FinancialStatementValue) -> StoredValue: amount=value.amount, text=value.source_text, confidence=_confidence(value.confidence), - is_recognized=bool(value.is_recognized), + is_recognized=value.is_recognized is not False, # I-2: NULL (заглушки, ячейки правки) — не «не распознано» value_fixed=bool(value.is_modified) or bool(value.is_confirmed), ) diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 4ee7802..a6ccb9a 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -81,7 +81,14 @@ from src.repositories.counterparties import CounterpartyIndex from src.services import consolidation_documents from src.services.audit import AuditService from src.services.file_status import set_file_status_for_job -from src.services.statement_intake_v2 import FormV2, OtherV2, file_warnings, insert_forms_v2, map_statement_docs_v2 +from src.services.statement_intake_v2 import ( + FILE_WARNINGS_KEY, + FormV2, + OtherV2, + file_warnings, + insert_forms_v2, + map_statement_docs_v2, +) from src.services.statement_recompute import checks_on_intake_1x from src.services.statements_engine import StatementsEngine, default_engine @@ -486,7 +493,7 @@ async def _insert_statements( # noqa: PLR0913 - все входы именов meta = await statements_repo.insert_statement(session, document_id=document.id, job_id=job.id, draft=draft) warnings = [*draft.warnings, *([repeats[document.id]] if document.id in repeats else [])] await statements_repo.add_issues(session, document.id, [mapping_warning(text) for text in warnings], now=now) - checks_passed = await checks_on_intake_1x(session, document=document, meta=meta, engine=engine) + checks_passed = await checks_on_intake_1x(session, document=document, meta=meta, engine=engine, now=now) document.status = statement_status( job_done=job_done, checks_passed=checks_passed, @@ -697,7 +704,7 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай await _insert_forms(session, plan, job=job, result=result, job_done=job_done, now=now, engine=engine) job.group_schema = [_group_schema(sheet) for sheet in sheets] - job.metrics = {**_metrics(job), MAPPING_WARNINGS_KEY: warnings} + job.metrics = _mapped_metrics(job, result, warnings) await session.flush() if not await set_file_status_for_job( session, job, FileStatus.PROCESSED, now=now, only_from={FileStatus.PROCESSING} @@ -707,6 +714,17 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай return await _supersede_and_warn(session, job=job, file=file, documents=documents, warnings=warnings, now=now) +def _mapped_metrics( + job: RecognitionJob, result: RecognitionResult | RecognitionResultV2, warnings: list[str] +) -> dict[str, Any]: + """`metrics` задания после маппинга: предупреждения маппинга и, для результата `2.0`, предупреждения файла + результата отдельным ключом — единый источник «ошибки файла» для пересчёта после правки (P3-53, R-11).""" + metrics = {**_metrics(job), MAPPING_WARNINGS_KEY: warnings} + if isinstance(result, RecognitionResultV2): + metrics[FILE_WARNINGS_KEY] = file_warnings(result) + return metrics + + async def _supersede_and_warn( # noqa: PLR0913 - входы автозамены именованные session: AsyncSession, *, diff --git a/api/src/services/statement_intake_v2.py b/api/src/services/statement_intake_v2.py index f623e51..ea2352c 100644 --- a/api/src/services/statement_intake_v2.py +++ b/api/src/services/statement_intake_v2.py @@ -34,7 +34,19 @@ from src.repositories import statements as statements_repo from src.services.statements_engine import DocStatus, FormVariant, StatementsEngine from src.services.statements_engine import DocumentType as PackageDocumentType -__all__ = ["FormV2", "OtherV2", "file_warnings", "insert_forms_v2", "map_statement_docs_v2"] +__all__ = [ + "FILE_WARNINGS_KEY", + "FormV2", + "OtherV2", + "file_error_in_metrics", + "file_warnings", + "insert_forms_v2", + "map_statement_docs_v2", +] + +# P3-53 (R-11): предупреждения файла результата `2.0` (`result.metrics.warnings`) хранятся в `recognition_job.metrics` +# отдельным ключом при приёме результата; пересчёт после правки читает их оттуда, а не из `metrics.warnings` конверта +FILE_WARNINGS_KEY = "file_warnings" @dataclass(frozen=True) @@ -135,6 +147,16 @@ def file_warnings(result: RecognitionResultV2) -> list[dict[str, object]]: return [warning.model_dump(mode="json") for warning in result.metrics.warnings] +def file_error_in_metrics(metrics: object) -> bool: + """Единый источник «ошибка файла» для пересчёта (P3-53): `metrics.file_warnings` (результат `2.0`), при его + отсутствии — `metrics.warnings` конверта (результат `1.x` и задания до части Б).""" + if not isinstance(metrics, Mapping): + return False + if FILE_WARNINGS_KEY in metrics: + return has_error_warning(metrics[FILE_WARNINGS_KEY]) + return has_error_warning(metrics.get("warnings")) + + async def insert_forms_v2( # noqa: PLR0913 - все входы именованные session: AsyncSession, *, diff --git a/api/src/services/statement_recompute.py b/api/src/services/statement_recompute.py index 17b7aeb..67f3a33 100644 --- a/api/src/services/statement_recompute.py +++ b/api/src/services/statement_recompute.py @@ -7,6 +7,7 @@ """ from collections.abc import Mapping, Sequence +from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession @@ -28,7 +29,7 @@ from src.domain.statement_issues import ( version_issue, ) from src.domain.statement_lines import load_line_catalog -from src.domain.statement_status import duplicate_period_forms, form_status_v2, has_error_warning +from src.domain.statement_status import duplicate_period_forms, form_status_v2 from src.domain.stored_form import build_stored_form from src.models.enums import ( DocumentCheckResult, @@ -42,6 +43,7 @@ from src.models.enums import ( from src.models.files import Document, RecognitionJob from src.models.statements import FinancialStatementColumn, FinancialStatementMeta from src.repositories import statements as repo +from src.services.statement_intake_v2 import file_error_in_metrics from src.services.statements_engine import FailedRelation, StatementChecks, StatementsEngine __all__ = [ @@ -174,7 +176,7 @@ async def recompute_form( forms = await repo.file_form_periods(session, document.stored_file_id) document.status = form_status_v2( service_done=result.status.value == "done", - file_error_warning=has_error_warning((job.metrics or {}).get("warnings") if job is not None else None), + file_error_warning=file_error_in_metrics(job.metrics if job is not None else None), counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN, period_known=document.period_year is not None and document.period_quarter is not None, names_ok=not mismatched, @@ -189,6 +191,7 @@ async def checks_on_intake_1x( document: Document, meta: FinancialStatementMeta, engine: StatementsEngine, + now: datetime, ) -> bool: """§7 и вариант формы при приёме результата `1.x` (Р-30): один вызов `engine.recompute` по вставленной форме. Пишет `meta.form_variant` и записи `relation` / `soft` (`source = core`); `needs_review` значений и строк не @@ -200,5 +203,5 @@ async def checks_on_intake_1x( meta.form_variant = StatementFormVariant(result.form_variant.value) if result.form_variant is not None else None _, aggregates = await repo.form_aggregate(session, meta.id) drafts = relations_to_issues(result.checks, columns=columns, aggregates=aggregates, source=IssueSource.core) - await repo.add_issues(session, document.id, drafts, now=utc_now()) + await repo.add_issues(session, document.id, drafts, now=now) return not result.checks.relations_failed diff --git a/api/tests/test_domain_statement_mapping_v2.py b/api/tests/test_domain_statement_mapping_v2.py index cd7004c..04fd406 100644 --- a/api/tests/test_domain_statement_mapping_v2.py +++ b/api/tests/test_domain_statement_mapping_v2.py @@ -75,6 +75,35 @@ def test_detail_parent_row_index() -> None: assert any("r:missing" in warning for warning in draft.warnings) +def test_duplicate_row_index_or_ref_skipped_with_warning() -> None: + """T4.4: повтор `row_index` или `ref` — строка пропущена вместе со значениями, о повторе предупреждение (P3-9).""" + lines = [ + line_v2(0, "1210", "Запасы", ref="r:a", amount="1"), + line_v2(0, "1220", "Другая", ref="r:b", amount="2"), # тот же row_index + line_v2(1, "1230", "Третья", ref="r:a", amount="3"), # тот же ref + line_v2(2, "1240", "Четвёртая", ref="r:d", amount="4"), + ] + draft = build_statement_v2(_doc(lines=lines)) + assert [(line.row_index, line.line_code) for line in draft.lines] == [(0, "1210"), (2, "1240")] + assert [(v.row_index, v.amount) for v in draft.values] == [(0, Decimal(1)), (2, Decimal(4))] + assert len([w for w in draft.warnings if "повторяет прежнюю" in w]) == 2 # noqa: PLR2004 + + +def test_parent_ref_of_skipped_duplicate_points_to_first_accepted() -> None: + """M-4: `ref` пропущенного дубля не подменяет принятую строку — детализация ссылается на первую принятую; ссылка + вперёд (родитель позже в списке) допустима.""" + lines = [ + line_v2(0, "2110", "Выручка", ref="r:a"), + line_v2(5, "2120", "Дубль ref", ref="r:a"), # пропущена: ref уже принят + line_v2(1, None, "в том числе", status="detail", ref="r:b", parent_ref="r:a", code_confidence=None), + line_v2(2, None, "вперёд", status="detail", ref="r:c", parent_ref="r:later", code_confidence=None), + line_v2(3, "2200", "Позже", ref="r:later"), + ] + draft = build_statement_v2(_doc(document_type="INCOME", lines=lines)) + assert [(line.row_index, line.parent_row_index) for line in draft.lines] == [(0, None), (1, 0), (2, 3), (3, None)] + assert not any("не найден" in warning for warning in draft.warnings) + + def test_value_for_every_cell_and_empty_record() -> None: """Значение на каждую пару «строка × колонка»; отсутствующее — пустая запись с `needs_review = false`.""" columns = [column_v2(2), column_v2(3, date="2024-12-31")] diff --git a/api/tests/test_domain_stored_form.py b/api/tests/test_domain_stored_form.py index 258eed0..7d619ee 100644 --- a/api/tests/test_domain_stored_form.py +++ b/api/tests/test_domain_stored_form.py @@ -168,7 +168,15 @@ def test_stored_form_types() -> None: assert stored.line_code_confidence == CONFIDENCE assert type(stored.line_code_confidence) is float assert type(stored.values[0].confidence) is float - assert stored.values[0].is_recognized is False + assert stored.values[0].is_recognized is True # I-2: NULL — не сомнение, сомнение только `False` + explicit = make_value(line, column, "1", recognized=False) + assert ( + build_stored_form(make_document(), make_meta(), [column], [(line, [explicit])], []) + .lines[0] + .values[0] + .is_recognized + is False + ) assert form.columns[0].period_kind is PeriodKind.NONE assert form.columns[0].key == str(column.id) assert stored.line_status is LineStatus.OK diff --git a/api/tests/test_statements_v2_edit.py b/api/tests/test_statements_v2_edit.py new file mode 100644 index 0000000..93780ba --- /dev/null +++ b/api/tests/test_statements_v2_edit.py @@ -0,0 +1,295 @@ +"""Правка и пересчёт формы, принятой маппером 2.0 (fix-волна части Б: I-2, I-3, I-4, T4.2, T4.4, T6.1; P3-53). + +Форма загружается `map_file` с результатом `2.0` и реальным пакетом `arm_statements`, правится через +`PATCH …/statement`. Проверяется, что пересчёт заменяет только записи ядра (`relation` / `soft` и коды +`RECOMPUTED_CORE_CODES`), сохраняет записи сервиса и факты приёма и не меняет статус от несвязанной правки. +""" + +from http import HTTPStatus +from typing import Any + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.enums import DocumentIssueKind, DocumentStatus, IssueSeverity, IssueSource +from src.models.files import Document, RecognitionJob +from src.models.statements import FinancialStatementValue +from src.services.statement_intake_v2 import FILE_WARNINGS_KEY, file_error_in_metrics +from src.services.statements_engine import DocumentType as DocumentType_ +from src.services.statements_engine import default_engine +from tests.conftest import AuthClientFactory +from tests.factories_v2 import balance_lines, column_v2, header_v2, line_v2, statement_doc_v2 +from tests.test_api_statements import _cell +from tests.test_mapping_statements_v2 import ( + ERROR_JSON_FAILED, + KA_RF, + _confirm_clean, + _entity, + _issues, + _lines, + _map, + _meta, + _osv_doc, +) + +pytestmark = pytest.mark.integration + +TABLE_ERROR = {"code": "TABLE_PARSE_ERROR", "severity": "error", "message": "таблица разобрана с ошибкой"} + + +def _consistent_balance() -> dict[str, Any]: + """Баланс, у которого сходится и пересчёт ядра: 1310 = 1300 = 1700, 1210 = 1200 = 1600 (реальный пакет). Строка + 9999 вне соотношений §7 — её ячейку не «покрывает» ни одна проверка, поэтому `needs_review` зависит только от + `is_recognized` и уверенности (I-2).""" + lines = [*balance_lines(), line_v2(5, "1310", "Уставный капитал"), line_v2(6, "9999", "Прочая строка", amount="5")] + return statement_doc_v2(lines=lines) + + +async def _form(session: AsyncSession, *documents: dict[str, Any], **kwargs: Any) -> Document: + """Первая форма файла 2.0 владельца с доступом `ka_rf` (по умолчанию — `_consistent_balance`).""" + entity = await _entity(session, rostov=True) + return (await _map(session, entity, list(documents) or [_consistent_balance()], **kwargs))[0] + + +async def _patch_units(client: Any, document: Document, units: str = "млн руб.") -> None: + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": units}}) + assert response.status_code == HTTPStatus.OK, response.text + + +async def _stub_values(session: AsyncSession, document: Document) -> int: + """Заглушки маппера 1.x/2.0: `is_recognized = NULL` у всех значений формы; возвращает их число.""" + line_ids = [line.id for line in await _lines(session, document)] + stmt = select(FinancialStatementValue).where(FinancialStatementValue.line_id.in_(line_ids)) + values = list((await session.execute(stmt)).scalars()) + for value in values: # через ORM: значения уже в сессии теста, SQL-UPDATE пересчёт не увидел бы + value.is_recognized = None + await session.flush() + return len(values) + + +# --- I-2: NULL у is_recognized не сомнение --------------------------------------------------------------------------- + + +async def test_null_is_recognized_stub_does_not_need_review_after_header_edit( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """I-2: после правки шапки значения с `is_recognized = NULL` (заглушки) не получают `needs_review`, форма остаётся + `RECOGNIZED`.""" + document = await _form(seeded) + assert document.status is DocumentStatus.RECOGNIZED + assert await _stub_values(seeded, document) > 0 + await _patch_units(await auth_client(KA_RF), document) + await seeded.refresh(document) + assert document.status is DocumentStatus.RECOGNIZED + values = (await seeded.execute(select(FinancialStatementValue))).scalars().all() + assert values and not any(value.needs_review for value in values) + assert not any(line.needs_review for line in await _lines(seeded, document)) + + +async def test_cell_filled_and_cleared_back_keeps_recognized( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """I-2: ячейка с `is_recognized = NULL` заполнена другой суммой и возвращена к исходной — `RECOGNIZED`.""" + document = await _form(seeded) + await _stub_values(seeded, document) + client = await auth_client(KA_RF) + line_id, column_id, original = await _cell(client, document, "1210", 2) + assert original is not None + url = f"/documents/{document.id}/statement" + changed = await client.patch(url, json={"values": [{"line_id": line_id, "column_id": column_id, "amount": "7"}]}) + assert changed.status_code == HTTPStatus.OK, changed.text + back = await client.patch(url, json={"values": [{"line_id": line_id, "column_id": column_id, "amount": original}]}) + assert back.status_code == HTTPStatus.OK, back.text + await seeded.refresh(document) + assert document.status is DocumentStatus.RECOGNIZED + assert not any(value.needs_review for value in (await seeded.execute(select(FinancialStatementValue))).scalars()) + + +# --- I-3 (R-11, P3-53): единый источник «ошибка файла» -------------------------------------------------------------- + + +def test_file_error_in_metrics_prefers_file_warnings_and_falls_back() -> None: + error = [{"code": "X", "severity": "error", "message": "m"}] + assert file_error_in_metrics({FILE_WARNINGS_KEY: error}) is True + assert file_error_in_metrics({FILE_WARNINGS_KEY: [], "warnings": error}) is False # приоритет у ключа приёма 2.0 + assert file_error_in_metrics({"warnings": error}) is True # результат 1.x и задания до части Б + assert file_error_in_metrics({"warnings": ["строка 1.x"]}) is False + assert file_error_in_metrics(None) is False and file_error_in_metrics({}) is False + + +async def test_file_error_warning_is_saved_and_holds_needs_check_after_edit( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """P3-53: предупреждение файла результата `error` сохраняется в `metrics.file_warnings`, форма `NEEDS_CHECK`, и + несвязанная правка шапки статус не меняет (конверт `metrics.warnings` в тесте пуст — источник один).""" + document = await _form(seeded, file_warnings=[ERROR_JSON_FAILED]) + job = await seeded.get(RecognitionJob, (await _meta(seeded, document)).recognition_job_id) + assert job is not None and job.metrics is not None + assert [w["code"] for w in job.metrics[FILE_WARNINGS_KEY]] == ["JSON_FAILED"] + assert document.status is DocumentStatus.NEEDS_CHECK + await _patch_units(await auth_client(KA_RF), document) + await seeded.refresh(document) + assert document.status is DocumentStatus.NEEDS_CHECK + + +async def test_info_file_warning_keeps_recognized_after_edit( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + info = {"code": "SOMETHING", "severity": "info", "message": "к сведению"} + document = await _form(seeded, file_warnings=[info]) + await _patch_units(await auth_client(KA_RF), document) + await seeded.refresh(document) + assert document.status is DocumentStatus.RECOGNIZED + + +# --- I-4: правка и акцепт формы 2.0 ---------------------------------------------------------------------------------- + + +async def test_service_relations_replaced_by_core_after_edit( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """I-4: записи `relation` / `soft` источника `service` после первой правки заменяются записями `core` (суммы + сходятся — нарушений ядра нет), warning сервиса уровня `error` остаётся и держит `NEEDS_CHECK`.""" + checks = { + "relations_failed": [ + {"column_index": 2, "relation": "1600 = 1700", "codes": ["1600", "1700"], "expected": "1", "actual": "2"} + ], + "soft_relations_failed": [ + {"column_index": 2, "relation": "1210 = 1200", "codes": ["1210", "1200"], "expected": "1", "actual": "2"} + ], + } + doc = {**_consistent_balance(), "status": "needs_check", "warnings": [TABLE_ERROR], "checks": checks} + document = await _form(seeded, doc) + before = await _issues(seeded, document) + service_checks = [i for i in before if i.kind in (DocumentIssueKind.relation, DocumentIssueKind.soft)] + assert len(service_checks) == 2 and all(i.source is IssueSource.service for i in service_checks) # noqa: PLR2004 + await _patch_units(await auth_client(KA_RF), document) + after = await _issues(seeded, document) + assert not [i for i in after if i.kind in (DocumentIssueKind.relation, DocumentIssueKind.soft)] + kept = [i for i in after if i.code == "TABLE_PARSE_ERROR"] + assert len(kept) == 1 and kept[0].source is IssueSource.service and kept[0].severity is IssueSeverity.error + await seeded.refresh(document) + assert document.status is DocumentStatus.NEEDS_CHECK + + +async def test_result_status_inconsistent_survives_edit(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """I-4: `RESULT_STATUS_INCONSISTENT` — факт приёма, правка шапки его не удаляет; форма остаётся `NEEDS_CHECK`.""" + lines = [*balance_lines(), line_v2(9, None, "Строка", status="unreadable", amount="50", code_text="???")] + document = await _form(seeded, statement_doc_v2(lines=lines, status="done")) + assert [i.code for i in await _issues(seeded, document) if i.code == "RESULT_STATUS_INCONSISTENT"] + await _patch_units(await auth_client(KA_RF), document) + assert len([i for i in await _issues(seeded, document) if i.code == "RESULT_STATUS_INCONSISTENT"]) == 1 + await seeded.refresh(document) + assert document.status is DocumentStatus.NEEDS_CHECK + + +async def test_recognized_form_stays_recognized_after_header_edit_and_accept( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """I-4: статус `RECOGNIZED` формы 2.0 не меняется от правки шапки (units); акцепт без нарушений — 409.""" + document = await _form(seeded) + assert document.status is DocumentStatus.RECOGNIZED + client = await auth_client(KA_RF) + await _patch_units(client, document) + await seeded.refresh(document) + assert document.status is DocumentStatus.RECOGNIZED + nothing = await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": "проверено"}) + assert nothing.status_code == HTTPStatus.CONFLICT and nothing.json()["detail"]["code"] == "NOTHING_TO_ACCEPT" + + +async def test_accept_form_with_core_violation_after_edit(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """I-4: у формы 2.0 с нарушением, найденным ядром после правки (1600 ≠ 1700), акцепт возможен; запись `relation` + (`core`) остаётся, форма подтверждается (`CHECKS_NOT_PASSED` снят акцептом).""" + lines = [*balance_lines(), line_v2(5, "1310", "Уставный капитал", amount="50")] + document = await _form(seeded, statement_doc_v2(lines=lines)) + client = await auth_client(KA_RF) + await _patch_units(client, document) + assert [i for i in await _issues(seeded, document) if i.kind is DocumentIssueKind.relation] + await seeded.refresh(document) + assert document.status is DocumentStatus.NEEDS_CHECK + blocked = await client.post(f"/documents/{document.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED" + accepted = await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": "проверено"}) + assert accepted.status_code == HTTPStatus.OK, accepted.text + assert [i for i in await _issues(seeded, document) if i.kind is DocumentIssueKind.relation] + confirmed = await client.post(f"/documents/{document.id}/confirm") + assert confirmed.status_code == HTTPStatus.OK, confirmed.text + + +# --- T4.4: ветки приёма 2.0 ------------------------------------------------------------------------------------------- + + +async def test_intake_duplicate_row_and_missing_parent_warnings(seeded: AsyncSession) -> None: + """T4.4: дубль `row_index` / `ref` пропущен, родитель не найден — предупреждения маппинга (`MAPPING_WARNING`).""" + lines = [ + *balance_lines(), + line_v2(0, "1230", "Дубль строки 0"), + line_v2(20, None, "сирота", status="detail", ref="r:orphan", parent_ref="r:none", code_confidence=None), + ] + document = await _form(seeded, statement_doc_v2(lines=lines)) + messages = [i.message for i in await _issues(seeded, document) if i.code == "MAPPING_WARNING"] + assert any("повторяет прежнюю" in m for m in messages) and any("r:none" in m for m in messages) + assert len(await _lines(seeded, document)) == len(balance_lines()) + 1 # дубль не сохранён, сирота — да + + +async def test_intake_statements_version_mismatch(seeded: AsyncSession) -> None: + """T4.4: версия пакета сервиса отличается от версии копии ядра — `STATEMENTS_VERSION_MISMATCH` (`info`, `core`).""" + document = await _form(seeded, statements_version="0000000") + mismatch = [i for i in await _issues(seeded, document) if i.code == "STATEMENTS_VERSION_MISMATCH"] + assert len(mismatch) == 1 and mismatch[0].severity is IssueSeverity.info and mismatch[0].source is IssueSource.core + document_ok = await _form_again_same_version(seeded) + assert not [i for i in await _issues(seeded, document_ok) if i.code == "STATEMENTS_VERSION_MISMATCH"] + + +async def _form_again_same_version(session: AsyncSession) -> Document: + entity = await _entity(session, inn="7711111111", rostov=True) + return (await _map(session, entity, [statement_doc_v2()], statements_version=default_engine().version))[0] + + +async def test_intake_disclosure_with_error_warning_is_recognized(seeded: AsyncSession) -> None: + """T4.4 (= P3-41, вопрос владельцу): `DISCLOSURE` с предупреждением `error` — один документ `RECOGNIZED`, запись + предупреждения сохранена.""" + disclosure = statement_doc_v2("DISCLOSURE", lines=[], columns=[], form_variant=None, warnings=[TABLE_ERROR]) + entity = await _entity(seeded, rostov=True) + balance, other = await _map(seeded, entity, [statement_doc_v2(), disclosure]) + assert other.status is DocumentStatus.RECOGNIZED and balance.status is DocumentStatus.RECOGNIZED + (issue,) = await _issues(seeded, other) + assert (issue.code, issue.severity) == ("TABLE_PARSE_ERROR", IssueSeverity.error) + + +async def test_intake_edition_none_has_no_code_issues_but_name_mismatch(seeded: AsyncSession) -> None: + """T4.4: редакция не определена (`edition is None`) — `CODE_NOT_IN_*` не пишутся, `NAME_MISMATCH` остаётся.""" + # коды общие для обеих редакций и дата неизвестна — ничья без даты, редакцию выбрать нельзя + names = [("1200", "Итого по разделу I"), ("1600", "БАЛАНС")] + lines = [line_v2(i, code, name, code_confidence=50) for i, (code, name) in enumerate(names)] + undated = statement_doc_v2( + lines=lines, + header=header_v2(report_date=None, report_date_text=None, report_date_confidence=None), + columns=[column_v2(2, date=None, kind="none", label="Показатель")], + ) + document = await _form(seeded, undated) + pairs = [(code, False) for code, _ in names] + assert default_engine().edition(DocumentType_.BALANCE, pairs, None, None) is None + assert document.reporting_date is None + codes = {i.code for i in await _issues(seeded, document)} + assert "NAME_MISMATCH" in codes + assert not codes & {"CODE_NOT_IN_DICTIONARY", "CODE_NOT_IN_EDITION"} + + +# --- T6.1: предупреждение листа ОСВ уровня error (P3-49) ------------------------------------------------------------ + + +async def test_osv_v2_sheet_warning_error_does_not_block(seeded: AsyncSession) -> None: + """T6.1 (P3-49): предупреждение листа ОСВ `severity = error` при `done` листа и сошедшихся суммах подтверждение не + блокирует — решает статус листа и ошибка файла, а не уровень предупреждения листа.""" + entity = await _entity(seeded) + osv = _osv_doc() + osv["warnings"] = [{"code": "OSV_HIERARCHY_BY_OUTLINE", "severity": "error", "message": "иерархия по outline"}] + (document,) = await _map(seeded, entity, [osv]) + assert document.status is DocumentStatus.RECOGNIZED + issue = (await _issues(seeded, document))[0] + assert issue.code == "OSV_HIERARCHY_BY_OUTLINE" and issue.severity is IssueSeverity.error + assert issue.source is IssueSource.service + await _confirm_clean(seeded, [document]) + assert document.status is DocumentStatus.CONFIRMED -- 2.47.2 From 0f84088b739807ccfcf580d86aaca55d46249da6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 22:38:28 +0400 Subject: [PATCH 13/13] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20P3-53=E2=80=A6P3-59=20fix-=D0=B2?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D1=8B,=20=D1=83=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20P3-39?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/unified-normalization-core-b/design.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/openspec/changes/unified-normalization-core-b/design.md b/openspec/changes/unified-normalization-core-b/design.md index b460c91..0dded2d 100644 --- a/openspec/changes/unified-normalization-core-b/design.md +++ b/openspec/changes/unified-normalization-core-b/design.md @@ -47,7 +47,7 @@ Goals и Non-goals — см. `proposal.md`. | 2026-10-18 | **P3-36 (B3).** `checks_on_intake_1x` живёт в `statement_recompute.py`; движок передаётся в `_insert_statements` необязательным параметром (по умолчанию `default_engine()`), `map_file` сигнатуру не меняет. Приём `1.x` пишет `meta.form_variant` и записи `relation` / `soft` (`core`), но не `CODE_NOT_IN_*` / `NAME_MISMATCH` (P3-7) и не трогает `needs_review` (A9); условие D-6 «проверки сошлись» — `not result.checks.relations_failed` (нарушение с `expected = NULL` — жёсткое) | задание B3, спека «Пересчёт при приёме»; расхождение с прежним `run_checks` (его `failed`) возможно только на формах с пустой целью, где пакет строже | форма `1.x` с пустой целью получит `NEEDS_CHECK` вместо `RECOGNIZED` | | 2026-10-18 | **P3-37 (fix1, T2.1).** Аудит `lines[]` довносит смену `parent_row_index` после пересчёта: у правленных строк значение заменяется итоговым, у остальных появляется запись `line:` с одним ключом `parent_row_index` (`{"parent_row_index": старый}` → `{…: новый}`) | родителей пишет `recompute_form` уже после снимка строк правки; полный снимок для не правленных строк не нужен | в событии `RECOGNIZED_DATA_EDIT` у не правленных строк неполный набор ключей | | 2026-10-18 | **P3-38 (fix1, T1.1/T1.2/T2.4/T2.6/T2.8).** Паритет редакции приёма и пересчёта для строки с `line_code_confidence < 100` и `code_fixed` подтверждён тестом (расхождения нет, код не менялся); `edition_codes` пропускает строку `ok` без кода; `column_index` нарушения — общим помощником `issue_column_index` (запись, иначе колонка по `column_id`) в ответе и в `detail` отказа; `_unconfirmed_needs_review` и присвоения `needs_review` в `_apply_item` удалены (флаг пишет только пересчёт, он всегда идёт после правки строк); неизвестный `relative:*` в `_resolve_relative_columns` пропускается без ошибки | замечания ревью B1+B2 | колонка с неизвестным `relative:*` не следует за датой отчёта | -| 2026-10-18 | **P3-39 (B4).** Проверки ядра `info` (`CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH`, `STATEMENTS_VERSION_MISMATCH`, `RESULT_STATUS_INCONSISTENT`, `code_issues`, `name_issues`, `version_issue`, `name_check_lines`) вынесены из `services/statement_recompute.py` в `domain/statement_issues.py`; единая функция `core_info_issues(lines, *, form, edition: str \| None, order: Collection[str] \| None, catalog) -> CoreInfo(drafts, mismatched)` используется приёмом `2.0` и `recompute_form` (поведение пересчёта не изменилось, P3-34). Домен пакет не импортирует: редакцию (`engine.edition`) и перечень кодов (`engine.code_order`) считает сервис; `statement_recompute` реэкспортирует прежние имена. Вместо сигнатуры плана `core_info_issues(*, lines, edition, form_variant, catalog, engine)` | домен не должен знать `StatementsEngine`/`arm_statements` (D-1); одно правило для приёма и пересчёта | нет; импортёры прежних имён из `statement_recompute` работают | +| 2026-10-18 | **P3-39 (B4).** Проверки ядра `info` (`CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH`, `STATEMENTS_VERSION_MISMATCH`, `RESULT_STATUS_INCONSISTENT`, `code_issues`, `name_issues`, `version_issue`, `name_check_lines`) вынесены из `services/statement_recompute.py` в `domain/statement_issues.py`; единая функция `core_info_issues(lines, *, form, edition: str \| None, order: Collection[str] \| None, catalog) -> CoreInfo(drafts, mismatched)` используется приёмом `2.0` и `recompute_form` (поведение пересчёта не изменилось, P3-34). Домен пакет `arm_statements` не импортирует (исключение — `domain/stored_form.py` по D-1): редакцию (`engine.edition`) и перечень кодов (`engine.code_order`) считает сервис; `statement_recompute` реэкспортирует прежние имена. Вместо сигнатуры плана `core_info_issues(*, lines, edition, form_variant, catalog, engine)` | домен не должен знать `StatementsEngine`/`arm_statements` (D-1); одно правило для приёма и пересчёта | нет; импортёры прежних имён из `statement_recompute` работают | | 2026-10-18 | **P3-40 (B4).** Приём форм `2.0` — отдельный сервис `services/statement_intake_v2.py` (`map_statement_docs_v2`, `insert_forms_v2`); `map_file` выбирает маппер по типу результата через `_plan_forms` / `_insert_forms` (иначе функция превышала лимит ruff по операторам) и получил необязательный параметр `engine`. `_load_result` и тип в `process_once` остаются под `1.x` до B5 | размер `result_mapping.py` и лимиты линтера; граница B4/B5 из брифов | нет | | 2026-10-18 | **P3-41 (B4).** `DISCLOSURE` в `2.0` — по-прежнему один документ на файл со статусом `RECOGNIZED` (D-3), `file_ordinal` первого; предупреждения всех объединённых документов результата пишутся в его `document_issue` источником `service`. Статус сервиса и предупреждения файла уровня `error` на него не влияют | спека условие `RECOGNIZED` относится к формам ББ/ОФР; у `DISCLOSURE` нет данных | `DISCLOSURE` с `needs_check` сервиса автоподтверждается (данных нет — подтверждать нечего) | | 2026-10-18 | **P3-42 (B4).** `STATEMENTS_VERSION_MISMATCH` пишется и при приёме `2.0` (`version_issue(job.statements_version, engine.version)`), а не только при пересчёте после правки | иначе запись появлялась бы только после первой правки; один набор записей `core` у приёма и пересчёта | лишний `info` на формах, принятых копией иной версии | @@ -61,5 +61,12 @@ Goals и Non-goals — см. `proposal.md`. | 2026-10-18 | **P3-50 (B6).** `FailedCheck.codes` в ядре остаётся (вопреки плану, который удалял поле): оно нужно межформенной проверке 1370↔2400 (`codes = ("1370", "2400")`) для `codes` / `line_ids` в `checks.cross_form` ответа; нарушения §7 по колонкам читаются из `document_issue`, где коды хранятся сами | межформенную проверку пакет не считает (она по двум документам), без `codes` подсветка строк в ответе пропала бы | у `cross_form` не было бы `codes`/`line_ids`, фронт не подсвечивал бы строки 1370 и 2400 | | 2026-10-18 | **P3-51 (B6).** `checks_report` уже брал допуск у `engine.tolerance()` (с B2), поэтому правки сервиса в B6 нет; константа допуска ядра удалена вместе с `load_check_rules`. Состав и порядок редакций справочника имён `statement_lines.yaml` не переписывается на `engine.editions()` в коде: согласованность проверяет тест `test_name_dictionary_codes_in_code_orders` (редакции справочника = редакции пакета в том же порядке; каждый код справочника есть в перечне редакции пакета, любой вариант формы) | справочник остаётся данными имён и состава экрана; перечни кодов — единственный источник в пакете, расхождение ловит тест, а не молчаливая подмена в рантайме | при добавлении редакции в пакет без справочника имён тест красный; в рантайме новая редакция дала бы форму без шаблонных строк | | 2026-10-18 | **P3-52 (B6).** `edition_for` (граница 66n/fsbu4 по дате для шаблона экрана) в `domain/statement_mapping.py` не тронут: переход на `engine.edition(...)` требует кодов формы и варианта и относится к ответу формы (B7/доработка), а не к удалению копии словаря | не расширять B6 за рамки брифа | ответ формы и приём могут расходиться в редакции на формах без даты; существующее поведение сохранено | +| 2026-10-18 | **P3-53 (fix-волна, I-3, R-11).** Источник «ошибки файла» единый: при приёме результата `2.0` `map_file` сохраняет `result.metrics.warnings` структурами в `recognition_job.metrics.file_warnings` (рядом с `mapping_warnings`, прочие ключи не затираются, `_mapped_metrics`); `recompute_form` читает его через `file_error_in_metrics(metrics)`, при отсутствии ключа (результат `1.x`, задания до части Б) — прежний `metrics.warnings` конверта. Приём (`insert_forms_v2`, ОСВ) по-прежнему читает результат напрямую | раньше приём смотрел предупреждения файла результата, а пересчёт — конверта: статус формы мог измениться от несвязанной правки | при расхождении конверта и результата форма менялась бы `NEEDS_CHECK` ↔ `RECOGNIZED` без причины; задание без ключа читается по-старому | +| 2026-10-18 | **P3-54 (fix-волна, I-2).** `build_stored_form` передаёт пакету `is_recognized = value.is_recognized is not False`: `NULL` (заглушки маппера `1.x`/`2.0`, ячейки, созданные правкой) — не сомнение, как в `value_clean` ядра и в спеке пакета («позиции нет → `is_recognized = true`»). Прежнее `bool(...)` превращало `NULL` в `False`, и после любой правки шапки значения без сомнений получали `needs_review`, а форма — `NEEDS_CHECK` | `is_recognized = NULL` означает «признака нет», а не «не распознано» | форма из заглушек или с созданной ячейкой уходила бы в `NEEDS_CHECK` от правки шапки | +| 2026-10-18 | **P3-55 (fix-волна, T4.2).** `checks_on_intake_1x(..., now)` принимает момент приёма параметром (из `map_file`), а не `utc_now()`: записи `relation` / `soft` формы `1.x` получают тот же `created_at`, что и остальные записи приёма | единое «сейчас» приёма, воспроизводимость тестов | различие `created_at` на миллисекунды между записями одного приёма; порядок записей в ответе мог бы нарушиться | +| 2026-10-18 | **P3-56 (fix-волна, M-4).** `statement_mapping_v2._lines_and_values` строит `ref → row_index` только по принятым строкам (первый вход побеждает; ссылка вперёд на ещё не встреченную принятую строку допустима): `parent_ref` строки, чей `ref` повторяет прежний, указывает на первую принятую строку, а не на пропущенный дубль (раньше побеждал последний) | пропущенная строка не должна становиться родителем; детализация висела бы на несохранённом `row_index` | расшифровка привязана не к той строке; пересчёт `detail_parents` всё равно исправляет её после первой правки | +| 2026-10-18 | **P3-57 (fix-волна, T4.4, T6.1).** Закрыты тестами ветки приёма `2.0`: пропуск дубля `row_index` / `ref`, потерянный `parent_ref`, `STATEMENTS_VERSION_MISMATCH` (и его отсутствие при совпадении версии), `DISCLOSURE` с предупреждением `error` (документ `RECOGNIZED`, запись сохранена; вопрос владельцу P3-41 остаётся открытым), `edition is None` (нет `CODE_NOT_IN_*`, `NAME_MISMATCH` остаётся), предупреждение листа ОСВ `error` не блокирует подтверждение (P3-49) | покрытие веток без изменения поведения | нет | +| 2026-10-18 | **P3-58 (fix-волна, I-4).** Тесты правки и акцепта формы, принятой маппером `2.0` (`tests/test_statements_v2_edit.py`): записи `relation` / `soft` источника `service` после первой правки заменяются записями `core`; `warning` сервиса уровня `error` и `RESULT_STATUS_INCONSISTENT` сохраняются и держат `NEEDS_CHECK`; `RECOGNIZED` не меняется от правки шапки; акцепт нарушения, найденного ядром. Наблюдение: «чистая» форма с фабрики `balance_lines()` по реальному пакету не сходится (1300 без членов) — для тестов пересчёта берётся согласованная форма | тест должен ловить регрессию, а не проходить на форме, у которой пересчёт и так находит нарушение (мутационная проверка I-2 подтверждает) | тесты на несогласованной форме проходили бы вхолостую | +| 2026-10-18 | **P3-59 (fix-волна, M-5, M-6, M-7, T6.3).** Документация: CLAUDE.md репозитория исправлен под P3-30 (пустая цель при заполненных членах — нарушение с `expected = NULL`), «сбрасывает акцепт» — только правка данных (Р-25), добавлено описание приёма результата `2.0`; README — формы, принятые кодом части А, теряют проверку `CHECKS_NOT_PASSED` до первой правки (§7 в `document_issue` у них нет; обход — очистка форм `purge_statements.py`). P3-39 уточнён: домен не импортирует `arm_statements`, исключение — `domain/stored_form.py` (D-1) | документация должна совпадать с кодом | читатель CLAUDE.md/README получал бы неверные гарантии о проверках и акцепте | | 2026-10-18 | **P3-60 (fix-волна, I-5).** В ответах нарушение §7 может иметь `expected` / `actual` = `null` (пустая цель при непустых членах, P1-21). Индекс колонки при её отсутствии различается: `checks.failed[]` ответа `GET …/statement` отдаёт `-1` (`FailedCheckOut.column_index: int`, межформенная `cross_form` всегда `-1`), а `detail.failures[]` отказа 409 `CHECKS_NOT_PASSED` — `null` (`issue_column_index`). Спека описывает поведение как есть; код не правился. **Вопрос на выравнивание (`-1` или `null`) — план 6 / фронт (arm-web)** | фиксация фактического контракта до решения владельца; тип поля у фронта (arm-web) нужно сверить | фронт, ожидающий число, получит `null` в `detail.failures[].column_index` (только в 409, когда у записи нет ни `column_id`, ни `column_index`) | | 2026-10-18 | **P3-61 (fix-волна, C-1, M-1).** Дельты B переписаны под `openspec archive`: MODIFIED «Пересчёт после правки», «Словари ядра и достройка шаблонных строк», «Формы результата 1.x — §7 при приёме» теперь несут полный текст требования части А со всеми сценариями плюс уточнения Б (иначе архивация падает: «scenario(s) not present in the modified block»); добавлен MODIFIED «Хранение результата формы v2.0 (DBML v5.19)» и «Форма ББ/ОФР для экрана проверки» (сценарий «Строка, потерянная источником» приведён к P3-30). ADDED «Маппер результата 2.0 для форм» заменён MODIFIED «Хранение…» (устранено противоречие «первое остаётся» vs «без повторной дедупликации») и ADDED «Проверки ядра при приёме результата 2.0»; ADDED «`CHECKS_NOT_PASSED` из `document_issue`» и «Блок `checks`…» оставлены только с тем, чего нет в А (форма элемента нарушения, `null`), дубль порядка 409 убран. Проверено архивацией А, затем Б на копии `openspec/`. **Дополняет R-3 и D-4** | архивация обязана проходить; дубли и противоречия между А и Б вводят в заблуждение | если архивация А и Б падает — править заголовки и сценарии дельт Б; при будущей правке требований А в самой А тексты MODIFIED Б нужно обновить синхронно | -- 2.47.2