From 7db3e2b684f4f276a528e083244e8e1f2d586158 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:14:21 +0400 Subject: [PATCH 01/20] =?UTF-8?q?openspec(core-a):=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=90=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 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- .../unified-normalization-core-a/design.md | 50 +++ .../unified-normalization-core-a/proposal.md | 46 ++ .../specs/data-model/spec.md | 125 ++++++ .../specs/directories-api/spec.md | 44 ++ .../specs/documents-api/spec.md | 403 ++++++++++++++++++ .../specs/osv-result-mapping/spec.md | 246 +++++++++++ .../specs/primary-files/spec.md | 49 +++ .../specs/recognition-job-exchange/spec.md | 27 ++ .../specs/statement-checks/spec.md | 80 ++++ .../unified-normalization-core-a/tasks.md | 18 + 10 files changed, 1088 insertions(+) create mode 100644 openspec/changes/unified-normalization-core-a/design.md create mode 100644 openspec/changes/unified-normalization-core-a/proposal.md create mode 100644 openspec/changes/unified-normalization-core-a/specs/data-model/spec.md create mode 100644 openspec/changes/unified-normalization-core-a/specs/directories-api/spec.md create mode 100644 openspec/changes/unified-normalization-core-a/specs/documents-api/spec.md create mode 100644 openspec/changes/unified-normalization-core-a/specs/osv-result-mapping/spec.md create mode 100644 openspec/changes/unified-normalization-core-a/specs/primary-files/spec.md create mode 100644 openspec/changes/unified-normalization-core-a/specs/recognition-job-exchange/spec.md create mode 100644 openspec/changes/unified-normalization-core-a/specs/statement-checks/spec.md create mode 100644 openspec/changes/unified-normalization-core-a/tasks.md diff --git a/openspec/changes/unified-normalization-core-a/design.md b/openspec/changes/unified-normalization-core-a/design.md new file mode 100644 index 0000000..575b495 --- /dev/null +++ b/openspec/changes/unified-normalization-core-a/design.md @@ -0,0 +1,50 @@ +## Context + +Сквозной дизайн — change docs `unified-statement-normalization` (`design.md`, `specs/arm-core/spec.md`, плана `plans/2026-10-18-plan-3-arm-core.md`). Здесь — решения уровня ядра для части А: где лежит код, как идёт миграция, какие допущения сделаны планом. Схема данных — `../docs/model/data-model-v5.19.dbml`. + +## Goals / Non-Goals + +Goals и Non-goals — см. `proposal.md`. + +## Decisions + +- **D-1. Часть А не зависит от пакета.** До части Б §7 считается на лету по `api/rules/statement_checks.yaml` и агрегату формы по коду (P3-14); временный код удаляется в B6. +- **D-2. Миграция только добавляет.** Одна ревизия (0007): новые колонки, таблица, enum'ы, индексы и значения по умолчанию; §7 по существующим формам не пересчитывается (Р-24); откат останавливается на несовместимых данных (спека «Миграция DBML v5.19»). +- **D-3. `origin = template` ⇔ `row_index IS NULL`.** `line_code_original_value IS NULL` для `origin` больше не используется. +- **D-4. Порядок A5 — последним (R-1).** + +## Decisions made without discussion + +Решения, принятые без обсуждения с человеком на этапе плана и исполнения части А (маркеры `P3-n` — из плана 3; `R-n` — рулинги контроллера; `Р-n` — решения владельца, поверх плана). + +| Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| 2026-10-18 | **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 сырыми нет | подсказка у поля старой формы беднее; формы старых времён на контуре удалены очисткой | +| 2026-10-18 | **P3-2.** `organization_name_modified` — `false` у всех форм (миграция и оба маппера), не `NULL` | как `inn_modified` в маппере 1.x; API отдаёт `bool` | одна строка `UPDATE` | +| 2026-10-18 | **P3-3.** `needs_review_fields`: маппер `1.x` пишет `[]`, миграция оставляет `NULL`, чтение `NULL` = `[]` | спека миграции поле не заполняет | нет | +| 2026-10-18 | **P3-4.** Маппер `1.x` тоже хранит вторую форму того же типа (Р-10); формы одного типа и одной пары `period_year`, `period_quarter` получают `MAPPING_WARNING` «в файле есть другая форма <тип> за тот же период» и по D-6 — `NEEDS_CHECK` (аналог Р-18) | частичный индекс v5.19 больше не заставляет пропускать; одно правило хранения для обоих MAJOR | O-1: если владелец хочет прежний пропуск — вернуть ветку `continue` | +| 2026-10-18 | **P3-5.** Предупреждения маппинга листа ОСВ (повтор счёта, строки без родителя, нарастающая по данным Q1) пишутся ещё и в `document_issue` документа листа (`warning`, `MAPPING_WARNING`, `info`, `core`) при любом MAJOR; строки в `metrics.mapping_warnings` остаются | спека: `DocumentOut.issues` ОСВ — «вместе с предупреждениями маппинга ядра этого листа» | нет | +| 2026-10-18 | **P3-6.** `file_ordinal` объединённого документа `DISCLOSURE` / `OTHER` — индекс первого такого документа в `documents[]` | несозданный документ «номер не занимает» | нет | +| 2026-10-18 | **P3-7.** **Переопределено Р-40 (владелец, 18.10.2026): `CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` пишутся только для четырёхзначных кодов** (пятизначные подстроки Excel не дают `info`; закрывает O-3). Остальное — как ниже. `CODE_NOT_IN_DICTIONARY` и `CODE_NOT_IN_EDITION` — одна запись на код документа (`line_ids` — все строки `ok` с кодом), пятизначные коды тоже проверяются; при приёме `1.x` проверки ядра уровня `info` не пишутся | спека для `1.x` перечисляет только §7 и `MAPPING_WARNING` | O-3 (поток `info` у пятизначных) | +| 2026-10-18 | **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 | +| 2026-10-18 | **P3-9.** Результат `2.0`: значение с `column_index` вне колонок формы и повтор `column_index` колонки — первое остаётся, `MAPPING_WARNING` | FK значения на колонку; уникальность `(meta_id, column_index)` | нет | +| 2026-10-18 | **P3-10.** Части `PATCH` применяются в порядке `lines` → `values` → `columns` → `header`; вся адресация (422) проверяется до первой записи; `UNKNOWN_CELL` по `line_code` — по кодам строк `ok` с учётом назначений `lines` этого запроса; один `line_id` дважды в `lines` — 422 `INVALID_LINE_PATCH` | атомарность и предсказуемость | нет | +| 2026-10-18 | **P3-11.** `reference_name` — первое имя кода в редакции ответа (`edition` по дате); нет редакции или кода — `null` | спека: «первое имя кода в словаре»; `edition` ответа — по дате | нет (Р-29) | +| 2026-10-18 | **P3-12.** (= Р-35, решение владельца.) Очистка отказывает, только если документ формы упомянут в `ex_snapshot_item` или привязан к версии в статусе «Верифицирована» или дальше: `VERIFIED` или `ARCHIVED` с `ex_snapshot.source_version_id` = версия; привязки к версиям в работе, на согласовании, `REJECTED`, `CANCELLED` (и архивированным отменённым) снимаются, статус версий не меняется | решение владельца 18.10.2026 (Р-35): верифицированная версия read-only и заморожена в ЕХ, прочие — нет; на стенде версий дальше «в работе» нет | архивированная версия без снимка ЕХ (из `CANCELLED`) очистке не мешает — если бы мешала, владелец увидел бы лишний отказ | +| 2026-10-18 | **P3-13.** Коды выхода `purge_statements.py`: `0` — отчёт или выполнено, `1` — отказ (п. 2 спеки), `2` — ошибка аргументов | как у `check_dbml_vs_orm.py` | нет | +| 2026-10-18 | **P3-14.** До части Б §7 считает ядро на лету (`statement_checks.yaml`) по агрегату; `checks.failed[].codes` берутся из правила, `line_ids` — по агрегату | часть А должна быть самосогласованной | временный код удаляется в B6 | +| 2026-10-18 | **P3-15.** Для условия `RECOGNIZED` `2.0` предупреждения файла берутся из `metrics.warnings` файла результата; в `recognition_job.metrics.warnings` — из конверта (inbox) | маппер читает файл результата; по контракту наборы одинаковы | нет | +| 2026-10-18 | **P3-16.** (= Р-34, решение владельца.) Подтверждение кода (`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 приведены к этому | нет | +| 2026-10-18 | **P3-17.** Порядок строк ответа: строки `ok` (с шаблонными) — по коду, затем `row_index` (`NULL` последними), затем `id`; за каждой строкой `ok` источника — её «вложения» по `row_index`: строки `detail` с этим родителем и строки `unreadable` / `missing` / `detail` без родителя, у которых она — ближайшая предыдущая по `row_index` строка `ok` источника; вложения без такой строки — в начале | спека «Ответ `GET …/statement`» | нет | +| 2026-10-18 | **P3-18.** **Уточнено Р-41 (владелец): смена `crm_id` пишется в аудит как изменение группы (`GROUP_CHANGE`) — закрывает O-6.** Изменение `crm_id` ГСК/ГСЗ пишет одно событие `GROUP_CHANGE` с `before/after` всех изменённых полей вместо `MEMBERSHIP_CHANGE` | спека: «изменение — аудит `GROUP_CHANGE`» | O-6 | +| 2026-10-18 | **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 сравнивает их | +| 2026-10-18 | **P3-20.** `header.source.<поле>.confidence` — строка Decimal с двумя знаками (`"61.54"`), как прочие уверенности API; в JSONB `header_source` остаётся число как пришло | фронт читает все уверенности одним способом (план 6, A8) | округление до сотых в подсказке; исходное число — в БД | +| 2026-10-18 | **P3-21.** `StatementHeaderOut.okei` — из `financial_statement_meta.okei` (колонка есть с v5.18), только чтение; в `PATCH header` не входит | спека `header` контракта несёт `okei`, экран показывает `issues` с `field = "okei"` у поля | нет | +| 2026-10-18 | **P3-22.** Справочник строк для подсказки кода — поле `StatementOut.edition_lines [{code, name}]`, а не отдельный эндпоинт: данные уже загружены (`LineCatalog`, A6) при сборке ответа, нет нового маршрута, права, кэша и запроса на фронте; около 80 пар на форму (≈ 4 КБ) против строк формы с значениями; у формы без даты — коды всех редакций, по одному | наименьший объём реализации в ядре и во фронте; закрывает пробел «у формы без даты подсказки нет» (план 6, O-3) | ответ `GET` тяжелее на ≈ 4 КБ; если понадобится справочник вне формы — отдельный эндпоинт поверх того же `LineCatalog.edition_lines` | +| 2026-10-18 | **P3-23.** `DocumentBrief.issue_counts {error, info}` — все записи `document_issue` документа (и `relation` / `soft`, в том числе акцептованные) по `severity`; один групповой запрос на файл | панели файлов нужен признак «есть что смотреть» без запроса на каждый документ (план 6, O-4) | акцептованное нарушение продолжает помечать документ в панели — подробности на экране документа | +| 2026-10-18 | **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); одно правило для всех ошибок элемента | при нескольких неверных элементах фронт видит только первый — как и прежде | +| 2026-10-18 | **R-1.** Порядок исполнения части А: A0–A4, затем A6–A13; A5 (контракт 2.0, `recognition_exchange.py`) — последней, после слияния плана 0 (в `origin/main` нет `api/tests/contract_identity.py`) | A5 зависит от теста идентичности плана 0, остальные задачи его результат не потребляют | возможные конфликты слияния в `documents.py` / `recognition_exchange.py` при A5 в конце | +| 2026-10-18 | **R-2.** Исполнители задач и их ревьюеры — sonnet; финальное ревью ветки — opus | план даёт прозу, а не готовый код; ревью ветки ловит сквозные расхождения | более слабая модель на задаче — лишние итерации ревью | +| 2026-10-18 | **R-3.** Тесты с БД идут на scratch-БД `armcore_pytest_*` compose-Postgres :5432 (устройство conftest); контейнеры не останавливаются и не пересоздаются | общий стенд разработки не должен падать из-за прогонов | недоступный Postgres — задача BLOCKED, а не пропуск тестов | + +Открытые вопросы плана (`O-n`) — в `.superpowers/sdd/2026-10-18-plan-3-arm-core/p3-decisions.md` (ledger, в git не попадает); закрытые владельцем: O-2 (Р-34), O-3 (Р-40), O-5 (Р-35), O-6 (Р-41). Решения исполнителей по ходу задач A1–A13 дописываются сюда контроллером. diff --git a/openspec/changes/unified-normalization-core-a/proposal.md b/openspec/changes/unified-normalization-core-a/proposal.md new file mode 100644 index 0000000..b10fd6e --- /dev/null +++ b/openspec/changes/unified-normalization-core-a/proposal.md @@ -0,0 +1,46 @@ +## Why + +Единая нормализация ББ/ОФР (RSHB/arm-ka-docs#116, change docs `unified-statement-normalization`, DBML v5.19, решения владельца +17–18.10.2026): строки формы хранятся как в источнике (повтор кода, строки без кода, `row_index`), шапка — сырым текстом с +уверенностью, предупреждения и нарушения §7 — в `document_issue`, чтения «по коду» идут через агрегат, контракт +распознавания получает `MAJOR` 2. Спека `arm-core` в docs разбита на две части поставки; этот change — **часть А**, +не зависящая от пакета `arm_statements` (плана 1) и от мапперов результата `2.0`. Issue — RSHB/arm-core#126. + +## What Changes + +Часть А закрывает требования спеки `arm-core` (таблица «Покрытие спеки» плана 3): + +- **Схема и миграция v5.19** (A1, A2): `data-model` — модели, миграция вверх с заполнением, откат с остановкой на несовместимых данных. +- **Хранение формы и 1.x** (A9): `osv-result-mapping` — поля v5.19, `file_ordinal` у всех документов, вторая форма того же типа (Р-10, Р-18), `MAPPING_WARNING`, §7 при приёме `1.x`, полярность `needs_review`. +- **Признак `origin`, агрегат по коду** (A7, A13): `statement-checks`, `osv-result-mapping`. +- **Шапка `header_source`** (A1, A9, A8), **ответ `GET …/statement`** (A8), `issue_counts`, `DocumentOut.issues` — `documents-api`. +- **PATCH формы** — строки, колонки, подтверждение, наименование организации; пересчёт после правки; порядок 409 подтверждения (A11–A13): `documents-api`. +- **Условие `RECOGNIZED` 2.0 и статус ОСВ — чистые функции** (A10) и словарь имён с синонимами (A6). +- **Очистка форм перед выкладкой** (A4): `primary-files`, скрипт `scripts/purge_statements.py`. +- **`crm_id` ГСК/ГСЗ** (A3): `directories-api`. +- **Приём конвертов обоих MAJOR, предупреждения файла структурами** (A5): `recognition-job-exchange`, `documents-api` (`JobBrief.warnings`). + +## Остаётся части Б (change `unified-normalization-core-b`) + +Мапперы результата `2.0` (B4, B5), переход на пакет `arm_statements` (пересчёт и §7 через точку входа пакета, `document_issue` видов `relation` / `soft` с `core`, относительные даты, удаление копии словаря §7, B1–B3, B6), `CHECKS_NOT_PASSED` из `document_issue`. До части Б §7 считается ядром на лету (P3-14); код временного пересчёта удаляется в B6. Требования спеки формулируются здесь целиком по docs; пункты, помеченные в таблице покрытия как часть Б, этот change не реализует. + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `data-model`: DBML v5.19, миграция. +- `osv-result-mapping`: хранение формы, `file_ordinal`, `origin`, шапка, `RECOGNIZED` 2.0, формы `1.x`. +- `documents-api`: `GET`/`PATCH` формы, `document_issue`, пересчёт, порядок 409, словари, предупреждения файла и ОСВ. +- `primary-files`: очистка форм перед выкладкой. +- `recognition-job-exchange`: конверты обоих MAJOR. +- `directories-api`: `crm_id` ГСК/ГСЗ. +- `statement-checks`: агрегат формы по коду, §7 по агрегату. + +## Impact + +`api/src/models/**`, `api/alembic/versions/0007_*`, `api/src/services/{result_mapping,documents,statements,recognition_exchange,related_groups}.py`, `api/src/schemas/**`, `api/src/contract/**`, `api/contracts/**`, `api/rules/statement_lines.yaml`, `api/scripts/purge_statements.py`, `README.md` («Ошибки API»), `Makefile` (`DBML`). Выкладка: PR части А в `main` без снимка контура до части Б (O-9); `sync-web` — после плана 6. + +## Non-goals + +Мапперы `2.0`, пакет `arm_statements`, фронт (arm-web), docs-worktree. diff --git a/openspec/changes/unified-normalization-core-a/specs/data-model/spec.md b/openspec/changes/unified-normalization-core-a/specs/data-model/spec.md new file mode 100644 index 0000000..ccd4a1c --- /dev/null +++ b/openspec/changes/unified-normalization-core-a/specs/data-model/spec.md @@ -0,0 +1,125 @@ +## RENAMED Requirements +- FROM: `### Requirement: Схема БД равна DBML v5.18` +- TO: `### Requirement: Схема БД равна DBML v5.19` + +## MODIFIED Requirements + +### Requirement: Схема БД равна DBML v5.19 +ORM-метаданные SHALL содержать ровно 44 таблицы и 33 enum'а из `../docs/model/data-model-v5.19.dbml` в схеме PostgreSQL +из настройки `DB_SCHEMA` (по умолчанию `dkb`; в контуре — `OPENBAO__APP__DB_SCHEMA`; простой идентификатор +`[a-z_][a-z0-9_]{0,62}`, не начинающийся с `pg_`, иначе приложение не стартует); сверка с DBML идёт в эталонной схеме `dkb`; +имена таблиц, колонок, enum'ов и их значений SHALL совпадать с DBML буквально; nullability, PK, UNIQUE, FK, значения +по умолчанию и индексы SHALL соответствовать DBML, включая частичные уникальные индексы (`related_group_member` +`(related_group_id, legal_entity_id)` и `(legal_entity_id, kind)` WHERE `is_current`, `consolidation_lock` WHERE +`released_at IS NULL`) и `NULLS NOT DISTINCT` на `document (stored_file_id, document_type, osv_account_code)`. +`related_group_member.kind` SHALL быть NOT NULL и равняться `related_group.kind` группы (проверяет ядро при вставке). +`audit_action` SHALL содержать `LEGAL_ENTITY_CHANGE` и `USER_CHANGE`; `osv_data_type` SHALL содержать `NU` (после `BU`). +Enum `counterparty_match_kind` SHALL содержать `INN`, `NAME`, `ALIAS`, `MANUAL`, `PROPAGATED`; колонка +`osv_entry.counterparty_match_kind` (nullable) SHALL быть NULL тогда и только тогда, когда `counterparty_id` NULL +(проверяет ядро при записи). `app_user.email` SHALL храниться в нижнем регистре. Миграция `0004_dbml_v5_16` SHALL +создавать enum и колонку и заполнять её по уже смапленным строкам (ИНН строки без пробелов = `legal_entity.inn` → `INN`; +`trim(counterparty_name) = legal_entity.name` → `NAME`; остальные с `counterparty_id` → `ALIAS`); откат SHALL удалять +колонку и enum. Колонка `kpp` (`varchar`, nullable, без индексов и уникальности; уникален только `inn`) SHALL быть +последней в `legal_entity` и `legal_entity_history`; формат и запрет КПП у ИП проверяет ядро. Миграция `0005_dbml_v5_17` +SHALL добавлять обе колонки (существующие записи — `kpp IS NULL`), откат — удалять. +Колонка `stored_file.consolidation_id` (`uuid`, nullable, FK → `consolidation.id`; v5.18) SHALL быть последней в +`stored_file`; индекс `ix_stored_file_legal_entity_id_consolidation_id (legal_entity_id, consolidation_id)`. Миграция +`0006_dbml_v5_18` SHALL добавлять колонку, FK и индекс и заполнять колонку по `consolidation_document`: файлу с документом, +привязанным к версии, — консолидация этой версии; при привязках к версиям разных консолидаций — самый ранний период +(год, затем квартал); файлы без привязок остаются `NULL`; откат SHALL удалять индекс и колонку. Поля, отсутствующие в +DBML, MUST NOT существовать. +Схема v5.19 (RSHB/arm-ka-docs#116) SHALL добавлять: таблицу `document_issue` и enum'ы `statement_line_status`, `statement_form_variant`, `document_issue_kind` и прочие enum'ы DBML v5.19; колонки `document.file_ordinal` (уникальна в паре `(stored_file_id, file_ordinal)`), `recognition_job.statements_version`, `related_group.crm_id` (`varchar`, nullable, уникальна), в `financial_statement_meta` — `form_variant`, `needs_review_fields`, `header_source`, `organization_name_original_value`, `organization_name_modified`; в `financial_statement_column` — `period_kind`, `label_confidence`; в `financial_statement_line` — `row_index`, `source_ref`, `line_status`, `line_code_text`, `parent_row_index`, `needs_review`, `is_confirmed`; в `financial_statement_value` — `source_text`, `needs_review`, `is_confirmed` (с `confirmed_by/at`). Уникальность `(meta_id, line_code)` снимается, добавляется частичная уникальность `(meta_id, source_ref)` WHERE `source_ref IS NOT NULL`; уникальность документа `(stored_file_id, document_type, osv_account_code)` становится частичной (только для типов, кроме `BALANCE` / `INCOME`). Состав и порядок колонок — буквально по DBML v5.19. + +#### Scenario: Сверка ORM с DBML +- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.19.dbml` (`make dbml-check`) +- **THEN** вывод пуст и код возврата 0 + +#### Scenario: Лишняя колонка в ORM +- **WHEN** в ORM добавлена колонка, которой нет в DBML (или удалена существующая) +- **THEN** скрипт печатает `таблица.колонка: отсутствует в DBML` (или `отсутствует в ORM`) и возвращает код 1 + +#### Scenario: Расхождение типа или nullability +- **WHEN** колонка `osv_entry.opening_debit` объявлена в ORM как `Numeric(18, 4)` или `NOT NULL` +- **THEN** скрипт сообщает `ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL` и возвращает код 1 + +#### Scenario: ЮЛ одновременно в ГСК и ГСЗ +- **WHEN** для одного `legal_entity_id` вставлены две текущие строки `related_group_member` с `kind = gsk` и `kind = gsz` +- **THEN** обе вставки проходят; третья текущая строка с `kind = gsk` нарушает уникальный индекс + +#### Scenario: Строка налогового учёта +- **WHEN** в `osv_entry` вставляется строка с `data_type = 'NU'` +- **THEN** вставка проходит + +#### Scenario: Backfill при миграции +- **WHEN** до миграции 0004 есть строки с `counterparty_id`, найденным по ИНН, по имени и по алиасу, и строка без `counterparty_id` +- **THEN** после `alembic upgrade head` у них `counterparty_match_kind` = `INN`, `NAME`, `ALIAS` и NULL соответственно; + `alembic downgrade 0003` удаляет колонку и enum + +#### Scenario: Backfill консолидации файла при миграции 0006 +- **WHEN** до миграции 0006 есть файл `A` с документом, привязанным к версии консолидации 2025 Q2 и к версии консолидации + 2025 Q1, и файл `B` без привязок +- **THEN** после `alembic upgrade head` у `A` `consolidation_id` = консолидация 2025 Q1, у `B` — NULL; колонка последняя в + `stored_file`; `alembic downgrade 0005_dbml_v5_17` удаляет индекс и колонку, повторный `upgrade` их возвращает + +#### Scenario: Сверка с DBML v5.19 +- **WHEN** выполняется `make dbml-check` по `../docs/model/data-model-v5.19.dbml` +- **THEN** таблицы, колонки, enum'ы, ключи и индексы ORM совпадают с DBML без расхождений + +### Requirement: Версия DBML зафиксирована в коде +Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.19"`); скрипт сверки SHALL сравнивать её с версией в шапке файла +DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна. + +#### Scenario: Новая версия DBML без обновления ORM +- **WHEN** в `docs/model/` появляется `data-model-v5.20.dbml`, а константа в ORM — `5.19` +- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц + +## ADDED Requirements + +### Requirement: Миграция DBML v5.19 +Одна миграция Alembic SHALL приводить схему к DBML v5.19 и **только** добавлять колонки, таблицу, enum'ы и индексы и +заполнять значения по умолчанию (Р-24); §7 по существующим формам миграция MUST NOT считать, импорта копии +`arm_statements` в ревизии нет. Перед выкладкой база очищается от форм ББ/ОФР (требование «Очистка форм перед +выкладкой»), но миграция SHALL проходить и на непустой базе (стенды разработчиков, база, очищенная не полностью): +`row_index` — `NULL` у строк с `line_code_original_value IS NULL` (созданных правкой), у остальных — по возрастанию +`line_code` внутри формы с 0; `line_status = ok`, `needs_review` строк — `false`, `needs_review` значений — **НЕ** +`values_ok` (`values_ok` — правлено, или `is_recognized` не `false` и `confidence` равна 100 или пуста), `source_text = +string_value`, `is_confirmed = false`, `period_kind` — `absolute` при `period_date`, иначе `none`, `form_variant = +null`, `header_source` — из `*_original_value` и `inn_confidence`, `organization_name_original_value = +organization_name`, `document.file_ordinal` — по `(created_at, id)` внутри файла с 0 (у всех документов), +`related_group.crm_id` — `null`; `document_issue` — пуст. **Корректность нарушений у форм, оставшихся с до-миграционных +времён, не гарантируется:** у них нет записей `document_issue`, поэтому до первой правки экран не показывает нарушений +§7, а `POST …/confirm` не отвечает `CHECKS_NOT_PASSED`; первый PATCH пересчитывает форму общим кодом. Это цена Р-24; на +контуре её снимает очистка. Сверка `check_dbml_vs_orm.py` — против `data-model-v5.19.dbml`. + +Откат (`downgrade`) SHALL быть гарантирован на пустой БД и на данных, которые схема v5.18 может представить без потери +первичных данных и ручного ввода. `downgrade` SHALL останавливаться с ошибкой, называющей причину и число записей, и +ничего не менять, если есть: две строки с одним кодом в форме, строка с `line_code = null`, две формы одного типа в файле, +непустой `related_group.crm_id`, `organization_name_modified = true` (исходное наименование хранится только в новой +колонке). Остальные новые колонки и таблица `document_issue` — производные и сигнальные данные v5.19 (`needs_review`, +`source_text`, `header_source`, `is_confirmed` с `confirmed_by/at`, `period_kind`, `label_confidence`, `form_variant`, +`needs_review_fields`, `file_ordinal`, `statements_version`, `document_issue`): `downgrade` удаляет их и пишет в журнал +миграции, сколько записей с непустыми значениями удалено (подтверждения аналитика остаются в `audit_event`). Это не +«молчаливое удаление»: потеря названа в журнале и в этом требовании. + +#### Scenario: Цикл миграций +- **WHEN** `alembic upgrade head` → `downgrade base` → `upgrade head` на чистой БД +- **THEN** все три шага проходят, сверка ORM с DBML v5.19 — без расхождений + +#### Scenario: Миграция на непустой базе +- **WHEN** на стенде есть форма ББ, загруженная до v5.19, с несошедшимся `1600 = 1700`, и выполнена миграция +- **THEN** миграция проходит: у строк `row_index` по коду, `line_status = ok`, у документа `file_ordinal`, записей + `document_issue` нет — нарушение до первой правки не показано (корректность не гарантируется); после любого PATCH форма + пересчитана, и запись `RELATION_FAILED` появляется + +#### Scenario: Строка правки до миграции +- **WHEN** до миграции у формы есть строка 1250, созданная правкой (`line_code_original_value = null`), с суммой +- **THEN** после миграции у неё `row_index = null`, `origin = template`; строки источника пронумерованы с 0 + +#### Scenario: Откат на данных v2.0 +- **WHEN** в БД есть форма с двумя строками 2410, выполняется `downgrade` ниже v5.19 +- **THEN** откат останавливается с ошибкой о повторе кода; данные не изменены + +#### Scenario: Откат после подтверждений +- **WHEN** в БД нет несовместимых данных, но у ячеек есть `is_confirmed = true` и записи `document_issue` +- **THEN** `downgrade` проходит, удаляет колонки и таблицу и пишет в журнал число удалённых подтверждений и записей + diff --git a/openspec/changes/unified-normalization-core-a/specs/directories-api/spec.md b/openspec/changes/unified-normalization-core-a/specs/directories-api/spec.md new file mode 100644 index 0000000..3feed18 --- /dev/null +++ b/openspec/changes/unified-normalization-core-a/specs/directories-api/spec.md @@ -0,0 +1,44 @@ +## REMOVED Requirements + +### Requirement: ГСК и ГСЗ — группы и состав +**Reason**: `crm_id` теперь сохраняется (колонка `related_group.crm_id`, DBML v5.19); требование переименовано и дополнено. +**Migration**: см. требование «ГСК и ГСЗ — группы, состав и crm_id». + +## ADDED Requirements + +### Requirement: ГСК и ГСЗ — группы, состав и crm_id +`POST /api/v1/directories/company-groups/{gk_id}/related-groups` (`DIRECTORY_EDIT`; тело `kind` ∈ {gsk, gsz}, `name`, +`created_date`) SHALL создавать `related_group` с `code` `GSK-NNNNNN`/`GSZ-NNNNNN`; `PATCH …/related-groups/{id}` — менять +`name`, `created_date`; `DELETE …/related-groups/{id}` (`DIRECTORY_DELETE`) SHALL удалять только группу без текущих +членов (иначе 409 `GROUP_NOT_EMPTY`). `POST …/related-groups/{id}/members` (тело `legal_entity_id`) SHALL добавлять текущее +членство с `kind` группы, `valid_from = now`, `is_current = true`; ЮЛ MUST принадлежать той же ГК (иначе 422 +`DIFFERENT_COMPANY_GROUP`); если у ЮЛ уже есть текущее членство в группе того же вида — 409 `MEMBER_OF_OTHER_GROUP` +(с `related_group_id`); повтор в той же группе — 409 `MEMBER_EXISTS`. `DELETE …/members/{legal_entity_id}` SHALL закрывать +членство (`valid_to`, `is_current = false`). Операции над составом пишут `MEMBERSHIP_CHANGE`; `POST` и `PATCH` группы, меняющие поля группы (в том числе `crm_id`), пишут `GROUP_CHANGE` с `before/after` всех изменённых полей (Р-41). `GET …/related-groups` и +`GET …/related-groups/{id}` SHALL отдавать группы с числом текущих членов и списком членов. Тела `POST` и `PATCH` SHALL +принимать необязательный `crm_id` («ID ГСК/ГСЗ в CRM» форм 51-03/51-04/77-02) и сохранять его в `related_group.crm_id` (`varchar`, nullable, уникален; пустая строка после обрезки — `null`; явный `null` в `PATCH` очищает); `RelatedGroupOut` и `RelatedGroupDetail` отдают `crm_id`; повтор — 409 `CRM_ID_EXISTS`. Импорт ЕЦП `crm_id` групп не заполняет (OQ-91). + +#### Scenario: `crm_id` сохраняется и аудируется +- **WHEN** ГСК создаётся с `crm_id = " 51-03-0001 "`, затем `PATCH` меняет его на `77-02-0002` +- **THEN** создание — 201 с `crm_id = "51-03-0001"`; `PATCH` пишет `GROUP_CHANGE` с `before.crm_id = "51-03-0001"`, `after.crm_id = "77-02-0002"` + +#### Scenario: Занятый crm_id +- **WHEN** создаётся ГСЗ с `crm_id`, который уже есть у другой ГСК или ГСЗ +- **THEN** 409 `CRM_ID_EXISTS`, ничего не создано + +#### Scenario: Одновременно в ГСК и ГСЗ +- **WHEN** ЮЛ добавлено в ГСК своей ГК, затем в ГСЗ той же ГК +- **THEN** обе операции успешны, у ЮЛ два текущих членства с `kind = gsk` и `kind = gsz` + +#### Scenario: Вторая ГСК запрещена +- **WHEN** ЮЛ с текущим членством в ГСК A добавляют в ГСК B той же ГК +- **THEN** ответ 409 `MEMBER_OF_OTHER_GROUP` с идентификатором ГСК A; данные не меняются + +#### Scenario: Повторное включение после исключения +- **WHEN** членство закрыто, затем ЮЛ снова добавляют в ту же группу +- **THEN** появляется новая строка `related_group_member` с `is_current = true`, старая остаётся с `valid_to` + +#### Scenario: Удаление непустой группы +- **WHEN** удаляется ГСК с текущими членами +- **THEN** ответ 409 `GROUP_NOT_EMPTY` + diff --git a/openspec/changes/unified-normalization-core-a/specs/documents-api/spec.md b/openspec/changes/unified-normalization-core-a/specs/documents-api/spec.md new file mode 100644 index 0000000..558db63 --- /dev/null +++ b/openspec/changes/unified-normalization-core-a/specs/documents-api/spec.md @@ -0,0 +1,403 @@ +## MODIFIED Requirements + +### Requirement: Подтверждение документа +`POST /api/v1/documents/{document_id}/confirm` (`DOCUMENT_EDIT`, policy) SHALL переводить документ из `RECOGNIZED` +или `NEEDS_CHECK` в `CONFIRMED` (`confirmed_by`, `confirmed_at`) с `audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS)`; +из `CONFIRMED` повторно — 409 `ALREADY_CONFIRMED`; документ файла `SUPERSEDED` — 409 `FILE_SUPERSEDED`; документ в любом +другом статусе (например, `ERROR`) — 409 `DOCUMENT_NOT_CONFIRMABLE`. Для `BALANCE`/`INCOME` дополнительно: пустые +`period_year` или `period_quarter` — 409 `PERIOD_REQUIRED`; строки `unreadable` / `missing` с непустой суммой или текстом значения — 409 `LINES_UNRESOLVED` (`detail.lines[] {line_id, row_index, line_code_text}`); вторая подтверждённая форма того же типа и периода в файле — 409 `DUPLICATE_FORM` (`detail.document_id`); записи `document_issue` вида `relation` без акцепта — 409 +`CHECKS_NOT_PASSED` со списком нарушений в `detail.failures`; при подтверждении SHALL записываться +`financial_statement_meta.reviewed_by/at`. Несовпадение контрагента и уверенность значений подтверждение MUST NOT +блокировать. Если совпали несколько условий, порядок: `FILE_SUPERSEDED`, `ALREADY_CONFIRMED`, +`DOCUMENT_NOT_CONFIRMABLE`, `PERIOD_REQUIRED`, `LINES_UNRESOLVED`, `DUPLICATE_FORM`, `CHECKS_NOT_PASSED`. Подтверждение берёт блокировки в порядке «файл → +документ → (advisory-блокировка привязок ГК) → версии консолидации (по возрастанию `id`)»: файл — `FOR UPDATE`, +документ — `FOR NO KEY UPDATE` (R-3; совместима со вставкой привязки `FOR KEY SHARE`), версии — `FOR UPDATE`; состояние +перечитывается под этими блокировками; документ или файл, исчезнувшие за время ожидания блокировки, — 404 `NOT_FOUND`. +В той же транзакции документ SHALL привязываться (`consolidation_document`) ко всем версиям «в работе» (`INITIATED`, +`DOCUMENTS_PROCESSING`, `DATA_CORRECTION`), которым он подходит: ЮЛ документа ∈ `consolidation_member` версии, +`period_year` = год версии, `period_quarter` не NULL и (`is_cumulative` true или NULL → `period_quarter` = квартал +версии; false → `period_quarter` ≤ квартала версии). ОСВ за `Q1` равнозначна нарастающей и квартальной (R-14): она +подходит любой версии года при любом `is_cumulative`; ББ/ОФР за `Q1` — отчёт на конец квартала и подходят только версии +за `Q1` (R-14a). Привязанный ранее документ того же ЮЛ с тем же ключом (`document_type`, `osv_account_code`, +`period_quarter`, признак нарастающего: `is_cumulative` true или NULL; для ОСВ за `Q1` признак не различается) в той же +версии SHALL отвязываться (R-1). Каждая версия, получившая +привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` (`changed_by` = +подтвердивший). Блокировка версии (`consolidation_lock`) для подтверждения MUST NOT требоваться. + +#### Scenario: Причина ошибки маппинга в карточке файла +- **WHEN** файл в `ERROR` после неудачного маппинга (задание `done`, `error_code` пуст) +- **THEN** `job.mapping_error` содержит причину, `job.error_code` пуст + +#### Scenario: Документ не подтверждается +- **WHEN** подтверждается документ в статусе `ERROR` +- **THEN** 409 `DOCUMENT_NOT_CONFIRMABLE`, статус не изменился + +#### Scenario: Подтверждение +- **WHEN** `ka_rf` подтверждает документ `NEEDS_CHECK` своего филиала +- **THEN** `status = CONFIRMED`, `confirmed_by` = пользователь, `audit_event(DOCUMENT_CONFIRM)` с `before_value.status = NEEDS_CHECK` + +#### Scenario: Привязка к версии и переход статуса +- **WHEN** версия ГК 2024 Q3 в `INITIATED` с ЮЛ в периметре; подтверждается квартальная ОСВ 60 этого ЮЛ за 2024 Q2 +- **THEN** `consolidation_document(version, document)` создан; версия в `DATA_CORRECTION`; запись истории + `INITIATED → DATA_CORRECTION` с `changed_by` = пользователь + +#### Scenario: Не подходит по периоду +- **WHEN** подтверждается нарастающая ОСВ 60 за 2024 Q2 при версии за 2024 Q3 +- **THEN** привязки нет, статус версии не изменился + +#### Scenario: Версия в CANCELLED +- **WHEN** подходящий документ подтверждается при версии `CANCELLED` +- **THEN** привязки нет + +#### Scenario: Форма с нарушением проверок +- **WHEN** подтверждается `BALANCE` с непройденным жёстким соотношением без акцепта +- **THEN** 409 `CHECKS_NOT_PASSED`, в `detail.failures` — нарушение; после акцепта подтверждение проходит + +#### Scenario: Форма без периода +- **WHEN** подтверждается форма из OCR, у которой нет ни даты отчёта, ни дат колонок +- **THEN** 409 `PERIOD_REQUIRED`; после правки даты отчёта подтверждение проходит + +#### Scenario: Форма в чек-листе +- **WHEN** версия ГК 2025 Q3 `INITIATED`, ЮЛ в периметре; подтверждается его `BALANCE` за 2025 Q3 +- **THEN** привязка создана, ячейка «Бух. баланс» чек-листа ЮЛ — `CONFIRMED`, `reviewed_by` шапки = пользователь + +#### Scenario: Неразобранная строка с суммой +- **WHEN** у формы строка `missing` с суммой и одновременно жёсткое нарушение +- **THEN** 409 `LINES_UNRESOLVED` с этой строкой в `detail.lines` + +#### Scenario: Неразобранная строка только с текстом +- **WHEN** у формы строка `unreadable` без сумм, но с мусором `211(1 |` в ячейке значения (`string_value`) +- **THEN** 409 `LINES_UNRESOLVED` — потерянная сумма тоже требует разбора + +#### Scenario: Вторая форма того же периода +- **WHEN** в файле два ББ на 30.09.2025, первый подтверждён, аналитик подтверждает второй +- **THEN** 409 `DUPLICATE_FORM` со ссылкой на первый + +### Requirement: Форма ББ/ОФР для экрана проверки +`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`). Документ другого типа — 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 записываться в БД при чтении. + +#### 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` — те же, что без достройки + (`1600 = 1700` нарушено: 1700 отсутствует и считается 0) + +#### 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 /api/v1/documents/{document_id}/statement` (`DOCUMENT_EDIT`, policy) SHALL принимать частичное тело `header` +(`inn` — 10 или 12 цифр, иначе 422 `INVALID_INN`; `report_date` — дата; `units` — непустая строка ≤ 50) и `values[]` +(ровно одно из `line_id` | `line_code`, плюс `column_id`, `amount` — число с ≤ 2 знаками или `null`) и в одной +транзакции: при первой правке значения записывать `original_amount` = прежняя сумма; ставить `amount`, +`is_modified = (amount ≠ original_amount)`, `modified_by/at`; для поля шапки — `*_modified = true`, `*_original_value` +не менять; после `inn` — пересчитать `counterparty_check`; после `report_date` — пересчитать `period_year`, +`period_quarter`, `reporting_date`; сбросить акцепт проверок; пересчитать статус; записать одно +`audit_event(RECOGNIZED_DATA_EDIT, RECOGNITION)` с `before/after` изменённых полей и ячеек; вернуть форму, как `GET`. +Документ `CONFIRMED` — 409 `DOCUMENT_READONLY`; файл `SUPERSEDED` — 409 `FILE_SUPERSEDED`; не форма — 409 +`DOCUMENT_NOT_STATEMENT`; ячейка не этого документа — 422 `UNKNOWN_CELL`. Запрос без изменений — 200 без аудита. +Блокировки — «файл `FOR UPDATE` → документ `FOR NO KEY UPDATE`». + +**Расширения v2.0.** `PATCH /documents/{id}/statement` SHALL принимать помимо `header {inn, report_date, units}` и `values` (сумма): +- `header.organization_name` — непустая строка до 1000 символов: `organization_name_modified = true`, + `organization_name_original_value` не меняется, пересчитывается `counterparty_check`, аудит `before/after`; +- `header.report_date`, равный текущему, у поля из `needs_review_fields` — подтверждение даты: поле уходит из + `needs_review_fields`, снимается `REPORT_DATE_UNCERTAIN`, аудит; иначе запрос без изменений — 200 без эффектов, как + прежде; +- `values[] {line_id, column_id, confirmed: true | false}` — подтвердить ячейку без изменения значения или снять + подтверждение; +- `columns[] {column_id, period_date}` — дата колонке (обязательная дата; `null` — 422 ошибка валидации; пересчитывает + `is_current` и период документа по правилу §9.5 п. 2); ручная дата ставит колонке `period_kind = absolute`, чтобы + следующая правка даты отчёта её не перезаписала; дата, равная текущей, у колонки с `COLUMN_DATE_UNCERTAIN` или + `COLUMN_WITHOUT_DATE` — подтверждение: предупреждение снимается, `period_kind = absolute`, аудит (иначе запрос без + изменений — 200 без эффектов); правка `header.report_date` (в том числе `null`) пересчитывает `period_date` колонок с + `period_kind = relative:*` общим кодом (`null` даёт `null`); +- `lines[] {line_id, line_code}` — назначить или сменить код; `lines[] {line_id, line_status: "detail"}` — отметить + расшифровкой; `lines[] {line_id, confirmed: true | false}` — подтвердить код или снять подтверждение. + +Переходы: `unreadable` / `missing` / `detail` → `ok` назначением кода; `unreadable` / `missing` / `ok` → `detail` +отметкой; `ok` → `ok` сменой кода; `ok` с неуверенным кодом — подтверждением. Обратного перехода `detail` → +`unreadable` / `missing` нет: ошибочная отметка исправляется назначением кода, исходное состояние — перезагрузкой файла. +`line_status` принимает только `"detail"`; `"ok"` — 422 `LINE_CODE_REQUIRED` (в `ok` строку переводит только +`line_code`). Назначенный код: `line_code_confidence = 100`, `line_code_modified = true`, `needs_review` строки = +`false`. Подтверждённый код (`confirmed: true`): `is_confirmed = true`, `needs_review` строки = `false`, +`line_code_confidence` остаётся как пришло, `line_code_modified` не ставится; снятие подтверждения (`confirmed: false`) +возвращает `needs_review` по прежнему правилу (Р-34). `line_code_original_value` не меняется; после любого изменения статусов или кодов +`parent_row_index` **всех** строк `detail` документа пересчитывается как ближайшая строка `ok` выше по `row_index` +(требование «Пересчёт после правки»). Код — 4–5 цифр (иначе 422 `INVALID_LINE_CODE`), может совпадать с +существующим; незнакомый словарю — `info`. Строка с тем же кодом, **созданная правкой** (`row_index IS NULL`), с суммами — +409 `LINE_CODE_TAKEN` (сначала очистить её суммы или выбрать другой код); без сумм — удаляется (её `id` убирается из +`document_issue.line_ids`), её место занимает назначенная строка. Строки источника с тем же кодом не трогаются. В одном +элементе больше одного действия или ни одного — 422 `INVALID_LINE_PATCH`; строка не этого документа — 422 +`UNKNOWN_LINE`; колонка не этого документа — 422 `UNKNOWN_COLUMN`. Ошибки элемента `lines[]` (`LINE_CODE_TAKEN`, +`LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`) SHALL нести в `detail` `line_id` и +`line_code` первого неверного элемента (`line_code` — код элемента или `null`). Правка суммы по `line_code` (для достроенной строки) +при наличии строки `ok` с этим кодом — 422 `UNKNOWN_CELL` (передайте `line_id`). Удаления строк источника нет. Строки, +созданные правкой: `row_index = null`, `source_ref = null`, `line_status = ok`, `line_code_original_value = null`. Аудит +`RECOGNIZED_DATA_EDIT` с `before/after` по коду, статусу, родителю, подтверждениям и наименованию организации. +Ограничения состояния — как прежде (`DOCUMENT_READONLY`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_STATEMENT`). + +Части запроса применяются в порядке `lines` → `values` → `columns` → `header`; вся адресация (422) проверяется до первой записи. Один `line_id` дважды в `lines` — 422 `INVALID_LINE_PATCH`; `UNKNOWN_CELL` по `line_code` — по кодам строк `ok` с учётом назначений `lines` этого запроса. + +Ячейка, которой нет в БД, SHALL создаваться при правке: +- по `line_id` строки формы и `column_id` колонки формы, если у строки нет значения в этой колонке; +- по `line_code`, если строки с этим кодом в форме нет, а код есть в справочнике редакции документа для типа формы: + создаётся `financial_statement_line` (`line_code`, `line_name` из справочника, `line_code_original_value = NULL`, `row_index = NULL`, `source_ref = NULL`, `line_status = ok`, + `line_name_original_value = NULL`, `*_confidence = NULL`, `*_modified = false`); несколько ячеек одной новой строки в + запросе создают одну строку. +Созданная ячейка — `original_amount = NULL`, `is_recognized = NULL`, `confidence = NULL`, `string_value = NULL`, далее +общая логика правки. `line_code`, строка с которым в форме уже есть, код вне справочника редакции или редакция не +определена — 422 `UNKNOWN_CELL`; колонка не этой формы — 422 `UNKNOWN_CELL`; одна ячейка дважды в запросе — 422 +`DUPLICATE_CELL`. `amount = null` для ячейки, которой нет в БД, — не изменение: ничего не создаётся и не аудируется. + +#### Scenario: Правка значения +- **WHEN** аналитик меняет сумму строки 1150 колонки 0 с 100 на 120 +- **THEN** `amount = 120`, `original_amount = 100`, `is_modified = true`, аудит `RECOGNIZED_DATA_EDIT` с `before 100`, `after 120` + +#### Scenario: Возврат к исходному +- **WHEN** затем та же ячейка меняется обратно на 100 +- **THEN** `is_modified = false`, `original_amount = 100` + +#### Scenario: Заполнение строки, которой нет в форме +- **WHEN** у формы `BALANCE` редакции `fsbu4` нет строки 1700, аналитик передаёт `{line_code: "1700", column_id: <колонка 0>, amount: 2371333}` +- **THEN** в БД создана строка 1700 (`line_code_original_value = NULL`, `line_name = "БАЛАНС"`) и значение + `amount = 2371333`, `original_amount = NULL`, `is_modified = true`; в ответе у 1700 `id` заполнен, `origin = template`; + аудит `RECOGNIZED_DATA_EDIT` с `before.amount = null`; акцепт сброшен, статус пересчитан + +#### Scenario: Код, который уже есть в форме +- **WHEN** передан `line_code: "1150"`, а строка 1150 в форме есть +- **THEN** 422 `UNKNOWN_CELL`, данные не изменились + +#### Scenario: Код вне справочника +- **WHEN** передан `line_code: "1105"` для формы редакции `66n` +- **THEN** 422 `UNKNOWN_CELL` + +#### Scenario: Пустая сумма в несуществующую ячейку +- **WHEN** передан `{line_code: "1530", column_id: <колонка 0>, amount: null}` и строки 1530 нет +- **THEN** 200, строка не создана, аудита нет + +#### Scenario: Правка даты отчёта +- **WHEN** у формы OCR без периода дата отчёта правится на 2025-06-30 +- **THEN** `period_year = 2025`, `period_quarter = Q2`, `report_date_modified = true`, `report_date_original_value` не изменился + +#### Scenario: Подтверждённая форма +- **WHEN** правится форма `CONFIRMED` +- **THEN** 409 `DOCUMENT_READONLY`, данные не изменились + +#### Scenario: Назначение кода строке-мусору (AURORA-1462) +- **WHEN** аналитик назначает строке `unreadable` «ИЧ,250» (`row_index = 2`) код 1250 +- **THEN** строка `ok`, `line_code = 1250`, `line_code_confidence = 100`, `line_code_modified = true`, + `line_code_original_value` остаётся `null`, `row_index` остаётся 2 (`origin = source`); §7 и статус пересчитаны + +#### Scenario: Отметка расшифровкой +- **WHEN** аналитик отмечает строку `missing` с суммой как расшифровку +- **THEN** `line_status = detail`, строка выходит из сумм и не блокирует подтверждение + +#### Scenario: Код занят шаблонной строкой с суммами +- **WHEN** аналитик назначает строке `unreadable` код 1250, а строка 1250 создана правкой аналитика (`row_index = null`) и + имеет сумму +- **THEN** 409 `LINE_CODE_TAKEN`, ничего не изменено + +#### Scenario: Код занят пустой шаблонной строкой +- **WHEN** аналитик назначает строке `unreadable` код 1250, а строка 1250 создана правкой и сумм не имеет +- **THEN** шаблонная строка удалена, её `id` убран из `document_issue.line_ids`; строка источника стала `ok` с кодом 1250 + +#### Scenario: Правка наименования организации +- **WHEN** у формы `NO_ORGANIZATION_NO_INN`, аналитик вводит `header.organization_name` +- **THEN** наименование сохранено, `organization_name_modified = true`, исходное — в `organization_name_original_value`, + `counterparty_check` пересчитан, предупреждение снято, аудит + +#### Scenario: Подтверждение неуверенной даты отчёта +- **WHEN** `report_date` в `needs_review_fields`, аналитик отправляет PATCH с тем же `header.report_date` +- **THEN** `needs_review_fields` пуст, `REPORT_DATE_UNCERTAIN` снят, статус пересчитан, аудит + +#### Scenario: Ручная дата относительной колонки +- **WHEN** колонке `relative:prev_year_end` аналитик ставит `period_date` 31.12.2024, затем правит дату отчёта +- **THEN** после первой правки `period_kind = absolute`; правка даты отчёта её дату не меняет + +#### Scenario: Подтверждение неуверенной даты колонки +- **WHEN** у колонки `COLUMN_DATE_UNCERTAIN`, аналитик отправляет `columns: [{column_id, period_date}]` с той же датой +- **THEN** предупреждение снято, `period_kind = absolute`, статус пересчитан, аудит + +#### Scenario: Статус ok без кода +- **WHEN** приходит `lines: [{line_id, line_status: "ok"}]` +- **THEN** 422 `LINE_CODE_REQUIRED` + +## ADDED Requirements + +### Requirement: Предупреждения и нарушения документа — `document_issue` +Ядро SHALL хранить предупреждения и нарушения документа в `document_issue`: вид (`warning` | `relation` | `soft`), код, +уровень (`error` | `info`), сообщение, список `line_id`, `column_id` (колонка формы) или `column_index` (столбец +предупреждения, которого нет среди колонок формы), поле шапки, источник (`service` | `core`); для нарушений — `codes` +(коды цели и членов), текст соотношения, `expected`, `actual`. Нарушение из `checks.relations_failed` — `kind = +relation`, `code = RELATION_FAILED`, `severity = error`; из `soft_relations_failed` — `kind = soft`, `code = +RELATION_SOFT`, `severity = info`; `line_ids` — все строки `ok` кодов `codes` в документе. При загрузке пишутся +предупреждения и `checks` результата (источник `service`) и проверки ядра уровня `info` (источник `core`): +`CODE_NOT_IN_DICTIONARY` (кода нет в словаре имён редакции документа) и `CODE_NOT_IN_EDITION` (код не из перечня +редакции документа и его варианта — по копии перечня `arm_statements`) — только для четырёхзначных кодов, одна запись на код документа (Р-40), `NAME_MISMATCH`, `RESULT_STATUS_INCONSISTENT`. +**Редакция документа** для этих проверок — `edition` точки входа пакета для пересчёта (`statement-normalization`): +редакция с лучшей подпоследовательностью кодов строк `ok` документа, при равенстве — по эффективной дате; так бланк 66н, +сданный в 2025 году, проверяется по `66n`, а не по дате. Поле `edition` ответа `GET …/statement` и выбор шаблонных +строк по-прежнему — по дате периода (§7 «Полный состав формы»). `document_issue` ведётся у **любого** документа файла: у +ОСВ — предупреждения `osvDocument.warnings` результата `2.0` (вид `warning`, источник `service`, `OSV_*`, `info`). При перезагрузке файла записи документа +удаляются вместе с ним. При пересчёте после правки записи `relation` и `soft` заменяются целиком независимо от источника; +записи `warning` источника `service` сохраняются, кроме снимаемых правкой: правка `header.report_date` (в том числе +подтверждение тем же значением) снимает `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`, `REPORT_DATE_UNCERTAIN`; правка +`header.inn` или `header.organization_name` — `NO_ORGANIZATION_NO_INN`; правка `columns[].period_date` — +`COLUMN_WITHOUT_DATE` и `COLUMN_DATE_UNCERTAIN` этой колонки; прочие `error` сервиса снимаются только перезагрузкой +файла. При удалении шаблонной строки её `id` SHALL убираться из `line_ids` всех записей документа. `GET …/statement` и +`POST …/confirm` SHALL читать нарушения §7 только из `document_issue`. + +#### Scenario: Дата колонке +- **WHEN** у колонки предупреждение `COLUMN_WITHOUT_DATE`, аналитик ставит ей `period_date` через PATCH +- **THEN** предупреждение удаляется, статус пересчитывается + +#### Scenario: Неснимаемое предупреждение +- **WHEN** у документа `SEGMENT_COLUMNS_MISMATCH`, аналитик правит значения +- **THEN** предупреждение остаётся; документ можно подтвердить вручную (оно подтверждение не блокирует) + +#### Scenario: Бланк 66н в 2025 году без ложного info +- **WHEN** форма Межениновской за 3 кв. 2025 на бланке 66н с кодом 1120 (в перечне `fsbu4` его нет) +- **THEN** редакция документа для проверок ядра — `66n` (подпоследовательность длиннее), `CODE_NOT_IN_EDITION` у 1120 нет + +#### Scenario: Пропущенная колонка +- **WHEN** в результате предупреждение `COLUMN_SKIPPED` с `column_index = 5`, колонки 5 среди `columns[]` нет +- **THEN** запись `document_issue` с `column_index = 5` и `column_id = null`; экран показывает её у документа + +### Requirement: Пересчёт после правки +После каждого PATCH формы ядро SHALL пересчитывать `checks`, `needs_review`, `form_variant`, родителей строк `detail` +и статус точкой входа пакета для пересчёта по сохранённым данным (`statement-normalization`; конвенция знака — по +`source_text`), с правилами: `is_modified` или `is_confirmed` значения ⇒ `needs_review = false`; `line_code_modified` или +`is_confirmed` строки ⇒ `needs_review` строки = `false`; значения строк `detail` — `needs_review = false` (Р-23), у +строки, выведенной из `detail` назначением кода, `needs_review` её значений считается по общему правилу; акцепт ⇒ +жёсткие нарушения не учитываются; правленые ячейки в голосование знака не входят. Результат пишется: `needs_review` +строк и значений, `form_variant`, `parent_row_index` всех строк `detail` (ближайшая строка `ok` выше по `row_index`; +нет такой — `null`, строка остаётся `detail`), записи `document_issue` видов `relation` / `soft`, статус. Отличие `statements_version` задания от версии копии ядра — `document_issue` +`STATEMENTS_VERSION_MISMATCH` (`info`); у задания без версии (`1.x`) запись не пишется. Один PATCH — один пересчёт и одна +запись аудита `RECOGNIZED_DATA_EDIT`. Акцепт SHALL сбрасываться правкой данных — суммы, кода или статуса строки, даты +колонки, поля шапки; подтверждение или снятие подтверждения (`confirmed`) и подтверждение даты отчёта или даты колонки +тем же значением акцепт MUST NOT сбрасывать (Р-25, S-47). + +#### Scenario: Подтверждение ячейки без правки +- **WHEN** аналитик подтверждает ячейку с `needs_review = true` без изменения суммы +- **THEN** `is_confirmed = true`, `confirmed_by/at`, `is_modified` не ставится, `needs_review = false`, аудит; акцепт + проверок, если был, сохраняется + +#### Scenario: Снятие подтверждения +- **WHEN** аналитик снимает подтверждение с ячейки (`confirmed: false`), которая не покрыта соотношением +- **THEN** `is_confirmed = false`, `needs_review = true` после пересчёта, аудит + +#### Scenario: Родитель уходит в расшифровки +- **WHEN** у строки 1150 две расшифровки (`detail`, `parent_row_index` = 1150), аналитик отмечает саму 1150 расшифровкой +- **THEN** после пересчёта родитель всех трёх строк `detail` — ближайшая строка `ok` выше 1150; строк над ними нет — `null` + (на экране — в начале раздела) + +### Requirement: Словари ядра и достройка шаблонных строк +Копия словаря §7 ядра (`api/rules/statement_checks.yaml`) SHALL удаляться: проверки ядро считает копией пакета +`arm_statements`. `api/rules/statement_lines.yaml` SHALL оставаться только словарём имён (код → наименования по +редакциям): у кода может быть несколько имён (синонимы и законные повторы кода в бланке, Р-20), загрузчик повтор кода в +редакции MUST NOT запрещать; сверка наименования — с любым именем кода; шаблонная строка берёт первое имя. Порядок и +состав редакций — из копии перечня кодов пакета; `sync-statements --check` сверяет, что все коды словаря имён есть в +перечне. В PR перехода ядра на v2.0 словарь имён SHALL быть дополнен: в редакции `fsbu4` — вторые имена законных +повторов 1220 «НДС, начисленный по отгрузке», 1520 «Обязательство по аренде», 2410 «Налог при упрощенной системе +налогообложения» (Р-20) и коды печатной формы 1С 1105 «Гудвил», 1130 «Нематериальные поисковые активы», 1140 +«Материальные поисковые активы», 1530 «Доходы будущих периодов», 2460 «Прочее» (имена — по `fixtures/ocr` и шаблонам +банка); без этого законные повторы РБ, Счастливочной и aep_6 и любой неуверенный 1105 дают `NAME_MISMATCH`. Шаблонные строки достраиваются по словарю имён (одна на код); код, у которого есть хотя бы одна строка `ok` из +файла, шаблоном MUST NOT достраиваться; строки `unreadable` / `missing` не заменяются шаблонной строкой молча. + +#### Scenario: Код есть только у строки unreadable +- **WHEN** у формы нет строки `ok` с кодом 1250, но есть строка `unreadable` «ИЧ,250» +- **THEN** шаблонная строка 1250 достраивается пустой, строка `unreadable` показывается отдельно со статусом + +#### Scenario: Два имени у кода +- **WHEN** в редакции `fsbu4` словаря имён код 1520 записан дважды — «Кредиторская задолженность» и «Обязательство по + аренде» +- **THEN** словарь загружается; шаблонная строка 1520 одна, с именем «Кредиторская задолженность» + +#### Scenario: Гудвил с заголовком раздела в имени +- **WHEN** строка 1105 (уверенность 67) с наименованием «АКТИВ I. Внеоборотные активы Гудвил» +- **THEN** справочное «Гудвил» (≥ 5 символов) входит в него — совпало, `NAME_MISMATCH` нет + +### Requirement: Предупреждения файла и ОСВ в API, статус ОСВ при приёме 2.0 +`recognition_job.metrics.warnings` SHALL хранить предупреждения уровня файла структурами `{code, severity, message, +locator?}` (`fileWarning` контракта): у `2.0` — как пришли, у `1.x` — строки, обёрнутые в `LEGACY_WARNING` `info`. +`JobBrief.warnings` карточки файла (`FileOut.job`) SHALL отдавать список этих структур, а не строк; строки заданий, +сохранённых до выкладки ядра v5.19, при чтении конвертируются в `{code: "LEGACY_WARNING", severity: "info", message: +<строка>, locator: null}` (данные в БД не переписываются). `JobBrief.mapping_warnings` (предупреждения маппинга ядра) +остаётся списком строк. Это несовместимое изменение типа поля для фронта — фиксируется в гайде фронта. + +Предупреждения листа ОСВ результата `2.0` (`osvDocument.warnings`, коды `OSV_*`, `info`) SHALL храниться в +`document_issue` документа ОСВ (вид `warning`, источник `service`) и отдаваться в `DocumentOut.issues[]` (те же поля, +что `issues` формы) вместе с предупреждениями маппинга ядра этого листа; `DocumentOut.warnings` (строки маппинга файла) +сохраняется на окно перехода. `osvDocument.status` SHALL использоваться: при приёме `2.0` документ ОСВ `RECOGNIZED` ⇔ +`osvDocument.status = done` ∧ `checks` листа без нарушений ∧ в `metrics.warnings` файла нет предупреждений уровня +`error` ∧ `counterparty_check ≠ MISMATCH` ∧ нет предупреждений маппинга ядра по этому листу (повтор счёта в файле, строки +без родителя, нарастающая по данным Q1); статус конверта в условие не входит (как Р-19 для форм). При приёме `1.x` — как +прежде (§9.3). + +#### Scenario: Карточка файла задания до выкладки +- **WHEN** у задания, сохранённого до v5.19, `metrics.warnings = ["sheet=ОСВ 60 строки без родителя"]` +- **THEN** `JobBrief.warnings = [{code: "LEGACY_WARNING", severity: "info", message: "sheet=ОСВ 60 строки без родителя", + locator: null}]` + +#### Scenario: Потерянный json в карточке файла +- **WHEN** результат `2.0` scan-gateway несёт `metrics.warnings` с `JSON_FAILED` +- **THEN** `JobBrief.warnings` содержит `{code: "JSON_FAILED", severity: "error", message, locator}`; предупреждение не + теряется + +#### Scenario: ОСВ в файле с ОФР, требующим проверки +- **WHEN** книга из ОСВ (`status = done`, контрольные суммы сошлись) и ОФР (`needs_check`), конверт `needs_check`, + предупреждений файла `error` нет +- **THEN** ОСВ `RECOGNIZED` и автоподтверждается; её предупреждения `OSV_*` — в `DocumentOut.issues` + diff --git a/openspec/changes/unified-normalization-core-a/specs/osv-result-mapping/spec.md b/openspec/changes/unified-normalization-core-a/specs/osv-result-mapping/spec.md new file mode 100644 index 0000000..f78a26a --- /dev/null +++ b/openspec/changes/unified-normalization-core-a/specs/osv-result-mapping/spec.md @@ -0,0 +1,246 @@ +## MODIFIED Requirements + +### Requirement: Документ формы ББ/ОФР +Каждый `statementDocument` с `document_type ∈ {BALANCE, INCOME}` SHALL давать `document` (`osv_account_code = NULL`, +`is_cumulative = NULL`, `locator` из результата, `period_check = UNKNOWN`) и `financial_statement_meta` 1:1. Дата +периода — `header.report_date`, при её отсутствии — самая поздняя `columns[].period_date`; `period_year` — год даты, +`period_quarter` — квартал, если дата — последний день марта, июня, сентября или декабря, иначе `NULL` с +предупреждением; `reporting_date` — эта дата. Шапка: `inn`, `inn_original_value` = `header.inn`, `inn_confidence`, +`inn_verified = (inn_confidence = 100)`; `organization_name`; `report_date` = `header.report_date`, +`report_date_original_value` = `header.report_date_text`; `units` = `units_original_value` = `header.units`; `okud`, +`okpo`, `okopf`, `okfs`, `okei`, `address` — как пришли (шапка 1.x — по полям 1.x); `statement_type` — `balance`/`income`. Колонки — по `columns[]`, +`is_current = true` у колонки с самой поздней `period_date`. Строки — по `lines[]` с исходниками кода и названия; повтор +кода сохраняется как отдельная строка (Р-4), порядок строк — `row_index` (с 0). Значение SHALL создаваться на каждую пару «строка × колонка»: из результата — +`amount`, `string_value`, `confidence`, `is_recognized`; отсутствующая ячейка — пустая запись с `needs_review = false`; `is_modified = false`, +`original_amount = NULL`. Сохранение результата — требование «Хранение результата формы v2.0 (DBML v5.19)». Нарушения `checks` результата `2.0` — в `document_issue`; для `1.x` §7 считает ядро при приёме (требование «Формы результата 1.x — §7 при приёме»). `counterparty_check` — тем же +правилом, что у листа ОСВ (ИНН шапки, иначе имя или алиас владельца). + +#### Scenario: Форма из Excel +- **WHEN** смаплен golden-снимок `mezheninovskaya/2025-Q3/1_Форма1_бух_09` +- **THEN** документ `BALANCE`, `period_year = 2025`, `period_quarter = Q3`, `reporting_date = 2025-09-30`; у `financial_statement_meta` 37 строк и по значению на каждую пару строка × колонка + +#### Scenario: Книга с двумя формами +- **WHEN** смаплен golden-снимок книги НПЗ `npz/2025-Q3/…НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД` +- **THEN** у файла документы `BALANCE` (132 строки) и `INCOME` (43 строки), оба за 2025 Q3 + +#### Scenario: Форма из OCR без даты отчёта +- **WHEN** смаплен эталон нормализатора `ocr/expected_normalize/aep_6` (дата отчёта `null`, колонки баланса 2025-06-30, 2024-12-31, 2023-12-31) +- **THEN** документ `BALANCE` за 2025 Q2, `is_current` — у колонки 2025-06-30, `report_date` в шапке `NULL`, `inn` `NULL` + +#### Scenario: Дата не конец квартала +- **WHEN** дата отчёта формы — 2025-10-31 +- **THEN** `period_year = 2025`, `period_quarter = NULL`, предупреждение маппинга, документ `NEEDS_CHECK` + +### Requirement: Повтор формы в файле и прочие документы +Каждый `BALANCE` / `INCOME` результата SHALL давать отдельный `document` с собственным `file_ordinal`; вторая форма того же типа в файле хранится (Р-10). Формы одного типа и одной пары `period_year`, `period_quarter` получают предупреждение маппинга «в файле есть другая форма <тип> за тот же период», документ — `NEEDS_CHECK`, автоподтверждения нет (Р-18). Документы `DISCLOSURE` и `OTHER` результата SHALL давать по одному `document` каждого типа на файл (`file_ordinal` — индекс первого такого документа в `documents[]`) без +данных (`locator` — все `locator` этого типа через запятую, статус при маппинге `RECOGNIZED`, затем автоподтверждение → +`CONFIRMED`). Форма `BALANCE`/`INCOME` без строк SHALL создаваться с шапкой и колонками (если колонки есть в результате). + +#### Scenario: Расшифровки +- **WHEN** смаплен синтетический снимок `st06_header_variants` (форма `INCOME` и пять `DISCLOSURE`) +- **THEN** у файла два документа: `INCOME` и один `DISCLOSURE` с пятью `locator` через запятую; `DISCLOSURE` — `CONFIRMED` + +#### Scenario: Форма без колонки кодов +- **WHEN** смаплен синтетический снимок `st03_no_codes` (`BALANCE`, строк 0) +- **THEN** документ `BALANCE` `NEEDS_CHECK` с шапкой, строк нет (колонок в снимке тоже нет — создаются те, что есть в результате) + +#### Scenario: Две формы одного типа и периода +- **WHEN** результат содержит два `BALANCE` с одной парой `period_year`, `period_quarter` +- **THEN** создаются два документа с разными `file_ordinal`, оба `NEEDS_CHECK` с предупреждением маппинга; ни один не автоподтверждается + +### Requirement: Статус документа формы +Документ `BALANCE`/`INCOME`, принятый по результату `1.x`, SHALL быть `RECOGNIZED`, только если одновременно: задание `done`; нет записей `document_issue` вида `relation` без акцепта (§7, посчитанный ядром при приёме, `statement-checks`); `counterparty_check ≠ MISMATCH`; `period_year` и +`period_quarter` определены; ни у одного значения нет `needs_review = true` (при приёме `1.x` `needs_review` = **НЕ** `values_ok`); нет предупреждений маппинга по документу; в файле нет второй формы того же типа и периода (Р-18); есть хотя бы одна строка. Иначе `NEEDS_CHECK`. Для результата `2.0` условие — требование «Условие `RECOGNIZED` при приёме 2.0». Статус +SHALL пересчитываться при маппинге, после правки формы и после акцепта, кроме документа `CONFIRMED`. Документ +`RECOGNIZED` после маппинга SHALL подтверждаться автоматически (требование «Автоподтверждение чистого документа»); +`RECOGNIZED`, полученный пересчётом после правки или акцепта, — только вручную. Документ формы `NEEDS_CHECK` SHALL +участвовать в переходе версии в `DATA_CORRECTION` наравне с ОСВ (требование «Документ NEEDS_CHECK переводит версию в +корректировку»). + +#### Scenario: Чистая форма Excel +- **WHEN** смаплен `mezheninovskaya/2025-Q3/1_Форма2_бух_09` (ОФР, `done`, соотношения сходятся, ИНН шапки = ИНН владельца файла) +- **THEN** документ `INCOME` `CONFIRMED` (автоподтверждение) + +#### Scenario: Форма OCR +- **WHEN** смаплен эталон `ocr/expected_normalize/aep_3` (уверенность значений < 100, ИНН шапки ≠ ИНН владельца) +- **THEN** документ `BALANCE` `NEEDS_CHECK`, `counterparty_check = MISMATCH` + +#### Scenario: Форма двигает версию +- **WHEN** версия ГК 2025 Q3 в `DOCUMENTS_PROCESSING`, в периметре ЮЛ файла; смаплена форма `BALANCE` за 2025 Q3 в `NEEDS_CHECK` +- **THEN** версия в `DATA_CORRECTION`, привязки `consolidation_document` нет + +#### Scenario: Чистая после правки — вручную +- **WHEN** форма `NEEDS_CHECK` исправлена аналитиком (`PATCH …/statement`) и пересчёт дал `RECOGNIZED` +- **THEN** документ остаётся `RECOGNIZED` до `POST …/confirm` + +## ADDED Requirements + +### Requirement: Хранение результата формы v2.0 (DBML v5.19) +Маппер результата `2.0` SHALL писать документ формы как пришёл, без повторной дедупликации колонок, кодов и значений: +каждая строка результата — `financial_statement_line` (`row_index`, `source_ref` = `ref`, `line_status`, `line_code`, +`line_code_original_value` = `line_code` результата, `line_code_text`, `line_code_confidence`, `line_name`, +`line_name_confidence`, `parent_row_index` — `row_index` строки с `ref = parent_ref`, `needs_review`, `is_confirmed = +false`); каждая пара «строка × колонка» — одна `financial_statement_value` (`amount`, `string_value`, `source_text` = +`values[].text`, `confidence`, `is_recognized`, `needs_review`; значения, которого нет в результате, — пустая запись с +`needs_review = false`); колонки — `financial_statement_column` с `period_kind`, `label_confidence`; шапка — +`financial_statement_meta` с `form_variant`, `needs_review_fields` и `header_source` (раздел «Шапка формы»); +предупреждения и нарушения документа — `document_issue` (источник `service`); версия общего кода — +`recognition_job.statements_version`. Уникальность `(meta_id, line_code)` снята; `(meta_id, source_ref)` уникален там, +где `source_ref` не `null`. Каждый `BALANCE` / `INCOME` результата SHALL становиться отдельным `document`; вторая +форма того же типа в файле хранится (Р-10); ОСВ по счёту, `DISCLOSURE` и `OTHER` — по-прежнему один документ на тип (и +счёт) на файл. **Каждый** создаваемый `document` (ОСВ, формы, `DISCLOSURE`, `OTHER`) при приёме результата любого +`MAJOR` (мапперы `1.x` и `2.0`) SHALL получать `file_ordinal` = индекс документа в `documents[]` результата (с 0); +документ, который не создаётся (второй лист ОСВ с тем же счётом, второй `DISCLOSURE`), номер не занимает, но и не +сдвигает остальные — номера уникальны в файле и могут идти с пропусками. + +#### Scenario: Книга из двух ОСВ +- **WHEN** результат содержит две ОСВ по счетам 60 и 62 (`documents[0]`, `documents[1]`), принятый маппером `1.x` или `2.0` +- **THEN** два документа с `file_ordinal` 0 и 1; уникальность `(stored_file_id, file_ordinal)` не нарушена, файл не `ERROR` + +#### Scenario: PDF с ББ и ОФР по 1.x +- **WHEN** результат `1.2` содержит `BALANCE` и `INCOME` +- **THEN** документы с `file_ordinal` 0 и 1 + +#### Scenario: Повтор кода сохраняется +- **WHEN** в результате две строки `ok` с кодом 2410 (пустая и с `(649)`) +- **THEN** в БД две строки 2410 с разными `row_index` и `source_ref`, обе со своими значениями + +#### Scenario: Строка без кода +- **WHEN** в результате строка `unreadable` с `line_code_text = "ИЧ,250"` и суммой +- **THEN** строка хранится с `line_code = null`, `line_code_original_value = null`, `line_status = unreadable`, + `line_code_text = "ИЧ,250"` и `row_index` из результата + +#### Scenario: Два баланса одного периода в одном файле +- **WHEN** результат содержит два `BALANCE` с одной парой `period_year`, `period_quarter` (лист-продолжение другого + типа или двойная печать) +- **THEN** создаются два документа с разными `file_ordinal`; ни один не автоподтверждается (Р-18) + +#### Scenario: Два баланса разных периодов в одном файле +- **WHEN** результат содержит `BALANCE` на 30.09.2025 и `BALANCE` на 31.12.2024 +- **THEN** два документа с разными `file_ordinal`; каждый получает статус по своим условиям, правило Р-18 их не касается + +### Requirement: Признак строки, созданной правкой +Строка формы SHALL считаться созданной правкой аналитика (шаблонной, `origin = template`) тогда и только тогда, когда +`row_index IS NULL`. Маппер `1.x` и маппер `2.0` SHALL всегда писать `row_index` (у `1.x` — по порядку строк результата с +0), поэтому у строки из источника он не `null`. `line_code_original_value` SHALL хранить код строки из результата как +пришёл (`null` у `unreadable` / `missing` / `detail` и у строк, созданных правкой) и правками кода MUST NOT меняться. +Признак `line_code_original_value IS NULL` для `origin` MUST NOT использоваться. + +#### Scenario: Строка источника без кода — не шаблонная +- **WHEN** у строки `unreadable` из результата `line_code_original_value = null` и `row_index = 7` +- **THEN** `origin = source`; правила шаблонных строк (удаление без сумм, 409) к ней не применяются + +#### Scenario: Назначение одного кода двум строкам источника +- **WHEN** аналитик назначил код 1250 строке `unreadable` (`row_index = 7`, без сумм), а затем тот же код — строке + `missing` (`row_index = 2`) с суммой +- **THEN** обе строки `ok` с кодом 1250, ни одна не удалена, 409 нет (повтор кода законен, Р-4) + +### Requirement: Шапка формы — сырой текст и уверенность +Маппер `2.0` SHALL писать в `financial_statement_meta.header_source` объект `{"<поле>": {"text": <поле>_text, +"confidence": <поле>_confidence}}` для всех полей шапки результата (`organization_name`, `inn`, `report_date`, `units`, +`okud`, `okpo`, `okopf`, `okfs`, `okei`, `address`); разобранные значения — в одноимённые колонки; `*_original_value` — +как прежде: `inn_original_value = inn`, `report_date_original_value = report_date_text`, `units_original_value = +units_text`; `organization_name_original_value = organization_name`, `inn_confidence = inn_confidence`. +`header_source` и `*_original_value` правками MUST NOT меняться. Маппер `1.x` заполняет `header_source` из полей шапки +1.x. `StatementOut.header.source` SHALL отдавать `header_source`. + +#### Scenario: Подсказка у поля шапки +- **WHEN** в результате `organization_name_text = "ООО «Ромашкa»"`, `organization_name_confidence = 67` +- **THEN** `header_source.organization_name = {"text": "ООО «Ромашкa»", "confidence": 67}`; экран показывает их в подсказке + у поля + +### Requirement: Условие `RECOGNIZED` при приёме 2.0 +При приёме `2.0` статус документа формы SHALL быть `RECOGNIZED` ⇔ `document.status = done` и он согласован с +содержимым документа ∧ в `metrics.warnings` файла нет предупреждений уровня `error` ∧ `counterparty_check ≠ MISMATCH` ∧ +период — конец квартала (`period_year` и `period_quarter` определены) ∧ у каждой строки `ok` с неуверенным кодом +(`line_code_confidence < 100`) распознанное наименование совпало со справочным по коду ∧ в файле нет второй формы того +же типа и периода (та же пара `period_year`, `period_quarter`; формы без квартала не считаются); иначе `NEEDS_CHECK`. +**Согласованность:** ядро SHALL вызывать общую функцию статуса пакета (`statement-normalization`, «Шаг 8 — статус +документа и конверта») по входу, собранному из результата: `layout_found` — `lines[]` непуст, `has_organization_or_inn` +и `period_known` — по шапке и колонкам результата, строки и значения с их `needs_review` (значения `detail` функция не +учитывает), число `checks.relations_failed`, `accepted = false`, уровни `warnings[]` документа. `done` согласован, если +функция тоже даёт `done`; иначе ядро считает документ `needs_check` и пишет `document_issue` +`RESULT_STATUS_INCONSISTENT` (`info`, источник `core`). Это вызов той же функции, а не пересчёт §7 и `needs_review`. Статус конверта и `needs_check` соседнего документа в условие +MUST NOT входить (Р-19). Проверки §7, `needs_review` и предупреждения сервиса при загрузке MUST NOT пересчитываться. + +**Совпадение наименований:** обе строки приводятся к нижнему регистру, `ё` → `е`, всё, кроме букв и цифр, — пробел, +пробелы схлопываются, крайние пробелы обрезаются (`strip`); совпало, если строки равны, или более короткая (не короче 5 +символов) входит в более длинную, или отношение сходства `difflib.SequenceMatcher` ≥ 0,8; сравнение — с **любым** +справочным именем кода (у кода может быть несколько имён — синонимы и законные повторы, Р-20) в любой редакции словаря +ядра. Если хотя бы одно из сравниваемых имён начинается с «итого по разделу», сравниваются **номера раздела**: номер — +начальная часть первого слова после фразы из букв `i`, `v`, `x` (латиница; кириллические «і», «х» приводятся к ним), +остальное в слове отбрасывается («v1» → V, «v» → V); совпало ⇔ номера равны; номер не выделен хотя бы у одного имени — +совпадение только при равенстве строк целиком. Кода нет в словаре имён или наименование пусто — не совпало. +Несовпадение — `document_issue` `NAME_MISMATCH` (`info`, источник `core`, строка). `RECOGNIZED` SHALL, как и прежде, +автоподтверждаться (OQ-157). + +#### Scenario: Ячейка соседнего документа +- **WHEN** ББ `done`, ОФР того же файла `needs_check`, предупреждений файла уровня `error` нет, ИНН совпал, период — + 30.09.2025 +- **THEN** ББ `RECOGNIZED` и автоподтверждается, ОФР — `NEEDS_CHECK` + +#### Scenario: Потерянный json +- **WHEN** все документы `done`, но в `metrics.warnings` файла `JSON_FAILED` (или `TABLE_PARSE_ERROR`) уровня `error` +- **THEN** все документы формы файла `NEEDS_CHECK` + +#### Scenario: Подмена неуверенного кода на соседа +- **WHEN** строка с кодом 1120 (уверенность 50) и наименованием «Нематериальные активы» +- **THEN** наименование не совпало со справочным «Результаты исследований и разработок», документ `NEEDS_CHECK`, + `NAME_MISMATCH` у строки + +#### Scenario: Законный повтор с другим именем +- **WHEN** строка 1220 (уверенность 50) с наименованием «НДС, начисленный по отгрузке», а в словаре имён `fsbu4` у 1220 + есть это имя вторым +- **THEN** наименование совпало, `NAME_MISMATCH` нет + +#### Scenario: Подмена итога раздела +- **WHEN** строка с кодом 1200 (уверенность 50) и наименованием «Итого по разделу I» +- **THEN** номер раздела I не равен II у справочного «Итого по разделу II», `NAME_MISMATCH` + +#### Scenario: Хвост OCR после номера раздела (Счастливочная, РБ) +- **WHEN** строка 1500 (уверенность 56, разлепленная) с наименованием «Итого по разделу V1», или строка с наименованием + «Итого по разделу V /» +- **THEN** номер раздела V равен номеру справочного «Итого по разделу V», наименование совпало, `NAME_MISMATCH` нет + +#### Scenario: Сервис прислал done при неразобранной строке без значений +- **WHEN** документ пришёл `done`, но в нём строка `unreadable` без значений +- **THEN** функция статуса даёт `needs_check` (не все строки `ok` / `detail`), документ `NEEDS_CHECK`, + `RESULT_STATUS_INCONSISTENT`; автоподтверждения нет + +#### Scenario: Сервис ошибся со статусом +- **WHEN** документ пришёл `done`, но в нём строка `unreadable` с суммой +- **THEN** документ `NEEDS_CHECK`, `RESULT_STATUS_INCONSISTENT` у документа; автоподтверждения нет + +#### Scenario: Период не конец квартала +- **WHEN** документ `done` с датой отчёта 15.08.2025 +- **THEN** квартал не определён, документ `NEEDS_CHECK` (как в D-6) и не автоподтверждается; ручное подтверждение — + 409 `PERIOD_REQUIRED`, пока аналитик не поправит дату отчёта + +### Requirement: Формы результата 1.x — §7 при приёме +Для формы, принятой по результату `1.x` (окно перехода после выкладки ядра; `checks` контракта 2.0 у неё нет), ядро +SHALL **один раз при приёме** посчитать §7 точкой входа пакета для пересчёта (`statement-normalization`; вариант формы — +по структуре кодов, как у 2.0) по суммам результата и записать нарушения в `document_issue` (источник `core`, виды +`relation` / `soft`, как в требовании о `document_issue`) и `form_variant` в `financial_statement_meta`. Условие D-6 +«проверки сошлись» для `1.x` SHALL браться из этого расчёта; `needs_review` значения при приёме `1.x` = **НЕ** +`values_ok` (`values_ok` — `is_recognized` не `false` и `confidence` равна 100 или пуста), `needs_review` строки — +`false`. Предупреждения маппера `1.x` по документу (повтор кода, значение вне колонок) — `document_issue` +`MAPPING_WARNING` (`info`, источник `core`); строки `metrics.warnings` результата `1.x` — в +`recognition_job.metrics.warnings` структурами `{code: "LEGACY_WARNING", severity: "info", message}`. После правки такая +форма пересчитывается общим кодом, как форма 2.0; `MAPPING_WARNING` и `LEGACY_WARNING` статус не держат. Формы, +существовавшие в БД до миграции v5.19, §7 не пересчитываются (требования «Миграция DBML v5.19» и «Очистка форм перед +выкладкой»). + +#### Scenario: Полярность needs_review при приёме 1.x +- **WHEN** в результате `1.2` у значения `confidence = 100`, `is_recognized = true`, у другого — `confidence = 80` +- **THEN** у первого `needs_review = false`, у второго — `true` + +#### Scenario: Форма 1.x с нарушением +- **WHEN** результат `1.2` содержит ББ с несошедшимся `1600 = 1700` +- **THEN** у формы запись `document_issue` `relation` `RELATION_FAILED` с `codes = ["1600", "1700"]` (источник `core`), + документ `NEEDS_CHECK`; `POST …/confirm` без акцепта — 409 `CHECKS_NOT_PASSED` + +#### Scenario: 1.x-документ после PATCH +- **WHEN** форма принята по результату `1.2`, аналитик правит сумму 1230 так, что `1200 = 1210 + … + 1260` сходится +- **THEN** ядро пересчитывает §7 и статус общим кодом, запись `relation` по 1200 заменена (её больше нет), статус + пересчитан; `STATEMENTS_VERSION_MISMATCH` не пишется (у задания 1.x версии нет) + diff --git a/openspec/changes/unified-normalization-core-a/specs/primary-files/spec.md b/openspec/changes/unified-normalization-core-a/specs/primary-files/spec.md new file mode 100644 index 0000000..16a52b6 --- /dev/null +++ b/openspec/changes/unified-normalization-core-a/specs/primary-files/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: Очистка форм перед выкладкой +Перед выкладкой ядра с миграцией v5.19 на контур (шаг 3 порядка выкладки) база SHALL очищаться от форм ББ/ОФР (Р-24): +формы пересоздаются повторной загрузкой файлов уже по новым правилам. Очистку выполняет владелец или платформенная +команда по его заявке, на контуре — после резервной копии БД и до `alembic upgrade` v5.19 (на стендах разработчиков — по +желанию). Инструмент — скрипт ядра `scripts/purge_statements.py` (пишется в PR шага 3; описание здесь, кода нет) с +режимами `--dry-run` (по умолчанию: только отчёт) и `--apply`, одной транзакцией: +1. **Отбор:** документы `BALANCE` / `INCOME` (у файлов любого статуса, включая `SUPERSEDED`). +2. **Отказ без изменений**, если хотя бы один отобранный документ упомянут в `ex_snapshot_item` или привязан + (`consolidation_document`) к версии консолидации в статусе «Верифицирована» или дальше — `VERIFIED` или `ARCHIVED` + верифицированной версии (есть `ex_snapshot` с `source_version_id` версии): такая версия read-only и заморожена в ЕХ. + Отчёт называет версии, решение — за владельцем. Версии в работе, на согласовании, отклонённые (`REJECTED`) и + отменённые (`CANCELLED`, в том числе архивированные) очистке не мешают: привязки к ним снимаются, как к версиям + «в работе» (п. 3, 4), статус версий не меняется (Р-35). +3. **Файлы только с формами** — у файла нет документов `OSV`: удаляется файл целиком, как `DELETE /files/{id}` (§9.5 п. 5), + но без запрета `FILE_HAS_CONFIRMED_DOCUMENTS`: привязки документов к неверифицированным версиям (п. 2) снимаются + (статус версий не меняется), удаляются документы всех типов файла (`BALANCE`, `INCOME`, `DISCLOSURE`, `OTHER`) с + `financial_statement_*` и комментариями документов, задания `recognition_job` файла и `stored_file`; объектам S3 + ставится `expires_at = now()`; ссылка `superseded_by_file_id` других файлов на удаляемый обнуляется, а заменённые им + файлы остаются `SUPERSEDED` (повторная загрузка заменит их снова; отчёт перечисляет такие файлы, если в них есть ОСВ). +4. **Смешанные файлы** — у файла есть и ОСВ, и формы: удаляются только документы `BALANCE` / `INCOME` с их + `financial_statement_*`, комментариями и привязками; файл, ОСВ, `DISCLOSURE` / `OTHER` и задания остаются. Повторно + загрузить такой файл нельзя (дедуп по `sha256`), поэтому отчёт `--dry-run` перечисляет смешанные файлы отдельно, и + владелец решает до `--apply`, удалять ли их целиком (ключ `--include-mixed`). +5. **Аудит:** на каждый удалённый файл — системное `FILE_DELETE` (`user_id = NULL`, описание «очистка форм перед v5.19», + карточка файла в `before_value`); на документ смешанного файла — то же событие по файлу с перечнем удалённых + документов. +6. **Не трогаются:** ОСВ (`osv_entry`), консолидации и их версии, корректировки, гудвилл, справочники, пользователи, + журнал аудита. Результаты КФО неверифицированных версий, посчитанные с удалёнными формами, пересчитываются, как после + удаления файла. + +#### Scenario: Отчёт перед очисткой +- **WHEN** владелец запускает скрипт без ключей +- **THEN** скрипт ничего не меняет и печатает число файлов только с формами, смешанных файлов, документов форм, + подтверждённых документов и привязок к неверифицированным версиям + +#### Scenario: Форма в верифицированной версии +- **WHEN** документ ББ привязан к версии `VERIFIED` +- **THEN** `--apply` отказывает с перечнем версий, база не изменена + +#### Scenario: Форма в версии на согласовании или отклонённой +- **WHEN** документ ББ привязан к версии `VERIFICATION_RF`, а ОФР — к версии `REJECTED`; верифицированных версий нет +- **THEN** `--apply` выполняется: привязки сняты, статусы версий не изменились + +#### Scenario: Очистка и повторная загрузка +- **WHEN** очистка выполнена, ядро v5.19 выложено, аналитик загружает тот же PDF с ББ и ОФР +- **THEN** дедуп не мешает (файла больше нет), формы создаются по мапперу 1.x или 2.0 — смотря что шлёт сервис + diff --git a/openspec/changes/unified-normalization-core-a/specs/recognition-job-exchange/spec.md b/openspec/changes/unified-normalization-core-a/specs/recognition-job-exchange/spec.md new file mode 100644 index 0000000..c79a0db --- /dev/null +++ b/openspec/changes/unified-normalization-core-a/specs/recognition-job-exchange/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Приём конвертов и результатов обоих MAJOR контракта +Inbox SHALL принимать конверты (включая ack `processing`) и файлы результата и `MAJOR` 1, и `MAJOR` 2 («сначала ядро», +`recognition-contract`): версия определяется по `schema_version`, конверт валидируется моделью своего `MAJOR`, неизвестный +`MAJOR` (например, `3.0`) отбрасывается с предупреждением в лог (задание закроется по сроку). Ядро 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** конверт отбрасывается с предупреждением в лог, задание не меняется + diff --git a/openspec/changes/unified-normalization-core-a/specs/statement-checks/spec.md b/openspec/changes/unified-normalization-core-a/specs/statement-checks/spec.md new file mode 100644 index 0000000..5b94a1f --- /dev/null +++ b/openspec/changes/unified-normalization-core-a/specs/statement-checks/spec.md @@ -0,0 +1,80 @@ +## MODIFIED Requirements + +### Requirement: Соотношения §7 по колонкам +Проверки SHALL считаться чистой функцией по текущим `financial_statement_value.amount` документа для каждой колонки +отдельно по словарю `api/rules/statement_checks.yaml` (копия словаря tabular-parser): соотношение проверяется, только +если присутствует его левая часть; отсутствующий код и пустая сумма — 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` этих кодов). До перехода ядра на пакет `arm_statements` (часть Б) соотношения считает ядро на лету по `statement_checks.yaml` и агрегату; запись в БД — только в виде `document_issue` при приёме и пересчёте. + +#### 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 + +### Requirement: Межформенная проверка +Для документа `BALANCE` или `INCOME` SHALL считаться мягкая проверка `1370(колонка отчётной даты ББ) − 1370(колонка +31 декабря прошлого года ББ) = 2400(колонка отчётного периода ОФР)` с допуском `tolerance`, если найдена форма-пара: +документ другого типа формы того же ЮЛ, `period_year` и `period_quarter`, файла не `SUPERSEDED` (подтверждённый, затем +самый поздний по `created_at`), и все три колонки. Значение `1370` / `2400` берётся по агрегату формы по коду (сумма строк `ok` с этим кодом). Нарушение SHALL попадать в `cross_form` и MUST NOT блокировать +подтверждение. + +#### Scenario: Пара найдена +- **WHEN** у ЮЛ ББ за 2025 Q3 (1370 = 500 на 30.09.2025, 300 на 31.12.2024) и ОФР за 2025 Q3 (2400 = 150) +- **THEN** `cross_form` — нарушение `expected 200, actual 150`; подтверждение ББ разрешено + +#### Scenario: Пары нет +- **WHEN** у ЮЛ есть только ББ за период +- **THEN** `cross_form` — `null` + +#### Scenario: Межформенная проверка по агрегату +- **WHEN** в ББ две строки 1370 (сумма 500 и 300 на отчётную дату) +- **THEN** межформенная проверка берёт 1370 = 800 + +## ADDED Requirements + +### Requirement: Агрегат формы по коду +Все чтения формы «по коду» SHALL идти через одну чистую функцию «агрегат формы по коду» без хранения (DECISION-5): +для кода и колонки — сумма `amount` строк `ok` с этим кодом (`null`, если ни одной непустой), список `line_id`, +справочное имя. На неё переводятся межформенная проверка `1370 / 2400`, суммы формы для консолидации и выгрузок, +`parent_code` подстрок, раскладка строк на экране, правка ячеек по `line_code`, метрики `fields_*`. Строки `detail`, +`unreadable`, `missing` в агрегат, КФО и снимок ЕХ MUST NOT попадать. + +#### Scenario: Межформенная проверка по агрегату +- **WHEN** в ББ две строки 1370 (сумма 500 и 300 на отчётную дату) +- **THEN** межформенная проверка берёт 1370 = 800 + diff --git a/openspec/changes/unified-normalization-core-a/tasks.md b/openspec/changes/unified-normalization-core-a/tasks.md new file mode 100644 index 0000000..3f3b2ee --- /dev/null +++ b/openspec/changes/unified-normalization-core-a/tasks.md @@ -0,0 +1,18 @@ +## Задачи (SDD-план `docs/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-3-arm-core.md`, часть А) + +- [ ] A0. Change OpenSpec части А в arm-core +- [ ] A1. Схема v5.19 — модели и upgrade миграции +- [ ] A2. Откат миграции v5.19 +- [ ] A3. `crm_id` ГСК/ГСЗ +- [ ] A4. Очистка форм перед выкладкой `purge_statements.py` +- [ ] A5. Контракт 2.0 — копии, модели, приём конвертов, `JobBrief.warnings` +- [ ] A6. Словарь имён с синонимами и совпадение наименований +- [ ] A7. Агрегат формы по коду и перевод чтений «по коду» +- [ ] A8. Ответ `GET …/statement` и `DocumentOut.issues` по v5.19 +- [ ] A9. Маппер `1.x` — поля v5.19, `file_ordinal`, вторая форма, `MAPPING_WARNING` +- [ ] A10. Условие `RECOGNIZED` и статус ОСВ при приёме 2.0 — чистые функции +- [ ] A11. Подтверждение формы — порядок 409 и автоподтверждение +- [ ] A12. `PATCH …/statement` — подтверждение ячейки, дата колонки, шапка +- [ ] A13. `PATCH …/statement` — строки (`lines`) + +Порядок исполнения (R-1): A0–A4, A6–A13, затем A5 (ждёт слияния плана 0). -- 2.47.2 From 2db0a66a86b7200e8735bff2d883dec613d84c5c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:28:37 +0400 Subject: [PATCH 02/20] =?UTF-8?q?feat(models):=20DBML=20v5.19=20=E2=80=94?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B8=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC,=20document=5Fissue,=20file=5Fordinal,=20crm=5Fid?= =?UTF-8?q?=20=D0=93=D0=A1=D0=9A=20(=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=200007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Модели и upgrade ревизии 0007_dbml_v5_19: пять enum'ов, колонки строк/значений/колонок/шапок форм, document.file_ordinal (уникален в файле, уникальность типа только для не-форм), recognition_job.statements_version, related_group.crm_id, таблица document_issue; заполнение по умолчанию (row_index, needs_review, header_source и др.). delete_file_statements удаляет document_issue; make_document берёт следующий file_ordinal; маппер пока нумерует документы по порядку создания. downgrade — заглушка (задача A2). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- CLAUDE.md | 2 +- Makefile | 2 +- api/alembic/versions/0007_dbml_v5_19.py | 192 +++++++++++++++++ api/scripts/dbml_parity.py | 2 +- api/src/models/__init__.py | 4 +- api/src/models/directories.py | 2 + api/src/models/enums.py | 47 ++++- api/src/models/files.py | 12 +- api/src/models/statements.py | 84 +++++++- api/src/repositories/statements.py | 4 +- api/src/services/result_mapping.py | 3 + api/tests/factories.py | 28 +++ api/tests/test_api_files_delete.py | 12 +- api/tests/test_check_dbml_script.py | 14 +- api/tests/test_dbml_parity.py | 2 +- api/tests/test_enums.py | 6 +- api/tests/test_mapping_statements.py | 5 +- api/tests/test_migrations.py | 266 +++++++++++++++++++++++- api/tests/test_models_part_04_06.py | 20 +- api/tests/test_models_part_07_09.py | 8 +- 20 files changed, 673 insertions(+), 42 deletions(-) create mode 100644 api/alembic/versions/0007_dbml_v5_19.py diff --git a/CLAUDE.md b/CLAUDE.md index 89de9b0..79f79cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,7 +160,7 @@ - NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`. ## Правила репозитория -- ORM 1:1 с `../docs/model/data-model-v5.18.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. +- ORM 1:1 с `../docs/model/data-model-v5.19.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. - Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring. - Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц. - Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR. diff --git a/Makefile b/Makefile index 65ebfcc..70b6548 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ VENV := api/.venv BIN := $(CURDIR)/$(VENV)/bin COMPOSE_FILE ?= ../docs/dev/docker-compose.yml # DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/ -DBML ?= ../../docs/model/data-model-v5.18.dbml +DBML ?= ../../docs/model/data-model-v5.19.dbml .PHONY: venv lint test test-integration e2e dbml-check migration-cycle check check-infra docker-build web-check check-full up migrate seed seed-dev diff --git a/api/alembic/versions/0007_dbml_v5_19.py b/api/alembic/versions/0007_dbml_v5_19.py new file mode 100644 index 0000000..3abb319 --- /dev/null +++ b/api/alembic/versions/0007_dbml_v5_19.py @@ -0,0 +1,192 @@ +"""DBML v5.19: единая нормализация форм ББ/ОФР (RSHB/arm-ka-docs#116, openspec unified-statement-normalization). + +Только добавляет: enum'ы, колонки строк/значений/колонок/шапок форм, `document.file_ordinal`, +`recognition_job.statements_version`, `related_group.crm_id`, таблицу `document_issue`, индексы; §7 по существующим +формам не считает (`document_issue` остаётся пустой — корректность нарушений у форм неочищенной базы не гарантируется +до первой правки). Заполнение по умолчанию (миграция проходит и на непустой базе): +- `row_index` — строки источника по возрастанию `line_code` внутри формы с 0; строки без + `line_code_original_value` (созданные правкой) — NULL; +- `needs_review` значений = НЕ (правлено или (`is_recognized` не false и уверенность 100 / NULL)), `source_text` = + `string_value`; `period_kind` колонок — `absolute` при `period_date`, иначе `none`; +- `header_source` — из `*_original_value` и `inn_confidence` (P3-1), `organization_name_original_value` = + `organization_name`, `organization_name_modified` = false (P3-2); `form_variant` = NULL (набор §7 — полный); +- `file_ordinal` — по `(created_at, id)` внутри файла с 0. +Уникальные индексы создаются после заполнения. Откат (`downgrade`) — задача A2 плана 3. + +Revision ID: 0007_dbml_v5_19 +Revises: 0006_dbml_v5_18 +Create Date: 2026-10-18 +""" + +from collections.abc import Sequence + +from alembic import op +from src.models.base import SCHEMA + +revision: str = "0007_dbml_v5_19" +down_revision: str | Sequence[str] | None = "0006_dbml_v5_18" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +S = SCHEMA + +ENUMS_V5_19: dict[str, tuple[str, ...]] = { + "statement_line_status": ("ok", "unreadable", "missing", "detail"), + "statement_form_variant": ("full", "simplified"), + "document_issue_kind": ("warning", "relation", "soft"), + "issue_severity": ("error", "info"), + "issue_source": ("service", "core"), +} + + +def _create_enums() -> None: + for name, labels in ENUMS_V5_19.items(): + values = ", ".join(f"'{label}'" for label in labels) + op.execute(f"CREATE TYPE {S}.{name} AS ENUM ({values})") + + +def _add_columns() -> None: + statements = [ + # financial_statement_line + f"ALTER TABLE {S}.financial_statement_line" + " ADD COLUMN row_index integer," + " ADD COLUMN source_ref varchar," + f" ADD COLUMN line_status {S}.statement_line_status NOT NULL DEFAULT 'ok'," + " ADD COLUMN line_code_text varchar," + " ADD COLUMN parent_row_index integer," + " ADD COLUMN needs_review boolean NOT NULL DEFAULT false," + " ADD COLUMN is_confirmed boolean NOT NULL DEFAULT false", + # financial_statement_value + f"ALTER TABLE {S}.financial_statement_value" + " ADD COLUMN source_text varchar," + " ADD COLUMN needs_review boolean NOT NULL DEFAULT false," + " ADD COLUMN is_confirmed boolean NOT NULL DEFAULT false," + " ADD COLUMN confirmed_by uuid" + f" CONSTRAINT fk_financial_statement_value_confirmed_by_app_user REFERENCES {S}.app_user (id)," + " ADD COLUMN confirmed_at timestamptz", + # financial_statement_column + f"ALTER TABLE {S}.financial_statement_column" + " ADD COLUMN period_kind varchar, ADD COLUMN label_confidence numeric(5,2)", + # financial_statement_meta + f"ALTER TABLE {S}.financial_statement_meta" + f" ADD COLUMN form_variant {S}.statement_form_variant," + " ADD COLUMN needs_review_fields jsonb," + " ADD COLUMN header_source jsonb," + " ADD COLUMN organization_name_original_value varchar," + " ADD COLUMN organization_name_modified boolean", + # document, recognition_job, related_group + f"ALTER TABLE {S}.document ADD COLUMN file_ordinal smallint NOT NULL DEFAULT 0", + f"ALTER TABLE {S}.recognition_job ADD COLUMN statements_version varchar", + f"ALTER TABLE {S}.related_group ADD COLUMN crm_id varchar CONSTRAINT uq_related_group_crm_id UNIQUE", + ] + for statement in statements: + op.execute(statement) + + +def _backfill() -> None: + # row_index: строки источника по возрастанию line_code внутри формы с 0; созданные правкой — NULL + op.execute( + f""" + 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 + """ + ) + op.execute( + f""" + 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))) + """ + ) + op.execute( + f""" + UPDATE {S}.financial_statement_column + SET period_kind = CASE WHEN period_date IS NULL THEN 'none' ELSE 'absolute' END + """ + ) + op.execute( + f""" + UPDATE {S}.financial_statement_meta SET + header_source = jsonb_build_object( + 'inn', jsonb_build_object('text', inn_original_value, 'confidence', inn_confidence), + 'report_date', jsonb_build_object('text', report_date_original_value, 'confidence', NULL), + 'units', jsonb_build_object('text', units_original_value, 'confidence', NULL), + 'organization_name', jsonb_build_object('text', organization_name, 'confidence', NULL)), + organization_name_original_value = organization_name, + organization_name_modified = false + """ + ) + op.execute( + f""" + 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 + """ + ) + + +def _indexes() -> None: + # старый уникальный индекс (meta_id, line_code) → обычный: повтор кода законен + op.execute(f"DROP INDEX {S}.uq_financial_statement_line_meta_id_line_code") + op.execute( + "CREATE INDEX ix_financial_statement_line_meta_id_line_code " + f"ON {S}.financial_statement_line (meta_id, line_code)" + ) + op.execute( + "CREATE UNIQUE INDEX uq_financial_statement_line_meta_id_source_ref " + f"ON {S}.financial_statement_line (meta_id, source_ref) WHERE source_ref IS NOT NULL" + ) + # уникальность типа — только для не-форм; формы ББ/ОФР одного типа могут повторяться (Р-10) + op.execute(f"DROP INDEX {S}.uq_document_file_type_account") + op.execute( + "CREATE UNIQUE INDEX uq_document_file_type_account " + f"ON {S}.document (stored_file_id, document_type, osv_account_code) NULLS NOT DISTINCT " + "WHERE document_type NOT IN ('BALANCE', 'INCOME')" + ) + op.execute( + f"CREATE UNIQUE INDEX uq_document_stored_file_id_file_ordinal ON {S}.document (stored_file_id, file_ordinal)" + ) + + +def _create_document_issue() -> None: + op.execute( + f""" + CREATE TABLE {S}.document_issue ( + id uuid NOT NULL, + document_id uuid NOT NULL, + kind {S}.document_issue_kind NOT NULL, + code varchar NOT NULL, + severity {S}.issue_severity NOT NULL, + message text NOT NULL, + line_ids jsonb, + column_id uuid, + column_index smallint, + codes jsonb, + field varchar, + source {S}.issue_source NOT NULL, + relation varchar, + expected numeric(18,2), + actual numeric(18,2), + created_at timestamptz NOT NULL, + CONSTRAINT pk_document_issue PRIMARY KEY (id), + CONSTRAINT fk_document_issue_document_id_document FOREIGN KEY (document_id) REFERENCES {S}.document (id), + CONSTRAINT fk_document_issue_column_id_financial_statement_column + FOREIGN KEY (column_id) REFERENCES {S}.financial_statement_column (id) + ) + """ + ) + op.execute(f"CREATE INDEX ix_document_issue_document_id ON {S}.document_issue (document_id)") + + +def upgrade() -> None: + _create_enums() + _add_columns() + _backfill() + _indexes() + _create_document_issue() + + +def downgrade() -> None: + # откат (охранные проверки, потеря данных, журнал) — задача A2 плана 3 + raise NotImplementedError("downgrade 0007_dbml_v5_19 ещё не реализован (задача A2 плана 3)") diff --git a/api/scripts/dbml_parity.py b/api/scripts/dbml_parity.py index 4c8dac5..4ff47a4 100644 --- a/api/scripts/dbml_parity.py +++ b/api/scripts/dbml_parity.py @@ -57,7 +57,7 @@ from sqlalchemy.sql.functions import FunctionElement #: Эталонная схема модели. Имя схемы в БД конфигурируемо (`DB_SCHEMA`), CLI сверяет ORM, загруженную с этим значением. SCHEMA = "dkb" -DBML_FILE_NAME = "data-model-v5.18.dbml" +DBML_FILE_NAME = "data-model-v5.19.dbml" ORM = "ORM" DBML = "DBML" ABSENT = "нет" diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index 534245d..277ec44 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -1,4 +1,4 @@ -"""ORM-модели. Источник истины — docs/model/data-model-v5.18.dbml; заполняется в Срезе 0. +"""ORM-модели. Источник истины — docs/model/data-model-v5.19.dbml; заполняется в Срезе 0. Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic). """ @@ -7,7 +7,7 @@ from . import access, accounting, audit_ex, consolidation, directories, files, k from .base import Base, UUIDPrimaryMixin #: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm). -DBML_VERSION = "5.18" +DBML_VERSION = "5.19" __all__ = [ "DBML_VERSION", diff --git a/api/src/models/directories.py b/api/src/models/directories.py index 7c64d69..d71249c 100644 --- a/api/src/models/directories.py +++ b/api/src/models/directories.py @@ -141,6 +141,8 @@ class RelatedGroup(UUIDPrimaryMixin, Base): kind: Mapped[RelatedGroupKind] = mapped_column(pg_enum("related_group_kind"), nullable=False) name: Mapped[str] = mapped_column(String, nullable=False) created_date: Mapped[datetime.date | None] = mapped_column(Date) + # v5.19: ID ГСК/ГСЗ в ЕЦП CRM; вводится вручную, NULL — не задан + crm_id: Mapped[str | None] = mapped_column(String, unique=True) class RelatedGroupMember(UUIDPrimaryMixin, Base): diff --git a/api/src/models/enums.py b/api/src/models/enums.py index 7279617..1e529a3 100644 --- a/api/src/models/enums.py +++ b/api/src/models/enums.py @@ -1,6 +1,6 @@ -"""Enum'ы модели данных DBML v5.18 (28 штук) и фабрика `pg_enum` для PostgreSQL-колонок. +"""Enum'ы модели данных DBML v5.19 (33 штуки) и фабрика `pg_enum` для PostgreSQL-колонок. -Источник истины — docs/model/data-model-v5.18.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке +Источник истины — docs/model/data-model-v5.19.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке DBML (включая кириллические `"ЮЛ"`/`"ИП"` и значения в разном регистре); `notes` DBML значениями не являются. Имя члена совпадает со значением (все значения DBML — валидные python-идентификаторы, кириллица допустима). Классы — `enum.StrEnum` (подкласс `str`) — удобно для pydantic/JSON; `values_callable` в `pg_enum` в БД пишет `.value`. @@ -136,6 +136,44 @@ class StatementType(enum.StrEnum): income = "income" +class StatementLineStatus(enum.StrEnum): + """DBML `statement_line_status` (v5.19): статус строки формы; `line_code` пуст ⇔ статус не `ok`.""" + + ok = "ok" + unreadable = "unreadable" + missing = "missing" + detail = "detail" + + +class StatementFormVariant(enum.StrEnum): + """DBML `statement_form_variant` (v5.19): вариант формы по структуре кодов (§11.7).""" + + full = "full" + simplified = "simplified" + + +class DocumentIssueKind(enum.StrEnum): + """DBML `document_issue_kind` (v5.19): вид записи `document_issue`.""" + + warning = "warning" + relation = "relation" + soft = "soft" + + +class IssueSeverity(enum.StrEnum): + """DBML `issue_severity` (v5.19): `error` — документ `needs_check`, `info` — на статус не влияет.""" + + error = "error" + info = "info" + + +class IssueSource(enum.StrEnum): + """DBML `issue_source` (v5.19): откуда запись — из результата распознавания или посчитана ядром.""" + + service = "service" + core = "core" + + class OsvGroupType(enum.StrEnum): """DBML `osv_group_type`.""" @@ -324,6 +362,11 @@ ENUMS: dict[str, type[enum.Enum]] = { "s3_bucket_role": S3BucketRole, "recognition_status": RecognitionStatus, "statement_type": StatementType, + "statement_line_status": StatementLineStatus, + "statement_form_variant": StatementFormVariant, + "document_issue_kind": DocumentIssueKind, + "issue_severity": IssueSeverity, + "issue_source": IssueSource, "osv_group_type": OsvGroupType, "counterparty_match_kind": CounterpartyMatchKind, "osv_data_type": OsvDataType, diff --git a/api/src/models/files.py b/api/src/models/files.py index 42a2e25..7006ef1 100644 --- a/api/src/models/files.py +++ b/api/src/models/files.py @@ -1,4 +1,4 @@ -"""Часть 04 DBML v5.18 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания. +"""Часть 04 DBML v5.19 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания. Цикл FK `stored_file.current_recognition_job_id` -> `recognition_job` и `recognition_job.file_id` -> `stored_file` разорван через `use_alter=True` на nullable-стороне (`stored_file.current_recognition_job_id`): `create_all` создаёт @@ -91,7 +91,8 @@ class Document(UUIDPrimaryMixin, Base): __tablename__ = "document" __table_args__ = ( - # один ББ / один ОФР / одна ОСВ по счёту на файл: NULL в osv_account_code считается значением (PG15+) + # v5.19: одна ОСВ по счёту / одна расшифровка / один прочий на файл; формы ББ/ОФР одного типа могут + # повторяться (Р-10). NULL в osv_account_code считается значением (PG15+) Index( "uq_document_file_type_account", "stored_file_id", @@ -99,7 +100,10 @@ class Document(UUIDPrimaryMixin, Base): "osv_account_code", unique=True, postgresql_nulls_not_distinct=True, + postgresql_where=text("document_type NOT IN ('BALANCE', 'INCOME')"), ), + # v5.19: номер документа в documents[] результата уникален в файле + Index("uq_document_stored_file_id_file_ordinal", "stored_file_id", "file_ordinal", unique=True), # чек-лист required_document_rule Index( "ix_document_legal_entity_period_status", @@ -130,6 +134,8 @@ class Document(UUIDPrimaryMixin, Base): confirmed_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + # v5.19: индекс документа в documents[] результата (с 0); несозданный документ номер не занимает + file_ordinal: Mapped[int] = mapped_column(SmallInteger, nullable=False, server_default=text("0")) class RecognitionJob(UUIDPrimaryMixin, Base): @@ -164,3 +170,5 @@ class RecognitionJob(UUIDPrimaryMixin, Base): fields_total: Mapped[int | None] = mapped_column(Integer) fields_low_confidence: Mapped[int | None] = mapped_column(Integer) fields_unrecognized: Mapped[int | None] = mapped_column(Integer) + # v5.19: service.statements_version результата — коммит копии общего кода arm_statements + statements_version: Mapped[str | None] = mapped_column(String) diff --git a/api/src/models/statements.py b/api/src/models/statements.py index 2a1d04d..20d1d09 100644 --- a/api/src/models/statements.py +++ b/api/src/models/statements.py @@ -1,19 +1,29 @@ -"""Часть 06 DBML v5.18 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н). +"""Часть 06 DBML v5.19 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н). Первичка не мутируется: `*_original_value` / `original_amount` / `is_modified` — рядом с распознанным значением. -Логические проверки (ТЗ 6.3) и «требует проверки» не хранятся — считаются на лету. +v5.19: предупреждения и нарушения §7 хранятся в `document_issue`, `needs_review` строк и значений — в колонках; +повтор кода в форме законен (строки хранятся как в источнике). """ import datetime import decimal import uuid +from typing import Any -from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Numeric, SmallInteger, String, Text, text +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Integer, Numeric, SmallInteger, String, Text, text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column -from .base import Base, UUIDPrimaryMixin -from .enums import StatementType, pg_enum +from .base import Base, UUIDPrimaryMixin, nullable_jsonb +from .enums import ( + DocumentIssueKind, + IssueSeverity, + IssueSource, + StatementFormVariant, + StatementLineStatus, + StatementType, + pg_enum, +) class FinancialStatementMeta(UUIDPrimaryMixin, Base): @@ -57,6 +67,12 @@ class FinancialStatementMeta(UUIDPrimaryMixin, Base): checks_accepted_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) checks_accepted_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) checks_accepted_comment: Mapped[str | None] = mapped_column(Text) + # v5.19: порядок колонок = порядок ADD COLUMN миграции 0007 (check_dbml сверяет порядок) + form_variant: Mapped[StatementFormVariant | None] = mapped_column(pg_enum("statement_form_variant")) + needs_review_fields: Mapped[Any | None] = mapped_column(nullable_jsonb()) + header_source: Mapped[Any | None] = mapped_column(nullable_jsonb()) + organization_name_original_value: Mapped[str | None] = mapped_column(String) + organization_name_modified: Mapped[bool | None] = mapped_column(Boolean) class FinancialStatementColumn(UUIDPrimaryMixin, Base): @@ -74,13 +90,25 @@ class FinancialStatementColumn(UUIDPrimaryMixin, Base): label: Mapped[str | None] = mapped_column(String) period_date: Mapped[datetime.date | None] = mapped_column(Date) is_current: Mapped[bool | None] = mapped_column(Boolean) + period_kind: Mapped[str | None] = mapped_column(String) + label_confidence: Mapped[decimal.Decimal | None] = mapped_column(Numeric(5, 2)) class FinancialStatementLine(UUIDPrimaryMixin, Base): """Строка формы с кодом; порядок, разделы и итоговые строки — шаблон формы на фронте.""" __tablename__ = "financial_statement_line" - __table_args__ = (Index("uq_financial_statement_line_meta_id_line_code", "meta_id", "line_code", unique=True),) + __table_args__ = ( + # v5.19: повтор кода в форме законен — индекс не уникален (чтение «по коду» — агрегат формы) + Index("ix_financial_statement_line_meta_id_line_code", "meta_id", "line_code"), + Index( + "uq_financial_statement_line_meta_id_source_ref", + "meta_id", + "source_ref", + unique=True, + postgresql_where=text("source_ref IS NOT NULL"), + ), + ) meta_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("financial_statement_meta.id"), nullable=False @@ -93,6 +121,16 @@ class FinancialStatementLine(UUIDPrimaryMixin, Base): line_name_original_value: Mapped[str | None] = mapped_column(String) line_name_confidence: Mapped[decimal.Decimal | None] = mapped_column(Numeric(5, 2)) line_name_modified: Mapped[bool | None] = mapped_column(Boolean) + # v5.19: `row_index IS NULL` ⇔ строка создана правкой (шаблонная); у строк из результата всегда задан + row_index: Mapped[int | None] = mapped_column(Integer) + source_ref: Mapped[str | None] = mapped_column(String) + line_status: Mapped[StatementLineStatus] = mapped_column( + pg_enum("statement_line_status"), nullable=False, server_default=text("'ok'") + ) + line_code_text: Mapped[str | None] = mapped_column(String) + parent_row_index: Mapped[int | None] = mapped_column(Integer) + needs_review: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + is_confirmed: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) class FinancialStatementValue(UUIDPrimaryMixin, Base): @@ -117,3 +155,37 @@ class FinancialStatementValue(UUIDPrimaryMixin, Base): original_amount: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) modified_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) modified_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + # v5.19 + source_text: Mapped[str | None] = mapped_column(String) + needs_review: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + is_confirmed: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + confirmed_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + confirmed_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + + +class DocumentIssue(UUIDPrimaryMixin, Base): + """v5.19: предупреждение или нарушение §7 документа с привязкой к строкам, ячейкам и полям шапки. + + Удаляется вместе с документом; при пересчёте после правки `relation` / `soft` заменяются целиком, `warning` + источника `service` сохраняются (кроме снимаемых правкой). `line_ids` — без FK: ядро убирает id удалённой + шаблонной строки само. + """ + + __tablename__ = "document_issue" + __table_args__ = (Index("ix_document_issue_document_id", "document_id"),) + + document_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("document.id"), nullable=False) + kind: Mapped[DocumentIssueKind] = mapped_column(pg_enum("document_issue_kind"), nullable=False) + code: Mapped[str] = mapped_column(String, nullable=False) + severity: Mapped[IssueSeverity] = mapped_column(pg_enum("issue_severity"), nullable=False) + message: Mapped[str] = mapped_column(Text, nullable=False) + line_ids: Mapped[Any | None] = mapped_column(nullable_jsonb()) + column_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("financial_statement_column.id")) + column_index: Mapped[int | None] = mapped_column(SmallInteger) + codes: Mapped[Any | None] = mapped_column(nullable_jsonb()) + field: Mapped[str | None] = mapped_column(String) + source: Mapped[IssueSource] = mapped_column(pg_enum("issue_source"), nullable=False) + relation: Mapped[str | None] = mapped_column(String) + expected: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + actual: Mapped[decimal.Decimal | None] = mapped_column(Numeric(18, 2)) + created_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index 3d8d8de..ac4debb 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -14,6 +14,7 @@ from src.domain.statement_mapping import StatementDraft from src.models.enums import DocumentStatus, DocumentType, FileStatus from src.models.files import Document, StoredFile from src.models.statements import ( + DocumentIssue, FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta, @@ -107,8 +108,9 @@ async def insert_statement( async def delete_file_statements(session: AsyncSession, file_id: uuid.UUID) -> None: - """Значения, строки, колонки и шапки форм всех документов файла (в порядке FK).""" + """`document_issue`, значения, строки, колонки и шапки форм всех документов файла (в порядке FK).""" documents = select(Document.id).where(Document.stored_file_id == file_id).scalar_subquery() + await session.execute(delete(DocumentIssue).where(DocumentIssue.document_id.in_(documents))) metas = select(FinancialStatementMeta.id).where(FinancialStatementMeta.document_id.in_(documents)).scalar_subquery() lines = select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id.in_(metas)).scalar_subquery() await session.execute(delete(FinancialStatementValue).where(FinancialStatementValue.line_id.in_(lines))) diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 61bdf40..cf3977d 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -532,6 +532,9 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай documents.extend(other_documents) documents.extend(document for document, _ in forms) + # v5.19: номер документа в файле уникален; до перехода на индекс в documents[] (задачи A5/A9) — порядок создания + for ordinal, document in enumerate(documents): + document.file_ordinal = ordinal session.add_all(documents) await session.flush() # T7.2: родитель раньше потомка на любой границе пачки (self-FK `parent_entry_id`) — не полагаемся на порядок строк diff --git a/api/tests/factories.py b/api/tests/factories.py index 7de3d6a..c70b270 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -54,11 +54,14 @@ from src.models.enums import ( ConsolidationScenario, ConsolidationStatus, DocumentCheckResult, + DocumentIssueKind, DocumentStatus, DocumentType, EntityType, FileStatus, FileType, + IssueSeverity, + IssueSource, OsvDataType, OsvGroupType, PeriodQuarter, @@ -69,6 +72,7 @@ from src.models.enums import ( ) from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry +from src.models.statements import DocumentIssue from src.services.result_mapping import MAPPING_WARNINGS_KEY, _entry_values, _group_schema from tests.conftest import FakeStorage @@ -265,10 +269,17 @@ async def make_document( # noqa: PLR0913 - сигнатура фабрики ф """ assert job.file_id == file.id # noqa: S101 - защита от перепутанных аргументов в тестах now = _now() + # v5.19: file_ordinal уникален в файле — берём следующий свободный + ordinal = ( + await session.execute( + select(func.coalesce(func.max(Document.file_ordinal) + 1, 0)).where(Document.stored_file_id == file.id) + ) + ).scalar_one() document = Document( stored_file_id=file.id, legal_entity_id=file.legal_entity_id, document_type=DocumentType.OSV, + file_ordinal=ordinal, osv_account_code=account, period_year=year, period_quarter=quarter, @@ -282,6 +293,22 @@ async def make_document( # noqa: PLR0913 - сигнатура фабрики ф return document +async def make_document_issue(session: AsyncSession, *, document_id: uuid.UUID, code: str = "MAPPING_WARNING") -> Any: + """Запись `document_issue` (предупреждение ядра уровня `info`) документа (`flush`).""" + issue = DocumentIssue( + document_id=document_id, + kind=DocumentIssueKind.warning, + code=code, + severity=IssueSeverity.info, + message="тестовое предупреждение", + source=IssueSource.core, + created_at=_now(), + ) + session.add(issue) + await session.flush() + return issue + + async def make_result_object( session: AsyncSession, *, job: RecognitionJob, storage: FakeStorage, result: dict[str, Any] ) -> S3Object: @@ -337,6 +364,7 @@ async def make_entries_from_result( stored_file_id=file.id, legal_entity_id=file.legal_entity_id, document_type=DocumentType.OSV, + file_ordinal=len(documents), osv_account_code=fields.osv_account_code, period_year=fields.period_year, period_quarter=fields.period_quarter, diff --git a/api/tests/test_api_files_delete.py b/api/tests/test_api_files_delete.py index 7273dc5..9dd722e 100644 --- a/api/tests/test_api_files_delete.py +++ b/api/tests/test_api_files_delete.py @@ -34,6 +34,7 @@ from src.models.enums import ( from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry from src.models.statements import ( + DocumentIssue, FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta, @@ -45,6 +46,7 @@ from tests.factories import ( make_company_group, make_consolidation, make_document, + make_document_issue, make_entries_from_result, make_legal_entity, make_result_object, @@ -146,7 +148,13 @@ async def _form_file_with_comment(session: AsyncSession, fixtures_dir: Path) -> async def _assert_form_rows_gone(session: AsyncSession, file_id: uuid.UUID, document_ids: list[uuid.UUID]) -> None: assert await _count(session, StoredFile, StoredFile.id == file_id) == 0 assert await _count(session, Document, Document.stored_file_id == file_id) == 0 - for model in (FinancialStatementMeta, FinancialStatementColumn, FinancialStatementLine, FinancialStatementValue): + for model in ( + DocumentIssue, + FinancialStatementMeta, + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementValue, + ): assert await _count(session, model) == 0, model.__tablename__ assert await _count(session, Comment, Comment.object_id.in_(document_ids)) == 0 @@ -161,6 +169,8 @@ async def test_delete_file_with_statement_form( file_id = await _form_file_with_comment(seeded, fixtures_dir) document_ids = list((await seeded.execute(select(Document.id).where(Document.stored_file_id == file_id))).scalars()) assert await _count(seeded, FinancialStatementValue) > 0 and await _count(seeded, Comment) == 1 + await make_document_issue(seeded, document_id=document_ids[0]) # v5.19: document_issue уходит вместе с файлом + assert await _count(seeded, DocumentIssue) == 1 if superseded: await seeded.execute(update(StoredFile).where(StoredFile.id == file_id).values(status=FileStatus.SUPERSEDED)) await seeded.flush() diff --git a/api/tests/test_check_dbml_script.py b/api/tests/test_check_dbml_script.py index 502ae5a..dcc623c 100644 --- a/api/tests/test_check_dbml_script.py +++ b/api/tests/test_check_dbml_script.py @@ -140,18 +140,18 @@ def test_enum_values_and_sets_are_compared(dbml_file: Path) -> None: def test_newer_dbml_version_fails_before_comparing_tables(tmp_path: Path, dbml_file: Path) -> None: lines = dbml_file.read_text(encoding="utf-8").splitlines() - newer = tmp_path / "data-model-v5.19.dbml" - # шапка 5.18 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора - newer.write_text(lines[0].replace("v5.18", "v5.19") + "\nTable broken {{{\n", encoding="utf-8") - assert run_check(newer) == (1, ["версия DBML 5.19 не совпадает с версией ORM 5.18: обновите модели"]) + newer = tmp_path / "data-model-v5.20.dbml" + # шапка 5.19 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора + newer.write_text(lines[0].replace("v5.19", "v5.20") + "\nTable broken {{{\n", encoding="utf-8") + assert run_check(newer) == (1, ["версия DBML 5.20 не совпадает с версией ORM 5.19: обновите модели"]) def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None: - newer = tmp_path / "data-model-v5.19.dbml" - newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.18", "v5.19", 1), encoding="utf-8") + newer = tmp_path / "data-model-v5.20.dbml" + newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.19", "v5.20", 1), encoding="utf-8") result = _run_cli(str(newer)) assert result.returncode == 1 - assert result.stdout == "версия DBML 5.19 не совпадает с версией ORM 5.18: обновите модели\n" + assert result.stdout == "версия DBML 5.20 не совпадает с версией ORM 5.19: обновите модели\n" def test_header_without_version_is_usage_error(tmp_path: Path) -> None: diff --git a/api/tests/test_dbml_parity.py b/api/tests/test_dbml_parity.py index 883ba25..ca63e7b 100644 --- a/api/tests/test_dbml_parity.py +++ b/api/tests/test_dbml_parity.py @@ -193,7 +193,7 @@ def test_nulls_not_distinct_flag_is_compared_both_ways() -> None: """DBML-note `NULLS NOT DISTINCT` требует флаг в ORM-индексе; лишний флаг без note — тоже расхождение.""" metadata = clone_metadata() document = metadata.tables["dkb.document"] - index = next(i for i in document.indexes if i.unique) + index = next(i for i in document.indexes if i.name == "uq_document_file_type_account") assert find_problems(metadata, {"document"}) == [] index.dialect_options["postgresql"]["nulls_not_distinct"] = None # индекс без флага problems = find_problems(metadata, {"document"}) diff --git a/api/tests/test_enums.py b/api/tests/test_enums.py index 6b13db3..66b2738 100644 --- a/api/tests/test_enums.py +++ b/api/tests/test_enums.py @@ -1,4 +1,4 @@ -"""Enum'ы ORM против блоков `Enum {}` DBML v5.18: имена, значения и их порядок (без БД).""" +"""Enum'ы ORM против блоков `Enum {}` DBML v5.19: имена, значения и их порядок (без БД).""" import enum import re @@ -10,12 +10,12 @@ from src.models import DBML_VERSION from src.models.enums import ENUMS, pg_enum from tests.dbml_helpers import dbml_path, load_dbml -EXPECTED_ENUM_COUNT = 28 +EXPECTED_ENUM_COUNT = 33 def test_dbml_version_format() -> None: assert re.fullmatch(r"\d+\.\d+", DBML_VERSION) - assert DBML_VERSION == "5.18" + assert DBML_VERSION == "5.19" assert f"v{DBML_VERSION}" in dbml_path().name diff --git a/api/tests/test_mapping_statements.py b/api/tests/test_mapping_statements.py index f43d832..cc1d426 100644 --- a/api/tests/test_mapping_statements.py +++ b/api/tests/test_mapping_statements.py @@ -22,6 +22,7 @@ from src.models.enums import ( ) from src.models.files import Document, RecognitionJob, StoredFile from src.models.statements import ( + DocumentIssue, FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta, @@ -29,7 +30,7 @@ from src.models.statements import ( ) from src.repositories.counterparties import CounterpartyIndex from src.services.result_mapping import MAPPING_WARNINGS_KEY, map_file -from tests.factories import make_legal_entity +from tests.factories import make_document_issue, make_legal_entity from tests.fixtures_helpers import GOLDEN_STATEMENTS, OCR_EXPECTED, golden_result, map_golden_snapshot, ocr_result pytestmark = [pytest.mark.integration, pytest.mark.fixtures] @@ -291,6 +292,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) 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) assert file is not None and file.current_recognition_job_id is not None job = await seeded.get(RecognitionJob, file.current_recognition_job_id) @@ -311,6 +313,7 @@ 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 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/api/tests/test_migrations.py b/api/tests/test_migrations.py index 020b0fb..0faed3f 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -1,4 +1,5 @@ -"""Миграции 0001 (DBML v5.13), 0002 (v5.14), 0003 (v5.15), 0004 (v5.16), 0005 (v5.17), 0006 (v5.18) на PostgreSQL 16: +"""Миграции 0001 (DBML v5.13), 0002 (v5.14), 0003 (v5.15), 0004 (v5.16), 0005 (v5.17), 0006 (v5.18), 0007 (v5.19) +на PostgreSQL 16: полный цикл, откат на шаг, пустой autogenerate, схема = схеме ORM. Spec data-model, требование «Миграции применяются с нуля и откатываются» (сценарии «Полный цикл» и «Автогенерация @@ -15,7 +16,7 @@ import pytest from alembic.autogenerate import compare_metadata from alembic.runtime.migration import MigrationContext from sqlalchemy import Connection, make_url, text -from sqlalchemy.exc import ProgrammingError +from sqlalchemy.exc import IntegrityError, ProgrammingError from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine from src.models import Base @@ -36,7 +37,8 @@ from tests.test_models_part_07_09 import ( _check_active_lock_is_unique, ) -HEAD = "0006_dbml_v5_18" +HEAD = "0007_dbml_v5_19" +V5_18 = "0006_dbml_v5_18" V5_17 = "0005_dbml_v5_17" V5_16 = "0004_dbml_v5_16" V5_15 = "0003_dbml_v5_15" @@ -620,6 +622,253 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N await engine.dispose() +async def _seed_v5_18_statements(conn: AsyncConnection, ids: dict[str, uuid.UUID]) -> None: + """Данные на ревизии 0006: ЮЛ, файл, `INCOME` (t0) и `BALANCE` (t1 > t0) с формой ББ; ГСК без `crm_id`.""" + t0 = datetime.datetime(2025, 10, 1, 9, 0, tzinfo=datetime.UTC) + t1 = t0 + datetime.timedelta(minutes=5) + await conn.execute( + text("INSERT INTO dkb.legal_entity (id, inn, name, valid_from) VALUES (:le, '7701234567', 'ООО Ромашка', :t0)"), + {**ids, "t0": t0}, + ) + await conn.execute( + text( + "INSERT INTO dkb.s3_object (id, bucket_role, bucket, key, created_at) " + "VALUES (:s3, 'uploads', 'b', 'k', :t0)" + ), + {**ids, "t0": t0}, + ) + await conn.execute( + text( + "INSERT INTO dkb.stored_file (id, legal_entity_id, file_type, s3_object_id, status) " + "VALUES (:file, :le, 'XLSX', :s3, 'PROCESSED')" + ), + ids, + ) + await conn.execute( + text( + "INSERT INTO dkb.recognition_job (id, file_id, pipeline, request_subject, schema_version, status) " + "VALUES (:job, :file, 'tabular', 'files.tabular_uploaded', '1', 'done')" + ), + ids, + ) + for key, doc_type, created in (("income", "INCOME", t0), ("balance", "BALANCE", t1)): + await conn.execute( + text( + "INSERT INTO dkb.document (id, stored_file_id, legal_entity_id, document_type, status, created_at) " + f"VALUES (:{key}, :file, :le, '{doc_type}', 'NEEDS_CHECK', :created)" + ), + {**ids, "created": created}, + ) + await conn.execute( + text( + "INSERT INTO dkb.financial_statement_meta (id, document_id, recognition_job_id, statement_type, inn, " + "inn_original_value, inn_confidence, report_date_original_value, units_original_value, organization_name) " + "VALUES (:meta, :balance, :job, 'balance', '7701234567', '7701234567', 87, '30.09.2025', 'тыс. руб.', " + "'ООО Ромашка')" + ), + ids, + ) + for key, index, period in (("c1", 0, datetime.date(2025, 9, 30)), ("c2", 1, None)): + await conn.execute( + text( + "INSERT INTO dkb.financial_statement_column (id, meta_id, column_index, period_date) " + "VALUES (:id, :meta, :idx, :period)" + ), + {"id": ids[key], "meta": ids["meta"], "idx": index, "period": period}, + ) + for key, code, original in (("l1600", "1600", "1600"), ("l1100", "1100", "1100"), ("l1250", "1250", None)): + await conn.execute( + text( + "INSERT INTO dkb.financial_statement_line (id, meta_id, line_code, line_code_original_value) " + "VALUES (:id, :meta, :code, :original)" + ), + {"id": ids[key], "meta": ids["meta"], "code": code, "original": original}, + ) + values = [ + # (строка, колонка, сумма, строка-значение, уверенность, распознано, правлено) + ("l1100", "c1", 1, None, 100, True, False), + ("l1100", "c2", 2, None, 80, True, False), + ("l1600", "c1", 3, None, 80, True, True), + ("l1600", "c2", 4, None, None, False, False), + ("l1250", "c1", 5, "12 3", None, None, False), + ] + for line, column, amount, text_value, confidence, recognized, modified in values: + await conn.execute( + text( + "INSERT INTO dkb.financial_statement_value (id, line_id, column_id, amount, string_value, confidence, " + "is_recognized, is_modified) VALUES (:id, :line, :column, :amount, :sv, :conf, :rec, :mod)" + ), + { + "id": uuid.uuid4(), + "line": ids[line], + "column": ids[column], + "amount": amount, + "sv": text_value, + "conf": confidence, + "rec": recognized, + "mod": modified, + }, + ) + await conn.execute( + text("INSERT INTO dkb.company_group (id, code, name, valid_from) VALUES (:grp, 'GK-1', 'ГК', :t0)"), + {**ids, "t0": t0}, + ) + await conn.execute( + text( + "INSERT INTO dkb.related_group (id, code, company_group_id, kind, name) " + "VALUES (:rg, 'GSK-1', :grp, 'gsk', 'ГСК')" + ), + ids, + ) + + +@pytest.mark.integration +async def test_upgrade_0007_backfills_v5_19_columns(scratch_db_url: str) -> None: + """0007: заполнение по умолчанию на данных v5.18 (row_index, needs_review, source_text, period_kind, header_source, + file_ordinal, crm_id); `document_issue` пуста.""" + engine = create_async_engine(scratch_db_url) + ids = { + k: uuid.uuid4() + for k in ( + "le", + "s3", + "file", + "job", + "income", + "balance", + "meta", + "c1", + "c2", + "l1600", + "l1100", + "l1250", + "grp", + "rg", + ) + } + try: + await alembic(scratch_db_url, "upgrade", V5_18) + async with engine.begin() as conn: + await _seed_v5_18_statements(conn, ids) + await alembic(scratch_db_url, "upgrade", "head") + async with engine.connect() as conn: + lines = ( + await conn.execute( + text( + "SELECT line_code, row_index, line_status::text, needs_review, is_confirmed " + "FROM dkb.financial_statement_line" + ) + ) + ).all() + rows = {r.line_code: r for r in lines} + 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()) + values = ( + await conn.execute( + text( + "SELECT v.needs_review, v.source_text, v.string_value, v.is_confirmed FROM " + "dkb.financial_statement_value v JOIN dkb.financial_statement_line l ON l.id = v.line_id " + "ORDER BY v.amount" + ) + ) + ).all() + assert [v.needs_review for v in values] == [False, True, False, True, False] + assert not any(v.is_confirmed for v in values) + value_by_text = {v.string_value: v for v in values if v.string_value} + assert value_by_text["12 3"].source_text == "12 3" + period_kind = dict( + (await conn.execute(text("SELECT column_index, period_kind FROM dkb.financial_statement_column"))).all() + ) + assert period_kind == {0: "absolute", 1: "none"} + meta = (await conn.execute(text("SELECT * FROM dkb.financial_statement_meta"))).one() + assert meta.form_variant is None and meta.needs_review_fields 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 + ordinals = dict( + (await conn.execute(text("SELECT document_type::text, file_ordinal FROM dkb.document"))).all() + ) + assert ordinals == {"INCOME": 0, "BALANCE": 1} + assert await _scalars(conn, "SELECT crm_id FROM dkb.related_group") == [None] + assert await _scalars(conn, "SELECT count(*) FROM dkb.document_issue") == [0] + finally: + await engine.dispose() + + +async def _insert_document(conn: AsyncConnection, ids: dict[str, uuid.UUID], doc_type: str, ordinal: int) -> None: + await conn.execute( + text( + "INSERT INTO dkb.document (id, stored_file_id, legal_entity_id, document_type, status, file_ordinal) " + f"VALUES (:id, :file, :le, '{doc_type}', 'NEEDS_CHECK', :ordinal)" + ), + {"id": uuid.uuid4(), "file": ids["file"], "le": ids["le"], "ordinal": ordinal}, + ) + + +async def _insert_line( + conn: AsyncConnection, ids: dict[str, uuid.UUID], code: str | None, source_ref: str | None +) -> None: + await conn.execute( + text( + "INSERT INTO dkb.financial_statement_line (id, meta_id, line_code, source_ref) " + "VALUES (:id, :meta, :code, :ref)" + ), + {"id": uuid.uuid4(), "meta": ids["meta"], "code": code, "ref": source_ref}, + ) + + +@pytest.mark.integration +async def test_v5_19_constraints(scratch_db_url: str) -> None: + """Поведение индексов v5.19: формы одного типа, одна расшифровка, повтор кода, `source_ref`, `file_ordinal`.""" + engine = create_async_engine(scratch_db_url) + ids = { + k: uuid.uuid4() + for k in ( + "le", + "s3", + "file", + "job", + "income", + "balance", + "meta", + "c1", + "c2", + "l1600", + "l1100", + "l1250", + "grp", + "rg", + ) + } + try: + await alembic(scratch_db_url, "upgrade", V5_18) + async with engine.begin() as conn: + await _seed_v5_18_statements(conn, ids) + await alembic(scratch_db_url, "upgrade", "head") + async with engine.begin() as conn: + await _insert_document(conn, ids, "BALANCE", 2) # вторая форма того же типа — можно + await _insert_document(conn, ids, "DISCLOSURE", 3) + await _insert_line(conn, ids, "1100", None) # повтор кода — можно + await _insert_line(conn, ids, None, None) + await _insert_line(conn, ids, None, None) # source_ref IS NULL — можно повторять + await _insert_line(conn, ids, "1500", "row:8") + with pytest.raises(IntegrityError): # вторая DISCLOSURE (uq_document_file_type_account) + async with engine.begin() as conn: + await _insert_document(conn, ids, "DISCLOSURE", 4) + with pytest.raises(IntegrityError): # два документа файла с одним file_ordinal + async with engine.begin() as conn: + await _insert_document(conn, ids, "OTHER", 3) + with pytest.raises(IntegrityError): # тот же source_ref в форме + async with engine.begin() as conn: + await _insert_line(conn, ids, "1501", "row:8") + finally: + await engine.dispose() + + @pytest.mark.integration async def test_upgrade_0005_adds_nullable_kpp_columns(scratch_db_url: str) -> None: """0005: `kpp varchar` nullable у `legal_entity` и `legal_entity_history`, существующие ЮЛ — с `kpp IS NULL`; @@ -883,8 +1132,15 @@ def test_offline_sql_creates_schema_before_version_table() -> None: assert schema_at < output.index("CREATE TABLE dkb.alembic_version") assert schema_at < output.index("CREATE TYPE dkb.entity_type AS ENUM ('ЮЛ', 'ИП')") assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{PREVIOUS}')" in output - # версия head пишется UPDATE'ами по цепочке 0001 → 0002 → 0003 → 0004 → 0005 → 0006 — офлайн-SQL доходит до head - for new_version, old_version in ((V5_14, PREVIOUS), (V5_15, V5_14), (V5_16, V5_15), (V5_17, V5_16), (HEAD, V5_17)): + # версия head пишется UPDATE'ами по цепочке 0001 → … → 0007 — офлайн-SQL доходит до head + for new_version, old_version in ( + (V5_14, PREVIOUS), + (V5_15, V5_14), + (V5_16, V5_15), + (V5_17, V5_16), + (V5_18, V5_17), + (HEAD, V5_18), + ): assert ( f"UPDATE dkb.alembic_version SET version_num='{new_version}' " f"WHERE dkb.alembic_version.version_num = '{old_version}'" in output diff --git a/api/tests/test_models_part_04_06.py b/api/tests/test_models_part_04_06.py index 99a029b..8688c4e 100644 --- a/api/tests/test_models_part_04_06.py +++ b/api/tests/test_models_part_04_06.py @@ -2,6 +2,7 @@ import datetime import decimal +import itertools import uuid import warnings from dataclasses import dataclass @@ -41,6 +42,7 @@ PART_04_06_TABLES = { "financial_statement_column", "financial_statement_line", "financial_statement_value", + "document_issue", } @@ -85,7 +87,7 @@ def test_fk_cycle_is_broken_by_use_alter_without_warning() -> None: def test_document_unique_index_is_nulls_not_distinct() -> None: - index = next(i for i in orm_tables()["document"].indexes if i.unique) + index = next(i for i in orm_tables()["document"].indexes if i.name == "uq_document_file_type_account") assert index.dialect_options["postgresql"]["nulls_not_distinct"] is True assert [c.name for c in index.columns] == ["stored_file_id", "document_type", "osv_account_code"] @@ -170,8 +172,12 @@ async def _make_file_with_job(session: AsyncSession) -> Chain: return Chain(entity_id=entity.id, file_id=file.id, job_id=job.id) +_FILE_ORDINALS = itertools.count() # v5.19: file_ordinal уникален в файле — тесту нужен любой свободный номер + + def _document(chain: Chain, document_type: DocumentType, account: str | None) -> Document: return Document( + file_ordinal=next(_FILE_ORDINALS), stored_file_id=chain.file_id, legal_entity_id=chain.entity_id, document_type=document_type, @@ -181,16 +187,18 @@ def _document(chain: Chain, document_type: DocumentType, account: str | None) -> async def _check_nulls_not_distinct(session: AsyncSession, chain: Chain) -> None: - """Два документа с одним (stored_file_id, document_type) и osv_account_code = NULL -> IntegrityError.""" - session.add(_document(chain, DocumentType.BALANCE, None)) + """v5.19: два документа с одним (stored_file_id, document_type) и osv_account_code = NULL — `IntegrityError` для + не-форм (DISCLOSURE); формы ББ/ОФР одного типа в файле допустимы (Р-10).""" + session.add(_document(chain, DocumentType.DISCLOSURE, None)) await session.flush() - session.add(_document(chain, DocumentType.BALANCE, None)) + session.add(_document(chain, DocumentType.DISCLOSURE, None)) with pytest.raises(IntegrityError): await session.flush() await session.rollback() - # разные счета ОСВ в одном файле и ББ/ОФР рядом — допустимы + # две формы ББ одного типа, разные счета ОСВ и ОФР рядом — допустимы session.add_all( - [_document(chain, DocumentType.OSV, "58"), _document(chain, DocumentType.OSV, "60")] + [_document(chain, DocumentType.BALANCE, None), _document(chain, DocumentType.BALANCE, None)] + + [_document(chain, DocumentType.OSV, "58"), _document(chain, DocumentType.OSV, "60")] + [_document(chain, DocumentType.INCOME, None)] ) await session.flush() diff --git a/api/tests/test_models_part_07_09.py b/api/tests/test_models_part_07_09.py index d3892a8..2e46db0 100644 --- a/api/tests/test_models_part_07_09.py +++ b/api/tests/test_models_part_07_09.py @@ -48,8 +48,8 @@ PART_07_09_TABLES = { "required_document_rule", } ALL_TABLES = PART_01_03_TABLES | PART_04_06_TABLES | PART_07_09_TABLES -EXPECTED_TABLE_COUNT = 43 -EXPECTED_ENUM_COUNT = 28 +EXPECTED_TABLE_COUNT = 44 +EXPECTED_ENUM_COUNT = 33 def test_part_07_09_tables_are_new() -> None: @@ -277,6 +277,10 @@ def test_all_nullable_jsonb_columns_are_listed() -> None: "recognition_job.group_schema", "recognition_job.metrics", "osv_entry.original_values", + "financial_statement_meta.needs_review_fields", + "financial_statement_meta.header_source", + "document_issue.line_ids", + "document_issue.codes", } -- 2.47.2 From 4f21160cf5ffb5d5febf3f420a2448d8b8388c29 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:34:09 +0400 Subject: [PATCH 03/20] =?UTF-8?q?feat(migrations):=20=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=20v5.19=20=D1=81=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D0=BE=D0=BC=20=D0=BD=D0=B0=20=D0=BD=D0=B5=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=B8=D0=BC=D1=8B=D1=85=20=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B8=20=D0=B6=D1=83=D1=80=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=BC=20=D0=BF=D0=BE=D1=82=D0=B5=D1=80=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- api/alembic/versions/0007_dbml_v5_19.py | 115 ++++++++++++++++- api/tests/test_migrations.py | 156 ++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 3 deletions(-) diff --git a/api/alembic/versions/0007_dbml_v5_19.py b/api/alembic/versions/0007_dbml_v5_19.py index 3abb319..c293f8d 100644 --- a/api/alembic/versions/0007_dbml_v5_19.py +++ b/api/alembic/versions/0007_dbml_v5_19.py @@ -11,13 +11,15 @@ - `header_source` — из `*_original_value` и `inn_confidence` (P3-1), `organization_name_original_value` = `organization_name`, `organization_name_modified` = false (P3-2); `form_variant` = NULL (набор §7 — полный); - `file_ordinal` — по `(created_at, id)` внутри файла с 0. -Уникальные индексы создаются после заполнения. Откат (`downgrade`) — задача A2 плана 3. +Уникальные индексы создаются после заполнения. Откат: сперва проверки (повтор/пустой код строки, две формы типа +в файле, `crm_id`, правка наименования — отказ `RuntimeError`), затем журнал потерь и DDL. Revision ID: 0007_dbml_v5_19 Revises: 0006_dbml_v5_18 Create Date: 2026-10-18 """ +import logging from collections.abc import Sequence from alembic import op @@ -187,6 +189,113 @@ def upgrade() -> None: _create_document_issue() +# (причина отказа, SQL подсчёта) — порядок = порядок в сообщении +_REFUSALS: tuple[tuple[str, str], ...] = ( + ( + "повтор кода строки в форме", + f"SELECT count(*) FROM (SELECT 1 FROM {S}.financial_statement_line WHERE line_code IS NOT NULL " + "GROUP BY meta_id, line_code HAVING count(*) > 1) t", + ), + ("строки формы без кода", f"SELECT count(*) FROM {S}.financial_statement_line WHERE line_code IS NULL"), + ( + "две формы одного типа в файле", + f"SELECT count(*) FROM (SELECT 1 FROM {S}.document WHERE document_type IN ('BALANCE', 'INCOME') " + "GROUP BY stored_file_id, document_type HAVING count(*) > 1) t", + ), + ("crm_id ГСК/ГСЗ задан", f"SELECT count(*) FROM {S}.related_group WHERE crm_id IS NOT NULL"), + ( + "наименование организации правилось", + f"SELECT count(*) FROM {S}.financial_statement_meta WHERE organization_name_modified IS TRUE", + ), +) + +# (таблица, колонка, условие «непусто») — колонки v5.19, значения которых откат теряет +_LOST_COLUMNS: tuple[tuple[str, str, str], ...] = ( + ("financial_statement_line", "row_index", "IS NOT NULL"), + ("financial_statement_line", "source_ref", "IS NOT NULL"), + ("financial_statement_line", "line_status", "<> 'ok'"), + ("financial_statement_line", "line_code_text", "IS NOT NULL"), + ("financial_statement_line", "parent_row_index", "IS NOT NULL"), + ("financial_statement_line", "needs_review", "IS TRUE"), + ("financial_statement_line", "is_confirmed", "IS TRUE"), + ("financial_statement_value", "source_text", "IS NOT NULL"), + ("financial_statement_value", "needs_review", "IS TRUE"), + ("financial_statement_value", "is_confirmed", "IS TRUE"), + ("financial_statement_value", "confirmed_by", "IS NOT NULL"), + ("financial_statement_value", "confirmed_at", "IS NOT NULL"), + ("financial_statement_column", "period_kind", "IS NOT NULL"), + ("financial_statement_column", "label_confidence", "IS NOT NULL"), + ("financial_statement_meta", "form_variant", "IS NOT NULL"), + ("financial_statement_meta", "needs_review_fields", "IS NOT NULL"), + ("financial_statement_meta", "header_source", "IS NOT NULL"), + ("financial_statement_meta", "organization_name_original_value", "IS NOT NULL"), + ("financial_statement_meta", "organization_name_modified", "IS TRUE"), + ("document", "file_ordinal", "<> 0"), + ("recognition_job", "statements_version", "IS NOT NULL"), + ("related_group", "crm_id", "IS NOT NULL"), +) + + +def _scalar(sql: str) -> int: + return int(op.get_bind().exec_driver_sql(sql).scalar_one()) + + +def _check_downgrade_allowed() -> None: + """Все проверки — до любого DDL: несовместимые данные v5.18 не вместить, откат отказывает целиком.""" + problems = [f"{reason}: {count}" for reason, sql in _REFUSALS if (count := _scalar(sql))] + if problems: + raise RuntimeError("откат v5.19 невозможен: " + "; ".join(problems)) + + +def _log_lost_values() -> None: + lost = [ + f"{table}.{column}: {count}" + for table, column, condition in _LOST_COLUMNS + if (count := _scalar(f"SELECT count(*) FROM {S}.{table} WHERE {column} {condition}")) + ] + if count := _scalar(f"SELECT count(*) FROM {S}.document_issue"): + lost.append(f"document_issue: {count}") + if lost: + logging.getLogger("alembic.runtime.migration").warning( + "откат v5.19: удалены непустые значения — %s", "; ".join(lost) + ) + + def downgrade() -> None: - # откат (охранные проверки, потеря данных, журнал) — задача A2 плана 3 - raise NotImplementedError("downgrade 0007_dbml_v5_19 ещё не реализован (задача A2 плана 3)") + _check_downgrade_allowed() + _log_lost_values() + op.execute(f"DROP TABLE {S}.document_issue") + for index in ( + "uq_document_stored_file_id_file_ordinal", + "uq_financial_statement_line_meta_id_source_ref", + "ix_financial_statement_line_meta_id_line_code", + "uq_document_file_type_account", + ): + op.execute(f"DROP INDEX {S}.{index}") + op.execute( + "CREATE UNIQUE INDEX uq_financial_statement_line_meta_id_line_code " + f"ON {S}.financial_statement_line (meta_id, line_code)" + ) + op.execute( + "CREATE UNIQUE INDEX uq_document_file_type_account " + f"ON {S}.document (stored_file_id, document_type, osv_account_code) NULLS NOT DISTINCT" + ) + drops = { + "financial_statement_line": ( + "row_index", "source_ref", "line_status", "line_code_text", "parent_row_index", + "needs_review", "is_confirmed", + ), + "financial_statement_value": ("source_text", "needs_review", "is_confirmed", "confirmed_by", "confirmed_at"), + "financial_statement_column": ("period_kind", "label_confidence"), + "financial_statement_meta": ( + "form_variant", "needs_review_fields", "header_source", "organization_name_original_value", + "organization_name_modified", + ), + "document": ("file_ordinal",), + "recognition_job": ("statements_version",), + "related_group": ("crm_id",), + } # fmt: skip + for table, columns in drops.items(): + op.execute(f"ALTER TABLE {S}.{table} " + ", ".join(f"DROP COLUMN {column}" for column in columns)) + for name in reversed(ENUMS_V5_19): + op.execute(f"DROP TYPE {S}.{name}") diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 0faed3f..82aca1d 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -869,6 +869,162 @@ async def test_v5_19_constraints(scratch_db_url: str) -> None: await engine.dispose() +_INSERT_2000 = ( + "INSERT INTO dkb.financial_statement_line (id, meta_id, line_code) VALUES (gen_random_uuid(), :meta, '2000')" +) +_SEED_KEYS = ( + "le", + "s3", + "file", + "job", + "income", + "balance", + "meta", + "c1", + "c2", + "l1600", + "l1100", + "l1250", + "grp", + "rg", +) + + +async def _count(conn: AsyncConnection, table: str) -> int: + return (await _scalars(conn, f"SELECT count(*) FROM dkb.{table}"))[0] + + +async def _expect_downgrade_0007_refused(scratch_db_url: str, mutate_sql: str, reason: str) -> None: + """Данные v5.19, несовместимые с v5.18: откат отказывает целиком, ревизия и данные не меняются.""" + engine = create_async_engine(scratch_db_url) + ids = {k: uuid.uuid4() for k in _SEED_KEYS} + try: + await alembic(scratch_db_url, "upgrade", V5_18) + async with engine.begin() as conn: + await _seed_v5_18_statements(conn, ids) + await alembic(scratch_db_url, "upgrade", "head") + async with engine.begin() as conn: + await conn.execute(text(mutate_sql.format(**{k: str(v) for k, v in ids.items()}))) + lines_before = await _count(conn, "financial_statement_line") + documents_before = await _count(conn, "document") + with pytest.raises(AssertionError) as error: + await alembic(scratch_db_url, "downgrade", V5_18) + assert f"откат v5.19 невозможен: {reason}: 1" in str(error.value) + async with engine.connect() as conn: + assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == ["0007_dbml_v5_19"] + assert await _count(conn, "financial_statement_line") == lines_before # данные не изменены + assert await _count(conn, "document") == documents_before + assert await _scalars( + conn, "SELECT count(*) FROM information_schema.tables WHERE table_name = 'document_issue'" + ) == [1] + finally: + await engine.dispose() + + +@pytest.mark.integration +async def test_downgrade_0007_refuses_duplicate_code(scratch_db_url: str) -> None: + await _expect_downgrade_0007_refused( + scratch_db_url, + "INSERT INTO dkb.financial_statement_line (id, meta_id, line_code) " + "VALUES (gen_random_uuid(), '{meta}', '1100')", + "повтор кода строки в форме", + ) + + +@pytest.mark.integration +async def test_downgrade_0007_refuses_null_code(scratch_db_url: str) -> None: + await _expect_downgrade_0007_refused( + scratch_db_url, + "INSERT INTO dkb.financial_statement_line (id, meta_id, line_code, line_status) " + "VALUES (gen_random_uuid(), '{meta}', NULL, 'unreadable')", + "строки формы без кода", + ) + + +@pytest.mark.integration +async def test_downgrade_0007_refuses_two_forms_of_type(scratch_db_url: str) -> None: + await _expect_downgrade_0007_refused( + scratch_db_url, + "INSERT INTO dkb.document (id, stored_file_id, legal_entity_id, document_type, status, file_ordinal) " + "VALUES (gen_random_uuid(), '{file}', '{le}', 'BALANCE', 'NEEDS_CHECK', 2)", + "две формы одного типа в файле", + ) + + +@pytest.mark.integration +async def test_downgrade_0007_refuses_related_group_crm_id(scratch_db_url: str) -> None: + await _expect_downgrade_0007_refused( + scratch_db_url, "UPDATE dkb.related_group SET crm_id = 'CRM-1' WHERE id = '{rg}'", "crm_id ГСК/ГСЗ задан" + ) + + +@pytest.mark.integration +async def test_downgrade_0007_refuses_modified_organization_name(scratch_db_url: str) -> None: + await _expect_downgrade_0007_refused( + scratch_db_url, + "UPDATE dkb.financial_statement_meta SET organization_name_modified = true WHERE id = '{meta}'", + "наименование организации правилось", + ) + + +@pytest.mark.integration +async def test_downgrade_0007_drops_signals_and_logs_counts(scratch_db_url: str) -> None: + """Без несовместимых данных откат проходит, журнал называет потерянное; цикл замыкается.""" + engine = create_async_engine(scratch_db_url) + ids = {k: uuid.uuid4() for k in _SEED_KEYS} + try: + await alembic(scratch_db_url, "upgrade", V5_18) + async with engine.begin() as conn: + await _seed_v5_18_statements(conn, ids) + await alembic(scratch_db_url, "upgrade", "head") + async with engine.begin() as conn: + await conn.execute( + text("UPDATE dkb.financial_statement_value SET is_confirmed = true WHERE amount IN (1, 2)") + ) + await conn.execute( + text( + "INSERT INTO dkb.document_issue (id, document_id, kind, code, severity, message, source, " + "created_at) " + "VALUES (gen_random_uuid(), :balance, 'warning', 'X', 'info', 'm', 'core', now())" + ), + ids, + ) + output = await alembic(scratch_db_url, "downgrade", V5_18) + assert "откат v5.19: удалены непустые значения" in output + assert "financial_statement_value.is_confirmed: 2" in output + assert "document_issue: 1" in output + async with engine.connect() as conn: + assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [V5_18] + assert await _scalars( + conn, + "SELECT count(*) FROM information_schema.columns WHERE table_schema = 'dkb' AND " + "((table_name = 'financial_statement_line' " + "AND column_name IN ('row_index', 'source_ref', 'is_confirmed')) " + "OR (table_name = 'document' AND column_name = 'file_ordinal') " + "OR (table_name = 'related_group' AND column_name = 'crm_id'))", + ) == [0] + assert await _scalars( + conn, "SELECT count(*) FROM information_schema.tables WHERE table_name = 'document_issue'" + ) == [0] + assert await _scalars(conn, "SELECT count(*) FROM pg_type WHERE typname = 'document_issue_kind'") == [0] + await engine.dispose() + async with engine.begin() as conn: # уникальность (meta_id, line_code) вернулась + await conn.execute( + text(_INSERT_2000), + ids, + ) + with pytest.raises(IntegrityError): + async with engine.begin() as conn: + await conn.execute( + text(_INSERT_2000), + ids, + ) + await engine.dispose() + await alembic(scratch_db_url, "upgrade", "head") + finally: + await engine.dispose() + + @pytest.mark.integration async def test_upgrade_0005_adds_nullable_kpp_columns(scratch_db_url: str) -> None: """0005: `kpp varchar` nullable у `legal_entity` и `legal_entity_history`, существующие ЮЛ — с `kpp IS NULL`; -- 2.47.2 From b7f45c6a478e0447467e57648b188adc22a2cda5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:39:21 +0400 Subject: [PATCH 04/20] =?UTF-8?q?feat(directories):=20crm=5Fid=20=D0=93?= =?UTF-8?q?=D0=A1=D0=9A/=D0=93=D0=A1=D0=97=20=D1=81=D0=BE=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D1=8F=D0=B5=D1=82=D1=81=D1=8F,=20409=20CRM=5FID=5F?= =?UTF-8?q?EXISTS=20(arm-core#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crm_id пишется в колонку related_group.crm_id (v5.19), попадает в ответы и в payload аудита; занятый значением другой ГСК/ГСЗ даёт 409 CRM_ID_EXISTS (pre-check и uq_related_group_crm_id на гонку). Смена crm_id пишется как GROUP_CHANGE с before/after всех изменённых полей (Р-41, P3-18). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- README.md | 4 +- api/src/common/constraint_conflicts.py | 1 + api/src/repositories/directories.py | 12 ++- api/src/schemas/directories.py | 6 +- api/src/services/related_groups.py | 20 ++++- api/tests/test_api_related_group_crm_id.py | 92 ++++++++++++++-------- api/tests/test_api_related_groups.py | 1 + api/tests/test_exception_handlers.py | 1 + 8 files changed, 95 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index b4dc3d7..b82e3e8 100644 --- a/README.md +++ b/README.md @@ -830,7 +830,7 @@ API: список счетов `GET /directories/accounts`, правила ве **Гонки уникальных ограничений.** Если параллельный запрос успел вставить запись между проверкой сервиса и INSERT, БД отклоняет вставку (`unique_violation`), и ответ тот же 409 с тем же кодом, что у проверки (реестр — -`src/common/constraint_conflicts.py`): `CRM_ID_EXISTS` (ГК и ЮЛ), `INN_EXISTS`, `ALIAS_EXISTS`, `EMAIL_EXISTS`, +`src/common/constraint_conflicts.py`): `CRM_ID_EXISTS` (ГК, ЮЛ и ГСК/ГСЗ), `INN_EXISTS`, `ALIAS_EXISTS`, `EMAIL_EXISTS`, `MEMBER_EXISTS`/`MEMBER_OF_OTHER_GROUP`. В теле гонки — только код и постоянный текст, без дополнительных полей (`legal_entity_id`, `related_group_id`) и без значений ключей. Особенности: повторное добавление в ту же группу отдаёт `MEMBER_OF_OTHER_GROUP` с нейтральным текстом «ЮЛ уже входит в группу этого вида» (его раньше срабатывает индекс @@ -853,7 +853,7 @@ email без учёта регистра проверяет только сер | `WRONG_GROUP_KIND` | 422 | в `gsk_id` формы ЮЛ передана ГСЗ или в `gsz_id` — ГСК (`POST`/`PATCH /directories/legal-entities`) | | `UNKNOWN_ROLE` / `UNKNOWN_PERMISSION` | 422 | неизвестная или неактивная роль / неизвестный код права (поля `roles` / `permissions`) | | `MISSING_COLUMNS` / `UNSUPPORTED_FILE` | 422 | файл импорта целиком (`missing` — перечень колонок) | -| `CRM_ID_EXISTS` | 409 | ГК или ЮЛ с таким `crm_id` уже есть | +| `CRM_ID_EXISTS` | 409 | ГК, ЮЛ или ГСК/ГСЗ с таким `crm_id` уже есть | | `INN_EXISTS` | 409 | ЮЛ с таким ИНН уже есть (без дополнительных полей) | | `LEGAL_ENTITY_CLOSED` | 409 | перевод закрытого ЮЛ | | `SAME_COMPANY_GROUP` | 409 | перевод в ту же ГК | diff --git a/api/src/common/constraint_conflicts.py b/api/src/common/constraint_conflicts.py index 0c8a015..6fc9448 100644 --- a/api/src/common/constraint_conflicts.py +++ b/api/src/common/constraint_conflicts.py @@ -31,6 +31,7 @@ class ConstraintConflict: UNIQUE_CONSTRAINT_CONFLICTS: dict[str, ConstraintConflict] = { "uq_company_group_crm_id": ConstraintConflict("CRM_ID_EXISTS", "ГК с таким crm_id уже существует"), "uq_legal_entity_crm_id": ConstraintConflict("CRM_ID_EXISTS", "ЮЛ с таким crm_id уже существует"), + "uq_related_group_crm_id": ConstraintConflict("CRM_ID_EXISTS", "ГСК/ГСЗ с таким crm_id уже существует"), "uq_legal_entity_inn": ConstraintConflict("INN_EXISTS", "ЮЛ с таким ИНН уже существует"), "uq_legal_entity_alias_legal_entity_id_normalized_alias": ConstraintConflict( "ALIAS_EXISTS", "У этого ЮЛ уже есть такой алиас" diff --git a/api/src/repositories/directories.py b/api/src/repositories/directories.py index a475b57..a745220 100644 --- a/api/src/repositories/directories.py +++ b/api/src/repositories/directories.py @@ -101,13 +101,23 @@ async def get_company_group(session: AsyncSession, scope: VisibilityScope, group async def crm_id_taken(session: AsyncSession, crm_id: str, *, except_group_id: uuid.UUID | None = None) -> bool: - """Есть ли у какой-либо ГК (кроме `except_group_id`) такой `crm_id` — без фильтра видимости: уникален глобально.""" + """Есть ли у какой-либо ГК (кроме `except_group_id`) такой `crm_id`; без фильтра видимости (уникален глобально).""" stmt = select(CompanyGroup.id).where(CompanyGroup.crm_id == crm_id) if except_group_id is not None: stmt = stmt.where(CompanyGroup.id != except_group_id) return (await session.execute(stmt.limit(1))).first() is not None +async def related_group_crm_id_taken( + session: AsyncSession, crm_id: str, *, except_group_id: uuid.UUID | None = None +) -> bool: + """Есть ли у ГСК/ГСЗ (кроме `except_group_id`) такой `crm_id`; без фильтра видимости (уникален глобально).""" + stmt = select(RelatedGroup.id).where(RelatedGroup.crm_id == crm_id) + if except_group_id is not None: + stmt = stmt.where(RelatedGroup.id != except_group_id) + return (await session.execute(stmt.limit(1))).first() is not None + + async def count_legal_entities(session: AsyncSession, group_id: uuid.UUID) -> int: """Число ЮЛ/ИП с `company_group_id = group_id`.""" stmt = select(func.count(LegalEntity.id)).where(LegalEntity.company_group_id == group_id) diff --git a/api/src/schemas/directories.py b/api/src/schemas/directories.py index a376362..39636dd 100644 --- a/api/src/schemas/directories.py +++ b/api/src/schemas/directories.py @@ -363,7 +363,7 @@ class LegalEntityFilter(BaseModel): class RelatedGroupCreate(BaseModel): """Создание ГСК/ГСЗ в ГК: `code` (`GSK-`/`GSZ-NNNNNN`) назначает сервис. - `crm_id` («ID ГСК/ГСЗ в CRM» форм 51-03/51-04) принимается и отбрасывается: колонки в DBML нет (arm-core #112). + `crm_id` («ID ГСК/ГСЗ в CRM» форм 51-03/51-04, v5.19) сохраняется; пустая строка — «нет значения». """ model_config = ConfigDict(extra="forbid") @@ -387,7 +387,7 @@ class RelatedGroupCreate(BaseModel): class RelatedGroupUpdate(BaseModel): """Изменение ГСК/ГСЗ: `name`, `created_date` (вид и ГК не меняются); переданные поля — `model_fields_set`. - `crm_id` (форма 77-02) принимается и отбрасывается, как при создании (arm-core #112). + `crm_id` (форма 77-02) сохраняется; явный `null` очищает значение. """ model_config = ConfigDict(extra="forbid") @@ -422,7 +422,7 @@ class MemberAdd(BaseModel): class RelatedGroupOut(BaseModel): - """ГСК/ГСЗ; `members_count` — число текущих членов; `crm_id` — всегда `null`, пока нет колонки (arm-core #112).""" + """ГСК/ГСЗ; `members_count` — число текущих членов; `crm_id` — из БД (v5.19).""" model_config = ConfigDict(from_attributes=True) diff --git a/api/src/services/related_groups.py b/api/src/services/related_groups.py index bfd37b4..3aab025 100644 --- a/api/src/services/related_groups.py +++ b/api/src/services/related_groups.py @@ -35,7 +35,7 @@ MEMBERSHIP_NOT_FOUND = "ЮЛ не входит в эту группу" OBJECT_TYPE = "related_group" MEMBER_OBJECT_TYPE = "related_group_member" CODE_PREFIXES = {RelatedGroupKind.gsk: "GSK", RelatedGroupKind.gsz: "GSZ"} -TRACKED_FIELDS = ("name", "created_date") # поля группы, изменяемые PATCH +TRACKED_FIELDS = ("name", "created_date", "crm_id") # поля группы, изменяемые PATCH def member_payload(member: RelatedGroupMember) -> dict[str, Any]: @@ -115,12 +115,15 @@ class RelatedGroupService: async def create(self, company_group_id: uuid.UUID, data: RelatedGroupCreate) -> RelatedGroup: """Новая ГСК/ГСЗ видимой ГК с кодом `GSK-`/`GSZ-NNNNNN`; аудит `MEMBERSHIP_CHANGE`.""" company_group = await self._visible_company_group(company_group_id) + if data.crm_id is not None: + await self._check_crm_id_free(data.crm_id) group = RelatedGroup( code=await repo.next_code(self._session, RELATED_GROUP_CODE_SEQ, CODE_PREFIXES[data.kind]), company_group_id=company_group.id, kind=data.kind, name=data.name, created_date=data.created_date, + crm_id=data.crm_id, ) self._session.add(group) await self._session.flush() @@ -134,7 +137,11 @@ class RelatedGroupService: return group async def update(self, group_id: uuid.UUID, data: RelatedGroupUpdate) -> RelatedGroup: - """Изменение `name`/`created_date`; без фактических изменений ничего не пишется.""" + """Изменение `name`/`created_date`/`crm_id`; без фактических изменений ничего не пишется. + + Смена `crm_id` пишется как изменение группы — `GROUP_CHANGE` с `before`/`after` всех изменённых полей + (Р-41, P3-18); прочие правки — `MEMBERSHIP_CHANGE`, как раньше. + """ group = await self._get_group(group_id) changes: dict[str, Any] = { field: getattr(data, field) @@ -143,12 +150,14 @@ class RelatedGroupService: } if not changes: return group + if changes.get("crm_id") is not None: + await self._check_crm_id_free(changes["crm_id"], except_group_id=group.id) before = {field: getattr(group, field) for field in changes} for field, value in changes.items(): setattr(group, field, value) await self._session.flush() await self._audit.record( - action=AuditAction.MEMBERSHIP_CHANGE, + action=AuditAction.GROUP_CHANGE if "crm_id" in changes else AuditAction.MEMBERSHIP_CHANGE, section=AuditSection.DIRECTORIES, object_type=OBJECT_TYPE, object_id=group.id, @@ -249,4 +258,9 @@ class RelatedGroupService: "kind": group.kind, "name": group.name, "created_date": group.created_date, + "crm_id": group.crm_id, } + + async def _check_crm_id_free(self, crm_id: str, *, except_group_id: uuid.UUID | None = None) -> None: + if await repo.related_group_crm_id_taken(self._session, crm_id, except_group_id=except_group_id): + raise ConflictError("CRM_ID_EXISTS", "ГСК/ГСЗ с таким crm_id уже существует") diff --git a/api/tests/test_api_related_group_crm_id.py b/api/tests/test_api_related_group_crm_id.py index 191cf14..29de80a 100644 --- a/api/tests/test_api_related_group_crm_id.py +++ b/api/tests/test_api_related_group_crm_id.py @@ -1,8 +1,4 @@ -"""`crm_id` ГСК/ГСЗ в API без колонки в БД (arm-core #111; колонка — #112). - -Формы 51-03 / 51-04 / 77-02 шлют «ID ГСК/ГСЗ в CRM»: POST/PATCH его принимают, значение отбрасывается, -в ответах `crm_id` всегда `null`, в аудит не попадает. -""" +"""`crm_id` ГСК/ГСЗ (v5.19, arm-core #112): сохраняется, уникален, смена пишется в аудит как `GROUP_CHANGE` (Р-41).""" from collections.abc import Sequence from typing import Any @@ -19,16 +15,16 @@ from tests.factories import make_company_group HTTP_OK = 200 HTTP_CREATED = 201 +HTTP_CONFLICT = 409 HTTP_UNPROCESSABLE = 422 GROUPS = "/directories/company-groups" RELATED = "/directories/related-groups" GO = "ka_go@armka.local" +CONFLICT_DETAIL = {"code": "CRM_ID_EXISTS", "message": "ГСК/ГСЗ с таким crm_id уже существует"} -async def _events(session: AsyncSession) -> Sequence[AuditEvent]: - stmt = select(AuditEvent).where( - AuditEvent.action == AuditAction.MEMBERSHIP_CHANGE, AuditEvent.object_type == "related_group" - ) +async def _events(session: AsyncSession, action: AuditAction) -> Sequence[AuditEvent]: + stmt = select(AuditEvent).where(AuditEvent.action == action, AuditEvent.object_type == "related_group") return (await session.execute(stmt.order_by(AuditEvent.occurred_at, AuditEvent.id))).scalars().all() @@ -37,49 +33,79 @@ async def _create(client: httpx.AsyncClient, group_id: Any, **extra: Any) -> htt @pytest.mark.integration -async def test_create_accepts_crm_id_and_drops_it(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: +async def test_create_related_group_stores_crm_id(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: group = await make_company_group(seeded) client = await auth_client(GO) - response = await _create(client, group.id, crm_id=" 3-9CGSK01 ") + response = await _create(client, group.id, crm_id=" 51-03-0001 ") assert response.status_code == HTTP_CREATED, response.text body = response.json() - assert body["crm_id"] is None - assert body["name"] == "ГСК А" - events = await _events(seeded) - assert len(events) == 1 - assert "crm_id" not in events[0].after_value + assert body["crm_id"] == "51-03-0001" + assert (await client.get(f"{RELATED}/{body['id']}")).json()["crm_id"] == "51-03-0001" + listed = await client.get(f"{GROUPS}/{group.id}/related-groups") + assert [row["crm_id"] for row in listed.json()] == ["51-03-0001"] + (event,) = await _events(seeded, AuditAction.MEMBERSHIP_CHANGE) + assert event.after_value["crm_id"] == "51-03-0001" for value in ("", None): - assert (await _create(client, group.id, crm_id=value)).status_code == HTTP_CREATED + created = await _create(client, group.id, crm_id=value) + assert created.status_code == HTTP_CREATED + assert created.json()["crm_id"] is None assert (await _create(client, group.id, crm_id=123)).status_code == HTTP_UNPROCESSABLE @pytest.mark.integration -async def test_patch_accepts_crm_id_without_changes(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: +async def test_patch_related_group_crm_id_audited(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: group = await make_company_group(seeded) client = await auth_client(GO) gsk = (await _create(client, group.id)).json() - only_crm = await client.patch(f"{RELATED}/{gsk['id']}", json={"crm_id": "3-9CGSK01"}) - assert only_crm.status_code == HTTP_OK, only_crm.text - assert only_crm.json()["crm_id"] is None - assert len(await _events(seeded)) == 1 # только создание: crm_id не изменение + response = await client.patch(f"{RELATED}/{gsk['id']}", json={"crm_id": "77-02-0002", "name": "ГСК Б"}) + assert response.status_code == HTTP_OK, response.text + assert response.json()["crm_id"] == "77-02-0002" + (event,) = await _events(seeded, AuditAction.GROUP_CHANGE) + assert event.before_value["crm_id"] is None + assert event.after_value == {"crm_id": "77-02-0002", "name": "ГСК Б"} + assert event.before_value["name"] == "ГСК А" - with_name = await client.patch(f"{RELATED}/{gsk['id']}", json={"crm_id": None, "name": "ГСК Б"}) - assert with_name.status_code == HTTP_OK, with_name.text - assert (with_name.json()["name"], with_name.json()["crm_id"]) == ("ГСК Б", None) - events = await _events(seeded) - assert len(events) == 2 # noqa: PLR2004 - создание и смена имени - assert events[1].after_value == {"name": "ГСК Б"} + # смена без crm_id остаётся изменением состава/реквизитов прежнего вида + await client.patch(f"{RELATED}/{gsk['id']}", json={"name": "ГСК В"}) + assert len(await _events(seeded, AuditAction.GROUP_CHANGE)) == 1 @pytest.mark.integration -async def test_list_and_detail_return_null_crm_id(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: +async def test_crm_id_taken_is_409(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group = await make_company_group(seeded) + other = await make_company_group(seeded) + client = await auth_client(GO) + assert (await _create(client, group.id, crm_id="77-02-0002")).status_code == HTTP_CREATED + + same_group = await _create(client, group.id, crm_id="77-02-0002") + other_group = await _create(client, other.id, crm_id="77-02-0002", kind="gsz") + for response in (same_group, other_group): + assert response.status_code == HTTP_CONFLICT, response.text + assert response.json()["detail"] == CONFLICT_DETAIL + assert len((await client.get(f"{GROUPS}/{group.id}/related-groups")).json()) == 1 + assert (await client.get(f"{GROUPS}/{other.id}/related-groups")).json() == [] + + second = (await _create(client, group.id, crm_id="77-02-0003")).json() + patched = await client.patch(f"{RELATED}/{second['id']}", json={"crm_id": "77-02-0002"}) + assert patched.status_code == HTTP_CONFLICT + assert patched.json()["detail"] == CONFLICT_DETAIL + # своё же значение — не конфликт + same = await client.patch(f"{RELATED}/{second['id']}", json={"crm_id": "77-02-0003"}) + assert same.status_code == HTTP_OK + + +@pytest.mark.integration +async def test_patch_crm_id_null_clears(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: group = await make_company_group(seeded) client = await auth_client(GO) - gsk = (await _create(client, group.id, crm_id="3-9CGSK01")).json() + gsk = (await _create(client, group.id, crm_id="51-03-0001")).json() - listed = await client.get(f"{GROUPS}/{group.id}/related-groups") - assert [row["crm_id"] for row in listed.json()] == [None] - assert (await client.get(f"{RELATED}/{gsk['id']}")).json()["crm_id"] is None + response = await client.patch(f"{RELATED}/{gsk['id']}", json={"crm_id": None}) + assert response.status_code == HTTP_OK, response.text + assert response.json()["crm_id"] is None + (event,) = await _events(seeded, AuditAction.GROUP_CHANGE) + assert event.before_value == {"crm_id": "51-03-0001"} + assert event.after_value == {"crm_id": None} diff --git a/api/tests/test_api_related_groups.py b/api/tests/test_api_related_groups.py index d6d2257..f00e98e 100644 --- a/api/tests/test_api_related_groups.py +++ b/api/tests/test_api_related_groups.py @@ -98,6 +98,7 @@ async def test_create_gsk_and_gsz_codes_and_audit(auth_client: AuthClientFactory "kind": "gsk", "name": "ГСК Бисквит", "created_date": "2025-01-15", + "crm_id": None, } assert first.user_id is not None diff --git a/api/tests/test_exception_handlers.py b/api/tests/test_exception_handlers.py index 48c1480..fbe5cdb 100644 --- a/api/tests/test_exception_handlers.py +++ b/api/tests/test_exception_handlers.py @@ -180,6 +180,7 @@ def test_registry_codes_and_messages_match_service_precheck_texts() -> None: assert codes == { "uq_company_group_crm_id": "CRM_ID_EXISTS", "uq_legal_entity_crm_id": "CRM_ID_EXISTS", + "uq_related_group_crm_id": "CRM_ID_EXISTS", "uq_legal_entity_inn": "INN_EXISTS", "uq_legal_entity_alias_legal_entity_id_normalized_alias": "ALIAS_EXISTS", "uq_app_user_email": "EMAIL_EXISTS", -- 2.47.2 From 782580beb40b331328d2ece352bca8a46882ad81 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:44:37 +0400 Subject: [PATCH 05/20] =?UTF-8?q?feat(scripts):=20purge=5Fstatements=20?= =?UTF-8?q?=E2=80=94=20=D0=BE=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=20=D0=91=D0=91/=D0=9E=D0=A4=D0=A0=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B4=20=D0=B2=D1=8B=D0=BA=D0=BB=D0=B0=D0=B4?= =?UTF-8?q?=D0=BA=D0=BE=D0=B9=20v5.19=20(=D0=A0-24,=20=D0=A0-27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- README.md | 26 ++ api/scripts/purge_statements.py | 334 +++++++++++++++++++++ api/src/repositories/statements.py | 11 + api/tests/test_purge_statements.py | 460 +++++++++++++++++++++++++++++ 4 files changed, 831 insertions(+) create mode 100644 api/scripts/purge_statements.py create mode 100644 api/tests/test_purge_statements.py diff --git a/README.md b/README.md index b82e3e8..f1927c5 100644 --- a/README.md +++ b/README.md @@ -614,6 +614,32 @@ advisory-блокировкой. (физически объект не удаляется — ретеншн OQ-22, OQ-111) и пишет `FILE_DELETE` с карточкой файла в `before_value`. Нельзя удалить файл с документом, подтверждённым пользователем (`confirmed_by` задан), и с автоподтверждённым документом, привязанным к версии вне статусов «в работе» `INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION` (`409 FILE_HAS_CONFIRMED_DOCUMENTS`); автоподтверждённые привязки к версиям «в работе» снимаются (`FILE_DELETE.after_value.unbound_version_ids`, OQ-157). Нельзя также удалить файл, который заменил другие (`409 FILE_SUPERSEDES_OTHERS`); `SUPERSEDED` файл удалить можно. Блокировки — в порядке «задания → файл → документы → advisory ГК → версии по id». +## Выкладка v5.19: очистка форм + +Перед `alembic upgrade` v5.19 на контуре (после резервной копии БД) база очищается от форм ББ/ОФР — они пересоздаются +повторной загрузкой по новым правилам (Р-24). Запускает владелец или платформенная команда по его заявке; на стендах +разработчиков — по желанию. Из `api/`, одна транзакция: + +``` +.venv/bin/python -m scripts.purge_statements # --dry-run (по умолчанию): только отчёт +.venv/bin/python -m scripts.purge_statements --apply # очистка +.venv/bin/python -m scripts.purge_statements --apply --include-mixed # смешанные файлы удалить целиком +``` + +Отчёт: число файлов только с формами, смешанных файлов (есть и ОСВ, и формы; их повторная загрузка невозможна из-за +дедупа по `sha256`, поэтому владелец решает до `--apply`, нужен ли `--include-mixed`), документов форм, подтверждённых +документов, привязок к неверифицированным версиям, файлов с ОСВ, заменённых удаляемыми. + +- Файл только с формами удаляется целиком, как `DELETE /files/{id}`, но без запрета `FILE_HAS_CONFIRMED_DOCUMENTS`: + привязки снимаются (статус версий не меняется), `superseded_by_file_id` ссылающихся файлов обнуляется, объектам S3 + ставится `expires_at`. У смешанного файла удаляются только `BALANCE` / `INCOME` с формами, комментариями и привязками. +- Отказ без изменений — только если документ упомянут в `ex_snapshot_item` или привязан к версии `VERIFIED` либо + `ARCHIVED` со срезом ЕХ (Р-35); отчёт называет версии. Версии в работе, на согласовании, `REJECTED`, `CANCELLED` не + мешают. +- Аудит: на файл — системное `FILE_DELETE` (`user_id` пуст, описание «очистка форм перед v5.19», карточка файла в + `before_value`; у смешанного — `after_value.deleted_documents`). ОСВ, консолидации, справочники и журнал не трогаются. +- Коды выхода: `0` — отчёт или выполнено, `1` — отказ, `2` — ошибка аргументов. + ## Ограничения Среза 2c - Аудит обмена (смена статусов задания/файла в inbox, вахтёре, цикле маппинга) не пишется — `ASSUMPTION(OQ-55)`; diff --git a/api/scripts/purge_statements.py b/api/scripts/purge_statements.py new file mode 100644 index 0000000..fade70e --- /dev/null +++ b/api/scripts/purge_statements.py @@ -0,0 +1,334 @@ +"""Очистка форм ББ/ОФР перед выкладкой ядра с миграцией v5.19 (spec arm-core «Очистка форм перед выкладкой», +Р-24, Р-27). + +Формы пересоздаются повторной загрузкой файлов уже по новым правилам. Запуск из `api/`: + + .venv/bin/python -m scripts.purge_statements # отчёт (--dry-run), база не меняется + .venv/bin/python -m scripts.purge_statements --apply # очистка, одна транзакция + .venv/bin/python -m scripts.purge_statements --apply --include-mixed # смешанные файлы удаляются целиком + +Отбор — документы `BALANCE` / `INCOME` файлов любого статуса. +Отказ без изменений (P3-12, Р-35) — только если отобранный (или удаляемый вместе с файлом) документ упомянут в +`ex_snapshot_item` либо привязан к верифицированной версии: `VERIFIED` или `ARCHIVED` со срезом `ex_snapshot` +(`source_version_id` = версия). Привязки к версиям в работе, на согласовании, `REJECTED`, `CANCELLED` (и архивным +отменённым, без среза ЕХ) снимаются, статус версий не меняется. +Файл только с формами (нет документов `OSV`) удаляется целиком, как `DELETE /files/{id}`, но без запрета +`FILE_HAS_CONFIRMED_DOCUMENTS`; у смешанного файла (есть и ОСВ, и формы) удаляются только `BALANCE` / `INCOME` +(ключ `--include-mixed` — файл целиком). ОСВ вне удаляемых файлов, консолидации, справочники и аудит не трогаются. +В ядре результатов КФО ещё нет — пересчитывать после очистки нечего. + +Коды выхода (P3-13): 0 — отчёт или выполнено, 1 — отказ, 2 — ошибка аргументов. +""" + +import argparse +import asyncio +import sys +import uuid +from collections.abc import Callable, Sequence +from contextlib import AbstractAsyncContextManager +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path + +_API_DIR = Path(__file__).resolve().parents[1] +if str(_API_DIR) not in sys.path: # запуск файлом: в sys.path только `api/scripts`, а нужен `api/` + sys.path.insert(0, str(_API_DIR)) + +from sqlalchemy import and_, delete, exists, func, or_, select, update # noqa: E402 +from sqlalchemy.ext.asyncio import AsyncSession # noqa: E402 +from sqlalchemy.sql.elements import ColumnElement # noqa: E402 + +from src.common.clock import utc_now # noqa: E402 +from src.db.db import session_transaction # noqa: E402 +from src.models.audit_ex import Comment, ExSnapshot, ExSnapshotItem # noqa: E402 +from src.models.consolidation import ConsolidationDocument, ConsolidationVersion # noqa: E402 +from src.models.enums import ( # noqa: E402 + AuditAction, + AuditSection, + ConsolidationStatus, + DocumentStatus, + DocumentType, + S3BucketRole, +) +from src.models.files import Document, StoredFile # noqa: E402 +from src.repositories import files as files_repo # noqa: E402 +from src.repositories import statements as statements_repo # noqa: E402 +from src.services import consolidation_documents # noqa: E402 +from src.services.audit import AuditService # noqa: E402 +from src.services.documents import file_out # noqa: E402 +from src.services.primary_files import OBJECT_TYPE, UPLOADS_PREFIX # noqa: E402 +from src.services.result_mapping import RESULTS_PREFIX # noqa: E402 + +EXIT_OK, EXIT_REFUSED, EXIT_USAGE = 0, 1, 2 +DESCRIPTION = "очистка форм перед v5.19" +FORM_TYPES = (DocumentType.BALANCE, DocumentType.INCOME) +SNAPSHOT_ITEM_STATUS = "EX_SNAPSHOT_ITEM" # «статус» блокировки, найденной по ex_snapshot_item, а не по привязке + +Transaction = Callable[[], AbstractAsyncContextManager[AsyncSession]] + + +@dataclass(frozen=True) +class BlockingVersion: + """Причина отказа: документ формы заморожен в верифицированной версии (или упомянут в срезе ЕХ).""" + + version_id: uuid.UUID + status: str + document_id: uuid.UUID + + +@dataclass +class PurgePlan: + """Что сделает очистка; строится `plan_purge` (только чтение).""" + + form_only_files: list[uuid.UUID] = field(default_factory=list) + mixed_files: list[uuid.UUID] = field(default_factory=list) + form_documents: int = 0 + confirmed_documents: int = 0 + unverified_bindings: int = 0 + blocking: list[BlockingVersion] = field(default_factory=list) + superseded_with_osv: list[uuid.UUID] = field(default_factory=list) + include_mixed: bool = False + + +class PurgeRefusedError(Exception): + """Очистка отклонена: есть документы, замороженные в верифицированных версиях.""" + + def __init__(self, blocking: Sequence[BlockingVersion]) -> None: + super().__init__("очистка отклонена") + self.blocking = list(blocking) + + +def _frozen_version() -> ColumnElement[bool]: + """Условие «версия верифицирована»: `VERIFIED`, либо `ARCHIVED` со срезом ЕХ этой версии (P3-12).""" + snapshot = exists().where(ExSnapshot.source_version_id == ConsolidationVersion.id) + return or_( + ConsolidationVersion.status == ConsolidationStatus.VERIFIED, + and_(ConsolidationVersion.status == ConsolidationStatus.ARCHIVED, snapshot), + ) + + +async def _documents_of( + session: AsyncSession, file_ids: Sequence[uuid.UUID] +) -> list[tuple[uuid.UUID, uuid.UUID, DocumentType]]: + rows = await session.execute( + select(Document.id, Document.stored_file_id, Document.document_type).where( + Document.stored_file_id.in_(list(file_ids)) + ) + ) + return [(row[0], row[1], row[2]) for row in rows.all()] + + +async def _find_blocking(session: AsyncSession, document_ids: Sequence[uuid.UUID]) -> list[BlockingVersion]: + if not document_ids: + return [] + ids = list(document_ids) + bound = await session.execute( + select(ConsolidationVersion.id, ConsolidationVersion.status, ConsolidationDocument.document_id) + .join(ConsolidationDocument, ConsolidationDocument.consolidation_version_id == ConsolidationVersion.id) + .where(ConsolidationDocument.document_id.in_(ids), _frozen_version()) + ) + found = {BlockingVersion(version_id, status.value, document_id) for version_id, status, document_id in bound.all()} + items = await session.execute( + select(ExSnapshot.source_version_id, ExSnapshotItem.document_id) + .join(ExSnapshotItem, ExSnapshotItem.ex_snapshot_id == ExSnapshot.id) + .where(ExSnapshotItem.document_id.in_(ids)) + ) + found |= {BlockingVersion(version_id, SNAPSHOT_ITEM_STATUS, document_id) for version_id, document_id in items.all()} + return sorted(found, key=lambda item: (str(item.version_id), str(item.document_id), item.status)) + + +async def plan_purge(session: AsyncSession, *, include_mixed: bool) -> PurgePlan: + """Отбор и отчёт без изменений. «Только с формами» — нет документов `OSV`; «смешанный» — есть и ОСВ, и формы.""" + forms = ( + await session.execute( + select(Document.id, Document.stored_file_id, Document.status).where(Document.document_type.in_(FORM_TYPES)) + ) + ).all() + plan = PurgePlan(include_mixed=include_mixed, form_documents=len(forms)) + plan.confirmed_documents = sum(1 for _, _, status in forms if status is DocumentStatus.CONFIRMED) + file_ids = sorted({file_id for _, file_id, _ in forms}) + if not file_ids: + return plan + documents = await _documents_of(session, file_ids) + with_osv = {file_id for _, file_id, kind in documents if kind is DocumentType.OSV} + plan.form_only_files = [file_id for file_id in file_ids if file_id not in with_osv] + plan.mixed_files = [file_id for file_id in file_ids if file_id in with_osv] + deleted_files = set(plan.form_only_files) | (set(plan.mixed_files) if include_mixed else set()) + affected = [ + document_id for document_id, file_id, kind in documents if file_id in deleted_files or (kind in FORM_TYPES) + ] + plan.blocking = await _find_blocking(session, affected) + blocked_ids = {item.document_id for item in plan.blocking} + bindings = await session.execute( + select(ConsolidationDocument.document_id, func.count()) + .where(ConsolidationDocument.document_id.in_(affected)) + .group_by(ConsolidationDocument.document_id) + ) + plan.unverified_bindings = sum(count for document_id, count in bindings.all() if document_id not in blocked_ids) + if deleted_files: + rows = await session.execute( + select(StoredFile.id).where(StoredFile.superseded_by_file_id.in_(list(deleted_files))) + ) + replaced = [file_id for (file_id,) in rows.all()] + plan.superseded_with_osv = sorted(await _files_with_osv(session, replaced)) + return plan + + +async def _files_with_osv(session: AsyncSession, file_ids: Sequence[uuid.UUID]) -> set[uuid.UUID]: + rows = await session.execute( + select(Document.stored_file_id) + .where(Document.stored_file_id.in_(list(file_ids)), Document.document_type == DocumentType.OSV) + .distinct() + ) + return {file_id for (file_id,) in rows.all()} + + +async def _purge_whole_file(session: AsyncSession, audit: AuditService, file_id: uuid.UUID, now: datetime) -> None: + """Файл целиком — как `PrimaryFileService.delete`, но без запрета на подтверждённые документы.""" + jobs = await files_repo.lock_jobs(session, file_id) + file = await files_repo.lock_file(session, file_id) + if file is None: + return + documents = await files_repo.lock_file_documents(session, file_id) + bound = await consolidation_documents.bound_versions(session, documents=documents) + await consolidation_documents.unbind_documents(session, document_ids=[document.id for document in documents]) + await session.execute( + update(StoredFile).where(StoredFile.superseded_by_file_id == file_id).values(superseded_by_file_id=None) + ) + current = next((job for job in jobs if job.id == file.current_recognition_job_id), None) + counts = await files_repo.comments_count_by_document(session, [document.id for document in documents]) + before = file_out(file, current, documents, counts).model_dump(mode="json") + object_ids = [file.s3_object_id, *(job.result_s3_object_id for job in jobs if job.result_s3_object_id)] + await files_repo.delete_file_rows(session, file) + owned_prefixes = [ + (S3BucketRole.uploads, f"{UPLOADS_PREFIX}/{file_id}/"), + (S3BucketRole.results, f"{RESULTS_PREFIX}/{file_id}/"), + ] + await files_repo.expire_s3_objects(session, object_ids, owned_prefixes=owned_prefixes, now=now) + await audit.record( + action=AuditAction.FILE_DELETE, + section=AuditSection.PRIMARY_DOCUMENTS, + object_type=OBJECT_TYPE, + object_id=file_id, + user_id=None, + description=DESCRIPTION, + before_value=before, + after_value={"unbound_version_ids": sorted({version_id for version_id, _ in bound})}, + ) + + +async def _purge_forms_of_file(session: AsyncSession, audit: AuditService, file_id: uuid.UUID) -> None: + """Смешанный файл: удаляются только `BALANCE` / `INCOME`; файл, ОСВ и задания остаются.""" + jobs = await files_repo.lock_jobs(session, file_id) + file = await files_repo.lock_file(session, file_id) + if file is None: + return + documents = await files_repo.lock_file_documents(session, file_id) + forms = [document for document in documents if document.document_type in FORM_TYPES] + if not forms: + return + form_ids = [document.id for document in forms] + bound = await consolidation_documents.bound_versions(session, documents=forms) + await consolidation_documents.unbind_documents(session, document_ids=form_ids) + current = next((job for job in jobs if job.id == file.current_recognition_job_id), None) + counts = await files_repo.comments_count_by_document(session, [document.id for document in documents]) + before = file_out(file, current, documents, counts).model_dump(mode="json") + await statements_repo.delete_documents_statements(session, form_ids) + await session.execute( + delete(Comment).where(Comment.object_type == files_repo.COMMENT_OBJECT_TYPE, Comment.object_id.in_(form_ids)) + ) + await session.execute(delete(Document).where(Document.id.in_(form_ids))) + await audit.record( + action=AuditAction.FILE_DELETE, + section=AuditSection.PRIMARY_DOCUMENTS, + object_type=OBJECT_TYPE, + object_id=file_id, + user_id=None, + description=DESCRIPTION, + before_value=before, + after_value={ + "deleted_documents": [str(document_id) for document_id in form_ids], + "unbound_version_ids": sorted({version_id for version_id, _ in bound}), + }, + ) + + +async def apply_purge(session: AsyncSession, plan: PurgePlan, *, now: datetime) -> None: + """Выполняет очистку в транзакции сессии (коммит — за вызывающим). Порядок блокировок: файл → документы → версии. + + Блокирует файлы плана (по `id`), заново проверяет отказ под блокировками и только потом меняет данные: при + `PurgeRefusedError` база не изменена.""" + whole = list(plan.form_only_files) + (list(plan.mixed_files) if plan.include_mixed else []) + partial = [] if plan.include_mixed else list(plan.mixed_files) + for file_id in sorted([*whole, *partial]): + await files_repo.lock_jobs(session, file_id) + await files_repo.lock_file(session, file_id) + await files_repo.lock_file_documents(session, file_id) + fresh = await plan_purge(session, include_mixed=plan.include_mixed) + if fresh.blocking: + raise PurgeRefusedError(fresh.blocking) + audit = AuditService(session) + for file_id in sorted(whole): + await _purge_whole_file(session, audit, file_id, now) + for file_id in sorted(partial): + await _purge_forms_of_file(session, audit, file_id) + + +def render_report(plan: PurgePlan) -> str: + lines = [ + f"файлов только с формами: {len(plan.form_only_files)}", + f"смешанных файлов: {len(plan.mixed_files)}" + + (" (будут удалены целиком, --include-mixed)" if plan.include_mixed else " (удаляются только формы)"), + f"документов форм: {plan.form_documents}", + f"подтверждённых документов: {plan.confirmed_documents}", + f"привязок к неверифицированным версиям: {plan.unverified_bindings}", + ] + if plan.mixed_files: + lines.append("смешанные файлы (повторная загрузка невозможна — дедуп по sha256):") + lines.extend(f" {file_id}" for file_id in plan.mixed_files) + if plan.superseded_with_osv: + lines.append("заменённые удаляемыми файлами файлы с ОСВ (остаются SUPERSEDED):") + lines.extend(f" {file_id}" for file_id in plan.superseded_with_osv) + if plan.blocking: + lines.append("ОТКАЗ: формы заморожены в верифицированных версиях, решение — за владельцем:") + lines.extend( + f" версия {item.version_id} ({item.status}), документ {item.document_id}" for item in plan.blocking + ) + return "\n".join(lines) + + +def _parse(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(prog="purge_statements", description="Очистка форм ББ/ОФР перед выкладкой v5.19") + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--dry-run", action="store_true", help="только отчёт (по умолчанию)") + mode.add_argument("--apply", action="store_true", help="выполнить очистку одной транзакцией") + parser.add_argument("--include-mixed", action="store_true", help="смешанные файлы удалить целиком") + return parser.parse_args(list(argv)) + + +async def run(argv: Sequence[str], *, transaction: Transaction = session_transaction) -> int: + """Точка входа; `transaction` — фабрика транзакции (тесты подставляют свою сессию).""" + try: + args = _parse(argv) + except SystemExit as error: + return EXIT_USAGE if error.code else EXIT_OK + try: + async with transaction() as session: + plan = await plan_purge(session, include_mixed=args.include_mixed) + print(render_report(plan)) + if not args.apply: + return EXIT_OK + if plan.blocking: + print("очистка отклонена, база не изменена", file=sys.stderr) + return EXIT_REFUSED + await apply_purge(session, plan, now=utc_now()) + except PurgeRefusedError as error: + print(render_report(PurgePlan(blocking=error.blocking)), file=sys.stderr) + print("очистка отклонена, база не изменена", file=sys.stderr) + return EXIT_REFUSED + print("очистка выполнена") + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(asyncio.run(run(sys.argv[1:]))) diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index ac4debb..0153491 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -26,6 +26,7 @@ __all__ = [ "add_line", "add_value", "column_ids", + "delete_documents_statements", "delete_file_statements", "form_lines", "get_meta", @@ -110,6 +111,16 @@ async def insert_statement( async def delete_file_statements(session: AsyncSession, file_id: uuid.UUID) -> None: """`document_issue`, значения, строки, колонки и шапки форм всех документов файла (в порядке FK).""" documents = select(Document.id).where(Document.stored_file_id == file_id).scalar_subquery() + await _delete_statements(session, documents) + + +async def delete_documents_statements(session: AsyncSession, document_ids: Sequence[uuid.UUID]) -> None: + """То же для перечисленных документов (очистка форм смешанного файла: ОСВ и прочие документы файла остаются).""" + if document_ids: + await _delete_statements(session, list(document_ids)) + + +async def _delete_statements(session: AsyncSession, documents: Any) -> None: await session.execute(delete(DocumentIssue).where(DocumentIssue.document_id.in_(documents))) metas = select(FinancialStatementMeta.id).where(FinancialStatementMeta.document_id.in_(documents)).scalar_subquery() lines = select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id.in_(metas)).scalar_subquery() diff --git a/api/tests/test_purge_statements.py b/api/tests/test_purge_statements.py new file mode 100644 index 0000000..594c381 --- /dev/null +++ b/api/tests/test_purge_statements.py @@ -0,0 +1,460 @@ +"""Очистка форм ББ/ОФР перед выкладкой v5.19 `scripts/purge_statements.py` (spec arm-core «Очистка форм перед +выкладкой»; P3-12, P3-13, Р-24, Р-27, Р-35). + +Скрипт получает сессию из `transaction` — тесты подставляют `db_session` (транзакция теста), боевую БД не трогают. +""" + +import datetime +import uuid +from collections.abc import AsyncIterator, Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager + +import pytest +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from scripts import purge_statements +from src.models.audit_ex import AuditEvent, Comment, ExSnapshot, ExSnapshotItem +from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationVersion +from src.models.directories import CompanyGroup, LegalEntity +from src.models.enums import ( + AuditAction, + ConsolidationStatus, + DocumentStatus, + DocumentType, + ExSnapshotStatus, + FileStatus, + PeriodQuarter, + StatementType, +) +from src.models.files import Document, RecognitionJob, S3Object, StoredFile +from src.models.osv import OsvEntry +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) +from src.services.primary_files import PrimaryFilesService +from tests.factories import ( + make_company_group, + make_consolidation, + make_document, + make_legal_entity, + make_osv_entry, + make_regional_office, + make_related_group, + make_stored_file, + make_user, + make_version, +) + +pytestmark = pytest.mark.integration + +EXIT_OK, EXIT_REFUSED = 0, 1 +DESCRIPTION = "очистка форм перед v5.19" +FORM_AND_MIXED_FILES = 2 # событий FILE_DELETE: файл только с формами и смешанный + + +def _transaction(session: AsyncSession) -> Callable[[], AbstractAsyncContextManager[AsyncSession]]: + @asynccontextmanager + async def factory() -> AsyncIterator[AsyncSession]: + yield session + + return factory + + +async def _run(session: AsyncSession, *argv: str) -> int: + return await purge_statements.run(list(argv), transaction=_transaction(session)) + + +async def _count(session: AsyncSession, model: type, *where: object) -> int: + return (await session.execute(select(func.count()).select_from(model).where(*where))).scalar_one() + + +class World: + """ГК с ЮЛ, инициатор и счётчик номеров версий.""" + + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.entity: LegalEntity + self.user_id: uuid.UUID + self._version_no = 1 + + async def build(self) -> "World": + session = self.session + office = await _office(session) + group = await make_company_group(session, name="ГК Очистка", office=office) + self.group = group + self.entity = await make_legal_entity(session, name="ООО Очистка", group=group) + self.user = await make_user(session, email="purge@armka.local", office=office) + self.consolidation, self.first_version = await make_consolidation( + session, + group=group, + year=2025, + quarter=PeriodQuarter.Q1, + members=[self.entity], + initiator=self.user, + status=ConsolidationStatus.INITIATED, + ) + return self + + async def version(self, status: ConsolidationStatus): # type: ignore[no-untyped-def] + """Ещё одна версия консолидации в статусе `status`.""" + self._version_no += 1 + return await make_version( + self.session, + consolidation=self.consolidation, + version_no=self._version_no, + members=[self.entity], + initiator=self.user, + status=status, + ) + + async def form_document( + self, + file: StoredFile, + job: RecognitionJob, + document_type: DocumentType = DocumentType.BALANCE, + *, + status: DocumentStatus = DocumentStatus.RECOGNIZED, + ) -> Document: + """Документ формы с шапкой, колонкой, строкой и значением.""" + session = self.session + ordinal = ( + await session.execute( + select(func.coalesce(func.max(Document.file_ordinal) + 1, 0)).where(Document.stored_file_id == file.id) + ) + ).scalar_one() + now = datetime.datetime.now(datetime.UTC) + document = Document( + stored_file_id=file.id, + legal_entity_id=file.legal_entity_id, + document_type=document_type, + file_ordinal=ordinal, + period_year=2025, + period_quarter=PeriodQuarter.Q1, + status=status, + confirmed_by=self.user.id if status is DocumentStatus.CONFIRMED else None, + created_at=now, + updated_at=now, + ) + session.add(document) + await session.flush() + if document_type in (DocumentType.BALANCE, DocumentType.INCOME): + meta = FinancialStatementMeta( + document_id=document.id, + recognition_job_id=job.id, + statement_type=StatementType.balance if document_type is DocumentType.BALANCE else StatementType.income, + ) + session.add(meta) + await session.flush() + column = FinancialStatementColumn(meta_id=meta.id, column_index=0) + line = FinancialStatementLine(meta_id=meta.id, line_code="1600", row_index=1) + session.add_all([column, line]) + await session.flush() + session.add(FinancialStatementValue(line_id=line.id, column_id=column.id, amount=1)) + await session.flush() + return document + + async def bind(self, document: Document, version) -> None: # type: ignore[no-untyped-def] + self.session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id)) + await self.session.flush() + + async def comment(self, document: Document) -> None: + self.session.add( + Comment( + object_type="document", + object_id=document.id, + text="проверить", + author_id=self.user.id, + created_at=datetime.datetime.now(datetime.UTC), + ) + ) + await self.session.flush() + + async def snapshot(self, version, *, document: Document | None = None) -> ExSnapshot: # type: ignore[no-untyped-def] + """Срез ЕХ версии `version`; при `document` — с `ex_snapshot_item` на документ.""" + related = await make_related_group(self.session, group=self.group) + now = datetime.datetime.now(datetime.UTC) + snapshot = ExSnapshot( + related_group_id=related.id, + company_group_id=self.group.id, + reporting_date=datetime.date(2025, 4, 1), + year=2025, + quarter=PeriodQuarter.Q1, + version_no=1, + status=ExSnapshotStatus.ACTUAL, + source_version_id=version.id, + source_version_no=version.version_no, + verified_at=now, + written_at=now, + ) + self.session.add(snapshot) + await self.session.flush() + if document is not None: + self.session.add( + ExSnapshotItem(ex_snapshot_id=snapshot.id, item_type="primary_document", document_id=document.id) + ) + await self.session.flush() + return snapshot + + +async def _office(session: AsyncSession): # type: ignore[no-untyped-def] + return await make_regional_office(session) + + +@pytest.fixture +async def world(seeded: AsyncSession) -> World: + return await World(seeded).build() + + +async def _mixed_and_form_only(world: World) -> dict[str, object]: + """Файл только с ББ+ОФР (ББ CONFIRMED, привязан к INITIATED), смешанный файл (ОСВ + ББ), файл только с ОСВ.""" + session = world.session + form_file, form_job = await make_stored_file(session, entity=world.entity, original_name="forms.pdf") + balance = await world.form_document(form_file, form_job, DocumentType.BALANCE, status=DocumentStatus.CONFIRMED) + income = await world.form_document(form_file, form_job, DocumentType.INCOME) + await world.bind(balance, world.first_version) + mixed_file, mixed_job = await make_stored_file(session, entity=world.entity, original_name="mixed.xls") + osv = await make_document(session, file=mixed_file, job=mixed_job) + mixed_balance = await world.form_document(mixed_file, mixed_job, DocumentType.BALANCE) + osv_file, osv_job = await make_stored_file(session, entity=world.entity, original_name="osv.xls") + await make_document(session, file=osv_file, job=osv_job) + return { + "form_file": form_file, + "form_job": form_job, + "balance": balance, + "income": income, + "mixed_file": mixed_file, + "mixed_job": mixed_job, + "osv": osv, + "mixed_balance": mixed_balance, + "osv_file": osv_file, + } + + +TABLES = ( + StoredFile, + Document, + RecognitionJob, + FinancialStatementMeta, + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementValue, + Comment, + ConsolidationDocument, + OsvEntry, + AuditEvent, +) + + +async def _counts(session: AsyncSession) -> dict[str, int]: + return {model.__tablename__: await _count(session, model) for model in TABLES} + + +async def test_dry_run_reports_and_changes_nothing(world: World, capsys: pytest.CaptureFixture[str]) -> None: + await _mixed_and_form_only(world) + before = await _counts(world.session) + + code = await _run(world.session) + + out = capsys.readouterr().out + assert code == EXIT_OK + assert "файлов только с формами: 1" in out + assert "смешанных файлов: 1" in out + assert "документов форм: 3" in out + assert "подтверждённых документов: 1" in out + assert "привязок к неверифицированным версиям: 1" in out + assert await _counts(world.session) == before + + +async def test_usage_error(world: World) -> None: + assert await _run(world.session, "--unknown") == purge_statements.EXIT_USAGE + + +@pytest.mark.parametrize("status", [ConsolidationStatus.VERIFIED, ConsolidationStatus.ARCHIVED]) +async def test_apply_refuses_verified_binding( + world: World, capsys: pytest.CaptureFixture[str], status: ConsolidationStatus +) -> None: + objects = await _mixed_and_form_only(world) + version = await world.version(status) + if status is ConsolidationStatus.ARCHIVED: # архивированная версия мешает, только если заморожена в ЕХ + await world.snapshot(version) + await world.bind(objects["income"], version) # type: ignore[arg-type] + before = await _counts(world.session) + + code = await _run(world.session, "--apply") + + out = capsys.readouterr() + assert code == EXIT_REFUSED + assert str(version.id) in out.out + out.err + assert status.value in out.out + out.err + assert await _counts(world.session) == before + + +async def test_apply_refuses_ex_snapshot_item(world: World, capsys: pytest.CaptureFixture[str]) -> None: + objects = await _mixed_and_form_only(world) + version = await world.version(ConsolidationStatus.CANCELLED) + snapshot = await world.snapshot(version, document=objects["balance"]) # type: ignore[arg-type] + before = await _counts(world.session) + + code = await _run(world.session, "--apply") + + out = capsys.readouterr() + assert code == EXIT_REFUSED + assert str(objects["balance"].id) in out.out + out.err # type: ignore[attr-defined] + assert str(snapshot.source_version_id) in out.out + out.err + assert await _counts(world.session) == before + + +async def test_apply_ignores_unverified_versions(world: World) -> None: + session = world.session + file, job = await make_stored_file(session, entity=world.entity, original_name="forms.pdf") + balance = await world.form_document(file, job, DocumentType.BALANCE) + income = await world.form_document(file, job, DocumentType.INCOME) + other = await world.form_document(file, job, DocumentType.BALANCE) + archived = await world.form_document(file, job, DocumentType.BALANCE) + versions = { + ConsolidationStatus.VERIFICATION_RF: balance, + ConsolidationStatus.REJECTED: income, + ConsolidationStatus.CANCELLED: other, + ConsolidationStatus.ARCHIVED: archived, # архивная без ex_snapshot (из CANCELLED) + } + statuses = {} + for status, document in versions.items(): + version = await world.version(status) + await world.bind(document, version) + statuses[version.id] = status + + code = await _run(session, "--apply") + + assert code == EXIT_OK + assert await _count(session, ConsolidationDocument) == 0 + assert await _count(session, Document, Document.stored_file_id == file.id) == 0 + assert await _count(session, StoredFile, StoredFile.id == file.id) == 0 + rows = (await session.execute(select(ConsolidationVersion.id, ConsolidationVersion.status))).all() + for version_id, status in rows: + if version_id in statuses: + assert status is statuses[version_id] + + +async def test_apply_deletes_form_only_file(world: World) -> None: + session = world.session + file, job = await make_stored_file(session, entity=world.entity, original_name="forms.pdf") + balance = await world.form_document(file, job, DocumentType.BALANCE, status=DocumentStatus.CONFIRMED) + await world.form_document(file, job, DocumentType.INCOME) + await world.form_document(file, job, DocumentType.DISCLOSURE) + await world.comment(balance) + version = await world.version(ConsolidationStatus.DATA_CORRECTION) + await world.bind(balance, version) + old, _ = await make_stored_file(session, entity=world.entity, original_name="old.pdf") + old.status = FileStatus.SUPERSEDED + old.superseded_by_file_id = file.id + await session.flush() + file_id, s3_id, old_id, version_id = file.id, file.s3_object_id, old.id, version.id + before_status = version.status + + code = await _run(session, "--apply") + + assert code == EXIT_OK + assert await _count(session, StoredFile, StoredFile.id == file_id) == 0 + assert await _count(session, Document, Document.stored_file_id == file_id) == 0 + for model in (FinancialStatementMeta, FinancialStatementColumn, FinancialStatementLine, FinancialStatementValue): + assert await _count(session, model) == 0, model.__tablename__ + assert await _count(session, Comment) == 0 + assert await _count(session, RecognitionJob, RecognitionJob.file_id == file_id) == 0 + old_row = await session.get(StoredFile, old_id, populate_existing=True) + assert old_row is not None + assert old_row.superseded_by_file_id is None + assert old_row.status is FileStatus.SUPERSEDED + s3 = await session.get(S3Object, s3_id, populate_existing=True) + assert s3 is not None and s3.expires_at is not None + assert await _count(session, ConsolidationDocument) == 0 + reloaded = await session.get(ConsolidationVersion, version_id, populate_existing=True) + assert reloaded is not None and reloaded.status is before_status + [event] = ( + await session.execute( + select(AuditEvent).where(AuditEvent.action == AuditAction.FILE_DELETE, AuditEvent.object_id == file_id) + ) + ).scalars() + assert event.user_id is None + assert event.description == DESCRIPTION + assert event.before_value is not None + assert event.before_value["id"] == str(file_id) + + +async def test_apply_mixed_keeps_osv_by_default(world: World) -> None: + session = world.session + objects = await _mixed_and_form_only(world) + mixed_file: StoredFile = objects["mixed_file"] # type: ignore[assignment] + mixed_balance: Document = objects["mixed_balance"] # type: ignore[assignment] + osv: Document = objects["osv"] # type: ignore[assignment] + await world.comment(mixed_balance) + version = await world.version(ConsolidationStatus.DATA_CORRECTION) + await world.bind(mixed_balance, version) + file_id, osv_id, balance_id, job_id = mixed_file.id, osv.id, mixed_balance.id, objects["mixed_job"].id # type: ignore[attr-defined] + + code = await _run(session, "--apply") + + assert code == EXIT_OK + assert await _count(session, StoredFile, StoredFile.id == file_id) == 1 + assert await _count(session, Document, Document.id == osv_id) == 1 + assert await _count(session, Document, Document.id == balance_id) == 0 + assert await _count(session, RecognitionJob, RecognitionJob.id == job_id) == 1 + assert await _count(session, Comment, Comment.object_id == balance_id) == 0 + assert await _count(session, ConsolidationDocument, ConsolidationDocument.document_id == balance_id) == 0 + events = ( + ( + await session.execute( + select(AuditEvent).where(AuditEvent.action == AuditAction.FILE_DELETE, AuditEvent.object_id == file_id) + ) + ) + .scalars() + .all() + ) + [event] = events + assert event.user_id is None and event.description == DESCRIPTION + assert event.after_value is not None + assert event.after_value["deleted_documents"] == [str(balance_id)] + + +async def test_apply_include_mixed_deletes_whole_file(world: World) -> None: + session = world.session + objects = await _mixed_and_form_only(world) + mixed_file: StoredFile = objects["mixed_file"] # type: ignore[assignment] + file_id = mixed_file.id + + code = await _run(session, "--apply", "--include-mixed") + + assert code == EXIT_OK + assert await _count(session, StoredFile, StoredFile.id == file_id) == 0 + assert await _count(session, Document, Document.stored_file_id == file_id) == 0 + assert await _count(session, StoredFile) == 1 # остался только файл с ОСВ + + +async def test_osv_and_directories_untouched(world: World) -> None: + session = world.session + await _mixed_and_form_only(world) + await make_osv_entry(session) + models = (OsvEntry, Consolidation, ConsolidationVersion, LegalEntity, CompanyGroup) + before = {model.__tablename__: await _count(session, model) for model in models} + + assert await _run(session, "--apply") == EXIT_OK + + assert {model.__tablename__: await _count(session, model) for model in models} == before + assert await _count(session, AuditEvent, AuditEvent.action == AuditAction.FILE_DELETE) == FORM_AND_MIXED_FILES + + +async def test_reupload_after_purge_not_duplicate(world: World, seeded: AsyncSession) -> None: + session = world.session + file, job = await make_stored_file(session, entity=world.entity, original_name="forms.pdf", content_hash="ab" * 32) + await world.form_document(file, job, DocumentType.BALANCE) + await session.execute(update(StoredFile).where(StoredFile.id == file.id).values(status=FileStatus.PROCESSED)) + await session.flush() + entity_id = world.entity.id + assert await _run(session, "--apply") == EXIT_OK + + service = PrimaryFilesService.__new__(PrimaryFilesService) + service._session = session # type: ignore[attr-defined] # find_duplicate читает только сессию + assert await service.find_duplicate(entity_id, "ab" * 32) is None -- 2.47.2 From 1dc510fbb5643d86880fa6944eb5e0956f4fb3a3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:52:21 +0400 Subject: [PATCH 06/20] =?UTF-8?q?feat(domain):=20=D1=81=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=80=D1=8C=20=D0=B8=D0=BC=D1=91=D0=BD=20=D1=81=20=D1=81?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=BD=D0=B8=D0=BC=D0=B0=D0=BC=D0=B8=20(?= =?UTF-8?q?=D0=A0-20)=20=D0=B8=20=D1=81=D0=BE=D0=B2=D0=BF=D0=B0=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=D0=B8=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B9=20=C2=A711.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- api/rules/statement_lines.yaml | 12 ++++- api/src/domain/statement_lines.py | 50 +++++++++++++++---- api/src/domain/statement_names.py | 51 +++++++++++++++++++ api/src/services/statements.py | 9 ++-- api/tests/test_api_statements.py | 4 +- api/tests/test_domain_statement_lines.py | 63 ++++++++++++++++++------ api/tests/test_domain_statement_names.py | 40 +++++++++++++++ 7 files changed, 195 insertions(+), 34 deletions(-) create mode 100644 api/src/domain/statement_names.py create mode 100644 api/tests/test_domain_statement_names.py diff --git a/api/rules/statement_lines.yaml b/api/rules/statement_lines.yaml index ac0e1be..7dbac1f 100644 --- a/api/rules/statement_lines.yaml +++ b/api/rules/statement_lines.yaml @@ -1,4 +1,6 @@ -# Справочник строк форм ББ/ОФР по редакциям: полный состав формы для экрана проверки (05-domain-rules §7 «Ядро», +# Словарь имён: код → наименования по редакциям, несколько имён на код (Р-20; порядок — contracts/statement-code-orders.yaml). +# Первое имя кода — справочное имя и состав формы; остальные — синонимы и законные повторы для совпадения наименований +# (§11.9). Справочник строк форм ББ/ОФР по редакциям: полный состав формы для экрана проверки (05-domain-rules §7 «Ядро», # design D-2 change statement-template-lines). Порядок строк в списке — порядок формы. # Редакция выбирается по дате периода документа (edition_for): 66n — до 2024-12-31 включительно, fsbu4 — позже. # Источник обеих редакций — шаблоны банка fixtures/statements-xlsx/templates/ («ББ/ОФР до 01.01.2025», «… после @@ -72,7 +74,10 @@ editions: - id: fsbu4 forms: BALANCE: + - {code: "1105", name: "Гудвил"} - {code: "1110", name: "Нематериальные активы"} + - {code: "1130", name: "Нематериальные поисковые активы"} + - {code: "1140", name: "Материальные поисковые активы"} - {code: "1150", name: "Основные средства"} - {code: "1160", name: "Инвестиционная недвижимость"} - {code: "1170", name: "Финансовые вложения"} @@ -82,6 +87,7 @@ editions: - {code: "1210", name: "Запасы"} - {code: "1215", name: "Долгосрочные активы к продаже"} - {code: "1220", name: "Налог на добавленную стоимость по приобретенным ценностям"} + - {code: "1220", name: "НДС, начисленный по отгрузке"} - {code: "1230", name: "Дебиторская задолженность"} - {code: "1240", name: "Финансовые вложения (за исключением денежных эквивалентов)"} - {code: "1250", name: "Денежные средства и денежные эквиваленты"} @@ -102,6 +108,8 @@ editions: - {code: "1400", name: "Итого по разделу IV"} - {code: "1510", name: "Заемные средства"} - {code: "1520", name: "Кредиторская задолженность"} + - {code: "1520", name: "Обязательство по аренде"} + - {code: "1530", name: "Доходы будущих периодов"} - {code: "1540", name: "Оценочные обязательства"} - {code: "1550", name: "Прочие краткосрочные обязательства"} - {code: "1500", name: "Итого по разделу V"} @@ -122,7 +130,9 @@ editions: - {code: "2410", name: "Налог на прибыль организаций"} - {code: "2411", name: "текущий налог на прибыль организаций"} - {code: "2412", name: "отложенный налог на прибыль организаций"} + - {code: "2410", name: "Налог при упрощенной системе налогообложения"} - {code: "2420", name: "Прибыль (убыток) от прекращаемой деятельности"} + - {code: "2460", name: "Прочее"} - {code: "2400", name: "Чистая прибыль (убыток)"} - {code: "2510", name: "Результат переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток)"} - {code: "2520", name: "Результат прочих операций, не включаемый в чистую прибыль (убыток)"} diff --git a/api/src/domain/statement_lines.py b/api/src/domain/statement_lines.py index d1ef2a4..d20ca9c 100644 --- a/api/src/domain/statement_lines.py +++ b/api/src/domain/statement_lines.py @@ -1,9 +1,10 @@ -"""Справочник строк форм ББ/ОФР по редакциям — полный состав формы для экрана проверки (05-domain-rules §7 «Ядро»; -change statement-template-lines, design D-2). +"""Словарь имён строк форм ББ/ОФР по редакциям — состав формы для экрана проверки (05-domain-rules §7 «Ядро»; +change statement-template-lines, design D-2) и справочные имена для совпадения наименований (§11.9, Р-20). -Данные — `api/rules/statement_lines.yaml`; порядок строк в списке — порядок формы. Редакцию выбирает -`statement_mapping.edition_for`; здесь — только состав. Справочник ничего не убирает и не переименовывает в форме: -строки источника, которых в нём нет, остаются как есть. +Данные — `api/rules/statement_lines.yaml`; порядок строк в списке — порядок формы. Код в редакции может стоять +несколько раз (синонимы и законные повторы, Р-20): состав формы берёт первое имя, сравнение наименований — любое. +Редакцию выбирает `statement_mapping.edition_for`; здесь — только состав. Справочник ничего не убирает и не +переименовывает в форме: строки источника, которых в нём нет, остаются как есть. """ import functools @@ -28,14 +29,46 @@ class TemplateLine: @dataclass(frozen=True) class LineCatalog: + """`editions[редакция][форма]` — все записи словаря в порядке формы, повтор кода допустим.""" + editions: dict[str, dict[str, tuple[TemplateLine, ...]]] - def lines(self, edition: str | None, form: str) -> tuple[TemplateLine, ...]: - """Строки формы `form` редакции `edition` в порядке формы; без редакции или для чужой формы — пусто.""" + def _entries(self, edition: str | None, form: str) -> tuple[TemplateLine, ...]: if edition is None: return () return self.editions.get(edition, {}).get(form, ()) + def template_lines(self, edition: str | None, form: str) -> tuple[TemplateLine, ...]: + """Строки формы в порядке формы: по одной на код, первое имя, порядок первого появления. + Без редакции или для чужой формы — пусто.""" + seen: set[str] = set() + result: list[TemplateLine] = [] + for line in self._entries(edition, form): + if line.code not in seen: + seen.add(line.code) + result.append(line) + return tuple(result) + + def reference_name(self, edition: str | None, form: str, code: str) -> str | None: + """Первое имя кода в редакции (P3-11); кода нет — `None`.""" + for line in self._entries(edition, form): + if line.code == code: + return line.name + return None + + def names(self, form: str, code: str) -> tuple[str, ...]: + """Все имена кода во всех редакциях без повторов, в порядке появления.""" + result: dict[str, None] = {} + for forms in self.editions.values(): + for line in forms.get(form, ()): + if line.code == code: + result.setdefault(line.name) + return tuple(result) + + def edition_codes(self, edition: str, form: str) -> frozenset[str]: + """Коды формы в редакции.""" + return frozenset(line.code for line in self._entries(edition, form)) + def _form_lines(edition: str, form: str, items: object) -> tuple[TemplateLine, ...]: if not isinstance(items, list): @@ -44,9 +77,6 @@ def _form_lines(edition: str, form: str, items: object) -> tuple[TemplateLine, . for line in lines: if not _CODE.fullmatch(line.code): raise ValueError(f"редакция {edition}, форма {form}: код {line.code!r} — не четыре цифры") - codes = [line.code for line in lines] - if len(codes) != len(set(codes)): - raise ValueError(f"редакция {edition}, форма {form}: повтор кода") return lines diff --git a/api/src/domain/statement_names.py b/api/src/domain/statement_names.py new file mode 100644 index 0000000..c03434d --- /dev/null +++ b/api/src/domain/statement_names.py @@ -0,0 +1,51 @@ +"""Совпадение наименований строки формы со справочными (05-domain-rules §11.9, спека «Совпадение наименований»).""" + +import difflib +import re +from collections.abc import Iterable + +__all__ = ["names_match", "normalize", "section_number"] + +_NON_ALNUM = re.compile(r"[\W_]+") +_TOTAL_PREFIX = "итого по разделу" +_MIN_CONTAINED = 5 +_MIN_RATIO = 0.8 +_ROMAN_LATIN = str.maketrans({"і": "i", "х": "x"}) # кириллические «і», «х» -> латиница +_ROMAN_HEAD = re.compile(r"[ivx]+") + + +def normalize(text: str) -> str: + """Нижний регистр, `ё` -> `е`, всё кроме букв и цифр — пробел, схлопывание, `strip`.""" + return _NON_ALNUM.sub(" ", text.lower().replace("ё", "е")).strip() + + +def section_number(normalized: str) -> str | None: + """Номер раздела «Итого по разделу …» (строчными буквами i, v, x) из нормализованного имени; остаток слова — + хвост OCR («v1» -> «v») — отбрасывается. Имя не «итого по разделу» или номер не выделен — `None`.""" + if not normalized.startswith(_TOTAL_PREFIX): + return None + words = normalized[len(_TOTAL_PREFIX) :].split() + if not words: + return None + head = _ROMAN_HEAD.match(words[0].translate(_ROMAN_LATIN)) + return head.group() if head else None + + +def _match_pair(a: str, b: str) -> bool: + if a.startswith(_TOTAL_PREFIX) or b.startswith(_TOTAL_PREFIX): + na, nb = section_number(a), section_number(b) + return a == b if na is None or nb is None else na == nb + if a == b: + return True + short, long_ = sorted((a, b), key=len) + if len(short) >= _MIN_CONTAINED and short in long_: + return True + return difflib.SequenceMatcher(None, a, b).ratio() >= _MIN_RATIO + + +def names_match(recognized: str | None, references: Iterable[str]) -> bool: + """Наименование совпало хотя бы с одним справочным; пустое имя или нет справочных — `False`.""" + name = normalize(recognized or "") + if not name: + return False + return any(ref and _match_pair(name, ref) for ref in map(normalize, references)) diff --git a/api/src/services/statements.py b/api/src/services/statements.py index bd9ab93..6894416 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -140,7 +140,7 @@ class StatementService: columns, lines = await repo.load_form(self._session, meta.id) report, cross, parents = await self.checks_report(document, meta) edition = edition_for(document.reporting_date) - template = load_line_catalog().lines(edition, document.document_type.value) + template = load_line_catalog().template_lines(edition, document.document_type.value) return schemas.statement_out( document, meta, columns, lines, report, cross, edition=edition, template=template, parents=parents ) @@ -222,10 +222,9 @@ class StatementService: lines = await repo.form_lines(self._session, meta.id) by_id = {line.id: line for line in lines} codes = {line.line_code for line in lines} - template = { - item.code: item - for item in load_line_catalog().lines(edition_for(document.reporting_date), document.document_type.value) - } + edition = edition_for(document.reporting_date) + catalog_lines = load_line_catalog().template_lines(edition, document.document_type.value) + template = {item.code: item for item in catalog_lines} targets: list[tuple[schemas.CellPatch, FinancialStatementLine | None, TemplateLine | None]] = [] for cell in body.values: if cell.column_id not in columns: diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 7254481..c00261e 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -44,7 +44,7 @@ MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма UNAGRANDE_BALANCE = GOLDEN_STATEMENTS / "unagrande-group" / "2025-Q3" / "ББ. Лист 1 3кв.25.json" MEZH_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" MEZH_BALANCE_LINES = 37 -MEZH_BALANCE_TEMPLATE_ONLY = {"1215"} # коды fsbu4, которых нет в файле Межениновской (файл по форме 66н) +MEZH_BALANCE_TEMPLATE_ONLY = {"1105", "1215"} # коды fsbu4, которых нет в файле Межениновской (файл по форме 66н) MEZH_INCOME_TEMPLATE_ONLY = {"2420"} EVENTS_AFTER_BREAK_AND_ACCEPT = 2 @@ -1222,7 +1222,7 @@ async def test_patch_line_code_existing_or_unknown_rejected( client = await auth_client(KA_RF) col0 = await _column(client, document.id, 0) count = await db_line_count(seeded, document) - for code in ("1150", "1105"): # 1150 есть в форме; 1105 нет в справочнике fsbu4 + for code in ("1150", "1106"): # 1150 есть в форме; 1106 нет в справочнике fsbu4 response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_code": code, "column_id": col0, "amount": "1"}]}, diff --git a/api/tests/test_domain_statement_lines.py b/api/tests/test_domain_statement_lines.py index 32b4542..83ab0f6 100644 --- a/api/tests/test_domain_statement_lines.py +++ b/api/tests/test_domain_statement_lines.py @@ -14,38 +14,74 @@ CATALOG = load_line_catalog() [ ("66n", "BALANCE", 37, "1110", "1700"), ("66n", "INCOME", 22, "2110", "2910"), - ("fsbu4", "BALANCE", 34, "1110", "1700"), - ("fsbu4", "INCOME", 23, "2110", "2910"), + ("fsbu4", "BALANCE", 38, "1105", "1700"), + ("fsbu4", "INCOME", 24, "2110", "2910"), ], ) def test_edition_composition(edition: str, form: str, count: int, first: str, last: str) -> None: - lines = CATALOG.lines(edition, form) + lines = CATALOG.template_lines(edition, form) assert len(lines) == count assert lines[0].code == first and lines[-1].code == last assert len({line.code for line in lines}) == count def test_names_fixed_from_templates() -> None: - names = {line.code: line.name for line in CATALOG.lines("fsbu4", "BALANCE")} + names = {line.code: line.name for line in CATALOG.template_lines("fsbu4", "BALANCE")} assert names["1110"] == "Нематериальные активы" assert names["1210"] == "Запасы" assert names["1310"] == "Уставный капитал (складочный капитал, уставный фонд, вклады товарищей)" assert names["1410"] == names["1510"] == "Заемные средства" assert names["1600"] == names["1700"] == "БАЛАНС" - old = {line.code: line.name for line in CATALOG.lines("66n", "BALANCE")} + old = {line.code: line.name for line in CATALOG.template_lines("66n", "BALANCE")} assert old["1160"] == "Доходные вложения в материальные ценности" assert "1105" not in old and "1215" not in old and "1215" in names - income = {line.code: line.name for line in CATALOG.lines("fsbu4", "INCOME")} - assert "2420" in income and "2460" not in income + income = {line.code: line.name for line in CATALOG.template_lines("fsbu4", "INCOME")} + assert "2420" in income and "2460" in income assert all( - "\n" not in line.name for e in ("66n", "fsbu4") for f in ("BALANCE", "INCOME") for line in CATALOG.lines(e, f) + "\n" not in line.name + for e in ("66n", "fsbu4") + for f in ("BALANCE", "INCOME") + for line in CATALOG.template_lines(e, f) ) def test_no_edition_means_no_lines() -> None: - assert CATALOG.lines(None, "BALANCE") == () - assert CATALOG.lines("unknown", "BALANCE") == () - assert CATALOG.lines("66n", "OSV") == () + assert CATALOG.template_lines(None, "BALANCE") == () + assert CATALOG.template_lines("unknown", "BALANCE") == () + assert CATALOG.template_lines("66n", "OSV") == () + + +def test_catalog_two_names_one_template_line(tmp_path: Path) -> None: + lines = CATALOG.template_lines("fsbu4", "BALANCE") + assert [line.code for line in lines].count("1520") == 1 + assert {line.code: line.name for line in lines}["1520"] == "Кредиторская задолженность" + assert CATALOG.names("BALANCE", "1520") == ("Кредиторская задолженность", "Обязательство по аренде") + assert CATALOG.reference_name("fsbu4", "BALANCE", "1520") == "Кредиторская задолженность" + assert CATALOG.reference_name("fsbu4", "BALANCE", "9999") is None + assert "1520" in CATALOG.edition_codes("fsbu4", "BALANCE") and "9999" not in CATALOG.edition_codes("66n", "INCOME") + path = tmp_path / "statement_lines.yaml" + path.write_text( + "editions:\n - id: x\n forms:\n BALANCE: [{code: '1110', name: a}, {code: '1110', name: b}]\n" + " INCOME: []\n", + encoding="utf-8", + ) + own = load_line_catalog(path) + assert [line.name for line in own.template_lines("x", "BALANCE")] == ["a"] + assert own.names("BALANCE", "1110") == ("a", "b") + + +def test_catalog_fsbu4_additions() -> None: + assert "НДС, начисленный по отгрузке" in CATALOG.names("BALANCE", "1220") + assert "Налог при упрощенной системе налогообложения" in CATALOG.names("INCOME", "2410") + expected = { + "1105": "Гудвил", + "1130": "Нематериальные поисковые активы", + "1140": "Материальные поисковые активы", + "1530": "Доходы будущих периодов", + } + for code, name in expected.items(): + assert CATALOG.reference_name("fsbu4", "BALANCE", code) == name + assert CATALOG.reference_name("fsbu4", "INCOME", "2460") == "Прочее" def test_template_line_is_value_object() -> None: @@ -56,11 +92,6 @@ def test_template_line_is_value_object() -> None: ("body", "message"), [ ("editions:\n - id: x\n forms:\n BALANCE: [{code: '11', name: a}]\n INCOME: []\n", "код"), - ( - "editions:\n - id: x\n forms:\n BALANCE: [{code: '1110', name: a}, {code: '1110', name: b}]\n" - " INCOME: []\n", - "повтор", - ), ("editions:\n - id: x\n forms:\n BALANCE: []\n", "INCOME"), ("editions: [\n", "statement_lines"), ], diff --git a/api/tests/test_domain_statement_names.py b/api/tests/test_domain_statement_names.py new file mode 100644 index 0000000..3b5bb49 --- /dev/null +++ b/api/tests/test_domain_statement_names.py @@ -0,0 +1,40 @@ +"""Совпадение наименований §11.9 (спека «Совпадение наименований»).""" + +import pytest + +from src.domain.statement_names import names_match, normalize, section_number + + +@pytest.mark.parametrize( + ("recognized", "references", "expected"), + [ + ("Нематериальные активы", ["Результаты исследований и разработок"], False), + ( + "НДС, начисленный по отгрузке", + ["Налог на добавленную стоимость по приобретенным ценностям", "НДС, начисленный по отгрузке"], + True, + ), + ("Итого по разделу I", ["Итого по разделу II"], False), + ("Итого по разделу V1", ["Итого по разделу V"], True), + ("Итого по разделу V /", ["Итого по разделу V"], True), + ("Итого по разделу", ["Итого по разделу II"], False), + ("АКТИВ I. Внеоборотные активы Гудвил", ["Гудвил"], True), + ("Расчёты", ["Расчеты"], True), + ("", ["Запасы"], False), + (None, ["Запасы"], False), + ("Запасы", [], False), + ("Итого по разделу х", ["Итого по разделу X"], True), + ("Итого по разделу", ["Итого по разделу"], True), + ("Итого по разделу II", ["Итого по разделу IV"], False), + ("Запасы", ["Основные средства"], False), + ], +) +def test_names_match(recognized: str | None, references: list[str], expected: bool) -> None: + assert names_match(recognized, references) is expected + + +def test_normalize_and_section_number() -> None: + assert normalize(" Расчёты,\tс (покупателями)_ ") == "расчеты с покупателями" + assert section_number(normalize("Итого по разделу VІ")) == "vi" + assert section_number("запасы") is None + assert section_number("итого по разделу") is None -- 2.47.2 From a15111ab60514aa2aa899c962cde09926387fd9f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 14:00:21 +0400 Subject: [PATCH 07/20] =?UTF-8?q?feat(statements):=20=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D0=B5=D0=B3=D0=B0=D1=82=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BA=D0=BE=D0=B4=D1=83=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8,=20=D0=BC=D0=B5?= =?UTF-8?q?=D0=B6=D1=84=D0=BE=D1=80=D0=BC=D0=B5=D0=BD=D0=BD=D0=B0=D1=8F,?= =?UTF-8?q?=20parent=5Fcode,=20=D0=B4=D0=BE=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D0=BA=D0=B0,=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BA=D0=BE=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- api/src/domain/statement_aggregate.py | 87 +++++++++++ api/src/domain/statement_checks.py | 8 +- api/src/repositories/statements.py | 145 +++++++++++-------- api/src/schemas/statements.py | 9 +- api/src/services/result_mapping.py | 13 +- api/src/services/statements.py | 33 ++++- api/tests/test_api_statements.py | 129 +++++++++++++++++ api/tests/test_domain_statement_aggregate.py | 83 +++++++++++ api/tests/test_domain_statement_checks.py | 8 +- 9 files changed, 436 insertions(+), 79 deletions(-) create mode 100644 api/src/domain/statement_aggregate.py create mode 100644 api/tests/test_domain_statement_aggregate.py diff --git a/api/src/domain/statement_aggregate.py b/api/src/domain/statement_aggregate.py new file mode 100644 index 0000000..590280f --- /dev/null +++ b/api/src/domain/statement_aggregate.py @@ -0,0 +1,87 @@ +"""Агрегат формы ББ/ОФР по коду строки (спека `arm-core`, v5.19; P3-14). + +Код строки в форме может повторяться (лист-продолжение, повтор в источнике, добавленная строка). Все чтения «по коду» — +проверки §7, межформенная проверка, `parent_code` подстрок, достройка по шаблону — идут через агрегат: суммы строк +`ok` с одним кодом складываются по колонке, пустая сумма не превращается в 0. Чистая функция, без БД. +""" + +import uuid +from collections.abc import Callable, Iterable, Mapping, Sequence +from dataclasses import dataclass +from decimal import Decimal +from typing import Protocol + +from src.models.enums import StatementLineStatus + +__all__ = ["AggLine", "CodeAggregate", "aggregate_by_code", "checks_input"] + + +@dataclass(frozen=True) +class AggLine: + """Строка формы для агрегата: `amounts` — сумма по `column_id` (`None` — пустая ячейка или её нет).""" + + id: uuid.UUID + row_index: int | None + line_status: StatementLineStatus + line_code: str | None + amounts: Mapping[uuid.UUID, Decimal | None] + + +@dataclass(frozen=True) +class 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 # есть строка источника (`row_index IS NOT NULL`) + + +def _no_name(_: str) -> str | None: + return None + + +def aggregate_by_code( + lines: Iterable[AggLine], reference_name: Callable[[str], str | None] = _no_name +) -> dict[str, CodeAggregate]: + """Агрегат строк `ok` по коду, по возрастанию кода. Сумма по колонке — сумма непустых; нет непустых — `None`.""" + groups: dict[str, list[AggLine]] = {} + for line in lines: + if line.line_status is StatementLineStatus.ok and line.line_code is not None: + groups.setdefault(line.line_code, []).append(line) + result: dict[str, CodeAggregate] = {} + for code in sorted(groups): + ordered = sorted(groups[code], key=lambda item: (item.row_index is None, item.row_index or 0, str(item.id))) + amounts: dict[uuid.UUID, Decimal | None] = {} + for line in ordered: + for column_id, amount in line.amounts.items(): + previous = amounts.get(column_id) + amounts[column_id] = amount if previous is None else previous + (amount or Decimal(0)) + result[code] = CodeAggregate( + code=code, + amounts=amounts, + line_ids=tuple(line.id for line in ordered), + reference_name=reference_name(code), + has_source_line=any(line.row_index is not None for line in ordered), + ) + return result + + +class _Column(Protocol): + id: uuid.UUID + column_index: int + + +def checks_input( + columns: Sequence[_Column], aggregates: Mapping[str, CodeAggregate] +) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]: + """Вход `run_checks`: индексы колонок (по возрастанию) и `(код, {column_index: сумма})` по возрастанию кода. + + `columns` — колонки формы (`id`, `column_index`). Код без строки источника и без единой суммы (пустая шаблонная + строка) пропускается (design D-5 шаблонных строк).""" + index_by_id = {column.id: column.column_index for column in columns} + lines = [ + (code, {index_by_id[cid]: amount for cid, amount in agg.amounts.items() if cid in index_by_id}) + for code, agg in sorted(aggregates.items()) + if agg.has_source_line or any(amount is not None for amount in agg.amounts.values()) + ] + return sorted(column.column_index for column in columns), lines diff --git a/api/src/domain/statement_checks.py b/api/src/domain/statement_checks.py index 7b1125d..ea50d21 100644 --- a/api/src/domain/statement_checks.py +++ b/api/src/domain/statement_checks.py @@ -72,6 +72,7 @@ class FailedCheck: relation: str expected: Decimal actual: Decimal + codes: tuple[str, ...] = () # цель и члены правила (P3-14); у межформенной — 1370 и 2400 @dataclass(frozen=True) @@ -144,7 +145,7 @@ def subline_parents(codes: Sequence[str]) -> dict[str, str]: Подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами, иначе — к строке с кодом из её первых четырёх цифр, если такая есть; без родителя в результат не попадает. Порядок строк в БД не хранится - (DBML порядка не знает): «выше» — порядок `codes`, и вызывающий передаёт его тот же, в каком `statement_amounts` + (DBML порядка не знает): «выше» — порядок `codes`, и вызывающий передаёт его тот же, в каком `checks_input` отдаёт коды проверке (по возрастанию кода; M-9 2b). Порядок результата — порядок подстрок в `codes`.""" present = {code for code in codes if len(code) == _PARENT_LEN} result: dict[str, str] = {} @@ -211,7 +212,8 @@ def run_checks( holds, expected, actual = _holds(amounts, rule, rules.tolerance) if holds: continue - failed = FailedCheck(column_index, rule.relation, expected, actual) + 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: @@ -234,4 +236,4 @@ def cross_form_check( delta = current - year_start if abs(delta - income_2400) <= tolerance: return None - return FailedCheck(-1, CROSS_FORM_RELATION, delta, income_2400) + return FailedCheck(-1, CROSS_FORM_RELATION, delta, income_2400, ("1370", "2400")) diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index 0153491..20d150b 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -3,15 +3,16 @@ import datetime import uuid -from collections.abc import Sequence +from collections.abc import Callable, Sequence from decimal import Decimal from typing import Any -from sqlalchemy import delete, insert, select +from sqlalchemy import delete, func, insert, select from sqlalchemy.ext.asyncio import AsyncSession +from src.domain.statement_aggregate import AggLine, CodeAggregate, aggregate_by_code from src.domain.statement_mapping import StatementDraft -from src.models.enums import DocumentStatus, DocumentType, FileStatus +from src.models.enums import DocumentStatus, DocumentType, FileStatus, StatementLineStatus from src.models.files import Document, StoredFile from src.models.statements import ( DocumentIssue, @@ -25,16 +26,18 @@ __all__ = [ "BATCH", "add_line", "add_value", + "code_amount", "column_ids", "delete_documents_statements", "delete_file_statements", + "form_aggregate", "form_lines", "get_meta", + "has_source_lines", "insert_statement", "load_form", "lock_cells", "pair_amounts", - "statement_amounts", "value_flags", ] @@ -72,12 +75,13 @@ async def insert_statement( await session.execute(insert(FinancialStatementColumn).values(columns)) line_ids: dict[str, uuid.UUID] = {} lines: list[dict[str, Any]] = [] - for line in draft.lines: + for row_index, line in enumerate(draft.lines): line_ids[line.line_code] = uuid.uuid4() lines.append( { "id": line_ids[line.line_code], "meta_id": meta.id, + "row_index": row_index, # строка источника (до маппера `2.0`: порядок черновика) "line_code": line.line_code, "line_code_original_value": line.line_code, "line_code_confidence": line.line_code_confidence, @@ -130,52 +134,60 @@ async def _delete_statements(session: AsyncSession, documents: Any) -> None: await session.execute(delete(FinancialStatementMeta).where(FinancialStatementMeta.document_id.in_(documents))) -async def has_lines(session: AsyncSession, meta_id: uuid.UUID) -> bool: - """В форме есть строки `financial_statement_line` (единое определение «есть строки» для статуса D-6).""" - stmt = select(select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id == meta_id).exists()) +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() + ) return bool((await session.execute(stmt)).scalar_one()) -async def statement_amounts( - session: AsyncSession, meta_id: uuid.UUID -) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]: - """Индексы колонок и `(код строки, {колонка: сумма})` по возрастанию кода — вход `run_checks`. Порядок строк - формы в БД не хранится (DBML не хранит), поэтому порядок строк — лексикографический порядок кодов. - Созданные правкой строки (`line_code_original_value IS NULL`) без единой суммы не участвуют - (spec statement-checks, design D-5).""" - columns = ( +async def form_aggregate( + session: AsyncSession, + meta_id: uuid.UUID, + reference_name: Callable[[str], str | None] = lambda _: None, +) -> tuple[list[FinancialStatementColumn], dict[str, CodeAggregate]]: + """Колонки формы (по `column_index`) и агрегат строк `ok` по коду (`domain/statement_aggregate`) — вход проверок + §7, `parent_code` и достройки по шаблону. Порядок строк формы в БД задаёт `row_index`, но проверки §7 читают коды + по возрастанию (M-9 2b).""" + columns = list( + ( + await session.execute( + select(FinancialStatementColumn) + .where(FinancialStatementColumn.meta_id == meta_id) + .order_by(FinancialStatementColumn.column_index) + ) + ).scalars() + ) + lines = ( await session.execute( - select(FinancialStatementColumn.id, FinancialStatementColumn.column_index) - .where(FinancialStatementColumn.meta_id == meta_id) - .order_by(FinancialStatementColumn.column_index) + select(FinancialStatementLine).where( + FinancialStatementLine.meta_id == meta_id, + FinancialStatementLine.line_status == StatementLineStatus.ok, + FinancialStatementLine.line_code.is_not(None), + ) ) - ).all() - index_by_id = {column_id: index for column_id, index in columns} + ).scalars() + by_line: dict[uuid.UUID, dict[uuid.UUID, Decimal | None]] = {} rows = ( await session.execute( - select( - FinancialStatementLine.line_code, - FinancialStatementLine.line_code_original_value, - FinancialStatementValue.column_id, - FinancialStatementValue.amount, + select(FinancialStatementValue.line_id, FinancialStatementValue.column_id, FinancialStatementValue.amount) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .where( + FinancialStatementLine.meta_id == meta_id, + FinancialStatementLine.line_status == StatementLineStatus.ok, ) - .join(FinancialStatementValue, FinancialStatementValue.line_id == FinancialStatementLine.id) - .where(FinancialStatementLine.meta_id == meta_id) - .order_by(FinancialStatementLine.line_code) ) ).all() - by_code: dict[str, dict[int, Decimal | None]] = {} - added: set[str] = set() # строки, созданные правкой аналитика (design D-5) - for code, original, column_id, amount in rows: - by_code.setdefault(code or "", {})[index_by_id[column_id]] = amount - if original is None: - added.add(code or "") - lines = [ - (code, amounts) - for code, amounts in by_code.items() - if code not in added or any(amount is not None for amount in amounts.values()) + for line_id, column_id, amount in rows: + by_line.setdefault(line_id, {})[column_id] = amount + agg_lines = [ + AggLine(line.id, line.row_index, line.line_status, line.line_code, by_line.get(line.id, {})) for line in lines ] - return [index for _, index in columns], lines + return columns, aggregate_by_code(agg_lines, reference_name) async def value_flags(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[bool, bool | None, Decimal | None]]: @@ -234,7 +246,7 @@ async def column_ids(session: AsyncSession, meta_id: uuid.UUID) -> set[uuid.UUID async def add_line(session: AsyncSession, meta_id: uuid.UUID, code: str, name: str) -> FinancialStatementLine: - """Строка справочника, созданная правкой аналитика: `*_original_value = NULL` — признак «не из источника».""" + """Шаблонная строка, созданная правкой аналитика: `row_index = NULL` — признак «не из источника»; статус `ok`.""" line = FinancialStatementLine( id=uuid.uuid4(), meta_id=meta_id, @@ -246,6 +258,9 @@ async def add_line(session: AsyncSession, meta_id: uuid.UUID, code: str, name: s line_name_original_value=None, line_name_confidence=None, line_name_modified=False, + row_index=None, + source_ref=None, + line_status=StatementLineStatus.ok, ) session.add(line) await session.flush() @@ -283,7 +298,8 @@ async def get_meta( async def load_form( session: AsyncSession, meta_id: uuid.UUID ) -> tuple[list[FinancialStatementColumn], list[tuple[FinancialStatementLine, list[FinancialStatementValue]]]]: - """Колонки по `column_index` и строки по `line_code` с их значениями по `column_index`.""" + """Колонки по `column_index` и строки по `line_code`, `row_index` (`NULL` последними), `id` с их значениями по + `column_index`.""" columns = list( ( await session.execute( @@ -299,7 +315,11 @@ async def load_form( await session.execute( select(FinancialStatementLine) .where(FinancialStatementLine.meta_id == meta_id) - .order_by(FinancialStatementLine.line_code, FinancialStatementLine.id) + .order_by( + FinancialStatementLine.line_code, + FinancialStatementLine.row_index.asc().nulls_last(), + FinancialStatementLine.id, + ) ) ).scalars() ) @@ -316,27 +336,34 @@ async def load_form( return columns, [(line, by_line[line.id]) for line in lines] -async def _line_amount( +async def code_amount( session: AsyncSession, meta_id: uuid.UUID, - line_code: str, + code: str, *, current: bool = False, period_date: datetime.date | None = None, ) -> Decimal | None: - """Сумма строки `line_code` в колонке `is_current` (`current`) или с датой `period_date`; нет — `None`.""" - stmt = ( - select(FinancialStatementValue.amount) - .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) - .join(FinancialStatementColumn, FinancialStatementColumn.id == FinancialStatementValue.column_id) - .where(FinancialStatementLine.meta_id == meta_id, FinancialStatementLine.line_code == line_code) - .order_by(FinancialStatementColumn.column_index) - .limit(1) - ) + """`SUM(amount)` строк `ok` с кодом `code` в колонке `is_current` (`current`) или с датой `period_date`; колонки + нет или непустых сумм нет — `None`.""" + column_stmt = select(FinancialStatementColumn.id).where(FinancialStatementColumn.meta_id == meta_id) if current: - stmt = stmt.where(FinancialStatementColumn.is_current.is_(True)) + column_stmt = column_stmt.where(FinancialStatementColumn.is_current.is_(True)) else: - stmt = stmt.where(FinancialStatementColumn.period_date == period_date) + column_stmt = column_stmt.where(FinancialStatementColumn.period_date == period_date) + column_id = (await session.execute(column_stmt.order_by(FinancialStatementColumn.column_index).limit(1))).scalar() + if column_id is None: + return None + stmt = ( + select(func.sum(FinancialStatementValue.amount)) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .where( + FinancialStatementLine.meta_id == meta_id, + FinancialStatementLine.line_code == code, + FinancialStatementLine.line_status == StatementLineStatus.ok, + FinancialStatementValue.column_id == column_id, + ) + ) return (await session.execute(stmt)).scalar_one_or_none() @@ -376,8 +403,8 @@ async def pair_amounts( if balance_meta is not None: year_start = datetime.date(document.period_year - 1, 12, 31) balance = ( - await _line_amount(session, balance_meta.id, "1370", current=True), - await _line_amount(session, balance_meta.id, "1370", period_date=year_start), + await code_amount(session, balance_meta.id, "1370", current=True), + await code_amount(session, balance_meta.id, "1370", period_date=year_start), ) - income = await _line_amount(session, income_meta.id, "2400", current=True) if income_meta is not None else None + income = await code_amount(session, income_meta.id, "2400", current=True) if income_meta is not None else None return balance, income diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index 5e4fb02..4a8ac19 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -15,7 +15,7 @@ from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_vali from src.domain.statement_checks import ChecksReport, FailedCheck from src.domain.statement_lines import TemplateLine -from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter +from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter, StatementLineStatus from src.models.files import Document from src.models.statements import ( FinancialStatementColumn, @@ -155,8 +155,8 @@ def _value_out(value: FinancialStatementValue, column_index: dict[uuid.UUID, int def line_origin(line: FinancialStatementLine) -> LineOrigin: - """`template` — строка создана правкой аналитика (маппинг всегда пишет `line_code_original_value`).""" - return "template" if line.line_code_original_value is None else "source" + """`template` — строка создана правкой аналитика (`row_index IS NULL`); у строк источника индекс задан всегда.""" + return "template" if line.row_index is None else "source" def _empty_value(column: FinancialStatementColumn) -> ValueOut: @@ -202,7 +202,8 @@ def _lines_out( values=cells, ) ) - codes = {line.line_code for line, _ in lines} + # достройка по шаблону — только для кода без строки `ok` (строка `unreadable` с тем же текстом кода не мешает) + codes = {line.line_code for line, _ in lines if line.line_status is StatementLineStatus.ok} for item in template: if item.code in codes: continue diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index cf3977d..74347e4 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -26,6 +26,7 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn import asyncio import dataclasses +import functools import uuid from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass @@ -49,8 +50,10 @@ 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_mapping import StatementDraft, build_statement, statement_status, values_ok +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.models.accounting import ChartOfAccount from src.models.consolidation import Consolidation, ConsolidationVersion from src.models.directories import CompanyGroup, LegalEntity @@ -415,9 +418,15 @@ async def _insert_statements( if not forms: return rules = load_check_rules() + catalog = load_line_catalog() 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) - column_indexes, amounts = await statements_repo.statement_amounts(session, meta.id) + 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) document.status = statement_status( job_done=job_done, diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 6894416..8869636 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -15,6 +15,7 @@ from src.common.clock import utc_now from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError 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, @@ -34,9 +35,10 @@ from src.models.enums import ( DocumentType, FileStatus, RecognitionStatus, + StatementLineStatus, ) from src.models.files import Document, RecognitionJob -from src.models.statements import FinancialStatementLine, FinancialStatementMeta +from src.models.statements import FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta from src.repositories import files as files_repo from src.repositories import statements as repo from src.repositories.counterparties import CounterpartyIndex @@ -56,13 +58,24 @@ INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII) FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME}) +async def form_aggregate( + session: AsyncSession, document: Document, meta: FinancialStatementMeta +) -> tuple[list[FinancialStatementColumn], dict[str, CodeAggregate]]: + """Колонки и агрегат формы по коду со справочными наименованиями редакции документа (P3-14).""" + catalog = load_line_catalog() + edition = edition_for(document.reporting_date) + form = document.document_type.value + return await repo.form_aggregate(session, meta.id, lambda code: catalog.reference_name(edition, form, code)) + + async def statement_hard_failures( session: AsyncSession, document: Document, meta: FinancialStatementMeta ) -> tuple[FailedCheck, ...]: """Нарушенные жёсткие проверки §7 по текущим суммам формы; при акцепте — пустой кортеж (D-8 Среза 3b).""" if meta.checks_accepted_at is not None: return () - column_indexes, amounts = await repo.statement_amounts(session, meta.id) + 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) @@ -121,10 +134,16 @@ class StatementService: self, document: Document, meta: FinancialStatementMeta ) -> tuple[ChecksReport, FailedCheck | None, dict[str, str]]: """Проверки §7 по текущим суммам формы, мягкая межформенная проверка (D-5) и родители подстрок - (`parent_code` ответа; AURORA-1527). Родители считаются по тем же кодам и в том же порядке - (`statement_amounts`), что и проверка «сумма подстрок = родитель», — ответ и проверка не расходятся.""" + (`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 = await repo.statement_amounts(self._session, meta.id) + column_indexes, amounts = checks_input(columns, aggregates) report = run_checks(document.document_type.value, amounts, column_indexes, rules) parents = subline_parents([code for code, _ in amounts]) balance_1370, income_2400 = await repo.pair_amounts(self._session, document) @@ -163,7 +182,7 @@ class StatementService: 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 repo.value_flags(self._session, meta.id)), - has_lines=await repo.has_lines(self._session, meta.id), + has_lines=await repo.has_source_lines(self._session, meta.id), warnings=(), ) document.updated_at = utc_now() @@ -221,7 +240,7 @@ class StatementService: columns = await repo.column_ids(self._session, meta.id) lines = await repo.form_lines(self._session, meta.id) by_id = {line.id: line for line in lines} - codes = {line.line_code for line in lines} + codes = {line.line_code for line in lines if line.line_status is StatementLineStatus.ok} edition = edition_for(document.reporting_date) catalog_lines = load_line_catalog().template_lines(edition, document.document_type.value) template = {item.code: item for item in catalog_lines} diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index c00261e..2b5cf18 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -1377,3 +1377,132 @@ async def test_template_line_checks_follow_filled_amounts( after_clear = response.json()["checks"]["failed"] assert not any(f["relation"].startswith("1700 =") for f in after_clear) # очищенная 1700 снова не цель assert {f["column_index"] for f in after_clear if f["relation"] == "1600 = 1700"} == {0, 1, 2} + + +# --- агрегат формы по коду (v5.19, A7) --- + + +async def insert_line( # noqa: PLR0913 - строка формы теста: код, статус, индекс, сумма + session: AsyncSession, + document: Document, + *, + code: str | None, + row_index: int | None, + status: str = "ok", + amount: Decimal | None = None, + current: bool = True, + code_original: str | None = None, + code_text: str | None = None, +) -> FinancialStatementLine: + """Строка формы напрямую в БД; ячейка с `amount` — в колонке отчётной даты (`current`) или нулевой.""" + meta = await _meta(session, document) + columns = ( + ( + await session.execute( + select(FinancialStatementColumn) + .where(FinancialStatementColumn.meta_id == meta.id) + .order_by(FinancialStatementColumn.column_index) + ) + ) + .scalars() + .all() + ) + column = next(c for c in columns if c.is_current) if current else columns[0] + line = FinancialStatementLine( + id=uuid.uuid4(), + meta_id=meta.id, + line_code=code, + line_code_original_value=code_original, + line_status=status, + line_code_text=code_text, + line_name="тест", + row_index=row_index, + ) + session.add(line) + await session.flush() + if amount is not None: + session.add( + FinancialStatementValue( + id=uuid.uuid4(), line_id=line.id, column_id=column.id, amount=amount, is_modified=False + ) + ) + await session.flush() + return line + + +async def test_cross_form_uses_aggregate( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Review Focus 2: две строки 1370 в колонке отчётной даты — 500 + 300 = 800.""" + balance, income = await two_forms(seeded, fixtures_dir) + await set_amount(seeded, balance, "1370", Decimal("500"), current=True) + await set_amount(seeded, balance, "1370", Decimal("0"), period_date=datetime.date(2024, 12, 31)) + await insert_line(seeded, balance, code="1370", row_index=99, amount=Decimal("300"), code_original="1370") + client = await auth_client(KA_RF) + await set_amount(seeded, income, "2400", Decimal("800"), current=True) + assert (await client.get(f"/documents/{income.id}/statement")).json()["checks"]["cross_form"] is None + await set_amount(seeded, income, "2400", Decimal("500"), current=True) + cross = (await client.get(f"/documents/{income.id}/statement")).json()["checks"]["cross_form"] + assert cross is not None and cross["expected"] == "800.00" and cross["actual"] == "500.00" + + +async def test_parent_code_with_repeated_parent( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE) + await insert_line(seeded, document, code="1150", row_index=98, amount=Decimal("1"), code_original="1150") + await insert_line(seeded, document, code="11501", row_index=99, amount=Decimal("1"), code_original="11501") + client = await auth_client(KA_RF) + body = (await client.get(f"/documents/{document.id}/statement")).json() + sub = [line for line in body["lines"] if line["line_code"] == "11501"] + assert [line["parent_code"] for line in sub] == ["1150", "1150"][: len(sub)] and sub + assert len([line for line in body["lines"] if line["line_code"] == "1150"]) == 2 # noqa: PLR2004 + + +async def test_template_not_completed_for_code_with_ok_line( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + await insert_line(seeded, document, code="1215", row_index=90, amount=Decimal("5"), code_original="1215") + client = await auth_client(KA_RF) + body = (await client.get(f"/documents/{document.id}/statement")).json() + lines = [line for line in body["lines"] if line["line_code"] == "1215"] + assert [(line["origin"], line["id"] is None) for line in lines] == [("source", False)] + + +async def test_template_completed_for_code_only_unreadable( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + bad = await insert_line( + seeded, document, code=None, row_index=91, status="unreadable", amount=Decimal("5"), code_text="ИЧ,215" + ) + client = await auth_client(KA_RF) + body = (await client.get(f"/documents/{document.id}/statement")).json() + template = [line for line in body["lines"] if line["line_code"] == "1215"] + assert [(line["origin"], line["id"]) for line in template] == [("template", None)] + assert any(line["id"] == str(bad.id) for line in body["lines"]) + + +async def test_patch_by_line_code_with_ok_line_is_unknown_cell( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + await insert_line(seeded, document, code="1215", row_index=90, amount=Decimal("5"), code_original="1215") + client = await auth_client(KA_RF) + body = (await client.get(f"/documents/{document.id}/statement")).json() + column_id = body["columns"][0]["id"] + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_code": "1215", "column_id": column_id, "amount": "1"}]}, + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "UNKNOWN_CELL" + + +async def test_origin_by_row_index(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + line = await insert_line(seeded, document, code="1250", row_index=7, amount=Decimal("5"), code_original=None) + client = await auth_client(KA_RF) + body = (await client.get(f"/documents/{document.id}/statement")).json() + assert next(item for item in body["lines"] if item["id"] == str(line.id))["origin"] == "source" diff --git a/api/tests/test_domain_statement_aggregate.py b/api/tests/test_domain_statement_aggregate.py new file mode 100644 index 0000000..5e1ca0b --- /dev/null +++ b/api/tests/test_domain_statement_aggregate.py @@ -0,0 +1,83 @@ +"""Агрегат формы по коду (спека `arm-core` v5.19; Review Focus 2).""" + +import uuid +from dataclasses import dataclass +from decimal import Decimal + +from src.domain.statement_aggregate import AggLine, aggregate_by_code, checks_input +from src.models.enums import StatementLineStatus + +C1 = uuid.UUID(int=1) +C2 = uuid.UUID(int=2) +L1, L5 = uuid.UUID(int=101), uuid.UUID(int=105) + + +def line( # noqa: PLR0913 - поля строки теста + status: str, + code: str | None, + *, + r: int | None, + c1: str | None = None, + c2: str | None = None, + id_: uuid.UUID | None = None, +) -> AggLine: + amounts = {C1: Decimal(c1) if c1 is not None else None, C2: Decimal(c2) if c2 is not None else None} + return AggLine(id_ or uuid.uuid4(), r, StatementLineStatus(status), code, amounts) + + +def ok(code: str, *, r: int | None, c1: str | None = None, c2: str | None = None, id_: uuid.UUID | None = None): + return line("ok", code, r=r, c1=c1, c2=c2, id_=id_) + + +def test_repeated_code_sums() -> None: + agg = aggregate_by_code([ok("2410", r=1, c1="100", id_=L1), ok("2410", r=5, c1="649", id_=L5)]) + assert agg["2410"].amounts[C1] == Decimal("749") and agg["2410"].line_ids == (L1, L5) + + +def test_empty_amounts_stay_null() -> None: + assert aggregate_by_code([ok("1110", r=0), ok("1110", r=3)])["1110"].amounts[C1] is None + assert aggregate_by_code([ok("1110", r=0), ok("1110", r=3, c1="5")])["1110"].amounts[C1] == Decimal("5") + + +def test_non_ok_lines_excluded() -> None: + 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() -> None: + 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 + + +def test_codes_ascending_and_template_line_ids_last() -> None: + agg = aggregate_by_code([ok("2400", r=None, id_=L5), ok("1600", r=2), ok("2400", r=7, id_=L1)]) + assert list(agg) == ["1600", "2400"] and agg["2400"].line_ids == (L1, L5) + assert agg["2400"].has_source_line is True + + +def test_reference_name_from_callback() -> None: + agg = aggregate_by_code([ok("1250", r=1)], reference_name=lambda code: f"имя {code}") + assert agg["1250"].reference_name == "имя 1250" + + +@dataclass +class Col: + id: uuid.UUID + column_index: int + + +def test_checks_input_skips_empty_template_codes() -> None: + agg = aggregate_by_code( + [ok("1250", r=None), ok("1260", r=None, c1="4"), ok("1270", r=3), ok("1280", r=4, c1="1"), ok("1280", r=None)] + ) + indexes, lines = checks_input([Col(C2, 1), Col(C1, 0)], agg) + assert indexes == [0, 1] + assert [code for code, _ in lines] == ["1260", "1270", "1280"] + assert dict(lines)["1260"] == {0: Decimal("4"), 1: None} diff --git a/api/tests/test_domain_statement_checks.py b/api/tests/test_domain_statement_checks.py index ca77c8e..586082e 100644 --- a/api/tests/test_domain_statement_checks.py +++ b/api/tests/test_domain_statement_checks.py @@ -35,7 +35,7 @@ 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")) in report.failed + assert FailedCheck(0, "1600 = 1100 + 1200", D("1000"), D("900"), ("1600", "1100", "1200")) in report.failed def test_relation_skipped_without_target() -> None: @@ -107,7 +107,7 @@ def test_relation_checked_per_column_and_none_is_zero() -> None: ) 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")) + FailedCheck(1, "1600 = 1100 + 1200", D("1000"), D("900"), ("1600", "1100", "1200")) ] assert _by_relation(report.soft_failed, "1600 = 1100 + 1200") == [] @@ -117,7 +117,7 @@ def test_tolerance_exceeded_by_one_unit_is_violation() -> None: 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")) + FailedCheck(0, "1600 = 1100 + 1200", D("902"), D("900"), ("1600", "1100", "1200")) ] @@ -179,7 +179,7 @@ def test_rounding_threshold_boundary() -> None: ( (D("500"), D("300")), D("150"), - FailedCheck(-1, "1370 − 1370(начало года) = 2400", D("200"), D("150")), + FailedCheck(-1, "1370 − 1370(начало года) = 2400", D("200"), D("150"), ("1370", "2400")), ), ((None, D("300")), D("200"), None), ((D("500"), D("300")), None, None), -- 2.47.2 From 65a833fd98eb84c294da1da8377fabd5b97223e4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 14:22:28 +0400 Subject: [PATCH 08/20] =?UTF-8?q?feat(api):=20StatementOut=20=D0=B8=20Docu?= =?UTF-8?q?mentOut=20=D0=BF=D0=BE=20v5.19=20=E2=80=94=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=83=D1=81=D1=8B=20=D1=81=D1=82=D1=80=D0=BE=D0=BA,=20ne?= =?UTF-8?q?eds=5Freview,=20issues,=20=D0=BF=D0=BE=D1=80=D1=8F=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- README.md | 19 +- api/src/domain/statement_layout.py | 53 +++++ api/src/domain/statement_lines.py | 10 + api/src/repositories/statements.py | 54 ++++- api/src/schemas/documents.py | 3 + api/src/schemas/files.py | 2 + api/src/schemas/issues.py | 56 +++++ api/src/schemas/statements.py | 152 +++++++++++-- api/src/services/documents.py | 51 ++++- api/src/services/primary_files.py | 14 +- api/src/services/statements.py | 18 +- api/tests/test_api_documents.py | 2 + api/tests/test_api_files_read.py | 56 ++++- api/tests/test_api_statements.py | 248 +++++++++++++++++++++- api/tests/test_domain_statement_layout.py | 46 ++++ api/tests/test_domain_statement_lines.py | 19 ++ 16 files changed, 767 insertions(+), 36 deletions(-) create mode 100644 api/src/domain/statement_layout.py create mode 100644 api/src/schemas/issues.py create mode 100644 api/tests/test_domain_statement_layout.py diff --git a/README.md b/README.md index f1927c5..1547791 100644 --- a/README.md +++ b/README.md @@ -566,12 +566,29 @@ S3 (storage); без S3 — предупреждение в логе, цикл текущим суммам формы и не хранятся; словарь — `api/rules/statement_checks.yaml` (копия словаря tabular-parser — правится в обоих репозиториях). -- **`GET /documents/{id}/statement`** — `{document, edition, header, columns[], lines[], checks}`: `header` — `inn`, +- **`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` — `{failed, soft_failed, cross_form, accepted}` (нарушение — `{column_index, relation, expected, actual}`, у межформенной проверки `column_index = -1`). Не форма (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) — 409 `DOCUMENT_NOT_STATEMENT`. Подтверждённая форма читается. Форма достраивается по справочнику строк редакции (`api/rules/statement_lines.yaml`): недостающие строки (`id = null`, `origin = template`) и ячейки (`id = null`); в БД не пишется. +- **Поля v5.19 в ответе `GET …/statement`** (change `unified-statement-normalization`, P3-11, P3-17, P3-20…P3-23): + `document.form_variant`; `header.okei`, `header.needs_review_fields[]`, `header.organization_name_modified`, + `header.source{поле: {text, confidence}}` (`header_source` или `{}`; `confidence` — строка с двумя знаками, `text` и + `confidence` могут быть `null`); `columns[].period_kind`, `label_confidence`; `lines[]` — `row_index`, `source_ref`, + `line_status` (`ok` / `detail` / `unreadable` / `missing`), `line_code_text`, `line_code_confidence`, + `line_code_modified`, `reference_name` (первое имя кода в словаре редакции ответа, иначе `null`), `parent_row_index`, + `needs_review`, `is_confirmed`; `lines[].values[]` — `text` (`source_text`), `needs_review`, `is_confirmed`; + `checks.*[]` — `codes` и `line_ids` (строки `ok` кодов правила); `issues[]` — записи `document_issue` по + `created_at, id`: `{id, kind, code, severity, message, line_ids, column_id, column_index, field, source, relation, + codes, expected, actual}`; `edition_lines[] {code, name}` — справочник кодов для подсказки (состав формы редакции + ответа по одному на код; у формы без даты — коды всех редакций). Порядок `lines[]`: строки `ok` (с достроенными + шаблонными) по коду, затем `row_index` (`NULL` последними); за строкой `ok` источника — её вложения (`detail` с этим + родителем, `unreadable` / `missing` / `detail` без родителя, у которых она — ближайшая предыдущая по `row_index` + строка `ok`); вложения без такой строки — в начале (`domain/statement_layout.py`). + Карточка документа (`GET /documents/{id}`) несёт `issues[]` (у документа любого типа) и `issue_counts {error, info}`; + `documents[]` карточки файла — `issue_counts` (все виды записей по `severity`, в том числе акцептованные; один групповой + запрос на список документов). - **`PATCH /documents/{id}/statement`** — `{header?: {inn?, report_date?, units?}, values?: [{line_id | line_code, column_id, amount}]}` (до 5000 ячеек; `amount: null` очищает ячейку; не более двух знаков; `units` — непустая строка после обрезки пробелов до 50 символов, пробельная строка — 422, `null` — «не менять»; `inn: null` — 422 `INVALID_INN`, ИНН не очищается). Правится только неподтверждённая форма diff --git a/api/src/domain/statement_layout.py b/api/src/domain/statement_layout.py new file mode 100644 index 0000000..dc7870c --- /dev/null +++ b/api/src/domain/statement_layout.py @@ -0,0 +1,53 @@ +"""Порядок строк ответа `GET …/statement` (P3-17; спека `arm-core` «Ответ `GET …/statement`»). Чистая функция. + +Строки `ok` (в том числе достроенные шаблонные и созданные правкой) — по коду, затем `row_index` (`NULL` последними), +затем в порядке входа (вызывающий передаёт строки по `id`). За строкой `ok` источника идут её «вложения» по +`row_index`: строки `detail` с этим родителем и строки `unreadable` / `missing` / `detail` без (известного) родителя, +у которых она — ближайшая предыдущая по `row_index` строка `ok` источника. Вложения без такой строки — в начале. +""" + +import bisect +from collections.abc import Hashable, Sequence +from dataclasses import dataclass + +__all__ = ["LayoutLine", "order_lines"] + +OK = "ok" +DETAIL = "detail" + + +@dataclass(frozen=True) +class LayoutLine: + key: Hashable + row_index: int | None + line_status: str + line_code: str | None + parent_row_index: int | None + + +def order_lines(lines: Sequence[LayoutLine]) -> list[Hashable]: + """Ключи строк в порядке ответа.""" + ok_lines = [line for line in lines if line.line_status == OK] + others = [line for line in lines if line.line_status != OK] + ok_ordered = sorted( + ok_lines, + key=lambda line: (line.line_code is None, line.line_code or "", line.row_index is None, line.row_index or 0), + ) + # строки `ok` источника по `row_index` — адресаты вложений + sources = sorted((line for line in ok_lines if line.row_index is not None), key=lambda line: line.row_index or 0) + source_rows = [line.row_index or 0 for line in sources] + by_row = {line.row_index: line.key for line in sources} + attached: dict[Hashable | None, list[LayoutLine]] = {} + for line in sorted((line for line in others), key=lambda item: (item.row_index is None, item.row_index or 0)): + owner: Hashable | None = None + if line.line_status == DETAIL and line.parent_row_index in by_row: + owner = by_row[line.parent_row_index] + elif line.row_index is not None: + position = bisect.bisect_left(source_rows, line.row_index) - 1 + owner = sources[position].key if position >= 0 else None + attached.setdefault(owner, []).append(line) + result: list[Hashable] = [line.key for line in attached.get(None, [])] + for line in ok_ordered: + result.append(line.key) + result.extend(item.key for item in attached.get(line.key, [])) + return result diff --git a/api/src/domain/statement_lines.py b/api/src/domain/statement_lines.py index d20ca9c..630da59 100644 --- a/api/src/domain/statement_lines.py +++ b/api/src/domain/statement_lines.py @@ -56,6 +56,16 @@ class LineCatalog: return line.name return None + def edition_lines(self, form: str, edition: str | None) -> list[tuple[str, str]]: + """Справочник кодов для подсказки (P3-22): `(код, имя)` по одной на код, имя — первое. С редакцией — состав её + формы в порядке словаря; без редакции — строки всех редакций по порядку (имя — из первой, где код есть).""" + sources = [edition] if edition is not None else list(self.editions) + result: dict[str, str] = {} + for source in sources: + for line in self._entries(source, form): + result.setdefault(line.code, line.name) + return list(result.items()) + def names(self, form: str, code: str) -> tuple[str, ...]: """Все имена кода во всех редакциях без повторов, в порядке появления.""" result: dict[str, None] = {} diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index 20d150b..1f40bb4 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -4,6 +4,7 @@ import datetime import uuid from collections.abc import Callable, Sequence +from dataclasses import dataclass from decimal import Decimal from typing import Any @@ -12,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.domain.statement_aggregate import AggLine, CodeAggregate, aggregate_by_code from src.domain.statement_mapping import StatementDraft -from src.models.enums import DocumentStatus, DocumentType, FileStatus, StatementLineStatus +from src.models.enums import DocumentStatus, DocumentType, FileStatus, IssueSeverity, StatementLineStatus from src.models.files import Document, StoredFile from src.models.statements import ( DocumentIssue, @@ -30,18 +31,32 @@ __all__ = [ "column_ids", "delete_documents_statements", "delete_file_statements", + "IssueCounts", "form_aggregate", "form_lines", "get_meta", "has_source_lines", "insert_statement", + "issue_counts_by_document", + "issues", "load_form", "lock_cells", "pair_amounts", "value_flags", ] -BATCH = 1000 # значений на один INSERT: 8 колонок × 1000 < 32767 параметров asyncpg +BATCH = 1000 + + +@dataclass(frozen=True) +class IssueCounts: + """Число записей `document_issue` документа по `severity`.""" + + error: int = 0 + info: int = 0 + + +# значений на один INSERT: 8 колонок × 1000 < 32767 параметров asyncpg async def insert_statement( @@ -69,6 +84,8 @@ async def insert_statement( "label": label, "period_date": period_date, "is_current": is_current, + # до маппера A9 (план 3): как миграция v5.19 — `absolute` при дате, иначе `none` + "period_kind": "absolute" if period_date is not None else "none", } ) if columns: @@ -408,3 +425,36 @@ async def pair_amounts( ) income = await code_amount(session, income_meta.id, "2400", current=True) if income_meta is not None else None return balance, income + + +async def issues(session: AsyncSession, document_id: uuid.UUID) -> list[DocumentIssue]: + """Записи `document_issue` документа по `created_at`, `id`.""" + stmt = ( + select(DocumentIssue) + .where(DocumentIssue.document_id == document_id) + .order_by(DocumentIssue.created_at, DocumentIssue.id) + ) + return list((await session.execute(stmt)).scalars()) + + +async def issue_counts_by_document( + session: AsyncSession, document_ids: Sequence[uuid.UUID] +) -> dict[uuid.UUID, IssueCounts]: + """Число записей `document_issue` по `severity` одним запросом на список документов (P3-23); документа с записями + нет в ответе (читать через `.get(id, IssueCounts())`).""" + if not document_ids: + return {} + stmt = ( + select(DocumentIssue.document_id, DocumentIssue.severity, func.count(DocumentIssue.id)) + .where(DocumentIssue.document_id.in_(document_ids)) + .group_by(DocumentIssue.document_id, DocumentIssue.severity) + ) + raw: dict[uuid.UUID, dict[IssueSeverity, int]] = {} + for document_id, severity, count in (await session.execute(stmt)).all(): + raw.setdefault(document_id, {})[severity] = count + return { + document_id: IssueCounts( + error=by_severity.get(IssueSeverity.error, 0), info=by_severity.get(IssueSeverity.info, 0) + ) + for document_id, by_severity in raw.items() + } diff --git a/api/src/schemas/documents.py b/api/src/schemas/documents.py index 2bbe179..f100e84 100644 --- a/api/src/schemas/documents.py +++ b/api/src/schemas/documents.py @@ -21,6 +21,7 @@ from src.models.enums import ( OsvGroupType, PeriodQuarter, ) +from src.schemas.issues import IssueCountsOut, IssueOut DEFAULT_ENTRIES_LIMIT = 500 MAX_ENTRIES_LIMIT = 2000 @@ -54,6 +55,8 @@ class DocumentOut(BaseModel): group_schema: list[GroupOut] | None warnings: list[str] comments_count: int = 0 # число комментариев документа (D-11) + issues: list[IssueOut] = Field(default_factory=list) # v5.19: записи `document_issue` документа (любого типа) + issue_counts: IssueCountsOut = Field(default_factory=IssueCountsOut) # P3-23 class EntryOut(BaseModel): diff --git a/api/src/schemas/files.py b/api/src/schemas/files.py index fe57ba4..1aab6ef 100644 --- a/api/src/schemas/files.py +++ b/api/src/schemas/files.py @@ -19,6 +19,7 @@ from src.models.enums import ( PeriodQuarter, RecognitionStatus, ) +from src.schemas.issues import IssueCountsOut class AcceptedOut(BaseModel): @@ -77,6 +78,7 @@ class DocumentBrief(BaseModel): counterparty_check: DocumentCheckResult | None locator: str | None comments_count: int = 0 # число комментариев документа (D-11); в ORM-модели поля нет — задаётся при сборке + issue_counts: IssueCountsOut = IssueCountsOut() # P3-23; так же задаётся при сборке class FileOut(BaseModel): diff --git a/api/src/schemas/issues.py b/api/src/schemas/issues.py new file mode 100644 index 0000000..84721da --- /dev/null +++ b/api/src/schemas/issues.py @@ -0,0 +1,56 @@ +"""Записи `document_issue` в API: `IssueOut` — общая для `StatementOut.issues` и `DocumentOut.issues`; +`IssueCountsOut` — счётчики по `severity` для карточки файла (P3-23).""" + +import uuid +from decimal import Decimal + +from pydantic import BaseModel + +from src.models.enums import DocumentIssueKind, IssueSeverity, IssueSource +from src.models.statements import DocumentIssue + + +class IssueOut(BaseModel): + id: uuid.UUID + kind: DocumentIssueKind + code: str + severity: IssueSeverity + message: str + line_ids: list[uuid.UUID] + column_id: uuid.UUID | None + column_index: int | None + field: str | None + source: IssueSource + relation: str | None + codes: list[str] + expected: Decimal | None + actual: Decimal | None + + +class IssueCountsOut(BaseModel): + """Число записей `document_issue` документа по `severity`, всех видов.""" + + error: int = 0 + info: int = 0 + + +def issue_out(issue: DocumentIssue) -> IssueOut: + """JSONB `line_ids` / `codes` (`NULL` — пусто) приводятся к спискам uuid и строк.""" + line_ids = issue.line_ids if isinstance(issue.line_ids, list) else [] + codes = issue.codes if isinstance(issue.codes, list) else [] + return IssueOut( + id=issue.id, + kind=issue.kind, + code=issue.code, + severity=issue.severity, + message=issue.message, + line_ids=[uuid.UUID(str(item)) for item in line_ids], + column_id=issue.column_id, + column_index=issue.column_index, + field=issue.field, + source=issue.source, + relation=issue.relation, + codes=[str(item) for item in codes], + expected=issue.expected, + actual=issue.actual, + ) diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index 4a8ac19..5e973a6 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -7,22 +7,35 @@ design D-5, D-14). import datetime import uuid -from collections.abc import Mapping, Sequence -from decimal import Decimal +from collections.abc import Callable, Mapping, Sequence +from decimal import Decimal, InvalidOperation 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_lines import TemplateLine -from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter, StatementLineStatus +from src.domain.statement_layout import LayoutLine, order_lines +from src.domain.statement_lines import LineCatalog, TemplateLine +from src.models.enums import ( + DocumentCheckResult, + DocumentStatus, + DocumentType, + PeriodQuarter, + StatementFormVariant, + StatementLineStatus, +) from src.models.files import Document from src.models.statements import ( + DocumentIssue, FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue, ) +from src.schemas.issues import IssueOut, issue_out + +HUNDREDTHS = Decimal("0.01") class HeaderFieldOut(BaseModel): @@ -35,6 +48,14 @@ class HeaderFieldOut(BaseModel): modified: bool | None +class HeaderSourceOut(BaseModel): + """Подсказка источника для поля шапки (`header_source`): текст как в источнике и уверенность; `text: null` — нет + подсказки.""" + + text: str | None = None + confidence: Decimal | None = None + + class StatementHeaderOut(BaseModel): inn: HeaderFieldOut report_date: HeaderFieldOut @@ -45,6 +66,10 @@ class StatementHeaderOut(BaseModel): okopf: str | None okfs: str | None address: str | None + okei: str | None + needs_review_fields: list[str] + source: dict[str, HeaderSourceOut] + organization_name_modified: bool class ColumnOut(BaseModel): @@ -55,6 +80,8 @@ class ColumnOut(BaseModel): label: str | None period_date: datetime.date | None is_current: bool | None + period_kind: str | None + label_confidence: Decimal | None LineOrigin = Literal["source", "template"] @@ -72,13 +99,25 @@ class ValueOut(BaseModel): original_amount: Decimal | None modified_by: uuid.UUID | None modified_at: datetime.datetime | None + text: str | None # `source_text`: текст ячейки как в источнике + needs_review: bool + is_confirmed: bool class LineOut(BaseModel): id: uuid.UUID | None # None — строка достроена из справочника редакции, в БД её нет origin: LineOrigin + row_index: int | None + source_ref: str | None + line_status: StatementLineStatus line_code: str | None + line_code_text: str | None line_code_confidence: Decimal | None + line_code_modified: bool + reference_name: str | None # первое имя кода в словаре редакции ответа (P3-11) + parent_row_index: int | None + needs_review: bool + is_confirmed: bool line_name: str | None line_name_confidence: Decimal | None # код четырёхзначной строки-родителя у пятизначной подстроки (вычисляется по `subline_parents`, в БД не хранится); @@ -96,6 +135,8 @@ class FailedCheckOut(BaseModel): relation: str expected: Decimal actual: Decimal + codes: list[str] = [] + line_ids: list[uuid.UUID] = [] class ChecksAcceptedOut(BaseModel): @@ -121,6 +162,14 @@ class StatementDocumentOut(BaseModel): counterparty_check: DocumentCheckResult | None locator: str | None file_id: uuid.UUID + form_variant: StatementFormVariant | None + + +class EditionLineOut(BaseModel): + """Строка справочника для подсказки кода (P3-22).""" + + code: str + name: str class StatementOut(BaseModel): @@ -130,11 +179,20 @@ class StatementOut(BaseModel): columns: list[ColumnOut] lines: list[LineOut] checks: ChecksOut + issues: list[IssueOut] + edition_lines: list[EditionLineOut] -def _failed(check: FailedCheck) -> FailedCheckOut: +def _failed(check: FailedCheck, aggregates: Mapping[str, CodeAggregate]) -> FailedCheckOut: + """`line_ids` — строки `ok` кодов правила по агрегату формы (кода нет в форме — вклада нет).""" + line_ids = [line_id for code in check.codes if code in aggregates for line_id in aggregates[code].line_ids] return FailedCheckOut( - column_index=check.column_index, relation=check.relation, expected=check.expected, actual=check.actual + column_index=check.column_index, + relation=check.relation, + expected=check.expected, + actual=check.actual, + codes=list(check.codes), + line_ids=list(dict.fromkeys(line_ids)), ) @@ -151,6 +209,9 @@ def _value_out(value: FinancialStatementValue, column_index: dict[uuid.UUID, int original_amount=value.original_amount, modified_by=value.modified_by, modified_at=value.modified_at, + text=value.source_text, + needs_review=value.needs_review, + is_confirmed=value.is_confirmed, ) @@ -173,29 +234,47 @@ def _empty_value(column: FinancialStatementColumn) -> ValueOut: original_amount=None, modified_by=None, modified_at=None, + text=None, + needs_review=False, + is_confirmed=False, ) -def _lines_out( +def _lines_out( # noqa: PLR0913, PLR0917 - сборка строк из частей формы columns: Sequence[FinancialStatementColumn], lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], template: Sequence[TemplateLine], parents: Mapping[str, str], + reference_name: Callable[[str], str | None], ) -> list[LineOut]: - """Строки БД с ячейками по всем колонкам + строки справочника, которых нет в форме (design D-3), по коду.""" + """Строки БД с ячейками по всем колонкам + строки справочника, которых нет в форме (design D-3); порядок — + `order_lines` (P3-17).""" column_index = {column.id: column.column_index for column in columns} out: list[LineOut] = [] - for line, values in lines: + layout: list[LayoutLine] = [] + for line, values in sorted(lines, key=lambda item: str(item[0].id)): present = {value.column_id for value in values} cells = [_value_out(value, column_index) for value in values] cells += [_empty_value(column) for column in columns if column.id not in present] cells.sort(key=lambda cell: cell.column_index) + layout.append( + LayoutLine(len(out), line.row_index, str(line.line_status), line.line_code, line.parent_row_index) + ) out.append( LineOut( id=line.id, origin=line_origin(line), + row_index=line.row_index, + source_ref=line.source_ref, + line_status=line.line_status, line_code=line.line_code, + line_code_text=line.line_code_text, line_code_confidence=line.line_code_confidence, + line_code_modified=bool(line.line_code_modified), + reference_name=reference_name(line.line_code) if line.line_code is not None else None, + parent_row_index=line.parent_row_index, + needs_review=line.needs_review, + is_confirmed=line.is_confirmed, line_name=line.line_name, line_name_confidence=line.line_name_confidence, parent_code=parents.get(line.line_code) if line.line_code is not None else None, @@ -207,20 +286,50 @@ def _lines_out( for item in template: if item.code in codes: continue + layout.append(LayoutLine(len(out), None, StatementLineStatus.ok.value, item.code, None)) out.append( LineOut( id=None, origin="template", + row_index=None, + source_ref=None, + line_status=StatementLineStatus.ok, line_code=item.code, + line_code_text=None, line_code_confidence=None, + line_code_modified=False, + reference_name=item.name, + parent_row_index=None, + needs_review=False, + is_confirmed=False, line_name=item.name, line_name_confidence=None, parent_code=None, values=[_empty_value(column) for column in columns], ) ) - out.sort(key=lambda line: (line.line_code is None, line.line_code or "")) - return out + return [out[position] for position in order_lines(layout) if isinstance(position, int)] + + +def _source_out(raw: object) -> dict[str, HeaderSourceOut]: + """`header_source` JSONB → подсказки по полям; число уверенности — строка с двумя знаками (P3-20), `text: null` и + нечисловая уверенность — «нет подсказки» по своему полю.""" + if not isinstance(raw, dict): + return {} + result: dict[str, HeaderSourceOut] = {} + for name, entry in raw.items(): + if not isinstance(entry, dict): + continue + text = entry.get("text") + confidence: Decimal | None = None + value = entry.get("confidence") + if isinstance(value, int | float | str) and not isinstance(value, bool): + try: + confidence = Decimal(str(value)).quantize(HUNDREDTHS) + except InvalidOperation: + confidence = None + result[str(name)] = HeaderSourceOut(text=text if isinstance(text, str) else None, confidence=confidence) + return result def _accepted(meta: FinancialStatementMeta) -> ChecksAcceptedOut | None: @@ -242,9 +351,13 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч edition: str | None, template: Sequence[TemplateLine], parents: Mapping[str, str], + aggregates: Mapping[str, CodeAggregate], + issues: Sequence[DocumentIssue], + catalog: LineCatalog, ) -> StatementOut: """Ответ экрана проверки: документ, шапка, колонки, строки со значениями и проверки; строки достроены по справочнику редакции (D-3).""" + form = document.document_type.value return StatementOut( document=StatementDocumentOut( id=document.id, @@ -256,6 +369,7 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч counterparty_check=document.counterparty_check, locator=document.locator, file_id=document.stored_file_id, + form_variant=meta.form_variant, ), edition=edition, header=StatementHeaderOut( @@ -284,15 +398,23 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч okopf=meta.okopf, okfs=meta.okfs, address=meta.address, + okei=meta.okei, + needs_review_fields=[str(item) for item in meta.needs_review_fields] + if isinstance(meta.needs_review_fields, list) + else [], + source=_source_out(meta.header_source), + organization_name_modified=bool(meta.organization_name_modified), ), columns=[ColumnOut.model_validate(column) for column in columns], - lines=_lines_out(columns, lines, template, parents), + lines=_lines_out(columns, lines, template, parents, lambda code: catalog.reference_name(edition, form, code)), checks=ChecksOut( - failed=[_failed(check) for check in report.failed], - soft_failed=[_failed(check) for check in report.soft_failed], - cross_form=_failed(cross) if cross is not None else None, + failed=[_failed(check, aggregates) for check in report.failed], + soft_failed=[_failed(check, aggregates) for check in report.soft_failed], + cross_form=_failed(cross, aggregates) if cross is not None else None, accepted=_accepted(meta), ), + issues=[issue_out(issue) for issue in issues], + edition_lines=[EditionLineOut(code=code, name=name) for code, name in catalog.edition_lines(form, edition)], ) diff --git a/api/src/services/documents.py b/api/src/services/documents.py index 8a7cf27..215d279 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -22,7 +22,7 @@ from src.common.exceptions import ConflictError, NotFoundError from src.domain.policy import VisibilityScope from src.models.access import AppUser from src.models.audit_ex import Comment -from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus +from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus, IssueSeverity from src.models.files import Document, RecognitionJob, StoredFile from src.models.osv import OsvEntry from src.models.statements import FinancialStatementMeta @@ -31,6 +31,7 @@ from src.repositories import statements as statements_repo from src.schemas.comments import CommentAuthorOut, CommentIn, CommentOut from src.schemas.documents import DocumentOut, EntriesPageParams, GroupOut from src.schemas.files import DocumentBrief, FileOut, JobBrief +from src.schemas.issues import IssueCountsOut, IssueOut, issue_out from src.services import consolidation_documents from src.services.result_mapping import MAPPING_ERROR_KEY, MAPPING_WARNINGS_KEY @@ -75,12 +76,16 @@ def file_out( job: RecognitionJob | None, documents: Sequence[Document], counts: Mapping[uuid.UUID, int] | None = None, + issue_counts: Mapping[uuid.UUID, statements_repo.IssueCounts] | None = None, ) -> FileOut: """Карточка файла (spec documents-api «Файлы организации и карточка файла»). `counts` — `document_id → число комментариев` (`files_repo.comments_count_by_document`); нет — нули. + `issue_counts` — `document_id → число записей document_issue по severity` + (`statements_repo.issue_counts_by_document`); нет — нули. """ counts = counts or {} + issue_counts = issue_counts or {} return FileOut( id=file.id, legal_entity_id=file.legal_entity_id, @@ -96,7 +101,12 @@ def file_out( consolidation_id=file.consolidation_id, job=job_brief(job) if job is not None else None, documents=[ - DocumentBrief.model_validate(document).model_copy(update={"comments_count": counts.get(document.id, 0)}) + DocumentBrief.model_validate(document).model_copy( + update={ + "comments_count": counts.get(document.id, 0), + "issue_counts": _counts_out(issue_counts.get(document.id)), + } + ) for document in documents ], ) @@ -113,9 +123,19 @@ def _group_schema(job: RecognitionJob | None, locator: str | None) -> list[Group return None -def document_out(document: Document, job: RecognitionJob | None, comments_count: int = 0) -> DocumentOut: +def _counts_out(counts: statements_repo.IssueCounts | None) -> IssueCountsOut: + return IssueCountsOut(error=counts.error, info=counts.info) if counts is not None else IssueCountsOut() + + +def document_out( # noqa: PLR0913, PLR0917 - карточка собирается из частей разных запросов + document: Document, + job: RecognitionJob | None, + comments_count: int = 0, + issues: Sequence[IssueOut] = (), + issue_counts: statements_repo.IssueCounts | None = None, +) -> DocumentOut: """Карточка документа; `job` — текущее задание его файла (схема группировок и предупреждения маппинга), - `comments_count` — число комментариев документа.""" + `comments_count` — число комментариев документа, `issues` — его записи `document_issue`.""" return DocumentOut( id=document.id, file_id=document.stored_file_id, @@ -135,6 +155,15 @@ def document_out(document: Document, job: RecognitionJob | None, comments_count: # ASSUMPTION(R-6): в DBML нет привязки предупреждения к документу — отдаются предупреждения маппинга файла warnings=_strings(_metrics(job).get(MAPPING_WARNINGS_KEY)), comments_count=comments_count, + issues=list(issues), + issue_counts=_counts_out(issue_counts), + ) + + +def _issue_counts(issues: Sequence[Any]) -> statements_repo.IssueCounts: + return statements_repo.IssueCounts( + error=sum(1 for issue in issues if issue.severity is IssueSeverity.error), + info=sum(1 for issue in issues if issue.severity is IssueSeverity.info), ) @@ -170,6 +199,16 @@ class DocumentService: counts = await files_repo.comments_count_by_document(self._session, [document_id]) return counts.get(document_id, 0) + async def _card(self, document: Document, file: StoredFile | None) -> DocumentOut: + issues = await statements_repo.issues(self._session, document.id) + return document_out( + document, + await self._current_job(file), + await self._comments_count(document.id), + [issue_out(issue) for issue in issues], + _issue_counts(issues), + ) + async def comments(self, document_id: uuid.UUID) -> list[CommentOut]: """Комментарии видимого документа по `created_at` с ФИО автора; иначе 404.""" document = await self._visible(document_id) @@ -197,7 +236,7 @@ class DocumentService: """Карточка видимого документа; иначе 404.""" document = await self._visible(document_id) file = await self._session.get(StoredFile, document.stored_file_id) - return document_out(document, await self._current_job(file), await self._comments_count(document.id)) + return await self._card(document, file) async def entries(self, document_id: uuid.UUID, page: EntriesPageParams) -> tuple[list[OsvEntry], int]: """Страница строк видимого документа по `row_number`; иначе 404.""" @@ -284,4 +323,4 @@ class DocumentService: before_value={"status": before.value}, after_value={"status": DocumentStatus.CONFIRMED.value}, ) - return document_out(document, await self._current_job(file), await self._comments_count(document.id)) + return await self._card(document, file) diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index ddbe5f3..5268450 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -52,6 +52,7 @@ from src.models.files import RecognitionJob, S3Object, StoredFile from src.repositories import consolidations as consolidations_repo from src.repositories import directories as directories_repo from src.repositories import files as files_repo +from src.repositories import statements as statements_repo from src.schemas.common import PageParams from src.schemas.files import FileOut from src.services import consolidation_documents @@ -350,7 +351,10 @@ class PrimaryFilesService: ) current = next((job for job in jobs if job.id == file.current_recognition_job_id), None) counts = await files_repo.comments_count_by_document(self._session, [document.id for document in documents]) - before = file_out(file, current, documents, counts).model_dump(mode="json") + issue_counts = await statements_repo.issue_counts_by_document( + self._session, [document.id for document in documents] + ) + before = file_out(file, current, documents, counts, issue_counts).model_dump(mode="json") object_ids = [file.s3_object_id, *(job.result_s3_object_id for job in jobs if job.result_s3_object_id)] await files_repo.delete_file_rows(self._session, file) # M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла @@ -371,10 +375,10 @@ class PrimaryFilesService: async def _cards(self, files: list[StoredFile]) -> list[FileOut]: jobs = await files_repo.current_jobs(self._session, files) documents = await files_repo.documents_by_file(self._session, [file.id for file in files]) - counts = await files_repo.comments_count_by_document( - self._session, [document.id for group in documents.values() for document in group] - ) - return [file_out(file, jobs.get(file.id), documents.get(file.id, []), counts) for file in files] + document_ids = [document.id for group in documents.values() for document in group] + counts = await files_repo.comments_count_by_document(self._session, document_ids) + issue_counts = await statements_repo.issue_counts_by_document(self._session, document_ids) + return [file_out(file, jobs.get(file.id), documents.get(file.id, []), counts, issue_counts) for file in files] async def find_duplicate(self, entity_id: uuid.UUID, sha256: str) -> StoredFile | None: """Файл ЮЛ с тем же содержимым, кроме заменённых (`SUPERSEDED`): дубль по spec «Дедуп по содержимому».""" diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 8869636..c1064fd 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -157,11 +157,23 @@ class StatementService: """Ответ формы по текущему состоянию БД, достроенный по справочнику редакции (design D-3; используется и ответами правки/акцепта). В БД ничего не пишет.""" columns, lines = await repo.load_form(self._session, meta.id) - report, cross, parents = await self.checks_report(document, meta) + _, aggregates = await form_aggregate(self._session, document, meta) + report, cross, parents = await self._report(document, columns, aggregates) edition = edition_for(document.reporting_date) - template = load_line_catalog().template_lines(edition, document.document_type.value) + catalog = load_line_catalog() return schemas.statement_out( - document, meta, columns, lines, report, cross, edition=edition, template=template, parents=parents + document, + meta, + columns, + lines, + report, + cross, + edition=edition, + template=catalog.template_lines(edition, document.document_type.value), + parents=parents, + aggregates=aggregates, + issues=await repo.issues(self._session, document.id), + catalog=catalog, ) async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]: diff --git a/api/tests/test_api_documents.py b/api/tests/test_api_documents.py index 8eb6ca3..af40e4b 100644 --- a/api/tests/test_api_documents.py +++ b/api/tests/test_api_documents.py @@ -140,6 +140,8 @@ async def test_document_card(seeded: AsyncSession, auth_client: AuthClientFactor # R-6: предупреждения маппинга файла целиком, без фильтра по листу "warnings": ["sheet=Лист_1: повтор", "sheet=Лист_2: сироты"], "comments_count": 0, + "issues": [], + "issue_counts": {"error": 0, "info": 0}, } diff --git a/api/tests/test_api_files_read.py b/api/tests/test_api_files_read.py index c70d771..bc35c0f 100644 --- a/api/tests/test_api_files_read.py +++ b/api/tests/test_api_files_read.py @@ -16,8 +16,18 @@ from sqlalchemy import event, select from sqlalchemy.ext.asyncio import AsyncSession from src.models.directories import LegalEntity, RegionalOffice -from src.models.enums import DocumentStatus, FileStatus, PeriodQuarter, RecognitionStatus +from src.models.enums import ( + DocumentIssueKind, + DocumentStatus, + DocumentType, + FileStatus, + IssueSeverity, + IssueSource, + PeriodQuarter, + RecognitionStatus, +) from src.models.files import RecognitionJob, StoredFile +from src.models.statements import DocumentIssue from tests.conftest import AuthClientFactory from tests.factories import make_company_group, make_document, make_legal_entity, make_stored_file @@ -143,6 +153,7 @@ async def test_list_processed_with_document_and_error(seeded: AsyncSession, auth "counterparty_check": None, "locator": "sheet:Лист_1", "comments_count": 0, + "issue_counts": {"error": 0, "info": 0}, } ] @@ -280,3 +291,46 @@ async def test_unconfirmed_document_status_is_listed(seeded: AsyncSession, auth_ body = (await client.get(f"/files/{file.id}")).json() assert [document["status"] for document in body["documents"]] == ["NEEDS_CHECK"] + + +# --- issue_counts (P3-23) -------------------------------------------------------------------------------------------- + + +def _issue(document_id: uuid.UUID, kind: DocumentIssueKind, severity: IssueSeverity) -> DocumentIssue: + return DocumentIssue( + document_id=document_id, + kind=kind, + code="X", + severity=severity, + message="тест", + source=IssueSource.core, + created_at=T0, + ) + + +async def test_issue_counts_in_file_and_document(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Все виды записей считаются по `severity`; документ без записей — нули; запросов не больше, чем без документов.""" + entity = await _rostov_entity(seeded) + file, job = await _file(seeded, entity, minutes=1) + balance = await make_document(seeded, file=file, job=job, account="60") + balance.document_type = DocumentType.BALANCE + income = await make_document(seeded, file=file, job=job, account="62") + income.document_type = DocumentType.INCOME + seeded.add_all( + [ + _issue(balance.id, DocumentIssueKind.warning, IssueSeverity.error), + _issue(balance.id, DocumentIssueKind.relation, IssueSeverity.error), + _issue(balance.id, DocumentIssueKind.soft, IssueSeverity.info), + ] + ) + await seeded.flush() + client = await auth_client(KA_RF) + + card = (await client.get(f"/files/{file.id}")).json() + counts = {item["id"]: item["issue_counts"] for item in card["documents"]} + assert counts == {str(balance.id): {"error": 2, "info": 1}, str(income.id): {"error": 0, "info": 0}} + listed = (await client.get(_files_url(entity.id))).json()["items"][0] + assert {item["id"]: item["issue_counts"] for item in listed["documents"]} == counts + document = (await client.get(f"/documents/{balance.id}")).json() + assert document["issue_counts"] == {"error": 2, "info": 1} + assert len(document["issues"]) == 3 # noqa: PLR2004 diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 2b5cf18..0a8cc1c 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -7,6 +7,7 @@ import uuid from decimal import Decimal from http import HTTPStatus from pathlib import Path +from typing import Any import pytest from sqlalchemy import delete, select, update @@ -16,9 +17,21 @@ from src.domain.statement_checks import CROSS_FORM_RELATION from src.models.access import AppUser from src.models.audit_ex import AuditEvent from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice -from src.models.enums import AuditAction, DocumentStatus, DocumentType, FileStatus, PeriodQuarter, RecognitionStatus +from src.models.enums import ( + AuditAction, + DocumentIssueKind, + DocumentStatus, + DocumentType, + FileStatus, + IssueSeverity, + IssueSource, + PeriodQuarter, + RecognitionStatus, + StatementLineStatus, +) from src.models.files import Document, StoredFile from src.models.statements import ( + DocumentIssue, FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta, @@ -158,15 +171,44 @@ async def test_get_statement_shape_and_decimal_strings( "counterparty_check", "locator", "file_id", + "form_variant", + } + assert { + "organization_name", + "okud", + "okpo", + "okopf", + "okfs", + "address", + "okei", + "needs_review_fields", + "source", + "organization_name_modified", + } <= set(body["header"]) + assert set(body["columns"][0]) == { + "id", + "column_index", + "label", + "period_date", + "is_current", + "period_kind", + "label_confidence", } - assert {"organization_name", "okud", "okpo", "okopf", "okfs", "address"} <= set(body["header"]) - assert set(body["columns"][0]) == {"id", "column_index", "label", "period_date", "is_current"} line = next(line for line in body["lines"] if any(v["amount"] is not None for v in line["values"])) assert set(line) == { "id", "origin", + "row_index", + "source_ref", + "line_status", "line_code", + "line_code_text", "line_code_confidence", + "line_code_modified", + "reference_name", + "parent_row_index", + "needs_review", + "is_confirmed", "line_name", "line_name_confidence", "parent_code", @@ -186,6 +228,9 @@ async def test_get_statement_shape_and_decimal_strings( "original_amount", "modified_by", "modified_at", + "text", + "needs_review", + "is_confirmed", } index_by_id = {column["id"]: column["column_index"] for column in body["columns"]} assert all(index_by_id[v["column_id"]] == v["column_index"] for line in body["lines"] for v in line["values"]) @@ -414,11 +459,15 @@ async def test_cross_form_violation_is_reported_on_both_forms( client = await auth_client(KA_RF) for document in (balance, income): body = (await client.get(f"/documents/{document.id}/statement")).json() + own = "1370" if document is balance else "2400" # line_ids — строки той формы, что читается + own_ids = [line["id"] for line in body["lines"] if line["line_code"] == own and line["id"] is not None] assert body["checks"]["cross_form"] == { "column_index": -1, "relation": CROSS_FORM_RELATION, "expected": "200.00", "actual": "150.00", + "codes": ["1370", "2400"], + "line_ids": own_ids, }, document.document_type # мягкая межформенная проверка не блокирует подтверждение: подмена 1370 ломает и жёсткие проверки самого ББ — # после их акцепта форма с нарушением 1370↔2400 подтверждается @@ -1506,3 +1555,196 @@ async def test_origin_by_row_index(seeded: AsyncSession, fixtures_dir: Path, aut client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() assert next(item for item in body["lines"] if item["id"] == str(line.id))["origin"] == "source" + + +# --- v5.19: поля ответа, порядок строк, issues, справочник строк ----------------------------------------------------- + + +async def _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta: + return ( + await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) + ).scalar_one() + + +async def _statement(client: Any, document: Document) -> dict[str, Any]: + response = await client.get(f"/documents/{document.id}/statement") + assert response.status_code == HTTPStatus.OK, response.text + return response.json() + + +async def test_get_statement_v5_19_fields_on_1x_form( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + body = await _statement(await auth_client(KA_RF), document) + source = [line for line in body["lines"] if line["origin"] == "source"] + assert all(line["line_status"] == "ok" and line["source_ref"] is None for line in source) + assert sorted(line["row_index"] for line in source) == list(range(len(source))) + assert all(not line["needs_review"] and not line["is_confirmed"] for line in body["lines"]) + assert all( + line["row_index"] is None and line["line_status"] == "ok" for line in body["lines"] if line["id"] is None + ) + 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["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"] == [] + + +async def test_reference_name_first_name( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + body = await _statement(await auth_client(KA_RF), document) + assert body["edition"] == "fsbu4" + line = next(line for line in body["lines"] if line["line_code"] == "1520") + assert line["reference_name"] == "Кредиторская задолженность" + + +async def test_issues_in_statement_and_document( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + seeded.add( + DocumentIssue( + document_id=document.id, + kind=DocumentIssueKind.warning, + code="COLUMN_SKIPPED", + severity=IssueSeverity.info, + message="колонка пропущена", + column_id=None, + column_index=5, + source=IssueSource.service, + created_at=datetime.datetime(2026, 10, 18, tzinfo=datetime.UTC), + ) + ) + await seeded.flush() + client = await auth_client(KA_RF) + expected = { + "kind": "warning", + "code": "COLUMN_SKIPPED", + "severity": "info", + "message": "колонка пропущена", + "line_ids": [], + "column_id": None, + "column_index": 5, + "field": None, + "source": "service", + "relation": None, + "codes": [], + "expected": None, + "actual": None, + } + [issue] = (await _statement(client, document))["issues"] + assert {key: value for key, value in issue.items() if key != "id"} == expected + card = (await client.get(f"/documents/{document.id}")).json() + assert [{key: value for key, value in item.items() if key != "id"} for item in card["issues"]] == [expected] + assert card["issue_counts"] == {"error": 0, "info": 1} + + +async def test_line_code_confidence_and_okei( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + meta = await _meta(seeded, document) + meta.okei = "384" + line = ( + await seeded.execute( + select(FinancialStatementLine).where( + FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "1210" + ) + ) + ).scalar_one() + line.line_code_confidence = Decimal("67.50") + await seeded.flush() + body = await _statement(await auth_client(KA_RF), document) + assert body["header"]["okei"] == "384" + assert next(item for item in body["lines"] if item["line_code"] == "1210")["line_code_confidence"] == "67.50" + + +async def test_header_source_confidence_is_decimal_string( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + meta = await _meta(seeded, document) + meta.header_source = { + "inn": {"text": "x", "confidence": 61.538464}, + "units": {"text": "тыс. руб.", "confidence": 100}, + "okpo": {"text": None, "confidence": None}, + } + meta.needs_review_fields = ["inn"] + await seeded.flush() + header = (await _statement(await auth_client(KA_RF), document))["header"] + assert header["source"]["inn"] == {"text": "x", "confidence": "61.54"} + assert header["source"]["units"]["confidence"] == "100.00" + assert header["source"]["okpo"] == {"text": None, "confidence": None} + assert header["needs_review_fields"] == ["inn"] + + +async def test_edition_lines(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + body = await _statement(client, document) + codes = [item["code"] for item in body["edition_lines"]] + assert len(codes) == len(set(codes)) and "1120" not in codes and "1215" in codes + assert {item["code"]: item["name"] for item in body["edition_lines"]}["1520"] == "Кредиторская задолженность" + document.reporting_date = None # форма без даты — редакции нет + await seeded.flush() + codes = [item["code"] for item in (await _statement(client, document))["edition_lines"]] + assert len(codes) == len(set(codes)) and "1120" in codes and "1215" in codes + + +async def test_failed_check_has_codes_and_line_ids( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + await set_amount(seeded, document, "1600", Decimal("1"), current=True) + body = await _statement(await auth_client(KA_RF), document) + failed = body["checks"]["failed"] + assert failed, body["checks"] + by_code = {line["line_code"]: line["id"] for line in body["lines"] if line["line_status"] == "ok"} + for item in failed: + assert "1600" in item["codes"] or item["codes"] + assert item["line_ids"] and {by_code[code] for code in item["codes"] if code in by_code} >= set( + item["line_ids"] + ) + + +async def test_lines_order_follows_layout( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Строки `ok` — по коду; нечитаемая строка источника идёт сразу за предшествующей строкой `ok`.""" + document = await form(seeded, fixtures_dir) + meta = await _meta(seeded, document) + anchor = ( + await seeded.execute( + select(FinancialStatementLine).where( + FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "1210" + ) + ) + ).scalar_one() + seeded.add( + FinancialStatementLine( + meta_id=meta.id, + line_code=None, + row_index=(anchor.row_index or 0) * 1000 + 1, + line_status=StatementLineStatus.unreadable, + line_code_text="12?0", + ) + ) + await seeded.flush() + await seeded.execute( + update(FinancialStatementLine) + .where( + FinancialStatementLine.meta_id == meta.id, + FinancialStatementLine.line_status == StatementLineStatus.unreadable, + ) + .values(row_index=(anchor.row_index or 0) * 1000 + 1) + ) + lines = (await _statement(await auth_client(KA_RF), document))["lines"] + ok_codes = [line["line_code"] for line in lines if line["line_status"] == "ok"] + assert ok_codes == sorted(ok_codes) + unreadable = next(i for i, line in enumerate(lines) if line["line_status"] == "unreadable") + assert unreadable > 0 + assert lines[unreadable]["line_code_text"] == "12?0" diff --git a/api/tests/test_domain_statement_layout.py b/api/tests/test_domain_statement_layout.py new file mode 100644 index 0000000..0cac669 --- /dev/null +++ b/api/tests/test_domain_statement_layout.py @@ -0,0 +1,46 @@ +"""Порядок строк ответа `GET …/statement` (P3-17): строки `ok` по коду, вложения источника — за своей строкой.""" + +from src.domain.statement_layout import LayoutLine, order_lines + + +def L( # noqa: N802 - короткая фабрика строки для таблиц тестов + key: str, row_index: int | None, status: str, code: str | None, parent: int | None +) -> LayoutLine: + return LayoutLine(key=key, row_index=row_index, line_status=status, line_code=code, parent_row_index=parent) + + +def test_order_lines_attachments() -> None: + 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() -> None: + assert order_lines([L("1110", 0, "ok", "1110", None), L("d1", 1, "detail", None, None)]) == ["1110", "d1"] + + +def test_detail_with_unknown_parent_falls_back_to_previous_ok() -> None: + lines = [L("1110", 0, "ok", "1110", None), L("d5", 5, "detail", None, 4)] + assert order_lines(lines) == ["1110", "d5"] + + +def test_attachments_keep_row_order() -> None: + lines = [ + L("u3", 3, "unreadable", None, None), + L("1110", 0, "ok", "1110", None), + L("d2", 2, "detail", None, 0), + L("m1", 1, "missing", None, None), + ] + assert order_lines(lines) == ["1110", "m1", "d2", "u3"] + + +def test_empty() -> None: + assert order_lines([]) == [] diff --git a/api/tests/test_domain_statement_lines.py b/api/tests/test_domain_statement_lines.py index 83ab0f6..2c2ada7 100644 --- a/api/tests/test_domain_statement_lines.py +++ b/api/tests/test_domain_statement_lines.py @@ -101,3 +101,22 @@ def test_invalid_catalog_is_value_error(tmp_path: Path, body: str, message: str) path.write_text(body, encoding="utf-8") with pytest.raises(ValueError, match=message): load_line_catalog(path) + + +def test_edition_lines_one_per_code_first_name() -> None: + lines = CATALOG.edition_lines("BALANCE", "fsbu4") + codes = [code for code, _ in lines] + assert len(codes) == len(set(codes)) and "1120" not in codes + assert dict(lines)["1520"] == "Кредиторская задолженность" + assert codes == [line.code for line in CATALOG.template_lines("fsbu4", "BALANCE")] + + +def test_edition_lines_without_edition_merges_editions() -> None: + lines = CATALOG.edition_lines("BALANCE", None) + codes = [code for code, _ in lines] + assert len(codes) == len(set(codes)) + assert "1120" in codes and "1215" in codes # 1120 — редакция 66n, 1215 — fsbu4 + assert ( + dict(lines)["1120"] == dict((line.code, line.name) for line in CATALOG.template_lines("66n", "BALANCE"))["1120"] + ) + assert CATALOG.edition_lines("OSV", None) == [] -- 2.47.2 From f3a86479aa9a75bf9f017982daf4963ef5a46a2c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 16:10:14 +0400 Subject: [PATCH 09/20] =?UTF-8?q?feat(mapping):=20=D0=BC=D0=B0=D0=BF=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=201.x=20=D0=BF=D0=B8=D1=88=D0=B5=D1=82=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8F=20v5.19,=20file=5Fordinal,=20=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=82=20=D0=B2=D1=82=D0=BE=D1=80=D1=83=D1=8E?= =?UTF-8?q?=20=D1=84=D0=BE=D1=80=D0=BC=D1=83,=20MAPPING=5FWARNING=20=D0=B2?= =?UTF-8?q?=20document=5Fissue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- api/src/domain/statement_issues.py | 45 +++++ api/src/domain/statement_mapping.py | 71 +++++++- api/src/repositories/statements.py | 73 ++++++-- api/src/services/result_mapping.py | 65 ++++--- api/tests/test_domain_statement_mapping.py | 85 ++++++++- api/tests/test_mapping_golden.py | 11 +- api/tests/test_mapping_statements.py | 13 +- api/tests/test_result_mapping.py | 200 +++++++++++++++++++++ 8 files changed, 502 insertions(+), 61 deletions(-) create mode 100644 api/src/domain/statement_issues.py diff --git a/api/src/domain/statement_issues.py b/api/src/domain/statement_issues.py new file mode 100644 index 0000000..5c03500 --- /dev/null +++ b/api/src/domain/statement_issues.py @@ -0,0 +1,45 @@ +"""Черновик записи `document_issue` (DBML v5.19) — чистые данные без ORM. + +Маппер `1.x` пишет только `MAPPING_WARNING` (P3-5, P3-7): предупреждения черновика формы, листа ОСВ и повтор формы +в файле; проверки §7 и прочие коды ядра в `1.x` не пишутся. Запись в БД — `repositories/statements.add_issues`. +""" + +import uuid +from dataclasses import dataclass +from decimal import Decimal + +from src.models.enums import DocumentIssueKind, IssueSeverity, IssueSource + +__all__ = ["MAPPING_WARNING_CODE", "IssueDraft", "mapping_warning"] + +MAPPING_WARNING_CODE = "MAPPING_WARNING" + + +@dataclass(frozen=True) +class IssueDraft: + """Запись `document_issue` до вставки: `line_ids`/`column_id` — id уже созданных строк и колонок.""" + + 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 + + +def mapping_warning(message: str) -> IssueDraft: + """Предупреждение маппинга ядра: `warning`, `MAPPING_WARNING`, `info`, источник `core`.""" + return IssueDraft( + kind=DocumentIssueKind.warning, + code=MAPPING_WARNING_CODE, + severity=IssueSeverity.info, + message=message, + source=IssueSource.core, + ) diff --git a/api/src/domain/statement_mapping.py b/api/src/domain/statement_mapping.py index 4c34b14..0101a49 100644 --- a/api/src/domain/statement_mapping.py +++ b/api/src/domain/statement_mapping.py @@ -11,6 +11,7 @@ from src.contract.result import StatementColumn, StatementDocument, StatementVal from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter, StatementType __all__ = [ + "ColumnDraft", "LineDraft", "StatementDraft", "ValueDraft", @@ -19,6 +20,7 @@ __all__ = [ "quarter_of", "statement_period", "statement_status", + "value_clean", "values_ok", ] @@ -33,6 +35,18 @@ _QUARTER_ENDS = { _STATEMENT_TYPES = {DocumentType.BALANCE: StatementType.balance, DocumentType.INCOME: StatementType.income} +@dataclass +class ColumnDraft: + """Колонка формы; `period_kind` (v5.19): `absolute` при дате колонки, иначе `none` (в `1.x` относительных нет).""" + + column_index: int + label: str + period_date: datetime.date | None + is_current: bool | None + period_kind: str = "none" + label_confidence: Decimal | None = None + + @dataclass class ValueDraft: line_code: str @@ -41,6 +55,9 @@ class ValueDraft: string_value: str | None confidence: Decimal | None is_recognized: bool | None + row_index: int = 0 # индекс строки в `doc.lines` — ключ строки при вставке (код может повторяться) + source_text: str | None = None + needs_review: bool = False @dataclass @@ -49,6 +66,12 @@ class LineDraft: line_code_confidence: Decimal | None line_name: str line_name_confidence: Decimal | None + row_index: int = 0 # индекс строки в `doc.lines` результата; пропущенный повтор кода индекс не занимает + line_status: str = "ok" + line_code_original_value: str | None = None + source_ref: str | None = None + line_code_text: str | None = None + needs_review: bool = False @dataclass @@ -60,7 +83,7 @@ class StatementDraft: period_year: int | None period_quarter: PeriodQuarter | None header: dict[str, Any] - columns: list[tuple[int, str, datetime.date | None, bool | None]] + columns: list[ColumnDraft] lines: list[LineDraft] values: list[ValueDraft] warnings: list[str] = field(default_factory=list) @@ -116,7 +139,7 @@ def build_statement(doc: StatementDocument) -> StatementDraft: warnings.append(f"{doc.locator}: дата {period_date.isoformat()} не конец квартала — квартал не определён") latest = max((day for day in column_dates if day is not None), default=None) columns = [ - ( + ColumnDraft( column.column_index, column.label, column.period_date, @@ -124,6 +147,7 @@ def build_statement(doc: StatementDocument) -> StatementDraft: if latest is None else column.period_date == latest and column.column_index == min(c.column_index for c in source_columns if c.period_date == latest), + period_kind="absolute" if column.period_date is not None else "none", ) for column in source_columns ] @@ -131,7 +155,7 @@ def build_statement(doc: StatementDocument) -> StatementDraft: lines: list[LineDraft] = [] values: list[ValueDraft] = [] seen: set[str] = set() - for line in doc.lines: + for row_index, line in enumerate(doc.lines): if line.line_code in seen: warnings.append(f"{doc.locator}: код {line.line_code} повторяется — оставлена первая строка") continue @@ -142,6 +166,8 @@ def build_statement(doc: StatementDocument) -> StatementDraft: _decimal(line.line_code_confidence), line.line_name, _decimal(line.line_name_confidence), + row_index=row_index, + line_code_original_value=line.line_code, ) ) by_column: dict[int, StatementValue] = {} @@ -169,10 +195,25 @@ def build_statement(doc: StatementDocument) -> StatementDraft: value.string_value if value is not None else None, _decimal(value.confidence) if value is not None else None, value.is_recognized if value is not None else None, + row_index=row_index, + source_text=value.string_value if value is not None else None, + needs_review=value_needs_review(value), ) ) inn_confidence = _decimal(header.inn_confidence) - meta = { + header_source = { + "organization_name": {"text": header.organization_name, "confidence": header.organization_name_confidence}, + "inn": {"text": header.inn, "confidence": header.inn_confidence}, + "report_date": {"text": header.report_date_text, "confidence": None}, + "units": {"text": header.units, "confidence": None}, + "okud": {"text": header.okud, "confidence": None}, + "okpo": {"text": header.okpo, "confidence": None}, + "okopf": {"text": header.okopf, "confidence": None}, + "okfs": {"text": header.okfs, "confidence": None}, + "okei": {"text": None, "confidence": None}, + "address": {"text": header.address, "confidence": None}, + } + meta: dict[str, Any] = { "inn": header.inn, "inn_original_value": header.inn, "inn_confidence": inn_confidence, @@ -192,6 +233,11 @@ def build_statement(doc: StatementDocument) -> StatementDraft: "okopf": header.okopf, "okfs": header.okfs, "address": header.address, + "header_source": header_source, + "organization_name_original_value": header.organization_name, + "organization_name_modified": False, + "needs_review_fields": [], + "form_variant": None, } return StatementDraft( document_type=document_type, @@ -208,13 +254,22 @@ def build_statement(doc: StatementDocument) -> StatementDraft: ) +def value_clean(confidence: Decimal | None, is_recognized: bool | None) -> bool: + """Ячейка без сомнений: `is_recognized` не false, уверенность 100 или не задана (D-6, v5.19 `needs_review`).""" + return is_recognized is not False and (confidence is None or confidence == FULL_CONFIDENCE) + + +def value_needs_review(value: StatementValue | None) -> bool: + """`needs_review` ячейки при приёме `1.x`: значение есть и распознано с сомнением (пустая ячейка — нет).""" + if value is None: + return False + return not value_clean(_decimal(value.confidence), value.is_recognized) + + def values_ok(values: Iterable[tuple[bool, bool | None, Decimal | None]]) -> bool: """D-6: каждое значение правлено аналитиком или распознано без сомнений (`is_recognized` не false, уверенность 100/NULL).""" - return all( - modified or (recognized is not False and (confidence is None or confidence == FULL_CONFIDENCE)) - for modified, recognized, confidence in values - ) + return all(modified or value_clean(confidence, recognized) for modified, recognized, confidence in values) def statement_status( # noqa: PLR0913 - условия D-6 перечислены явно diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index 1f40bb4..1ad0218 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -12,6 +12,7 @@ from sqlalchemy import delete, func, insert, select from sqlalchemy.ext.asyncio import AsyncSession from src.domain.statement_aggregate import AggLine, CodeAggregate, aggregate_by_code +from src.domain.statement_issues import IssueDraft from src.domain.statement_mapping import StatementDraft from src.models.enums import DocumentStatus, DocumentType, FileStatus, IssueSeverity, StatementLineStatus from src.models.files import Document, StoredFile @@ -25,6 +26,7 @@ from src.models.statements import ( __all__ = [ "BATCH", + "add_issues", "add_line", "add_value", "code_amount", @@ -74,35 +76,39 @@ async def insert_statement( await session.flush() column_ids: dict[int, uuid.UUID] = {} columns: list[dict[str, Any]] = [] - for column_index, label, period_date, is_current in draft.columns: - column_ids[column_index] = uuid.uuid4() + for column in draft.columns: + column_ids[column.column_index] = uuid.uuid4() columns.append( { - "id": column_ids[column_index], + "id": column_ids[column.column_index], "meta_id": meta.id, - "column_index": column_index, - "label": label, - "period_date": period_date, - "is_current": is_current, - # до маппера A9 (план 3): как миграция v5.19 — `absolute` при дате, иначе `none` - "period_kind": "absolute" if period_date is not None else "none", + "column_index": column.column_index, + "label": column.label, + "period_date": column.period_date, + "is_current": column.is_current, + "period_kind": column.period_kind, + "label_confidence": column.label_confidence, } ) if columns: await session.execute(insert(FinancialStatementColumn).values(columns)) - line_ids: dict[str, uuid.UUID] = {} + line_ids: dict[int, uuid.UUID] = {} # по `row_index`: код строки может повторяться lines: list[dict[str, Any]] = [] - for row_index, line in enumerate(draft.lines): - line_ids[line.line_code] = uuid.uuid4() + for line in draft.lines: + line_ids[line.row_index] = uuid.uuid4() lines.append( { - "id": line_ids[line.line_code], + "id": line_ids[line.row_index], "meta_id": meta.id, - "row_index": row_index, # строка источника (до маппера `2.0`: порядок черновика) + "row_index": line.row_index, + "source_ref": line.source_ref, + "line_status": line.line_status, "line_code": line.line_code, - "line_code_original_value": line.line_code, + "line_code_text": line.line_code_text, + "line_code_original_value": line.line_code_original_value, "line_code_confidence": line.line_code_confidence, "line_code_modified": False, + "needs_review": line.needs_review, "line_name": line.line_name, "line_name_original_value": line.line_name, "line_name_confidence": line.line_name_confidence, @@ -114,13 +120,15 @@ async def insert_statement( values = [ { "id": uuid.uuid4(), - "line_id": line_ids[value.line_code], + "line_id": line_ids[value.row_index], "column_id": column_ids[value.column_index], "amount": value.amount, "string_value": value.string_value, "confidence": value.confidence, "is_recognized": value.is_recognized, "is_modified": False, + "source_text": value.source_text, + "needs_review": value.needs_review, } for value in draft.values ] @@ -129,6 +137,39 @@ async def insert_statement( return meta +async def add_issues( + session: AsyncSession, document_id: uuid.UUID, drafts: Sequence[IssueDraft], *, now: datetime.datetime +) -> None: + """Записи `document_issue` документа из черновиков (без commit).""" + if not drafts: + return + await session.execute( + insert(DocumentIssue).values( + [ + { + "id": uuid.uuid4(), + "document_id": document_id, + "kind": draft.kind, + "code": draft.code, + "severity": draft.severity, + "message": draft.message, + "line_ids": [str(line_id) for line_id in draft.line_ids] or None, + "column_id": draft.column_id, + "column_index": draft.column_index, + "codes": list(draft.codes) or None, + "field": draft.field, + "source": draft.source, + "relation": draft.relation, + "expected": draft.expected, + "actual": draft.actual, + "created_at": now, + } + for draft in drafts + ] + ) + ) + + async def delete_file_statements(session: AsyncSession, file_id: uuid.UUID) -> None: """`document_issue`, значения, строки, колонки и шапки форм всех документов файла (в порядке FK).""" documents = select(Document.id).where(Document.stored_file_id == file_id).scalar_subquery() diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 74347e4..28e2543 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -52,6 +52,7 @@ from src.domain.osv_mapping import ( ) 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.models.accounting import ChartOfAccount @@ -344,30 +345,28 @@ def _split_duplicates(sheets: Sequence[OsvDocument]) -> tuple[list[OsvDocument], def _map_statement_docs( # noqa: PLR0913 - все входы именованные statement_docs: Sequence[StatementDocument], *, + ordinals: Mapping[int, int], file: StoredFile, owner: LegalEntity, owner_names: frozenset[str], now: datetime, -) -> tuple[list[tuple[Document, StatementDraft]], list[Document], list[str], set[DocumentType]]: +) -> tuple[list[tuple[Document, StatementDraft]], list[Document], list[str], dict[uuid.UUID, str]]: """Документы форм ББ/ОФР и расшифровок (spec «Документ формы ББ/ОФР», «Повтор формы в файле и прочие документы»; - D-2, D-3). Возвращает формы (документ + черновик; статус уточняется после вставки значений), документы - `DISCLOSURE`/`OTHER` (по одному на тип, `locator` через запятую, `RECOGNIZED`), предупреждения и типы форм, - у которых был повтор в файле (R-1: первый документ такого типа — `NEEDS_CHECK`).""" + D-2, D-3; P3-4, P3-6). Возвращает формы (документ + черновик; статус уточняется после вставки значений), + документы `DISCLOSURE`/`OTHER` (по одному на тип, `locator` через запятую, `RECOGNIZED`, `file_ordinal` — индекс + первого такого документа в `documents[]`), предупреждения маппинга и текст «другая форма» по `id` документов форм + одного типа и периода (Р-10, Р-18: хранятся все, каждый `NEEDS_CHECK`). `ordinals` — `id(документ результата)` → + индекс в `result.documents`.""" forms: list[tuple[Document, StatementDraft]] = [] - seen_forms: set[DocumentType] = set() - duplicated: set[DocumentType] = set() other_locators: dict[DocumentType, list[str]] = {} + other_ordinals: dict[DocumentType, int] = {} warnings: list[str] = [] for doc in statement_docs: doc_type = DocumentType(doc.document_type) if doc_type in (DocumentType.DISCLOSURE, DocumentType.OTHER): other_locators.setdefault(doc_type, []).append(doc.locator) + other_ordinals.setdefault(doc_type, ordinals[id(doc)]) continue - if doc_type in seen_forms: - warnings.append(f"{doc.locator}: вторая форма {doc_type.value} в файле пропущена (OQ-77)") - duplicated.add(doc_type) - continue - seen_forms.add(doc_type) draft = build_statement(doc) warnings.extend(draft.warnings) check = counterparty_check(doc.header.inn, doc.header.organization_name, owner.inn, owner_names) @@ -376,6 +375,7 @@ def _map_statement_docs( # noqa: PLR0913 - все входы именованн 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, @@ -389,12 +389,26 @@ def _map_statement_docs( # noqa: PLR0913 - все входы именованн updated_at=now, ) forms.append((document, draft)) + same_period: dict[tuple[DocumentType, int, PeriodQuarter], int] = {} + for document, _ in forms: + if document.period_year is not None and document.period_quarter is not None: + key = (document.document_type, document.period_year, document.period_quarter) + same_period[key] = same_period.get(key, 0) + 1 + repeats: dict[uuid.UUID, str] = {} + for document, _ in forms: + if document.period_year is not None and document.period_quarter is not None: + key = (document.document_type, document.period_year, document.period_quarter) + if same_period[key] > 1: + text = f"в файле есть другая форма {document.document_type.value} за тот же период" + repeats[document.id] = text + warnings.append(f"{document.locator}: {text}") others = [ Document( id=uuid.uuid4(), stored_file_id=file.id, legal_entity_id=file.legal_entity_id, document_type=doc_type, + file_ordinal=other_ordinals[doc_type], locator=",".join(locators), status=DocumentStatus.RECOGNIZED, period_check=DocumentCheckResult.UNKNOWN, @@ -403,18 +417,20 @@ def _map_statement_docs( # noqa: PLR0913 - все входы именованн ) for doc_type, locators in other_locators.items() ] - return forms, others, warnings, duplicated + return forms, others, warnings, repeats -async def _insert_statements( +async def _insert_statements( # noqa: PLR0913 - все входы именованные session: AsyncSession, *, job: RecognitionJob, forms: Sequence[tuple[Document, StatementDraft]], - duplicated: set[DocumentType], + repeats: Mapping[uuid.UUID, str], job_done: bool, + now: datetime, ) -> None: - """Шапка, колонки, строки и значения каждой формы; статус документа по D-6 (акцепта при маппинге нет).""" + """Шапка, колонки, строки и значения каждой формы; предупреждения черновика и повтор формы — `MAPPING_WARNING` + документа; статус документа по D-6 (акцепта при маппинге нет).""" if not forms: return rules = load_check_rules() @@ -423,6 +439,8 @@ async def _insert_statements( 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) ) @@ -435,7 +453,7 @@ async def _insert_statements( 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)), has_lines=bool(draft.lines), - warnings=[*draft.warnings, *(["повтор формы"] if document.document_type in duplicated else [])], + warnings=warnings, ) @@ -467,9 +485,11 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай owner_names = index.owner_names(owner) job_done = job.status is RecognitionStatus.done + ordinals = {id(doc): index for index, doc in enumerate(result.documents)} # v5.19: номер документа в файле await _clear_file(session, file.id) kept, duplicate_warnings, warnings = _split_duplicates(sheets) documents: list[Document] = [] + sheet_issues: list[tuple[Document, list[str]]] = [] entries: list[dict[str, Any]] = [] for sheet in kept: fields = document_fields(sheet) @@ -506,6 +526,7 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай stored_file_id=file.id, legal_entity_id=file.legal_entity_id, document_type=DocumentType.OSV, + file_ordinal=ordinals[id(sheet)], osv_account_code=fields.osv_account_code, period_year=fields.period_year, period_quarter=fields.period_quarter, @@ -523,6 +544,7 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай updated_at=now, ) documents.append(document) + sheet_issues.append((document, [*duplicate_warnings.get(sheet.locator, ()), *sheet_warnings])) warnings.extend(sheet_warnings) entries.extend( _entry_values( @@ -534,25 +556,24 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай for entry in mapped.entries ) - forms, other_documents, form_warnings, duplicated = _map_statement_docs( - statement_docs, file=file, owner=owner, owner_names=owner_names, now=now + 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) - # v5.19: номер документа в файле уникален; до перехода на индекс в documents[] (задачи A5/A9) — порядок создания - for ordinal, document in enumerate(documents): - document.file_ordinal = ordinal 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) # T7.2: родитель раньше потомка на любой границе пачки (self-FK `parent_entry_id`) — не полагаемся на порядок строк # результата; сортировка устойчивая, внутри уровня порядок строк сохраняется entries.sort(key=lambda values: values["level"]) 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, duplicated=duplicated, job_done=job_done) + await _insert_statements(session, job=job, forms=forms, repeats=repeats, job_done=job_done, now=now) job.group_schema = [_group_schema(sheet) for sheet in sheets] job.metrics = {**_metrics(job), MAPPING_WARNINGS_KEY: warnings} diff --git a/api/tests/test_domain_statement_mapping.py b/api/tests/test_domain_statement_mapping.py index c3d7dcb..4bfdf61 100644 --- a/api/tests/test_domain_statement_mapping.py +++ b/api/tests/test_domain_statement_mapping.py @@ -12,6 +12,7 @@ from src.domain.statement_mapping import ( quarter_of, statement_period, statement_status, + value_clean, values_ok, ) from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter, StatementType @@ -90,7 +91,10 @@ def test_build_statement_cells_and_header() -> None: assert draft.header["inn_verified"] is True assert draft.header["report_date_original_value"] == "на 30 сентября 2025 г." assert draft.header["units"] == draft.header["units_original_value"] == "тыс. руб." - assert draft.columns == [(0, "На 30.09.2025", d(2025, 9, 30), True), (1, "На 31.12.2024", d(2024, 12, 31), False)] + assert [(c.column_index, c.label, c.period_date, c.is_current) for c in draft.columns] == [ + (0, "На 30.09.2025", d(2025, 9, 30), True), + (1, "На 31.12.2024", d(2024, 12, 31), False), + ] assert [line.line_code for line in draft.lines] == ["1150", "1600"] # дубль 1150 отброшен cells = {(v.line_code, v.column_index): v for v in draft.values} assert len(cells) == expected_cells @@ -120,7 +124,7 @@ def test_period_from_columns_and_non_quarter() -> None: def test_no_dates_at_all() -> None: draft = build_statement(_doc(header={"form_name": "ББ"}, columns=[{"column_index": 0, "label": ""}])) assert (draft.period_year, draft.period_quarter, draft.period_date) == (None, None, None) - assert draft.columns == [(0, "", None, None)] + assert [(c.column_index, c.label, c.period_date, c.is_current) for c in draft.columns] == [(0, "", None, None)] def test_status_rules() -> None: @@ -162,7 +166,7 @@ def test_equal_column_dates_current_is_lowest_index_only() -> None: {"column_index": 3, "label": "в", "period_date": "2024-12-31"}, ] draft = build_statement(_doc(columns=columns, lines=[])) - assert {index: current for index, _, _, current in draft.columns} == {1: True, 2: False, 3: False} + assert {c.column_index: c.is_current for c in draft.columns} == {1: True, 2: False, 3: False} def test_column_without_date_next_to_dated_is_not_current() -> None: @@ -171,7 +175,7 @@ def test_column_without_date_next_to_dated_is_not_current() -> None: {"column_index": 1, "label": "На 30.09.2025", "period_date": "2025-09-30"}, ] draft = build_statement(_doc(columns=columns, lines=[])) - assert {index: current for index, _, _, current in draft.columns} == {0: False, 1: True} + assert {c.column_index: c.is_current for c in draft.columns} == {0: False, 1: True} def test_duplicate_code_values_belong_to_first_line() -> None: @@ -220,7 +224,10 @@ def test_duplicate_column_index_keeps_first_with_warning() -> None: {"column_index": 1, "label": "На 31.12.2024", "period_date": "2024-12-31"}, ] draft = build_statement(_doc(columns=columns)) - assert draft.columns == [(0, "На 30.09.2025", d(2025, 9, 30), True), (1, "На 31.12.2024", d(2024, 12, 31), False)] + assert [(c.column_index, c.label, c.period_date, c.is_current) for c in draft.columns] == [ + (0, "На 30.09.2025", d(2025, 9, 30), True), + (1, "На 31.12.2024", d(2024, 12, 31), False), + ] assert "sheet:ББ: колонка 0 повторяется — оставлена первая" in draft.warnings assert (draft.period_date, draft.period_quarter) == (d(2025, 9, 30), PeriodQuarter.Q3) assert [(v.line_code, v.column_index) for v in draft.values] == [("1150", 0), ("1150", 1), ("1600", 0), ("1600", 1)] @@ -241,3 +248,71 @@ def test_duplicate_cell_keeps_first_value_with_warning() -> None: draft = build_statement(_doc(lines=lines)) assert {(v.column_index): v.amount for v in draft.values} == {0: Decimal("10"), 1: None} assert "sheet:ББ: строка 1150: значение колонки 0 повторяется — оставлено первое" in draft.warnings + + +def test_1x_line_fields_domain() -> None: + """Строки 1.x несут `row_index` — индекс строки в `doc.lines` (пропущенный повтор индекс не занимает).""" + lines = [ + {"line_code": "1150", "line_name": "а", "values": []}, + {"line_code": "1150", "line_name": "повтор", "values": []}, + {"line_code": "1600", "line_name": "б", "values": []}, + ] + draft = build_statement(_doc(lines=lines)) + assert [(line.row_index, line.line_code) for line in draft.lines] == [(0, "1150"), (2, "1600")] + assert all( + line.line_status == "ok" and line.source_ref is None and line.line_code_text is None for line in draft.lines + ) + assert all(line.line_code_original_value == line.line_code and line.needs_review is False for line in draft.lines) + assert {v.row_index for v in draft.values} == {0, 2} + + +def test_value_clean_and_value_flags() -> None: + assert value_clean(Decimal("100"), True) is True + assert value_clean(None, None) is True + assert value_clean(Decimal("80"), True) is False + assert value_clean(Decimal("100"), False) is False + lines = [ + { + "line_code": "1150", + "line_name": "а", + "values": [ + {"column_index": 0, "amount": "10", "string_value": "10,0", "confidence": 100, "is_recognized": True}, + {"column_index": 1, "amount": "5", "string_value": "5", "confidence": 80, "is_recognized": True}, + ], + } + ] + draft = build_statement(_doc(lines=lines)) + by_column = {v.column_index: v for v in draft.values} + assert (by_column[0].source_text, by_column[0].needs_review) == ("10,0", False) + assert (by_column[1].source_text, by_column[1].needs_review) == ("5", True) + + +def test_1x_columns_period_kind() -> None: + draft = build_statement(_doc(columns=[{"column_index": 0, "label": "x"}])) + assert [(c.period_date, c.period_kind, c.label_confidence) for c in draft.columns] == [(None, "none", None)] + full = build_statement(_doc()) + assert [c.period_kind for c in full.columns] == ["absolute", "absolute"] + + +def test_1x_header_source() -> None: + draft = build_statement(_doc()) + source = draft.header["header_source"] + assert source["inn"] == {"text": "7700000000", "confidence": 100} + assert source["report_date"]["text"] == "на 30 сентября 2025 г." + assert source["organization_name"] == {"text": "ООО Пример", "confidence": None} + assert source["okei"] == {"text": None, "confidence": None} + assert set(source) == { + "organization_name", + "inn", + "report_date", + "units", + "okud", + "okpo", + "okopf", + "okfs", + "okei", + "address", + } + assert draft.header["organization_name_original_value"] == "ООО Пример" + assert draft.header["organization_name_modified"] is False + assert draft.header["needs_review_fields"] == [] and draft.header["form_variant"] is None diff --git a/api/tests/test_mapping_golden.py b/api/tests/test_mapping_golden.py index 996ba8f..c7f1ad1 100644 --- a/api/tests/test_mapping_golden.py +++ b/api/tests/test_mapping_golden.py @@ -110,7 +110,7 @@ def _check_sheet( # noqa: PLR0912 - все инварианты одного л async def _form_problems(session: AsyncSession, file_id: uuid.UUID, raw: dict[str, Any], name: str) -> list[str]: - """Формы смешанных снимков (s12 и др.): по документу на каждый тип ББ/ОФР снимка (повтор формы пропускается), + """Формы смешанных снимков (s12 и др.): по документу на каждую форму ББ/ОФР снимка (повтор формы хранится, P3-4), `DISCLOSURE`/`OTHER` — по одному документу на тип.""" types = Counter( document["document_type"] for document in raw["documents"] if document["document_type"] != DocumentType.OSV @@ -124,10 +124,15 @@ async def _form_problems(session: AsyncSession, file_id: uuid.UUID, raw: dict[st ) ).scalars() ) + merged = (DocumentType.DISCLOSURE, DocumentType.OTHER) + + def expected(kind: str) -> int: + return min(types[kind], 1) if kind in merged else types[kind] + return [ - f"{name}: документов {kind} {stored.get(kind, 0)}, ожидалось {1 if types[kind] else 0}" + f"{name}: документов {kind} {stored.get(kind, 0)}, ожидалось {expected(kind)}" for kind in sorted({*types, *stored}) - if stored.get(kind, 0) != (1 if types[kind] else 0) + if stored.get(kind, 0) != expected(kind) ] diff --git a/api/tests/test_mapping_statements.py b/api/tests/test_mapping_statements.py index cc1d426..99e6abe 100644 --- a/api/tests/test_mapping_statements.py +++ b/api/tests/test_mapping_statements.py @@ -221,8 +221,8 @@ async def test_all_statement_snapshots_map(seeded: AsyncSession, fixtures_dir: P assert await _count(seeded, FinancialStatementValue, meta_id) == lines * columns -async def test_duplicate_form_in_file_skipped(seeded: AsyncSession, fixtures_dir: Path, tmp_path: Path) -> None: - """R-1, spec «Повтор формы в файле»: вторая форма того же типа не создаётся, первая — `NEEDS_CHECK` даже чистая.""" +async def test_duplicate_form_in_file_stored(seeded: AsyncSession, fixtures_dir: Path, tmp_path: Path) -> None: + """P3-4 (Р-10, Р-18): вторая форма того же типа и периода хранится; обе `NEEDS_CHECK` (чистая — тоже).""" source = fixtures_dir / MEZH / "1_Форма2_бух_09.json" owner = await _owner(seeded, source) data = json.loads(source.read_text("utf-8")) @@ -230,11 +230,10 @@ async def test_duplicate_form_in_file_skipped(seeded: AsyncSession, fixtures_dir path = tmp_path / "duplicate.json" path.write_text(json.dumps(data, ensure_ascii=False), "utf-8") documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) - assert [d.document_type for d in documents] == [DocumentType.INCOME] - assert documents[0].status is DocumentStatus.NEEDS_CHECK # без повтора тот же снимок — RECOGNIZED - assert await _mapping_warnings(seeded, documents[0]) == [ - "sheet:повтор: вторая форма INCOME в файле пропущена (OQ-77)" - ] + assert [d.document_type for d in documents] == [DocumentType.INCOME, DocumentType.INCOME] + assert all(d.status is DocumentStatus.NEEDS_CHECK for d in documents) # без повтора тот же снимок — RECOGNIZED + assert sorted(d.file_ordinal for d in documents) == [0, 1] + assert any("другая форма INCOME за тот же период" in w for w in await _mapping_warnings(seeded, documents[0])) async def test_duplicate_column_index_does_not_fail_mapping( diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index 8c25454..871b466 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -54,6 +54,12 @@ from src.models.enums import ( ) from src.models.files import Document, RecognitionJob, StoredFile from src.models.osv import OsvEntry +from src.models.statements import ( + DocumentIssue, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) from src.repositories.counterparties import CounterpartyIndex from src.services import consolidation_documents from src.services import result_mapping as rm @@ -1781,3 +1787,197 @@ async def test_watchdog_timeout_writes_no_audit_event(committed_sessionmaker: as assert (await _file(maker, file_id)).status is FileStatus.ERROR assert (await _job(maker, job_id)).status is RecognitionStatus.timeout assert await _audit_count(maker) == before + + +# --- v5.19: поля 1.x, file_ordinal, вторая форма, MAPPING_WARNING (план 3, A9) --------------------------------------- + + +def _osv_sheet(account: str, locator: str) -> dict[str, Any]: + sheet = copy.deepcopy(_osv(_minimal())) + sheet["header"]["account_code_raw"] = account + sheet["locator"] = locator + return sheet + + +def _form(document_type: str, locator: str, *, report_date: str = "2025-09-30") -> dict[str, Any]: + form = copy.deepcopy(json.loads(STATEMENT_MINIMAL.read_text("utf-8"))["documents"][0]) + form["document_type"] = document_type + form["locator"] = locator + form["header"]["report_date"] = report_date + return form + + +async def _map_dict( + session: AsyncSession, documents: list[dict[str, Any]], *, owner_inn: str = "7700000000" +) -> tuple[StoredFile, list[Document]]: + """`map_file` на результате 1.x из словарей `documents`; документы файла по `file_ordinal`.""" + entity = await make_legal_entity(session, name=OWNER_NAME, inn=owner_inn) + file, job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSING, job_status=RecognitionStatus.done + ) + payload = _minimal() | {"job_id": str(job.id), "file_id": str(file.id), "documents": documents} + result = RecognitionResult.model_validate(payload) + chart = {code: key for code, key in await session.execute(select(ChartOfAccount.code, ChartOfAccount.id))} + await rm.map_file( + session, job=job, file=file, result=result, chart=chart, index=await CounterpartyIndex.load(session), now=_now() + ) + stored = ( + ( + await session.execute( + select(Document).where(Document.stored_file_id == file.id).order_by(Document.file_ordinal) + ) + ) + .scalars() + .all() + ) + return file, list(stored) + + +async def _issues(session: AsyncSession, document: Document) -> list[DocumentIssue]: + stmt = select(DocumentIssue).where(DocumentIssue.document_id == document.id) + return list((await session.execute(stmt)).scalars()) + + +async def test_two_osv_sheets_file_ordinals(seeded: AsyncSession) -> None: + file, documents = await _map_dict(seeded, [_osv_sheet("60", "sheet:60"), _osv_sheet("62", "sheet:62")]) + assert [(d.osv_account_code, d.file_ordinal) for d in documents] == [("60", 0), ("62", 1)] + await seeded.refresh(file, ["status"]) + assert file.status is FileStatus.PROCESSED + + +async def test_pdf_balance_income_1x_ordinals(seeded: AsyncSession) -> None: + _, documents = await _map_dict(seeded, [_form("BALANCE", "pages:1"), _form("INCOME", "pages:2")]) + assert [(d.document_type, d.file_ordinal) for d in documents] == [ + (DocumentType.BALANCE, 0), + (DocumentType.INCOME, 1), + ] + + +async def test_duplicate_osv_sheet_takes_no_ordinal(seeded: AsyncSession) -> None: + _, documents = await _map_dict( + seeded, [_osv_sheet("60", "sheet:a"), _osv_sheet("60", "sheet:b"), _form("BALANCE", "sheet:ББ")] + ) + assert [(d.document_type, d.file_ordinal) for d in documents] == [(DocumentType.OSV, 0), (DocumentType.BALANCE, 2)] + + +async def test_disclosure_ordinal_is_first_in_documents(seeded: AsyncSession) -> None: + """P3-6: объединённый `DISCLOSURE` — индекс первого такого документа в `documents[]`.""" + bare = {"header": {}, "columns": [], "lines": []} + disclosure = {"document_type": "DISCLOSURE", "locator": "pages:3", **bare} + other = {"document_type": "OTHER", "locator": "pages:9", **bare} + _, documents = await _map_dict( + seeded, [_form("BALANCE", "pages:1"), disclosure, other, disclosure | {"locator": "pages:4"}] + ) + assert {(d.document_type, d.file_ordinal) for d in documents} == { + (DocumentType.BALANCE, 0), + (DocumentType.DISCLOSURE, 1), + (DocumentType.OTHER, 2), + } + + +async def test_1x_line_fields(seeded: AsyncSession) -> None: + form = _form("BALANCE", "sheet:ББ") + form["lines"] = [ + { + "line_code": code, + "line_name": f"строка {code}", + "values": [ + { + "column_index": 0, + "amount": "10", + "string_value": "10,0", + "confidence": confidence, + "is_recognized": True, + } + ], + } + for code, confidence in (("1100", 100), ("1200", 80), ("1600", 100)) + ] + _, [balance] = await _map_dict(seeded, [form]) + meta = ( + await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == balance.id)) + ).scalar_one() + lines = ( + ( + await seeded.execute( + select(FinancialStatementLine) + .where(FinancialStatementLine.meta_id == meta.id) + .order_by(FinancialStatementLine.row_index) + ) + ) + .scalars() + .all() + ) + assert [line.row_index for line in lines] == [0, 1, 2] + assert all(line.line_status.value == "ok" and line.source_ref is None for line in lines) + assert all(line.line_code_original_value == line.line_code and line.needs_review is False for line in lines) + values = { + line.line_code: ( + await seeded.execute(select(FinancialStatementValue).where(FinancialStatementValue.line_id == line.id)) + ) + .scalars() + .all() + for line in lines + } + flags = {code: {v.needs_review for v in cells if v.confidence is not None} for code, cells in values.items()} + assert flags == {"1100": {False}, "1200": {True}, "1600": {False}} + assert {v.source_text for v in values["1100"] if v.string_value} == {"10,0"} + + +async def test_1x_duplicate_code_keeps_first(seeded: AsyncSession) -> None: + form = _form("INCOME", "sheet:ОФР") + form["lines"] = [ + {"line_code": "2110", "line_name": "а", "values": []}, + {"line_code": "2410", "line_name": "б", "values": []}, + {"line_code": "2410", "line_name": "повтор", "values": []}, + ] + _, [income] = await _map_dict(seeded, [form]) + meta = ( + await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id)) + ).scalar_one() + rows = ( + await seeded.execute( + select(FinancialStatementLine.row_index, FinancialStatementLine.line_code) + .where(FinancialStatementLine.meta_id == meta.id) + .order_by(FinancialStatementLine.row_index) + ) + ).all() + assert [tuple(row) for row in rows] == [(0, "2110"), (1, "2410")] + messages = [issue.message for issue in await _issues(seeded, income) if issue.code == "MAPPING_WARNING"] + assert any("2410" in message and "повторяется" in message for message in messages) + + +async def test_1x_second_form_same_period_stored(seeded: AsyncSession) -> None: + """Review Focus 1, P3-4 (Р-10/Р-18): две формы одного типа и периода хранятся, обе `NEEDS_CHECK`.""" + file, documents = await _map_dict(seeded, [_form("BALANCE", "sheet:а"), _form("BALANCE", "sheet:б")]) + assert [d.file_ordinal for d in documents] == [0, 1] + assert all(d.status is DocumentStatus.NEEDS_CHECK for d in documents) + for document in documents: + messages = [i.message for i in await _issues(seeded, document) if i.code == "MAPPING_WARNING"] + assert "в файле есть другая форма BALANCE за тот же период" in messages + issue = next(i for i in await _issues(seeded, document) if i.code == "MAPPING_WARNING") + assert (issue.kind.value, issue.severity.value, issue.source.value) == ("warning", "info", "core") + + +async def test_1x_second_form_other_period(seeded: AsyncSession) -> None: + _, documents = await _map_dict( + seeded, [_form("BALANCE", "sheet:а"), _form("BALANCE", "sheet:б", report_date="2024-12-31")] + ) + assert [(d.period_year, d.period_quarter) for d in documents] == [ + (2025, PeriodQuarter.Q3), + (2024, PeriodQuarter.Q4), + ] + for document in documents: + assert not any("другая форма" in i.message for i in await _issues(seeded, document)) + + +async def test_osv_orphans_mapping_warning_issue(seeded: AsyncSession) -> None: + sheet = _osv_sheet("60", "sheet:60") + sheet["rows"] = [*sheet["rows"], sheet["rows"][1] | {"row_number": 40, "parent_row_number": 99}] + file, [document] = await _map_dict(seeded, [sheet]) + issues = await _issues(seeded, document) + [orphans] = [i for i in issues if i.code == "MAPPING_WARNING"] + assert "без родителя" in orphans.message and "40" in orphans.message + 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]) -- 2.47.2 From b57431a2341378a13d896422ee60cb6342fbacda Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 16:13:17 +0400 Subject: [PATCH 10/20] =?UTF-8?q?feat(domain):=20=D1=83=D1=81=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=D0=B8=D0=B5=20RECOGNIZED=20=D0=B4=D0=BB=D1=8F=202.0=20(?= =?UTF-8?q?=D0=A0-16,=20=D0=A0-18,=20=D0=A0-19)=20=D0=B8=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=81=20=D0=9E=D0=A1=D0=92=20(=D0=A0-28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- api/src/domain/statement_status.py | 107 +++++++++++++++++++ api/tests/test_domain_statement_status.py | 122 ++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 api/src/domain/statement_status.py create mode 100644 api/tests/test_domain_statement_status.py diff --git a/api/src/domain/statement_status.py b/api/src/domain/statement_status.py new file mode 100644 index 0000000..0181b30 --- /dev/null +++ b/api/src/domain/statement_status.py @@ -0,0 +1,107 @@ +"""Условие `RECOGNIZED` при приёме контракта v2.0 — чистые функции (05-domain-rules §9.3). + +«Документ формы при приёме контракта v2.0» (Р-16, Р-18, Р-19) и «Документ ОСВ при приёме контракта v2.0» (Р-28). +Подключаются в приёме результата и общем пересчёте статуса; здесь нет ни БД, ни разбора файла. +""" + +from collections.abc import Hashable, Iterable +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +from src.domain.statement_lines import LineCatalog +from src.domain.statement_names import names_match +from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter, StatementLineStatus + +_CONFIDENT = Decimal(100) +_PERIOD_FORMS = (DocumentType.BALANCE, DocumentType.INCOME) + + +def has_error_warning(warnings: Any) -> bool: + """Среди предупреждений файла есть объект с `severity == "error"`. + Старые строковые предупреждения (1.x), мусор и не-список — не считаются.""" + if not isinstance(warnings, list): + return False + return any(isinstance(item, dict) and item.get("severity") == "error" for item in warnings) + + +def duplicate_period_forms( + forms: Iterable[tuple[Hashable, DocumentType, int | None, PeriodQuarter | None]], +) -> set[Hashable]: + """Ключи форм ББ/ОФР, у которых в файле есть другая форма того же типа и той же пары (год, квартал) (Р-18). + Формы без квартала (и без года) не считаются.""" + groups: dict[tuple[DocumentType, int, PeriodQuarter], list[Hashable]] = {} + for key, doc_type, year, quarter in forms: + if doc_type in _PERIOD_FORMS and year is not None and quarter is not None: + groups.setdefault((doc_type, year, quarter), []).append(key) + return {key for keys in groups.values() if len(keys) > 1 for key in keys} + + +@dataclass(frozen=True) +class NameCheckLine: + """Вход проверки наименования: строка формы с кодом и уверенностью.""" + + key: Hashable + line_status: str + line_code: str | None + line_code_confidence: Decimal | None + line_name: str | None + code_fixed: bool + + +def name_mismatches(lines: Iterable[NameCheckLine], catalog: LineCatalog, form: str) -> list[Hashable]: + """Ключи строк `ok` с неуверенным кодом (`line_code_confidence < 100`), без закреплённого кода (`code_fixed`), + чьё наименование не совпало ни с одним справочным именем кода (Р-16). Кода нет в словаре — несовпадение.""" + result: list[Hashable] = [] + for line in lines: + if line.line_status != StatementLineStatus.ok or line.code_fixed: + continue + confidence = line.line_code_confidence + if confidence is None or confidence >= _CONFIDENT: + continue + if not line.line_code or not names_match(line.line_name, catalog.names(form, line.line_code)): + result.append(line.key) + return result + + +def _recognized(ok: bool) -> DocumentStatus: + return DocumentStatus.RECOGNIZED if ok else DocumentStatus.NEEDS_CHECK + + +def form_status_v2( # noqa: PLR0913 + *, + service_done: bool, + file_error_warning: bool, + counterparty: DocumentCheckResult | None, + period_known: bool, + names_ok: bool, + duplicate_in_file: bool, +) -> DocumentStatus: + """Статус формы ББ/ОФР: `RECOGNIZED` только при выполнении всех условий (Р-16, Р-18, Р-19), иначе `NEEDS_CHECK`.""" + return _recognized( + service_done + and not file_error_warning + and counterparty is not DocumentCheckResult.MISMATCH + and period_known + and names_ok + and not duplicate_in_file + ) + + +def osv_status_v2( + *, + osv_done: bool, + checks_ok: bool, + file_error_warning: bool, + counterparty: DocumentCheckResult | None, + sheet_mapping_warnings: bool, +) -> DocumentStatus: + """Статус ОСВ: `RECOGNIZED` только если лист `done`, проверки без нарушений, нет `error` у файла, контрагент не + `MISMATCH` и нет предупреждений маппинга по листу (Р-28); статус конверта не учитывается.""" + return _recognized( + osv_done + and checks_ok + and not file_error_warning + and counterparty is not DocumentCheckResult.MISMATCH + and not sheet_mapping_warnings + ) diff --git a/api/tests/test_domain_statement_status.py b/api/tests/test_domain_statement_status.py new file mode 100644 index 0000000..4c7d758 --- /dev/null +++ b/api/tests/test_domain_statement_status.py @@ -0,0 +1,122 @@ +"""Условие RECOGNIZED при приёме 2.0 (05-domain-rules §9.3; Р-16, Р-18, Р-19, Р-28).""" + +from decimal import Decimal + +import pytest + +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, + osv_status_v2, +) +from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter + +BAL, INC = DocumentType.BALANCE, DocumentType.INCOME +Q3 = PeriodQuarter.Q3 +Q4 = PeriodQuarter.Q4 +CATALOG = load_line_catalog() + +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() -> None: + assert form_status_v2(**OK) is DocumentStatus.RECOGNIZED # type: ignore[arg-type] + + +@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: dict) -> None: + assert form_status_v2(**OK | change) is DocumentStatus.NEEDS_CHECK # type: ignore[arg-type] + + +@pytest.mark.parametrize("cp", [None, DocumentCheckResult.UNKNOWN]) +def test_unknown_counterparty_allowed(cp: DocumentCheckResult | None) -> None: + assert form_status_v2(**OK | {"counterparty": cp}) is DocumentStatus.RECOGNIZED # type: ignore[arg-type] + + +def test_duplicate_period_forms() -> None: + 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() -> None: + 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(["error", 5, None, {"severity": "ERROR"}]) + assert not has_error_warning(None) + assert not has_error_warning({"severity": "error"}) + + +OSV_OK = dict( + osv_done=True, + checks_ok=True, + file_error_warning=False, + counterparty=DocumentCheckResult.MATCH, + sheet_mapping_warnings=False, +) + + +def test_osv_recognized() -> None: + assert osv_status_v2(**OSV_OK) is DocumentStatus.RECOGNIZED # type: ignore[arg-type] + assert osv_status_v2(**OSV_OK | {"counterparty": None}) is DocumentStatus.RECOGNIZED # type: ignore[arg-type] + + +@pytest.mark.parametrize( + "change", + [ + {"osv_done": False}, + {"checks_ok": False}, + {"file_error_warning": True}, + {"counterparty": DocumentCheckResult.MISMATCH}, + {"sheet_mapping_warnings": True}, + ], +) +def test_osv_needs_check(change: dict) -> None: + assert osv_status_v2(**OSV_OK | change) is DocumentStatus.NEEDS_CHECK # type: ignore[arg-type] + + +def _line(key: int, code: str | None, conf: str | None, name: str | None, *, fixed: bool = False, status: str = "ok"): # noqa: PLR0913 + return NameCheckLine(key, status, code, None if conf is None else Decimal(conf), name, fixed) + + +def test_name_mismatches() -> None: + lines = [ + _line(1, "1120", "50", "Нематериальные активы"), + _line(2, "1220", "50", "НДС, начисленный по отгрузке"), + _line(3, "1200", "50", "Итого по разделу I"), + _line(4, "1500", "56", "Итого по разделу V1"), + _line(5, "1105", "67", "АКТИВ I. Внеоборотные активы Гудвил"), + _line(6, "1120", "50", "Нематериальные активы", fixed=True), + _line(7, "1110", "100", "Чужое имя"), + _line(8, "9999", "50", "Что угодно"), + _line(9, "1120", "50", ""), + _line(10, None, None, "Без кода", status="unreadable"), + ] + assert name_mismatches(lines, CATALOG, "BALANCE") == [1, 3, 8, 9] -- 2.47.2 From 361e7094e3bae33fc4480b363b86bbd2e08e7a3d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 16:17:45 +0400 Subject: [PATCH 11/20] =?UTF-8?q?feat(documents):=20=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=8F=D0=B4=D0=BE=D0=BA=20409=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D1=8B=20=E2=80=94=20LINES=5FUNRESOLVED,=20DUPLICAT?= =?UTF-8?q?E=5FFORM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit form_confirmable заменён на form_blocker: PERIOD_REQUIRED -> LINES_UNRESOLVED -> DUPLICATE_FORM -> CHECKS_NOT_PASSED. Ручное и автоподтверждение используют одну функцию. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- README.md | 6 +- api/src/repositories/files.py | 22 +++- api/src/repositories/statements.py | 25 ++++- api/src/services/auto_confirm.py | 21 ++-- api/src/services/documents.py | 34 ++----- api/src/services/statements.py | 74 +++++++++++--- api/tests/test_api_statements.py | 149 +++++++++++++++++++++++++++ api/tests/test_auto_confirm.py | 38 ++++++- api/tests/test_form_confirmable.py | 158 ++++++++++++++++++++++++++--- 9 files changed, 462 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 1547791..06bdcc7 100644 --- a/README.md +++ b/README.md @@ -483,7 +483,7 @@ curl -s "localhost:8000/api/v1/files/" -H "Authorization: Bearer $TOKEN - `CONFIRMED` — аналитик нажал «Подтвердить» (`POST /documents/{id}/confirm`, `confirmed_by/at`, аудит) либо система при автоподтверждении чистого документа (`confirmed_by = null`, аудит `DOCUMENT_CONFIRM` без пользователя; OQ-157); - `ERROR` — зарезервирован: в Срезе 2c ядро документ в `ERROR` не создаёт (результат без документов — «в результате нет документов» — переводит в `ERROR` файл). -Чистый документ (`RECOGNIZED` при маппинге) подтверждается системой сразу в маппинге. `RECOGNIZED` остаётся у форм, ставших чистыми после правки/акцепта (автоподтверждения после правки нет), у форм, не прошедших условия подтверждения (`form_confirmable`), и у документов, созданных до автоподтверждения; у документов с замечаниями статус `NEEDS_CHECK`. +Чистый документ (`RECOGNIZED` при маппинге) подтверждается системой сразу в маппинге. `RECOGNIZED` остаётся у форм, ставших чистыми после правки/акцепта (автоподтверждения после правки нет), у форм, не прошедших условия подтверждения (`form_blocker`), и у документов, созданных до автоподтверждения; у документов с замечаниями статус `NEEDS_CHECK`. `counterparty_check` (`MATCH` / `MISMATCH` / `UNKNOWN`) сверяет шапку (ИНН, иначе имя или алиас) с ЮЛ — владельцем файла; `period_check` — `UNKNOWN`: подходящесть документа версии консолидации — функция (документ, версия), а не свойство документа (Срез 4, OQ-120). Допущения помечены `ASSUMPTION(OQ-nn)` в коде. @@ -533,7 +533,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл | `DELETE /files/{file_id}` | `DOCUMENT_EDIT` | удалить файл (см. «Удаление и замена»), 204 | | `GET /documents/{document_id}` | `DOCUMENT_VIEW` | документ: статус, проверки, `locator`, `group_schema` листа, `warnings`, `file_id` | | `GET /documents/{document_id}/entries` | `DOCUMENT_VIEW` | строки ОСВ по `row_number` (`limit` ≤ 2000, по умолчанию 500); дерево — по `parent_entry_id` | -| `POST /documents/{document_id}/confirm` | `DOCUMENT_EDIT` | `RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED`, аудит `DOCUMENT_CONFIRM`; для автоподтверждённого документа — 409 `ALREADY_CONFIRMED` (D-6); для формы ББ/ОФР — ещё `PERIOD_REQUIRED` и `CHECKS_NOT_PASSED` | +| `POST /documents/{document_id}/confirm` | `DOCUMENT_EDIT` | `RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED`, аудит `DOCUMENT_CONFIRM`; для автоподтверждённого документа — 409 `ALREADY_CONFIRMED` (D-6); для формы ББ/ОФР — ещё (в порядке проверки) `PERIOD_REQUIRED`, `LINES_UNRESOLVED`, `DUPLICATE_FORM`, `CHECKS_NOT_PASSED` | | `GET /documents/{document_id}/statement` | `DOCUMENT_VIEW` | форма ББ/ОФР: шапка, колонки, строки со значениями, редакция, проверки §7 (см. «Формы ББ/ОФР») | | `PATCH /documents/{document_id}/statement` | `DOCUMENT_EDIT` | правка значений и шапки (`inn`, `report_date`, `units`) до подтверждения; аудит `RECOGNIZED_DATA_EDIT` | | `POST /documents/{document_id}/statement/checks/accept` | `DOCUMENT_EDIT` | акцепт непройденных жёстких проверок с комментарием (`{comment}`) | @@ -924,6 +924,8 @@ email без учёта регистра проверяет только сер | `DUPLICATE_CELL` | 422 | `PATCH …/statement`: одна ячейка указана в запросе дважды | | `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) | | `PERIOD_REQUIRED` | 409 | подтверждение формы: период не определён — поправьте дату отчёта | +| `LINES_UNRESOLVED` | 409 | подтверждение формы: есть строки `unreadable`/`missing` с непустой ячейкой; `detail.lines[] {line_id, row_index, line_code_text}` по `row_index` — назначьте код или очистите строку | +| `DUPLICATE_FORM` | 409 | подтверждение формы: в файле уже подтверждена форма того же типа, года и квартала; `detail.document_id` — подтверждённая форма | | `CHECKS_NOT_PASSED` | 409 | подтверждение формы: жёсткие проверки §7 не пройдены и не акцептованы; `detail.failures[] {column_index, relation, expected, actual}` | | `TOO_MANY_IDS` | 422 | `GET /users/brief`: больше 200 id | | `INVALID_IDS` | 422 | `GET /users/brief`: элемент `ids` — не UUID | diff --git a/api/src/repositories/files.py b/api/src/repositories/files.py index abed777..e51cd35 100644 --- a/api/src/repositories/files.py +++ b/api/src/repositories/files.py @@ -20,7 +20,7 @@ from src.models.access import AppUser from src.models.audit_ex import Comment from src.models.consolidation import ConsolidationDocument, ConsolidationVersion from src.models.directories import LegalEntity -from src.models.enums import FileStatus, S3BucketRole +from src.models.enums import DocumentStatus, FileStatus, S3BucketRole from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry from src.repositories import statements as statements_repo @@ -194,6 +194,26 @@ async def lock_file_documents(session: AsyncSession, file_id: uuid.UUID) -> list return list((await session.execute(stmt)).scalars().all()) +async def confirmed_twin(session: AsyncSession, document: Document) -> uuid.UUID | None: + """Подтверждённая форма того же файла, типа, года и квартала (другой `file_ordinal`) — для `DUPLICATE_FORM`.""" + if document.period_year is None or document.period_quarter is None: + return None + stmt = ( + select(Document.id) + .where( + Document.stored_file_id == document.stored_file_id, + Document.document_type == document.document_type, + Document.period_year == document.period_year, + Document.period_quarter == document.period_quarter, + Document.status == DocumentStatus.CONFIRMED, + Document.id != document.id, + ) + .order_by(Document.file_ordinal) + .limit(1) + ) + return (await session.execute(stmt)).scalar_one_or_none() + + async def supersedes_others(session: AsyncSession, file_id: uuid.UUID) -> bool: """На файл ссылаются `superseded_by_file_id` других файлов (файл заменил другие версии).""" stmt = select(select(StoredFile.id).where(StoredFile.superseded_by_file_id == file_id).exists()) diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index 1ad0218..782d40e 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, func, insert, select +from sqlalchemy import delete, func, insert, or_, select from sqlalchemy.ext.asyncio import AsyncSession from src.domain.statement_aggregate import AggLine, CodeAggregate, aggregate_by_code @@ -203,6 +203,29 @@ async def has_source_lines(session: AsyncSession, meta_id: uuid.UUID) -> bool: return bool((await session.execute(stmt)).scalar_one()) +async def unresolved_lines(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[uuid.UUID, int | None, str | None]]: + """Строки `unreadable`/`missing` с непустой ячейкой (`amount` или `string_value`) по `row_index`: + `(line_id, row_index, line_code_text)`. Строки без значений подтверждение не блокируют.""" + has_value = ( + select(FinancialStatementValue.id) + .where( + FinancialStatementValue.line_id == FinancialStatementLine.id, + or_(FinancialStatementValue.amount.is_not(None), FinancialStatementValue.string_value.is_not(None)), + ) + .exists() + ) + stmt = ( + select(FinancialStatementLine.id, FinancialStatementLine.row_index, FinancialStatementLine.line_code_text) + .where( + FinancialStatementLine.meta_id == meta_id, + FinancialStatementLine.line_status.in_((StatementLineStatus.unreadable, StatementLineStatus.missing)), + has_value, + ) + .order_by(FinancialStatementLine.row_index.asc().nulls_last(), FinancialStatementLine.id) + ) + return [(row[0], row[1], row[2]) for row in (await session.execute(stmt)).all()] + + async def form_aggregate( session: AsyncSession, meta_id: uuid.UUID, diff --git a/api/src/services/auto_confirm.py b/api/src/services/auto_confirm.py index f5bb462..84f943c 100644 --- a/api/src/services/auto_confirm.py +++ b/api/src/services/auto_confirm.py @@ -17,7 +17,7 @@ from src.models.files import Document, StoredFile from src.repositories import statements as statements_repo from src.services import consolidation_documents from src.services.audit import AuditService -from src.services.statements import FORM_TYPES, form_confirmable +from src.services.statements import FORM_TYPES, form_blocker AUTO_CONFIRM_DESCRIPTION = "автоподтверждение: без замечаний" OBJECT_TYPE = "document" @@ -34,24 +34,25 @@ def transition_comment(document: Document) -> str: async def auto_confirm( session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime ) -> list[Document]: - """Подтверждает документы `RECOGNIZED` файла (форма — только при `form_confirmable`, D-3); `NEEDS_CHECK` и прочие - не трогает. Возвращает подтверждённые документы по возрастанию `id`.""" + """Подтверждает документы `RECOGNIZED` файла (форма — только при `form_blocker is None`, D-3); `NEEDS_CHECK` + и прочие не трогает. Возвращает подтверждённые документы по возрастанию `id`.""" audit = AuditService(session) confirmed: list[Document] = [] for document in sorted(documents, key=lambda item: item.id): if document.status is not DocumentStatus.RECOGNIZED: continue - if not await form_confirmable(session, document): - continue + meta = None + if document.document_type in FORM_TYPES: + meta = await statements_repo.get_meta(session, document.id, refresh=True) + if meta is not None and await form_blocker(session, document, meta) is not None: + continue document.status = DocumentStatus.CONFIRMED document.confirmed_by = None document.confirmed_at = now document.updated_at = now - if document.document_type in FORM_TYPES: - meta = await statements_repo.get_meta(session, document.id) - if meta is not None: - meta.reviewed_by = None - meta.reviewed_at = now + if meta is not None: + meta.reviewed_by = None + meta.reviewed_at = now await session.flush() await consolidation_documents.on_document_confirmed( session, diff --git a/api/src/services/documents.py b/api/src/services/documents.py index 215d279..dfa82b9 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -244,45 +244,33 @@ class DocumentService: return await files_repo.list_entries(self._session, document.id, limit=page.limit, offset=page.offset) async def _check_statement(self, document: Document) -> FinancialStatementMeta | None: - """Условия подтверждения формы ББ/ОФР (D-9): период определён, жёсткие проверки §7 пройдены или акцептованы. + """Условия подтверждения формы ББ/ОФР (D-9): `form_blocker` — период, нераспознанные строки со значениями, + дубль формы, жёсткие проверки §7 (пройдены или акцептованы). Шапка читается под блокировками документа с `refresh` (иначе в сессии мог остаться устаревший акцепт). Для не-формы и документа без шапки — `None`, условий нет. """ - from src.services.statements import FORM_TYPES, statement_hard_failures # noqa: PLC0415 - цикл импорта + from src.services.statements import FORM_TYPES, form_blocker # noqa: PLC0415 - цикл импорта if document.document_type not in FORM_TYPES: return None meta = await statements_repo.get_meta(self._session, document.id, refresh=True) if meta is None: return None - if document.period_year is None or document.period_quarter is None: - raise ConflictError(PERIOD_REQUIRED, "у формы не определён период: поправьте дату отчёта") - failures = await statement_hard_failures(self._session, document, meta) - if failures: - raise ConflictError( - CHECKS_NOT_PASSED, - "не пройдены логические проверки формы: исправьте значения или акцептуйте с комментарием", - failures=[ - { - "column_index": failure.column_index, - "relation": failure.relation, - "expected": str(failure.expected), - "actual": str(failure.actual), - } - for failure in failures - ], - ) + blocker = await form_blocker(self._session, document, meta) + if blocker is not None: + raise ConflictError(blocker.code, blocker.message, **blocker.extra) return meta async def confirm(self, document_id: uuid.UUID) -> DocumentOut: """`RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED` (`confirmed_by/at`), аудит `DOCUMENT_CONFIRM`. 409: файл `SUPERSEDED` — `FILE_SUPERSEDED`; уже `CONFIRMED` — `ALREADY_CONFIRMED`; иной статус — - `DOCUMENT_NOT_CONFIRMABLE`; для формы ББ/ОФР далее — `PERIOD_REQUIRED` (период не определён) и - `CHECKS_NOT_PASSED` (неакцептованные нарушения проверок §7 в `failures`; D-9). Порядок блокировок и - перечитывание — см. docstring модуля. В той же транзакции документ привязывается к подходящим версиям - «в работе», версии из `INITIATED`/`DOCUMENTS_PROCESSING` переходят в `DATA_CORRECTION` + `DOCUMENT_NOT_CONFIRMABLE`; для формы ББ/ОФР далее — `PERIOD_REQUIRED` (период не определён), + `LINES_UNRESOLVED`, `DUPLICATE_FORM` и `CHECKS_NOT_PASSED` (неакцептованные нарушения §7 в + `failures`; D-9). Порядок блокировок и перечитывание — см. docstring модуля. В той же транзакции документ + привязывается к подходящим версиям «в работе», версии из `INITIATED`/`DOCUMENTS_PROCESSING` + переходят в `DATA_CORRECTION` (`on_document_confirmed`, spec documents-api «Подтверждение документа»). """ visible = await self._visible(document_id) diff --git a/api/src/services/statements.py b/api/src/services/statements.py index c1064fd..d22f2ea 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -6,6 +6,7 @@ import re import uuid +from dataclasses import dataclass from typing import Any from sqlalchemy.ext.asyncio import AsyncSession @@ -43,7 +44,13 @@ from src.repositories import files as files_repo from src.repositories import statements as repo from src.repositories.counterparties import CounterpartyIndex from src.schemas import statements as schemas -from src.services.documents import CONFIRMABLE_STATUSES, DOCUMENT_NOT_CONFIRMABLE, FILE_SUPERSEDED +from src.services.documents import ( + CHECKS_NOT_PASSED, + CONFIRMABLE_STATUSES, + DOCUMENT_NOT_CONFIRMABLE, + FILE_SUPERSEDED, + PERIOD_REQUIRED, +) DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT" @@ -53,6 +60,8 @@ UNKNOWN_CELL = "UNKNOWN_CELL" DUPLICATE_CELL = "DUPLICATE_CELL" NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT" INVALID_INN = "INVALID_INN" +LINES_UNRESOLVED = "LINES_UNRESOLVED" +DUPLICATE_FORM = "DUPLICATE_FORM" OBJECT_TYPE = "document" INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII) FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME}) @@ -80,18 +89,59 @@ async def statement_hard_failures( return tuple(report.failed) -async def form_confirmable(session: AsyncSession, document: Document) -> bool: - """Условия подтверждения формы ББ/ОФР без исключений (§7, D-9 Среза 3b; автоподтверждение — design D-3, OQ-157): - период определён и жёстких нарушений §7 нет или они акцептованы. Не-форма и форма без шапки (созданная до Среза 3b) - — `True`: условий у них нет. Ручной путь (`DocumentService._check_statement`) проверяет то же, но отвечает 409.""" - if document.document_type not in FORM_TYPES: - return True - meta = await repo.get_meta(session, document.id, refresh=True) - if meta is None: - return True +@dataclass(frozen=True) +class FormBlocker: + """Причина отказа в подтверждении формы: код 409, сообщение и поля `detail`.""" + + code: str + message: str + extra: dict[str, Any] + + +async def form_blocker(session: AsyncSession, document: Document, meta: FinancialStatementMeta) -> FormBlocker | None: + """Первое препятствие подтверждению формы ББ/ОФР или `None` (общий путь ручного и автоподтверждения). + + Порядок: `PERIOD_REQUIRED` → `LINES_UNRESOLVED` (строки `unreadable`/`missing` с непустой ячейкой) → + `DUPLICATE_FORM` (уже подтверждена форма того же файла, типа и периода) → `CHECKS_NOT_PASSED` (жёсткие §7 + без акцепта). + """ if document.period_year is None or document.period_quarter is None: - return False - return not await statement_hard_failures(session, document, meta) + return FormBlocker(PERIOD_REQUIRED, "у формы не определён период: поправьте дату отчёта", {}) + lines = await repo.unresolved_lines(session, meta.id) + if lines: + return FormBlocker( + LINES_UNRESOLVED, + "в форме есть нераспознанные строки со значениями: назначьте код или очистите строку", + { + "lines": [ + {"line_id": str(line_id), "row_index": row_index, "line_code_text": code_text} + for line_id, row_index, code_text in lines + ] + }, + ) + twin = await files_repo.confirmed_twin(session, document) + if twin is not None: + return FormBlocker( + DUPLICATE_FORM, "форма этого типа и периода в файле уже подтверждена", {"document_id": str(twin)} + ) + failures = await statement_hard_failures(session, document, meta) + if failures: + return FormBlocker( + CHECKS_NOT_PASSED, + "не пройдены логические проверки формы: исправьте значения или акцептуйте с комментарием", + { + "failures": [ + { + "column_index": failure.column_index, + "relation": failure.relation, + "expected": str(failure.expected), + "actual": str(failure.actual), + } + for failure in failures + ] + }, + ) + return None class StatementService: diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 0a8cc1c..2d3efde 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -908,6 +908,155 @@ async def test_confirm_requires_checks( assert meta.reviewed_by is not None and meta.reviewed_at is not None +async def _meta_of(session: AsyncSession, document: Document) -> FinancialStatementMeta: + return ( + await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) + ).scalar_one() + + +async def _add_unresolved( # noqa: PLR0913 - фабрика тестовой строки + session: AsyncSession, + document: Document, + *, + status: StatementLineStatus, + row_index: int, + code_text: str | None = None, + amount: Decimal | None = None, + string_value: str | None = None, +) -> FinancialStatementLine: + meta = await _meta_of(session, document) + line = FinancialStatementLine(meta_id=meta.id, line_status=status, row_index=row_index, line_code_text=code_text) + session.add(line) + await session.flush() + column = ( + await session.execute( + select(FinancialStatementColumn).where(FinancialStatementColumn.meta_id == meta.id).limit(1) + ) + ).scalar_one() + session.add(FinancialStatementValue(line_id=line.id, column_id=column.id, amount=amount, string_value=string_value)) + await session.flush() + return line + + +async def _add_confirmed_twin(session: AsyncSession, document: Document) -> Document: + twin = Document( + stored_file_id=document.stored_file_id, + legal_entity_id=document.legal_entity_id, + document_type=document.document_type, + file_ordinal=document.file_ordinal + 1, + period_year=document.period_year, + period_quarter=document.period_quarter, + status=DocumentStatus.CONFIRMED, + created_at=document.created_at, + updated_at=document.updated_at, + ) + session.add(twin) + await session.flush() + return twin + + +async def test_confirm_unresolved_line_with_amount( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + await _break_balance(client, document.id) # жёсткое нарушение не мешает: LINES_UNRESOLVED раньше + line = await _add_unresolved(seeded, document, status=StatementLineStatus.missing, row_index=40, amount=Decimal(7)) + blocked = await client.post(f"/documents/{document.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT + detail = blocked.json()["detail"] + assert detail["code"] == "LINES_UNRESOLVED" + assert detail["lines"] == [{"line_id": str(line.id), "row_index": 40, "line_code_text": None}] + + +async def test_confirm_unresolved_line_with_text_only( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + await _add_unresolved( + seeded, + document, + status=StatementLineStatus.unreadable, + row_index=41, + code_text="ИЧ,250", + string_value="211(1 |", + ) + blocked = await client.post(f"/documents/{document.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "LINES_UNRESOLVED" + + +async def test_unresolved_line_without_values_does_not_block( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + await _add_unresolved(seeded, document, status=StatementLineStatus.unreadable, row_index=41) + confirmed = await client.post(f"/documents/{document.id}/confirm") + assert confirmed.status_code == HTTPStatus.OK and confirmed.json()["status"] == "CONFIRMED" + + +async def test_confirm_duplicate_form(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + """Review Focus 1: вторая форма того же типа и периода в файле после подтверждения первой — `DUPLICATE_FORM`.""" + first = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + first_confirmed = await client.post(f"/documents/{first.id}/confirm") + assert first_confirmed.status_code == HTTPStatus.OK + second = Document( + stored_file_id=first.stored_file_id, + legal_entity_id=first.legal_entity_id, + document_type=first.document_type, + file_ordinal=first.file_ordinal + 1, + period_year=first.period_year, + period_quarter=first.period_quarter, + status=DocumentStatus.NEEDS_CHECK, + created_at=first.created_at, + updated_at=first.updated_at, + ) + seeded.add(second) + await seeded.flush() + first_meta = await _meta_of(seeded, first) + seeded.add( + FinancialStatementMeta( + document_id=second.id, + recognition_job_id=first_meta.recognition_job_id, + statement_type=first_meta.statement_type, + ) + ) + await seeded.flush() + blocked = await client.post(f"/documents/{second.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT + assert blocked.json()["detail"]["code"] == "DUPLICATE_FORM" + assert blocked.json()["detail"]["document_id"] == str(first.id) + + +async def test_conflict_order(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + """PERIOD_REQUIRED -> LINES_UNRESOLVED -> DUPLICATE_FORM -> CHECKS_NOT_PASSED.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + await _break_balance(client, document.id) + await _add_confirmed_twin(seeded, document) + line = await _add_unresolved(seeded, document, status=StatementLineStatus.missing, row_index=40, amount=Decimal(7)) + quarter = document.period_quarter + document.period_quarter = None + await seeded.flush() + + async def code() -> str: + return (await client.post(f"/documents/{document.id}/confirm")).json()["detail"]["code"] + + seen = [await code()] + document.period_quarter = quarter + await seeded.flush() + seen.append(await code()) + await seeded.execute(delete(FinancialStatementValue).where(FinancialStatementValue.line_id == line.id)) + await seeded.flush() + seen.append(await code()) + await seeded.execute(delete(Document).where(Document.status == DocumentStatus.CONFIRMED)) + await seeded.flush() + seen.append(await code()) + assert seen == ["PERIOD_REQUIRED", "LINES_UNRESOLVED", "DUPLICATE_FORM", "CHECKS_NOT_PASSED"] + + async def test_confirm_after_non_quarter_date_edit( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: diff --git a/api/tests/test_auto_confirm.py b/api/tests/test_auto_confirm.py index 4c731ea..05ebed8 100644 --- a/api/tests/test_auto_confirm.py +++ b/api/tests/test_auto_confirm.py @@ -2,6 +2,7 @@ OQ-157; design D-1–D-3).""" import uuid +from decimal import Decimal from pathlib import Path import pytest @@ -22,9 +23,15 @@ from src.models.enums import ( FileStatus, PeriodQuarter, RecognitionStatus, + StatementLineStatus, ) from src.models.files import Document, StoredFile -from src.models.statements import FinancialStatementMeta +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) from src.services.auto_confirm import AUTO_CONFIRM_DESCRIPTION, auto_confirm, transition_comment from tests.conftest import AuthClientFactory from tests.factories import make_company_group, make_consolidation, make_document, make_legal_entity, make_stored_file @@ -210,6 +217,35 @@ async def test_recognized_form_without_period_stays(seeded: AsyncSession, fixtur assert income.status is DocumentStatus.RECOGNIZED +@pytest.mark.fixtures +async def test_auto_confirm_skips_blocked_form(seeded: AsyncSession, fixtures_dir: Path) -> None: + path = fixtures_dir / GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" + import json # noqa: PLC0415 - нужен только здесь + + data = json.loads(path.read_text("utf-8")) + inn = next(d["header"]["inn"] for d in data["documents"] if d.get("header", {}).get("inn")) + owner = await make_legal_entity(seeded, name="ООО Владелец", inn=inn) + (income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + meta = ( + await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id)) + ).scalar_one() + line = FinancialStatementLine(meta_id=meta.id, line_status=StatementLineStatus.missing, row_index=900) + seeded.add(line) + await seeded.flush() + column = ( + await seeded.execute( + select(FinancialStatementColumn).where(FinancialStatementColumn.meta_id == meta.id).limit(1) + ) + ).scalar_one() + seeded.add(FinancialStatementValue(line_id=line.id, column_id=column.id, amount=Decimal(5))) + await seeded.flush() + file = await seeded.get(StoredFile, income.stored_file_id) + assert file is not None + + assert await auto_confirm(seeded, file=file, documents=[income], now=utc_now()) == [] + assert income.status is DocumentStatus.RECOGNIZED + + async def test_manual_confirm_of_auto_confirmed_is_409(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: """Review Focus 4: фронт с устаревшим статусом жмёт «Подтвердить» — 409 `ALREADY_CONFIRMED`.""" office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() diff --git a/api/tests/test_form_confirmable.py b/api/tests/test_form_confirmable.py index 67cd9f5..d5df08e 100644 --- a/api/tests/test_form_confirmable.py +++ b/api/tests/test_form_confirmable.py @@ -1,17 +1,23 @@ -"""Условия подтверждения формы ББ/ОФР — общие для ручного и автоматического пути (OQ-157, design D-3; D-9 Среза 3b).""" +"""Условия подтверждения формы ББ/ОФР: ручной и автоматический путь (OQ-157, design D-3; D-9 Среза 3b; A11).""" import json +from decimal import Decimal from pathlib import Path import pytest -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from src.models.directories import LegalEntity -from src.models.enums import DocumentType +from src.models.enums import DocumentStatus, DocumentType, StatementLineStatus from src.models.files import Document -from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue -from src.services.statements import form_confirmable, statement_hard_failures +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) +from src.services.statements import form_blocker, statement_hard_failures from tests.factories import make_legal_entity from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot @@ -35,15 +41,78 @@ async def _income(session: AsyncSession, fixtures_dir: Path) -> Document: return income +async def _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta: + return ( + await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) + ).scalar_one() + + +async def _unresolved_line( # noqa: PLR0913 - фабрика тестовой строки + session: AsyncSession, + meta: FinancialStatementMeta, + *, + status: StatementLineStatus, + row_index: int, + code_text: str | None = None, + amount: Decimal | None = None, + string_value: str | None = None, +) -> FinancialStatementLine: + """Строка без кода (ORM-фабрика) с одной ячейкой в первой колонке формы.""" + line = FinancialStatementLine(meta_id=meta.id, line_status=status, row_index=row_index, line_code_text=code_text) + session.add(line) + await session.flush() + column = ( + await session.execute( + select(FinancialStatementColumn).where(FinancialStatementColumn.meta_id == meta.id).limit(1) + ) + ).scalar_one() + session.add(FinancialStatementValue(line_id=line.id, column_id=column.id, amount=amount, string_value=string_value)) + await session.flush() + return line + + +async def _confirmed_twin(session: AsyncSession, document: Document) -> Document: + """Вторая форма того же файла, типа и периода со статусом `CONFIRMED` (другой `file_ordinal`).""" + twin = Document( + stored_file_id=document.stored_file_id, + legal_entity_id=document.legal_entity_id, + document_type=document.document_type, + file_ordinal=document.file_ordinal + 1, + period_year=document.period_year, + period_quarter=document.period_quarter, + status=DocumentStatus.CONFIRMED, + created_at=document.created_at, + updated_at=document.updated_at, + ) + session.add(twin) + await session.flush() + return twin + + +async def _break_2200(session: AsyncSession, meta: FinancialStatementMeta) -> None: + values = ( + await session.execute( + select(FinancialStatementValue) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "2200") + ) + ).scalars() + for value in values: + value.amount = (value.amount or 0) + 1000 + await session.flush() + + async def test_clean_form_is_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None: - assert await form_confirmable(seeded, await _income(seeded, fixtures_dir)) is True + income = await _income(seeded, fixtures_dir) + assert await form_blocker(seeded, income, await _meta(seeded, income)) is None async def test_form_without_period_is_not_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None: income = await _income(seeded, fixtures_dir) income.period_quarter = None await seeded.flush() - assert await form_confirmable(seeded, income) is False + blocker = await form_blocker(seeded, income, await _meta(seeded, income)) + assert blocker is not None and blocker.code == "PERIOD_REQUIRED" async def test_form_with_hard_failure_is_not_confirmable_until_accepted( @@ -67,17 +136,76 @@ async def test_form_with_hard_failure_is_not_confirmable_until_accepted( await seeded.flush() assert await statement_hard_failures(seeded, income, meta) != () - assert await form_confirmable(seeded, income) is False + blocker = await form_blocker(seeded, income, meta) + assert blocker is not None and blocker.code == "CHECKS_NOT_PASSED" and blocker.extra["failures"] meta.checks_accepted_at = income.created_at # акцепт: любые непустые дата и автор await seeded.flush() - assert await form_confirmable(seeded, income) is True + assert await form_blocker(seeded, income, meta) is None -async def test_disclosure_is_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None: - owner = await make_legal_entity(seeded, name="ООО Синтетика") - documents = await map_golden_snapshot( - seeded, entity=owner, path=fixtures_dir / SYNTH / "st06_header_variants.json", uploaded_by=None +async def test_unresolved_line_with_amount_blocks(seeded: AsyncSession, fixtures_dir: Path) -> None: + income = await _income(seeded, fixtures_dir) + meta = await _meta(seeded, income) + line = await _unresolved_line(seeded, meta, status=StatementLineStatus.missing, row_index=40, amount=Decimal(5)) + blocker = await form_blocker(seeded, income, meta) + assert blocker is not None and blocker.code == "LINES_UNRESOLVED" + assert blocker.extra == {"lines": [{"line_id": str(line.id), "row_index": 40, "line_code_text": None}]} + + +async def test_unresolved_line_with_text_only_blocks(seeded: AsyncSession, fixtures_dir: Path) -> None: + income = await _income(seeded, fixtures_dir) + meta = await _meta(seeded, income) + await _unresolved_line( + seeded, meta, status=StatementLineStatus.unreadable, row_index=41, code_text="ИЧ,250", string_value="211(1 |" ) - disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE) - assert await form_confirmable(seeded, disclosure) is True + blocker = await form_blocker(seeded, income, meta) + assert blocker is not None and blocker.code == "LINES_UNRESOLVED" + assert blocker.extra["lines"][0]["line_code_text"] == "ИЧ,250" + + +async def test_unresolved_line_without_values_does_not_block(seeded: AsyncSession, fixtures_dir: Path) -> None: + income = await _income(seeded, fixtures_dir) + meta = await _meta(seeded, income) + await _unresolved_line(seeded, meta, status=StatementLineStatus.unreadable, row_index=41) + assert await form_blocker(seeded, income, meta) is None + + +async def test_duplicate_form_blocks(seeded: AsyncSession, fixtures_dir: Path) -> None: + """Review Focus 1: подтверждённая форма того же файла, типа и периода -> `DUPLICATE_FORM` с её id.""" + income = await _income(seeded, fixtures_dir) + twin = await _confirmed_twin(seeded, income) + blocker = await form_blocker(seeded, income, await _meta(seeded, income)) + assert blocker is not None and blocker.code == "DUPLICATE_FORM" and blocker.extra == {"document_id": str(twin.id)} + + +async def test_other_period_twin_does_not_block(seeded: AsyncSession, fixtures_dir: Path) -> None: + income = await _income(seeded, fixtures_dir) + twin = await _confirmed_twin(seeded, income) + twin.period_year = (income.period_year or 0) - 1 + await seeded.flush() + assert await form_blocker(seeded, income, await _meta(seeded, income)) is None + + +async def test_conflict_order(seeded: AsyncSession, fixtures_dir: Path) -> None: + """PERIOD_REQUIRED -> LINES_UNRESOLVED -> DUPLICATE_FORM -> CHECKS_NOT_PASSED.""" + income = await _income(seeded, fixtures_dir) + meta = await _meta(seeded, income) + await _break_2200(seeded, meta) + await _confirmed_twin(seeded, income) + await _unresolved_line(seeded, meta, status=StatementLineStatus.missing, row_index=40, amount=Decimal(5)) + quarter = income.period_quarter + income.period_quarter = None + await seeded.flush() + codes = [] + codes.append((await form_blocker(seeded, income, meta)).code) # type: ignore[union-attr] + income.period_quarter = quarter + await seeded.flush() + codes.append((await form_blocker(seeded, income, meta)).code) # type: ignore[union-attr] + await seeded.execute(delete(FinancialStatementValue).where(FinancialStatementValue.amount == Decimal(5))) + await seeded.flush() + codes.append((await form_blocker(seeded, income, meta)).code) # type: ignore[union-attr] + await seeded.execute(delete(Document).where(Document.status == DocumentStatus.CONFIRMED)) + await seeded.flush() + codes.append((await form_blocker(seeded, income, meta)).code) # type: ignore[union-attr] + assert codes == ["PERIOD_REQUIRED", "LINES_UNRESOLVED", "DUPLICATE_FORM", "CHECKS_NOT_PASSED"] -- 2.47.2 From f1fcb861a68794cd3d6abba1bcd74cb080612d53 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 16:33:52 +0400 Subject: [PATCH 12/20] =?UTF-8?q?feat(statements):=20PATCH=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=8F=D1=87=D0=B5=D0=B9=D0=BA=D0=B8,=20?= =?UTF-8?q?=D0=B4=D0=B0=D1=82=D0=B0=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B8,=20=D0=BD=D0=B0=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=80=D0=B3=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D0=B8,=20=D0=BF=D0=BE=D0=B4=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4?= =?UTF-8?q?=D0=B0=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- README.md | 20 +- api/src/domain/statement_issues.py | 12 +- api/src/repositories/statements.py | 74 +++++- api/src/schemas/statements.py | 34 ++- api/src/services/statements.py | 281 +++++++++++++++----- api/tests/test_api_statements.py | 411 +++++++++++++++++++++++++++++ 6 files changed, 753 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 06bdcc7..3408a9c 100644 --- a/README.md +++ b/README.md @@ -549,7 +549,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл Коды ошибок этих маршрутов — в таблице «Ошибки API» (`FILE_TOO_LARGE`, `UNSUPPORTED_FILE`, `DUPLICATE_FILE`, `STORAGE_UNAVAILABLE`, `FILE_SUPERSEDED`, `ALREADY_CONFIRMED`, `DOCUMENT_NOT_CONFIRMABLE`, `FILE_HAS_CONFIRMED_DOCUMENTS`, `FILE_SUPERSEDES_OTHERS`, `FILE_CONTENT_NOT_FOUND`) и форм ББ/ОФР (`DOCUMENT_NOT_STATEMENT`, -`DOCUMENT_READONLY`, `UNKNOWN_CELL`, `DUPLICATE_CELL`, `INVALID_INN`, `NOTHING_TO_ACCEPT`, `PERIOD_REQUIRED`, +`DOCUMENT_READONLY`, `UNKNOWN_CELL`, `DUPLICATE_CELL`, `UNKNOWN_COLUMN`, `DUPLICATE_COLUMN`, `INVALID_INN`, `NOTHING_TO_ACCEPT`, `PERIOD_REQUIRED`, `CHECKS_NOT_PASSED`). События аудита: `FILE_UPLOAD`, `FILE_REPLACE`, `FILE_DELETE`, `DOCUMENT_CONFIRM` (раздел `PRIMARY_DOCUMENTS`), `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`; правка формы и акцепт). @@ -589,12 +589,20 @@ S3 (storage); без S3 — предупреждение в логе, цикл Карточка документа (`GET /documents/{id}`) несёт `issues[]` (у документа любого типа) и `issue_counts {error, info}`; `documents[]` карточки файла — `issue_counts` (все виды записей по `severity`, в том числе акцептованные; один групповой запрос на список документов). -- **`PATCH /documents/{id}/statement`** — `{header?: {inn?, report_date?, units?}, values?: [{line_id | line_code, column_id, amount}]}` - (до 5000 ячеек; `amount: null` очищает ячейку; не более двух знаков; `units` — непустая строка после обрезки пробелов до 50 символов, +- **`PATCH /documents/{id}/statement`** — `{header?: {inn?, report_date?, units?, organization_name?}, values?: [{line_id | line_code, column_id, amount | confirmed}], columns?: [{column_id, period_date}]}` + (до 5000 ячеек, до 100 колонок; в элементе `values[]` ровно одно из `amount` и `confirmed`, `confirmed: true | false` — подтвердить ячейку + или снять подтверждение без правки суммы, только с `line_id`; `columns[].period_date` обязательна, `null` — 422 массивом; ручная дата + ставит колонке `period_kind = absolute` и пересчитывает `is_current` и период документа; дата, равная текущей, у колонки с + `COLUMN_WITHOUT_DATE` / `COLUMN_DATE_UNCERTAIN` — подтверждение; `header.organization_name` — непустая строка до 1000 символов, + пересчитывает `counterparty_check`; `header.report_date`, равный текущему, у поля из `needs_review_fields` — подтверждение даты; + правка поля шапки или даты колонки снимает предупреждения `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`, `REPORT_DATE_UNCERTAIN`, + `NO_ORGANIZATION_NO_INN`, `COLUMN_WITHOUT_DATE`, `COLUMN_DATE_UNCERTAIN`; подтверждения акцепт проверок не сбрасывают; + `amount: null` очищает ячейку; не более двух знаков; `units` — непустая строка после обрезки пробелов до 50 символов, пробельная строка — 422, `null` — «не менять»; `inn: null` — 422 `INVALID_INN`, ИНН не очищается). Правится только неподтверждённая форма (`RECOGNIZED`/`NEEDS_CHECK`): `CONFIRMED` — 409 `DOCUMENT_READONLY` (исправление — новым файлом), заменённый файл — - 409 `FILE_SUPERSEDED`, статус `ERROR` — 409 `DOCUMENT_NOT_CONFIRMABLE`. Ячейка не из этой формы — 422 `UNKNOWN_CELL`, - одна ячейка дважды — 422 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно + 409 `FILE_SUPERSEDED`, статус `ERROR` — 409 `DOCUMENT_NOT_CONFIRMABLE`. Ячейка не из этой формы — 422 `UNKNOWN_CELL` + (в том числе подтверждение ячейки, которой нет в БД), колонка не этой формы — 422 `UNKNOWN_COLUMN`, одна колонка дважды — 422 + `DUPLICATE_COLUMN`, одна ячейка дважды — 422 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно событие `RECOGNIZED_DATA_EDIT` с `before/after`; возврат ячейки к исходному значению снимает `is_modified`; правка ИНН пересчитывает `counterparty_check`, даты отчёта — период документа; любая правка сбрасывает акцепт и пересчитывает статус. `values[]` адресует ячейку `line_id` или `line_code` (ровно одно): ячейка, которой нет, создаётся при первой непустой правке; строка по `line_code` — только код справочника редакции, которого нет в форме. @@ -922,6 +930,8 @@ email без учёта регистра проверяет только сер | `DOCUMENT_READONLY` | 409 | правка/акцепт формы: форма уже `CONFIRMED` (исправление — новым файлом) | | `UNKNOWN_CELL` | `422` | `PATCH …/statement`: `line_id`/`column_id` не этой формы; `line_code` уже есть в форме (передайте `line_id`), нет в справочнике редакции или редакция не определена | | `DUPLICATE_CELL` | 422 | `PATCH …/statement`: одна ячейка указана в запросе дважды | +| `UNKNOWN_COLUMN` | 422 | `PATCH …/statement`: `columns[].column_id` не этой формы; ничего не применено | +| `DUPLICATE_COLUMN` | 422 | `PATCH …/statement`: одна колонка указана в `columns[]` дважды | | `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) | | `PERIOD_REQUIRED` | 409 | подтверждение формы: период не определён — поправьте дату отчёта | | `LINES_UNRESOLVED` | 409 | подтверждение формы: есть строки `unreadable`/`missing` с непустой ячейкой; `detail.lines[] {line_id, row_index, line_code_text}` по `row_index` — назначьте код или очистите строку | diff --git a/api/src/domain/statement_issues.py b/api/src/domain/statement_issues.py index 5c03500..9d2d644 100644 --- a/api/src/domain/statement_issues.py +++ b/api/src/domain/statement_issues.py @@ -10,10 +10,20 @@ from decimal import Decimal from src.models.enums import DocumentIssueKind, IssueSeverity, IssueSource -__all__ = ["MAPPING_WARNING_CODE", "IssueDraft", "mapping_warning"] +__all__ = ["COLUMN_CLEARS", "HEADER_CLEARS", "MAPPING_WARNING_CODE", "IssueDraft", "mapping_warning"] MAPPING_WARNING_CODE = "MAPPING_WARNING" +# предупреждения сервиса, которые снимает правка поля шапки (в том числе подтверждение даты тем же значением) — +# spec arm-core «Записи document_issue» +HEADER_CLEARS: dict[str, frozenset[str]] = { + "report_date": frozenset({"PERIOD_UNKNOWN", "REPORT_DATE_UNPARSED", "REPORT_DATE_UNCERTAIN"}), + "inn": frozenset({"NO_ORGANIZATION_NO_INN"}), + "organization_name": frozenset({"NO_ORGANIZATION_NO_INN"}), +} +# ... и правка (подтверждение) даты колонки — предупреждения этой колонки +COLUMN_CLEARS = frozenset({"COLUMN_WITHOUT_DATE", "COLUMN_DATE_UNCERTAIN"}) + @dataclass(frozen=True) class IssueDraft: diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index 782d40e..6c1a9ee 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -3,18 +3,26 @@ import datetime import uuid -from collections.abc import Callable, Sequence +from collections.abc import Callable, Collection, Sequence from dataclasses import dataclass from decimal import Decimal from typing import Any -from sqlalchemy import delete, func, insert, or_, select +from sqlalchemy import delete, exists, func, insert, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from src.domain.statement_aggregate import AggLine, CodeAggregate, aggregate_by_code from src.domain.statement_issues import IssueDraft from src.domain.statement_mapping import StatementDraft -from src.models.enums import DocumentStatus, DocumentType, FileStatus, IssueSeverity, StatementLineStatus +from src.models.enums import ( + DocumentIssueKind, + DocumentStatus, + DocumentType, + FileStatus, + IssueSeverity, + IssueSource, + StatementLineStatus, +) from src.models.files import Document, StoredFile from src.models.statements import ( DocumentIssue, @@ -33,10 +41,12 @@ __all__ = [ "column_ids", "delete_documents_statements", "delete_file_statements", + "delete_issues", "IssueCounts", "form_aggregate", "form_lines", "get_meta", + "has_review_values", "has_source_lines", "insert_statement", "issue_counts_by_document", @@ -44,6 +54,7 @@ __all__ = [ "load_form", "lock_cells", "pair_amounts", + "settle_edited_values", "value_flags", ] @@ -170,6 +181,28 @@ async def add_issues( ) +async def delete_issues( # noqa: PLR0913 - фильтры записей независимы + session: AsyncSession, + document_id: uuid.UUID, + *, + codes: Collection[str], + kind: DocumentIssueKind | None = None, + source: IssueSource | None = None, + column_id: uuid.UUID | None = None, +) -> int: + """Удалить записи `document_issue` документа с кодами `codes` (и, если заданы, видом, источником, колонкой); + вернуть число удалённых.""" + stmt = delete(DocumentIssue).where(DocumentIssue.document_id == document_id, DocumentIssue.code.in_(codes)) + if kind is not None: + stmt = stmt.where(DocumentIssue.kind == kind) + if source is not None: + stmt = stmt.where(DocumentIssue.source == source) + if column_id is not None: + stmt = stmt.where(DocumentIssue.column_id == column_id) + result = await session.execute(stmt) + return int(result.rowcount or 0) # type: ignore[attr-defined] + + async def delete_file_statements(session: AsyncSession, file_id: uuid.UUID) -> None: """`document_issue`, значения, строки, колонки и шапки форм всех документов файла (в порядке FK).""" documents = select(Document.id).where(Document.stored_file_id == file_id).scalar_subquery() @@ -285,6 +318,41 @@ 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 5e973a6..8dd9566 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -419,37 +419,60 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч class CellPatch(BaseModel): - """Новая сумма ячейки: не более двух знаков и 16 цифр до запятой (`numeric(18,2)`), `null` — очистить. + """Правка ячейки: новая сумма (не более двух знаков и 16 цифр до запятой — `numeric(18,2)`, `null` — очистить) либо + подтверждение (`confirmed`) без изменения значения. Ровно одно из двух; наличие `amount` — по `model_fields_set`. Ячейка адресуется ровно одним из: `line_id` — строка формы (если ячейки в колонке нет, она создаётся); `line_code` — - строка справочника редакции, которой в форме нет (создаётся при первой непустой правке; design D-4).""" + строка справочника редакции, которой в форме нет (создаётся при первой непустой правке; design D-4). + `confirmed` — только с `line_id` (подтверждать нечего у несозданной строки).""" model_config = ConfigDict(extra="forbid") line_id: uuid.UUID | None = None line_code: str | None = Field(None, max_length=10) column_id: uuid.UUID - amount: Decimal | None = Field(max_digits=18, decimal_places=2) + amount: Decimal | None = Field(None, max_digits=18, decimal_places=2) + confirmed: bool | None = None @model_validator(mode="after") - def _one_address(self) -> "CellPatch": + def _one_address_one_action(self) -> "CellPatch": if (self.line_id is None) == (self.line_code is None): raise ValueError("укажите ровно одно из line_id и line_code") + if ("amount" in self.model_fields_set) == (self.confirmed is not None): + raise ValueError("укажите ровно одно из amount и confirmed") + if self.confirmed is not None and self.line_id is None: + raise ValueError("confirmed допустим только с line_id") return self + @property + def sets_amount(self) -> bool: + return self.confirmed is None + class HeaderPatch(BaseModel): """Отсутствующее поле не меняется; `report_date: null` — очистить дату (`model_fields_set`); `inn: null` — 422 `INVALID_INN` (ИНН нельзя очистить: иначе обходилась бы проверка контрагента). `units: null` — «не менять» (единицы измерения очищать нельзя); непустая строка после обрезки пробелов, до 50 - символов (пробельная строка — 422).""" + символов (пробельная строка — 422). `organization_name` — непустая строка после обрезки, до 1000 символов.""" model_config = ConfigDict(extra="forbid") inn: str | None = Field(None, description="10 или 12 цифр; null не допускается (422 INVALID_INN)") report_date: datetime.date | None = None units: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=50)] | None = None + organization_name: ( + Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=1000)] | None + ) = None + + +class ColumnPatch(BaseModel): + """Дата колонки: обязательная (`null` — 422 валидации тела).""" + + model_config = ConfigDict(extra="forbid") + + column_id: uuid.UUID + period_date: datetime.date class StatementPatch(BaseModel): @@ -457,6 +480,7 @@ class StatementPatch(BaseModel): header: HeaderPatch | None = None values: list[CellPatch] = Field(default_factory=list, max_length=5000) + columns: list[ColumnPatch] = Field(default_factory=list, max_length=100) class ChecksAcceptIn(BaseModel): diff --git a/api/src/services/statements.py b/api/src/services/statements.py index d22f2ea..95f1617 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -7,6 +7,7 @@ import re import uuid from dataclasses import dataclass +from decimal import Decimal from typing import Any from sqlalchemy.ext.asyncio import AsyncSession @@ -25,21 +26,29 @@ from src.domain.statement_checks import ( run_checks, 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, values_ok +from src.domain.statement_mapping import edition_for, quarter_of, statement_period, statement_status, value_clean 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 FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) from src.repositories import files as files_repo from src.repositories import statements as repo from src.repositories.counterparties import CounterpartyIndex @@ -57,12 +66,15 @@ DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT" DOCUMENT_READONLY = "DOCUMENT_READONLY" LINE_EXISTS = "строка уже есть в форме — передайте line_id" UNKNOWN_CELL = "UNKNOWN_CELL" +UNKNOWN_COLUMN = "UNKNOWN_COLUMN" +DUPLICATE_COLUMN = "DUPLICATE_COLUMN" DUPLICATE_CELL = "DUPLICATE_CELL" NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT" INVALID_INN = "INVALID_INN" LINES_UNRESOLVED = "LINES_UNRESOLVED" DUPLICATE_FORM = "DUPLICATE_FORM" OBJECT_TYPE = "document" +ABSOLUTE = "absolute" INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII) FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME}) @@ -144,6 +156,28 @@ async def form_blocker(session: AsyncSession, document: Document, meta: Financia return None +@dataclass +class _Edit: + """Накопитель одного PATCH: `before/after` для единственного события аудита и признак правки данных (сброс + акцепта — только ею; подтверждения акцепт не сбрасывают, Р-25).""" + + before: dict[str, Any] + after: dict[str, Any] + data_edited: bool = False + + def record(self, key: str, old: Any, new: Any, *, data: bool = True) -> None: # noqa: ANN401 - значения разных типов + self.before[key], self.after[key] = old, new + 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: self._session = session @@ -230,36 +264,34 @@ class StatementService: """Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы).""" return await statement_hard_failures(self._session, document, meta) - async def recompute_status(self, document: Document, meta: FinancialStatementMeta) -> None: - """D-6 после правки или акцепта; `CONFIRMED` не трогает. + async def recompute(self, document: Document, meta: FinancialStatementMeta) -> None: + """Единая точка пересчёта после правки или акцепта (spec arm-core «Пересчёт после правки»; D-6); `CONFIRMED` + не трогает. До части Б `values_ok` — нет `needs_review` у значений строк не `detail`; сигнатура остаётся и в Б. Предупреждения маппинга файла не хранятся на уровне документа, поэтому передаются пустыми (`warnings=()`). """ 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=values_ok(await repo.value_flags(self._session, meta.id)), + 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() async def _apply_cells( - self, - document: Document, - meta: FinancialStatementMeta, - body: schemas.StatementPatch, - before: dict[str, Any], - after: dict[str, Any], + self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch, edit: _Edit ) -> None: - """Значения `body.values` (D-7 Среза 3b; design D-4 statement-template-lines): `original_amount` при первой - правке, `is_modified`, `modified_by/at`. Ячейка, которой нет в БД, создаётся при первой непустой правке; строка - по `line_code` — из справочника редакции документа. Вся адресация проверяется до первой вставки.""" + """Значения `body.values` (D-7 Среза 3b; design D-4 statement-template-lines): сумма — `original_amount` при + первой правке, `is_modified`, `modified_by/at`; `confirmed` — подтверждение без правки значения (Р-25). + Ячейка, которой нет в БД, создаётся при первой непустой правке суммы; строка по `line_code` — из справочника + редакции документа. Вся адресация проверяется до первой вставки.""" if not body.values: return keys = [(cell.line_id or cell.line_code, cell.column_id) for cell in body.values] @@ -269,30 +301,64 @@ class StatementService: existing = await repo.lock_cells( self._session, meta.id, [(line.id, cell.column_id) for cell, line, _ in targets if line is not None] ) + for cell, line, _ in targets: + if cell.confirmed is not None and line is not None and (line.id, cell.column_id) not in existing: + raise DomainValidationError(UNKNOWN_CELL, "подтверждать нечего: значения в этой ячейке нет") created: dict[str, FinancialStatementLine] = {} now = utc_now() for cell, line, item in targets: value = existing.get((line.id, cell.column_id)) if line is not None else None + if cell.confirmed is not None: + if value is not None and line is not None: + self._apply_confirmation(value, line, cell.confirmed, edit, now) + continue if value is None: if cell.amount is None: continue # пустая сумма в несуществующую ячейку — не изменение - target = line - if target is None and item is not None: - target = created.get(item.code) - if target is None: - target = created[item.code] = await repo.add_line(self._session, meta.id, item.code, item.name) - if target is None: + line = line or created.get(item.code if item else "") # noqa: PLW2901 - строка, созданная выше + if line is None and item is not None: + line = created[item.code] = await repo.add_line(self._session, meta.id, item.code, item.name) # noqa: PLW2901 + if line is None: raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме") - value = await repo.add_value(self._session, target.id, cell.column_id) - if value.amount == cell.amount: - continue - key = f"value:{value.id}" - before[key], after[key] = value.amount, cell.amount - if not value.is_modified and value.original_amount is None: - value.original_amount = value.amount - value.amount = cell.amount - value.is_modified = value.amount != value.original_amount - value.modified_by, value.modified_at = self._audit.user_id, now + value = await repo.add_value(self._session, line.id, cell.column_id) + if line is not None: + self._apply_amount(value, line, cell.amount, edit, now) + + def _apply_amount( + self, + value: FinancialStatementValue, + line: FinancialStatementLine, + amount: Decimal | None, + edit: _Edit, + now: Any, # noqa: ANN401 - datetime + ) -> None: + """Новая сумма ячейки: `original_amount` при первой правке, `is_modified`, `modified_by/at`.""" + if value.amount == amount: + return + edit.record(f"value:{value.id}", value.amount, amount) + if not value.is_modified and value.original_amount is None: + value.original_amount = value.amount + 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, + value: FinancialStatementValue, + line: FinancialStatementLine, + confirmed: bool, # noqa: FBT001 - значение поля запроса + edit: _Edit, + now: Any, # noqa: ANN401 - datetime + ) -> None: + """Подтверждение ячейки (`is_confirmed`, `confirmed_by/at`) без правки значения; `is_modified` не ставится. + Снятие возвращает `needs_review` по уверенности распознавания. Акцепт проверок не сбрасывает (Р-25).""" + if value.is_confirmed == confirmed: + return + 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 @@ -324,19 +390,76 @@ class StatementService: targets.append((cell, None, item)) return targets + async def _apply_columns( + self, meta: FinancialStatementMeta, columns: list[schemas.ColumnPatch], edit: _Edit + ) -> bool: + """Даты колонок (spec arm-core «PATCH формы»): новая дата — `period_kind = absolute`, снимаются предупреждения + колонки; дата, равная текущей, у колонки с `COLUMN_WITHOUT_DATE` / `COLUMN_DATE_UNCERTAIN` — подтверждение, + иначе запрос не меняет ничего. Возвращает `True`, если даты колонок изменились (нужен пересчёт + `is_current` и периода).""" + if not columns: + return False + by_id = {column.id: column for column in (await repo.load_form(self._session, meta.id))[0]} + changed = False + for patch in columns: + column = by_id[patch.column_id] + cleared = await repo.delete_issues( + self._session, + meta.document_id, + codes=COLUMN_CLEARS, + kind=DocumentIssueKind.warning, + source=IssueSource.service, + column_id=column.id, + ) + if patch.period_date != column.period_date: + edit.record(f"column:{column.id}", column.period_date, patch.period_date) + column.period_date, column.period_kind = patch.period_date, ABSOLUTE + changed = True + elif cleared: + edit.record(f"column:{column.id}:date_confirmed", False, True, data=False) + column.period_kind = ABSOLUTE + return changed + + async def _check_columns(self, meta: FinancialStatementMeta, columns: list[schemas.ColumnPatch]) -> None: + """Адресация `columns[]` до первой записи (P3-10): колонка этой формы, без повторов.""" + if not columns: + return + known = await repo.column_ids(self._session, meta.id) + if any(patch.column_id not in known for patch in columns): + raise DomainValidationError(UNKNOWN_COLUMN, "колонка не принадлежит форме") + if len({patch.column_id for patch in columns}) != len(columns): + raise DomainValidationError(DUPLICATE_COLUMN, "колонка указана в запросе дважды") + + async def _clear_issues(self, document: Document, field: str) -> None: + """Снять предупреждения сервиса, которые снимает правка (подтверждение) поля шапки.""" + await repo.delete_issues( + self._session, + document.id, + codes=HEADER_CLEARS[field], + kind=DocumentIssueKind.warning, + source=IssueSource.service, + ) + async def _apply_header( - self, - document: Document, - meta: FinancialStatementMeta, - header: schemas.HeaderPatch, - before: dict[str, Any], - after: dict[str, Any], - ) -> None: - """Поля шапки (D-7): `*_modified = true`, `*_original_value` не трогаем; пересчёт контрагента и периода.""" + self, document: Document, meta: FinancialStatementMeta, header: schemas.HeaderPatch, edit: _Edit + ) -> bool: + """Поля шапки (D-7): `*_modified = true`, `*_original_value` не трогаем; пересчёт контрагента. Дата отчёта, + равная текущей, у поля из `needs_review_fields` — подтверждение даты. Возвращает `True`, если дата отчёта + изменилась (нужен пересчёт периода).""" fields = header.model_fields_set + who_changed = False if "inn" in fields and header.inn != meta.inn: - before["inn"], after["inn"] = meta.inn, header.inn + edit.record("inn", meta.inn, header.inn) meta.inn, meta.inn_modified = header.inn, True + await self._clear_issues(document, "inn") + who_changed = True + name = header.organization_name + if "organization_name" in fields and name is not None and name != meta.organization_name: + edit.record("organization_name", meta.organization_name, name) + meta.organization_name, meta.organization_name_modified = name, True + await self._clear_issues(document, "organization_name") + who_changed = True + if who_changed: owner = await self._session.get(LegalEntity, document.legal_entity_id) if owner is None: raise NotFoundError(DOCUMENT_NOT_FOUND) @@ -344,48 +467,76 @@ class StatementService: document.counterparty_check = counterparty_check( meta.inn, meta.organization_name, owner.inn, index.owner_names(owner) ) - if "report_date" in fields and header.report_date != meta.report_date: - before["report_date"], after["report_date"] = meta.report_date, header.report_date - meta.report_date, meta.report_date_modified = header.report_date, True - columns, _ = await repo.load_form(self._session, meta.id) - period = statement_period(meta.report_date, [column.period_date for column in columns]) - document.reporting_date = period - document.period_year = period.year if period is not None else None - document.period_quarter = quarter_of(period) if period is not None else None + date_changed = False + review = [str(item) for item in meta.needs_review_fields] if isinstance(meta.needs_review_fields, list) else [] + if "report_date" in fields: + if header.report_date != meta.report_date: + edit.record("report_date", meta.report_date, header.report_date) + meta.report_date, meta.report_date_modified = header.report_date, True + date_changed = True + await self._clear_issues(document, "report_date") + if "report_date" in review: + meta.needs_review_fields = [item for item in review if item != "report_date"] + elif header.report_date is not None and "report_date" in review: + edit.record( + "needs_review_fields", review, [item for item in review if item != "report_date"], data=False + ) + meta.needs_review_fields = [item for item in review if item != "report_date"] + await self._clear_issues(document, "report_date") if "units" in fields and header.units is not None and header.units != meta.units: - before["units"], after["units"] = meta.units, header.units + edit.record("units", meta.units, header.units) meta.units, meta.units_modified = header.units, True + return date_changed + + async def _refresh_period(self, document: Document, meta: FinancialStatementMeta, *, columns_changed: bool) -> None: + """Период документа — по §9.5 п. 2 (дата отчёта, иначе самая поздняя дата колонки); `is_current` колонок — + колонка с самой поздней датой (меньший `column_index` при равенстве), как при приёме.""" + columns, _ = await repo.load_form(self._session, meta.id) + if columns_changed: + latest = max((c.period_date for c in columns if c.period_date is not None), default=None) + first = min((c.column_index for c in columns if c.period_date == latest), default=None) + for column in columns: + column.is_current = None if latest is None else column.column_index == first + period = statement_period(meta.report_date, [column.period_date for column in columns]) + document.reporting_date = period + document.period_year = period.year if period is not None else None + document.period_quarter = quarter_of(period) if period is not None else None async def patch(self, document_id: uuid.UUID, body: schemas.StatementPatch) -> schemas.StatementOut: - """Правка значений и шапки (D-7): одна транзакция, один `audit_event`, сброс акцепта, пересчёт статуса.""" + """Правка значений, дат колонок и шапки (D-7): одна транзакция, один `audit_event`, один пересчёт. Порядок + частей — `values` → `columns` → `header` (P3-10); адресация проверяется до первой записи. Акцепт сбрасывает + только правка данных; подтверждения (ячейки, даты) — нет (Р-25).""" document, meta = await self._editable(document_id) header = body.header if header is not None and "inn" in header.model_fields_set and not INN_RE.fullmatch(header.inn or ""): raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр") - before: dict[str, Any] = {} - after: dict[str, Any] = {} - await self._apply_cells(document, meta, body, before, after) + await self._check_columns(meta, body.columns) + edit = _Edit({}, {}) + await self._apply_cells(document, meta, body, edit) + columns_changed = await self._apply_columns(meta, body.columns, edit) + date_changed = False if header is not None: - await self._apply_header(document, meta, header, before, after) - if not after: + date_changed = await self._apply_header(document, meta, header, edit) + if not edit.after: return await self.build_out(document, meta) - if meta.checks_accepted_at is not None: - before["checks_accepted"] = { - "by": meta.checks_accepted_by, - "at": meta.checks_accepted_at, - "comment": meta.checks_accepted_comment, - } - after["checks_accepted"] = None + if columns_changed or date_changed: + await self._refresh_period(document, meta, columns_changed=columns_changed) + if edit.data_edited and meta.checks_accepted_at is not None: + edit.record( + "checks_accepted", + {"by": meta.checks_accepted_by, "at": meta.checks_accepted_at, "comment": meta.checks_accepted_comment}, + None, + ) meta.checks_accepted_by = meta.checks_accepted_at = meta.checks_accepted_comment = None await self._session.flush() - await self.recompute_status(document, meta) + await self.recompute(document, meta) await self._audit.record( action=AuditAction.RECOGNIZED_DATA_EDIT, section=AuditSection.RECOGNITION, object_type=OBJECT_TYPE, object_id=document.id, - before_value=before, - after_value=after, + before_value=edit.before, + after_value=edit.after, ) return await self.build_out(document, meta) @@ -398,7 +549,7 @@ class StatementService: by = self._audit.user_id meta.checks_accepted_by, meta.checks_accepted_at, meta.checks_accepted_comment = by, now, body.comment await self._session.flush() - await self.recompute_status(document, meta) + await self.recompute(document, meta) await self._audit.record( action=AuditAction.RECOGNIZED_DATA_EDIT, section=AuditSection.RECOGNITION, diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 2d3efde..618fd86 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -1897,3 +1897,414 @@ async def test_lines_order_follows_layout( unreadable = next(i for i, line in enumerate(lines) if line["line_status"] == "unreadable") assert unreadable > 0 assert lines[unreadable]["line_code_text"] == "12?0" + + +# --- A12: PATCH — подтверждение ячейки, дата колонки, наименование организации, подтверждение даты --- + +NOW = datetime.datetime(2026, 10, 18, tzinfo=datetime.UTC) + + +async def _user_id(session: AsyncSession) -> uuid.UUID: + return (await session.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one() + + +async def _accept(session: AsyncSession, document: Document) -> None: + """Акцепт проверок в БД теста (как будто аналитик акцептовал нарушение).""" + meta = await _meta(session, document) + meta.checks_accepted_by, meta.checks_accepted_at = await _user_id(session), NOW + meta.checks_accepted_comment = "ок" + await session.flush() + + +async def _add_issue( + session: AsyncSession, + document: Document, + code: str, + *, + column_id: uuid.UUID | None = None, + field: str | None = None, +) -> None: + session.add( + DocumentIssue( + document_id=document.id, + kind=DocumentIssueKind.warning, + code=code, + severity=IssueSeverity.info, + message=code, + column_id=column_id, + field=field, + source=IssueSource.service, + created_at=NOW, + ) + ) + await session.flush() + + +async def _issue_codes(session: AsyncSession, document: Document) -> list[str]: + stmt = select(DocumentIssue.code).where(DocumentIssue.document_id == document.id).order_by(DocumentIssue.code) + return list((await session.execute(stmt)).scalars()) + + +async def _db_value(session: AsyncSession, line_id: str, column_id: str) -> FinancialStatementValue: + return ( + await session.execute( + select(FinancialStatementValue) + .where( + FinancialStatementValue.line_id == uuid.UUID(line_id), + FinancialStatementValue.column_id == uuid.UUID(column_id), + ) + .execution_options(populate_existing=True) + ) + ).scalar_one() + + +async def _fresh_meta(session: AsyncSession, document: Document) -> FinancialStatementMeta: + return ( + await session.execute( + select(FinancialStatementMeta) + .where(FinancialStatementMeta.document_id == document.id) + .execution_options(populate_existing=True) + ) + ).scalar_one() + + +async def _db_columns(session: AsyncSession, document: Document) -> list[FinancialStatementColumn]: + meta = await _meta(session, document) + return list( + ( + await session.execute( + select(FinancialStatementColumn) + .where(FinancialStatementColumn.meta_id == meta.id) + .order_by(FinancialStatementColumn.column_index) + .execution_options(populate_existing=True) + ) + ).scalars() + ) + + +async def _undate_columns(session: AsyncSession, document: Document) -> list[FinancialStatementColumn]: + """Форма без дат: колонки без даты, дата отчёта пуста, период документа не определён.""" + columns = await _db_columns(session, document) + for column in columns: + column.period_date, column.period_kind, column.is_current = None, "none", None + meta = await _meta(session, document) + meta.report_date = None + document.reporting_date = document.period_year = document.period_quarter = None + await session.flush() + return columns + + +async def test_confirm_cell_without_change( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, document.id) + value = await _db_value(seeded, line["id"], cell["column_id"]) + value.needs_review = True + await _accept(seeded, document) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "confirmed": True}]}, + ) + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + edited = next(item for item in body["lines"] if item["id"] == line["id"])["values"][0] + assert edited["is_confirmed"] is True and edited["is_modified"] is False and edited["needs_review"] is False + assert edited["amount"] == cell["amount"] + value = await _db_value(seeded, line["id"], cell["column_id"]) + assert value.confirmed_by == await _user_id(seeded) and value.confirmed_at is not None + assert body["checks"]["accepted"] is not None + assert len(await _edit_events(seeded, document)) == 1 + + +async def test_confirm_cell_twice_is_noop( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, document.id) + item = {"line_id": line["id"], "column_id": cell["column_id"], "confirmed": True} + for _ in range(2): + assert ( + await client.patch(f"/documents/{document.id}/statement", json={"values": [item]}) + ).status_code == HTTPStatus.OK + assert len(await _edit_events(seeded, document)) == 1 + + +async def test_unconfirm_cell(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, document.id) + 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 + 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_cell_amount_and_confirmed_is_422( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, document.id) + address = {"line_id": line["id"], "column_id": cell["column_id"]} + for item in ({**address, "amount": "1", "confirmed": True}, {**address}, {**address, "confirmed": None}): + response = await client.patch(f"/documents/{document.id}/statement", json={"values": [item]}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, item + assert isinstance(response.json()["detail"], list) + + +async def test_cell_confirmed_by_line_code_is_422( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + _, cell = await _first_cell(client, document.id) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_code": "1105", "column_id": cell["column_id"], "confirmed": True}]}, + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert isinstance(response.json()["detail"], list) + + +async def test_confirm_missing_cell_unknown( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, document.id) + await seeded.execute( + delete(FinancialStatementValue).where( + FinancialStatementValue.line_id == uuid.UUID(line["id"]), + FinancialStatementValue.column_id == uuid.UUID(cell["column_id"]), + ) + ) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "confirmed": True}]}, + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "UNKNOWN_CELL" + assert await _edit_events(seeded, document) == [] + + +async def test_patch_organization_name( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE, name="ООО Ромашка") + documents = await map_golden_snapshot(seeded, entity=entity, path=fixtures_dir / MEZH_BALANCE, uploaded_by=None) + document = next(d for d in documents if d.document_type == DocumentType.BALANCE) + meta = await _meta(seeded, document) + meta.inn, meta.organization_name, meta.organization_name_original_value = None, "прочее", "прочее" + document.counterparty_check = None + await _add_issue(seeded, document, "NO_ORGANIZATION_NO_INN") + await _add_issue(seeded, document, "SEGMENT_COLUMNS_MISMATCH") + await _accept(seeded, document) + client = await auth_client(KA_RF) + response = await client.patch( + f"/documents/{document.id}/statement", json={"header": {"organization_name": " ООО Ромашка "}} + ) + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + assert body["header"]["organization_name"] == "ООО Ромашка" + assert body["header"]["organization_name_modified"] is True + assert body["document"]["counterparty_check"] == "MATCH" + assert body["checks"]["accepted"] is None + meta = await _fresh_meta(seeded, document) + assert meta.organization_name_original_value == "прочее" + assert await _issue_codes(seeded, document) == ["SEGMENT_COLUMNS_MISMATCH"] + [event] = await _edit_events(seeded, document) + assert event.before_value["organization_name"] == "прочее" + assert event.after_value["organization_name"] == "ООО Ромашка" + + +async def test_patch_organization_name_invalid_is_422( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + for name in ("", " ", "я" * 1001): + response = await client.patch( + f"/documents/{document.id}/statement", json={"header": {"organization_name": name}} + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, name + assert isinstance(response.json()["detail"], list) + + +async def test_confirm_report_date(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + meta = await _meta(seeded, document) + meta.needs_review_fields = ["report_date"] + await _add_issue(seeded, document, "REPORT_DATE_UNCERTAIN", field="report_date") + await _accept(seeded, document) + client = await auth_client(KA_RF) + url = f"/documents/{document.id}/statement" + current = (await client.get(url)).json()["header"]["report_date"]["value"] + assert current is not None + response = await client.patch(url, json={"header": {"report_date": current}}) + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + assert body["header"]["needs_review_fields"] == [] and body["checks"]["accepted"] is not None + assert body["header"]["report_date"]["modified"] is not True + assert await _issue_codes(seeded, document) == [] + assert len(await _edit_events(seeded, document)) == 1 + again = await client.patch(url, json={"header": {"report_date": current}}) # подтверждать уже нечего + assert again.status_code == HTTPStatus.OK + assert len(await _edit_events(seeded, document)) == 1 + + +async def test_report_date_change_clears_warnings( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + for code in ("PERIOD_UNKNOWN", "REPORT_DATE_UNPARSED", "REPORT_DATE_UNCERTAIN", "SEGMENT_COLUMNS_MISMATCH"): + await _add_issue(seeded, document, code) + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-06-30"}}) + assert response.status_code == HTTPStatus.OK, response.text + assert await _issue_codes(seeded, document) == ["SEGMENT_COLUMNS_MISMATCH"] + + +async def test_inn_change_clears_no_organization( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + meta = await _meta(seeded, document) + inn = meta.inn + meta.inn = None + await _add_issue(seeded, document, "NO_ORGANIZATION_NO_INN") + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"inn": inn}}) + assert response.status_code == HTTPStatus.OK, response.text + assert await _issue_codes(seeded, document) == [] + + +async def test_column_date(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + columns = await _undate_columns(seeded, document) + await _add_issue(seeded, document, "COLUMN_WITHOUT_DATE", column_id=columns[0].id) + await _add_issue(seeded, document, "COLUMN_WITHOUT_DATE", column_id=columns[1].id) + await _accept(seeded, document) + client = await auth_client(KA_RF) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"columns": [{"column_id": str(columns[0].id), "period_date": "2024-12-31"}]}, + ) + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + column = next(c for c in body["columns"] if c["id"] == str(columns[0].id)) + assert column["period_date"] == "2024-12-31" and column["period_kind"] == "absolute" + assert column["is_current"] is True + assert body["document"]["reporting_date"] == "2024-12-31" and body["document"]["period_quarter"] == "Q4" + assert body["checks"]["accepted"] is None + [issue] = (await _statement(client, document))["issues"] + assert issue["column_id"] == str(columns[1].id) # предупреждение другой колонки осталось + [event] = await _edit_events(seeded, document) + assert event.after_value + + +async def test_column_date_null_is_422( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + column = await _column(client, document.id, 0) + for item in ({"column_id": column, "period_date": None}, {"column_id": column}): + response = await client.patch(f"/documents/{document.id}/statement", json={"columns": [item]}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, item + assert isinstance(response.json()["detail"], list) + + +async def test_unknown_column_is_422(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + other = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE) + client = await auth_client(KA_RF) + foreign = await _column(client, other.id, 0) + mine = await _column(client, document.id, 0) + for column in (str(uuid.uuid4()), foreign): + response = await client.patch( + f"/documents/{document.id}/statement", + json={"columns": [{"column_id": column, "period_date": "2024-12-31"}]}, + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "UNKNOWN_COLUMN" + duplicate = await client.patch( + f"/documents/{document.id}/statement", + json={ + "columns": [ + {"column_id": mine, "period_date": "2024-12-31"}, + {"column_id": mine, "period_date": "2024-06-30"}, + ] + }, + ) + assert duplicate.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert duplicate.json()["detail"]["code"] == "DUPLICATE_COLUMN" + assert await _edit_events(seeded, document) == [] + + +async def test_confirm_column_date(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + column = (await _db_columns(seeded, document))[0] + column.period_kind = "relative:prev_year_end" + await _add_issue(seeded, document, "COLUMN_DATE_UNCERTAIN", column_id=column.id) + await _accept(seeded, document) + client = await auth_client(KA_RF) + url = f"/documents/{document.id}/statement" + item = {"column_id": str(column.id), "period_date": column.period_date.isoformat()} + response = await client.patch(url, json={"columns": [item]}) + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + assert next(c for c in body["columns"] if c["id"] == str(column.id))["period_kind"] == "absolute" + assert body["checks"]["accepted"] is not None and body["issues"] == [] + assert len(await _edit_events(seeded, document)) == 1 + again = await client.patch(url, json={"columns": [item]}) # без предупреждения — без событий + assert again.status_code == HTTPStatus.OK + assert len(await _edit_events(seeded, document)) == 1 + + +async def test_relative_column_becomes_absolute( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + column = (await _db_columns(seeded, document))[1] + column.period_kind, column.period_date = "relative:prev_year_end", datetime.date(2024, 12, 30) + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"columns": [{"column_id": str(column.id), "period_date": "2024-12-31"}]}, + ) + assert response.status_code == HTTPStatus.OK, response.text + changed = next(c for c in response.json()["columns"] if c["id"] == str(column.id)) + assert changed["period_kind"] == "absolute" and changed["period_date"] == "2024-12-31" + + +async def test_segment_warning_stays_after_value_edit( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + await _add_issue(seeded, document, "SEGMENT_COLUMNS_MISMATCH") + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, document.id) + item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"]) + 1)} + response = await client.patch(f"/documents/{document.id}/statement", json={"values": [item]}) + assert response.status_code == HTTPStatus.OK, response.text + assert await _issue_codes(seeded, document) == ["SEGMENT_COLUMNS_MISMATCH"] + revert = {**item, "amount": cell["amount"]} + assert ( + await client.patch(f"/documents/{document.id}/statement", json={"values": [revert]}) + ).status_code == HTTPStatus.OK + confirm = await client.post(f"/documents/{document.id}/confirm") + assert confirm.status_code == HTTPStatus.OK, confirm.text -- 2.47.2 From 3753e3221906ec095d0171f18c98dcf2778b37fb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 17:38:24 +0400 Subject: [PATCH 13/20] =?UTF-8?q?feat(statements):=20PATCH=20lines=20?= =?UTF-8?q?=E2=80=94=20=D0=BD=D0=B0=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=B4=D0=B0,=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D1=88=D0=B8=D1=84=D1=80=D0=BE=D0=B2=D0=BA=D0=B0,=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=B4=D0=B0,=20LINE=5FCODE=5FTAKEN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- README.md | 9 +- api/src/domain/statement_layout.py | 15 +- api/src/repositories/statements.py | 42 +++ api/src/schemas/statements.py | 15 ++ api/src/services/statement_line_edits.py | 157 ++++++++++++ api/src/services/statements.py | 17 +- api/tests/test_api_statement_lines.py | 299 ++++++++++++++++++++++ api/tests/test_api_statements.py | 2 +- api/tests/test_domain_statement_layout.py | 13 +- 9 files changed, 562 insertions(+), 7 deletions(-) create mode 100644 api/src/services/statement_line_edits.py create mode 100644 api/tests/test_api_statement_lines.py diff --git a/README.md b/README.md index 3408a9c..e9ebc90 100644 --- a/README.md +++ b/README.md @@ -549,7 +549,8 @@ S3 (storage); без S3 — предупреждение в логе, цикл Коды ошибок этих маршрутов — в таблице «Ошибки API» (`FILE_TOO_LARGE`, `UNSUPPORTED_FILE`, `DUPLICATE_FILE`, `STORAGE_UNAVAILABLE`, `FILE_SUPERSEDED`, `ALREADY_CONFIRMED`, `DOCUMENT_NOT_CONFIRMABLE`, `FILE_HAS_CONFIRMED_DOCUMENTS`, `FILE_SUPERSEDES_OTHERS`, `FILE_CONTENT_NOT_FOUND`) и форм ББ/ОФР (`DOCUMENT_NOT_STATEMENT`, -`DOCUMENT_READONLY`, `UNKNOWN_CELL`, `DUPLICATE_CELL`, `UNKNOWN_COLUMN`, `DUPLICATE_COLUMN`, `INVALID_INN`, `NOTHING_TO_ACCEPT`, `PERIOD_REQUIRED`, +`DOCUMENT_READONLY`, `UNKNOWN_CELL`, `DUPLICATE_CELL`, `UNKNOWN_COLUMN`, `DUPLICATE_COLUMN`, `LINE_CODE_TAKEN`, `LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, +`INVALID_LINE_PATCH`, `UNKNOWN_LINE`, `INVALID_INN`, `NOTHING_TO_ACCEPT`, `PERIOD_REQUIRED`, `CHECKS_NOT_PASSED`). События аудита: `FILE_UPLOAD`, `FILE_REPLACE`, `FILE_DELETE`, `DOCUMENT_CONFIRM` (раздел `PRIMARY_DOCUMENTS`), `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`; правка формы и акцепт). @@ -605,6 +606,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл `DUPLICATE_COLUMN`, одна ячейка дважды — 422 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно событие `RECOGNIZED_DATA_EDIT` с `before/after`; возврат ячейки к исходному значению снимает `is_modified`; правка ИНН пересчитывает `counterparty_check`, даты отчёта — период документа; любая правка сбрасывает акцепт и пересчитывает статус. + `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`. - **`POST /documents/{id}/statement/checks/accept`** — `{comment}` (1–2000 символов): акцепт нарушенных жёстких проверок @@ -932,6 +934,11 @@ email без учёта регистра проверяет только сер | `DUPLICATE_CELL` | 422 | `PATCH …/statement`: одна ячейка указана в запросе дважды | | `UNKNOWN_COLUMN` | 422 | `PATCH …/statement`: `columns[].column_id` не этой формы; ничего не применено | | `DUPLICATE_COLUMN` | 422 | `PATCH …/statement`: одна колонка указана в `columns[]` дважды | +| `LINE_CODE_TAKEN` | 409 | `PATCH …/statement`, `lines[] {line_id, line_code}`: код занят строкой, созданной правкой (`row_index IS NULL`), с суммами; ничего из запроса не применено. `detail` несёт `line_id` строки, которой назначается код, и `line_code` | +| `LINE_CODE_REQUIRED` | 422 | `PATCH …/statement`: `lines[] {line_status: "ok"}` — в `ok` строку переводит только назначение кода. `detail.line_id`, `detail.line_code` (`null`) | +| `INVALID_LINE_CODE` | 422 | `PATCH …/statement`: `lines[].line_code` не из 4–5 цифр. `detail.line_id`, `detail.line_code` | +| `INVALID_LINE_PATCH` | 422 | `PATCH …/statement`: в элементе `lines[]` не ровно одно действие (`line_code` / `line_status` / `confirmed`), `line_id` дважды в запросе, подтверждение кода у строки не `ok`, отметка расшифровкой строки, созданной правкой. `detail.line_id`, `detail.line_code` (код элемента или `null`) | +| `UNKNOWN_LINE` | 422 | `PATCH …/statement`: `lines[].line_id` не этой формы. `detail.line_id` — чужой id, `detail.line_code` | | `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) | | `PERIOD_REQUIRED` | 409 | подтверждение формы: период не определён — поправьте дату отчёта | | `LINES_UNRESOLVED` | 409 | подтверждение формы: есть строки `unreadable`/`missing` с непустой ячейкой; `detail.lines[] {line_id, row_index, line_code_text}` по `row_index` — назначьте код или очистите строку | diff --git a/api/src/domain/statement_layout.py b/api/src/domain/statement_layout.py index dc7870c..3df26e5 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", "order_lines"] +__all__ = ["LayoutLine", "nearest_ok_parents", "order_lines"] OK = "ok" DETAIL = "detail" @@ -51,3 +51,16 @@ 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 6c1a9ee..217eb97 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -388,6 +388,48 @@ async def form_lines(session: AsyncSession, meta_id: uuid.UUID) -> list[Financia return list((await session.execute(stmt)).scalars()) +async def template_lines_by_code( + session: AsyncSession, meta_id: uuid.UUID, codes: Collection[str] +) -> list[tuple[FinancialStatementLine, bool]]: + """Строки, созданные правкой (`row_index IS NULL`, статус `ok`), с кодами `codes`, и признак «есть непустая + сумма» (`amount IS NOT NULL` хотя бы в одной ячейке).""" + has_amount = ( + select(FinancialStatementValue.id) + .where( + FinancialStatementValue.line_id == FinancialStatementLine.id, FinancialStatementValue.amount.is_not(None) + ) + .exists() + ) + stmt = select(FinancialStatementLine, has_amount).where( + FinancialStatementLine.meta_id == meta_id, + FinancialStatementLine.row_index.is_(None), + FinancialStatementLine.line_status == StatementLineStatus.ok, + FinancialStatementLine.line_code.in_(codes), + ) + return [(row[0], bool(row[1])) for row in (await session.execute(stmt)).all()] + + +async def delete_line(session: AsyncSession, line_id: uuid.UUID) -> None: + """Удалить строку формы вместе со значениями (порядок FK); записи `document_issue` чистит вызывающий.""" + await session.execute(delete(FinancialStatementValue).where(FinancialStatementValue.line_id == line_id)) + await session.execute(delete(FinancialStatementLine).where(FinancialStatementLine.id == line_id)) + await session.flush() + + +async def remove_line_from_issues(session: AsyncSession, document_id: uuid.UUID, line_id: uuid.UUID) -> None: + """Убрать `id` удалённой строки из `line_ids` всех записей `document_issue` документа (у `line_ids` нет FK).""" + await session.execute( + update(DocumentIssue) + .where( + DocumentIssue.document_id == document_id, + DocumentIssue.line_ids.is_not(None), + DocumentIssue.line_ids.contains([str(line_id)]), + ) + .values(line_ids=DocumentIssue.line_ids.op("-")(str(line_id))) + .execution_options(synchronize_session="fetch") + ) + + async def column_ids(session: AsyncSession, meta_id: uuid.UUID) -> set[uuid.UUID]: """Id колонок формы.""" stmt = select(FinancialStatementColumn.id).where(FinancialStatementColumn.meta_id == meta_id) diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index 8dd9566..060936e 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -475,9 +475,24 @@ class ColumnPatch(BaseModel): period_date: datetime.date +class LinePatch(BaseModel): + """Правка строки формы: ровно одно действие — `line_code` (назначить или сменить код), `line_status: "detail"` + (расшифровка), `confirmed` (подтвердить код или снять подтверждение). Число действий и формат кода проверяет + сервис: ошибки элемента несут `line_id` и `line_code` в `detail` (P3-24), а 422 валидации тела их не несёт. + `line_status: "ok"` принимается схемой, чтобы сервис ответил `LINE_CODE_REQUIRED`.""" + + model_config = ConfigDict(extra="forbid") + + line_id: uuid.UUID + line_code: str | None = Field(None, max_length=10) + line_status: Literal["detail", "ok"] | None = None + confirmed: bool | None = None + + class StatementPatch(BaseModel): model_config = ConfigDict(extra="forbid") + lines: list[LinePatch] = Field(default_factory=list, max_length=5000) header: HeaderPatch | None = None values: list[CellPatch] = Field(default_factory=list, max_length=5000) columns: list[ColumnPatch] = Field(default_factory=list, max_length=100) diff --git a/api/src/services/statement_line_edits.py b/api/src/services/statement_line_edits.py new file mode 100644 index 0000000..e9b39e4 --- /dev/null +++ b/api/src/services/statement_line_edits.py @@ -0,0 +1,157 @@ +"""Правка строк формы ББ/ОФР — часть `lines` запроса `PATCH …/statement` (spec arm-core «PATCH формы — строки…»; +P3-10, P3-16, P3-24). Вызывается первой в `StatementService.patch`: проверка элементов идёт до первой записи, +поэтому `LINE_CODE_TAKEN` и 422 оставляют форму нетронутой. +""" + +import re +import uuid +from collections.abc import Sequence +from decimal import Decimal +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 +from src.models.statements import FinancialStatementLine, FinancialStatementMeta +from src.repositories import statements as repo +from src.schemas.statements import LinePatch + +LINE_CODE_TAKEN = "LINE_CODE_TAKEN" +LINE_CODE_REQUIRED = "LINE_CODE_REQUIRED" +INVALID_LINE_CODE = "INVALID_LINE_CODE" +INVALID_LINE_PATCH = "INVALID_LINE_PATCH" +UNKNOWN_LINE = "UNKNOWN_LINE" +ASSIGNED_CONFIDENCE = Decimal("100.00") # `numeric(5,2)`: ответ отдаёт то, что лежит в сессии, без перечитывания +LINE_CODE_RE = re.compile(r"\d{4,5}", re.ASCII) + + +def _bad(code: str, message: str, item: LinePatch, *, conflict: bool = False) -> DomainError: + """Ошибка элемента `lines[]`: в `detail` — `line_id` элемента и его `line_code` или `null` (P3-24).""" + error = ConflictError if conflict else DomainValidationError + return error(code, message, line_id=str(item.line_id), line_code=item.line_code) + + +def _snapshot(line: FinancialStatementLine) -> dict[str, Any]: + return { + "line_code": line.line_code, + "line_status": line.line_status.value, + "parent_row_index": line.parent_row_index, + "is_confirmed": line.is_confirmed, + } + + +def _check(items: Sequence[LinePatch], by_id: dict[uuid.UUID, FinancialStatementLine]) -> None: + """Проверки элементов по порядку запроса (первый неверный — в ответ): действие, повтор `line_id`, строка формы, + `line_status`, формат кода, допустимость действия для строки.""" + seen: set[uuid.UUID] = set() + for item in items: + actions = [item.line_code is not None, item.line_status is not None, item.confirmed is not None] + if sum(actions) != 1: + raise _bad(INVALID_LINE_PATCH, "в элементе должно быть ровно одно действие", item) + if item.line_id in seen: + raise _bad(INVALID_LINE_PATCH, "строка указана в запросе дважды", item) + seen.add(item.line_id) + line = by_id.get(item.line_id) + if line is None: + raise _bad(UNKNOWN_LINE, "строка не принадлежит форме", item) + if item.line_status == "ok": + raise _bad(LINE_CODE_REQUIRED, "в статус ok строку переводит только назначение кода", item) + if item.line_code is not None and not LINE_CODE_RE.fullmatch(item.line_code): + raise _bad(INVALID_LINE_CODE, "код строки — 4 или 5 цифр", item) + if item.line_status == "detail" and line.row_index is None: + raise _bad(INVALID_LINE_PATCH, "строку, созданную правкой, нельзя отметить расшифровкой", item) + if item.confirmed is not None and line.line_status is not StatementLineStatus.ok: + 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: + if line.line_status is StatementLineStatus.ok and line.line_code == item.line_code: + 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 + return True + if item.line_status == "detail": + if line.line_status is StatementLineStatus.detail: + return False + line.line_code, line.line_status = None, StatementLineStatus.detail + line.needs_review = False + 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 + + +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, + *, + document: Document, + meta: FinancialStatementMeta, + items: Sequence[LinePatch], + user_id: uuid.UUID | None, # noqa: ARG001 - у строки нет полей автора правки; интерфейс сохранён для части Б + before: dict[str, Any], + after: dict[str, Any], +) -> bool: + """Применить `lines[]`; ключи `line:` ложатся в `before/after` единственного события аудита. Возвращает `True`, + если менялись данные (назначение кода, отметка расшифровкой) — тогда акцепт проверок сбрасывается; подтверждение + кода акцепт не сбрасывает (Р-25).""" + if not items: + return False + lines = await repo.form_lines(session, meta.id) + by_id = {line.id: line for line in lines} + _check(items, by_id) + codes = {item.line_code for item in items if item.line_code is not None} + templates = { + line.id: (line, has_amount) for line, has_amount in await repo.template_lines_by_code(session, meta.id, codes) + } + for item in items: + for template, has_amount in templates.values(): + if has_amount and template.line_code == item.line_code and template.id != item.line_id: + raise _bad(LINE_CODE_TAKEN, "код занят строкой, созданной правкой, с суммами", item, conflict=True) + # запись: пустые шаблонные строки назначаемых кодов уходят, их id — из `document_issue.line_ids` + targets = {item.line_id for item in items} + old = {line.id: _snapshot(line) for line in lines} + for template, _ in templates.values(): + if template.id in targets or template.line_code not in codes: + continue + await repo.delete_line(session, template.id) + await repo.remove_line_from_issues(session, document.id, template.id) + lines = [line for line in lines if line.id != template.id] + old.pop(template.id, None) + 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) + if now != old[line.id]: + before[f"line:{line.id}"], after[f"line:{line.id}"] = old[line.id], now + return data_edited diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 95f1617..029e7f2 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -60,6 +60,7 @@ from src.services.documents import ( FILE_SUPERSEDED, PERIOD_REQUIRED, ) +from src.services.statement_line_edits import apply_line_edits DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT" @@ -503,15 +504,25 @@ class StatementService: document.period_quarter = quarter_of(period) if period is not None else None async def patch(self, document_id: uuid.UUID, body: schemas.StatementPatch) -> schemas.StatementOut: - """Правка значений, дат колонок и шапки (D-7): одна транзакция, один `audit_event`, один пересчёт. Порядок - частей — `values` → `columns` → `header` (P3-10); адресация проверяется до первой записи. Акцепт сбрасывает - только правка данных; подтверждения (ячейки, даты) — нет (Р-25).""" + """Правка строк, значений, дат колонок и шапки (D-7): одна транзакция, один `audit_event`, один пересчёт. + Порядок частей — `lines` → `values` → `columns` → `header` (P3-10); адресация проверяется до первой + записи. Акцепт сбрасывает только правка данных; подтверждения (ячейки, даты) — нет (Р-25).""" document, meta = await self._editable(document_id) header = body.header if header is not None and "inn" in header.model_fields_set and not INN_RE.fullmatch(header.inn or ""): raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр") await self._check_columns(meta, body.columns) edit = _Edit({}, {}) + if await apply_line_edits( + self._session, + document=document, + meta=meta, + items=body.lines, + user_id=self._audit.user_id, + before=edit.before, + after=edit.after, + ): + edit.data_edited = True await self._apply_cells(document, meta, body, edit) columns_changed = await self._apply_columns(meta, body.columns, edit) date_changed = False diff --git a/api/tests/test_api_statement_lines.py b/api/tests/test_api_statement_lines.py new file mode 100644 index 0000000..d3a1050 --- /dev/null +++ b/api/tests/test_api_statement_lines.py @@ -0,0 +1,299 @@ +"""`PATCH …/statement`, часть `lines` (spec arm-core «PATCH формы — строки…»; P3-10, P3-16, P3-24; Review Focus 3, 4). + +Код 1215 — «только шаблонный» в ББ Межениновской (в файле такой строки нет), поэтому он годится для проверки +шаблонных строк и повторов; в брифе вместо него 1250. +""" + +import uuid +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.models.enums import DocumentIssueKind, IssueSeverity, IssueSource +from src.models.files import Document +from src.models.statements import DocumentIssue, FinancialStatementLine, FinancialStatementValue +from src.repositories import statements as repo +from tests.conftest import AuthClientFactory +from tests.test_api_statements import ( + KA_RF, + NOW, + _accept, + _edit_events, + _meta, + form, + insert_line, + two_forms, +) + +pytestmark = [pytest.mark.integration, pytest.mark.fixtures] + +ROW_INDEX = 2 +CODE = "1215" +FULL = Decimal(100) + + +async def _db(session: AsyncSession, line_id: uuid.UUID) -> FinancialStatementLine | None: + return ( + await session.execute( + select(FinancialStatementLine) + .where(FinancialStatementLine.id == line_id) + .execution_options(populate_existing=True) + ) + ).scalar_one_or_none() + + +async def _patch(client: Any, document: Document, body: dict[str, Any]) -> Any: + return await client.patch(f"/documents/{document.id}/statement", json=body) + + +async def _code_total(session: AsyncSession, document: Document, code: str) -> Decimal | None: + meta = await _meta(session, document) + columns, aggregate = await repo.form_aggregate(session, meta.id) + current = next(c for c in columns if c.is_current) + return aggregate[code].amounts[current.id] if code in aggregate else None + + +async def test_assign_code_to_garbage_line( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + garbage = await insert_line( + seeded, document, code=None, row_index=2, status="unreadable", amount=Decimal(5), code_text="ИЧ,215" + ) + client = await auth_client(KA_RF) + response = await _patch(client, document, {"lines": [{"line_id": str(garbage.id), "line_code": CODE}]}) + assert response.status_code == HTTPStatus.OK, response.text + line = next(item for item in response.json()["lines"] if item["id"] == str(garbage.id)) + assert line["line_status"] == "ok" and line["line_code"] == CODE and line["line_code_confidence"] == "100.00" + assert line["line_code_modified"] is True and line["row_index"] == ROW_INDEX and line["origin"] == "source" + assert line["needs_review"] is False + stored = await _db(seeded, garbage.id) + assert stored is not None and stored.line_code_original_value is None + assert await _code_total(seeded, document, CODE) == Decimal(5) # сумма вошла в агрегат + + +async def test_same_code_two_source_lines( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + first = await insert_line(seeded, document, code=None, row_index=7, status="unreadable") + second = await insert_line(seeded, document, code=None, row_index=2, status="missing", amount=Decimal(3)) + client = await auth_client(KA_RF) + for line in (first, second): + response = await _patch(client, document, {"lines": [{"line_id": str(line.id), "line_code": CODE}]}) + assert response.status_code == HTTPStatus.OK, response.text + stored = [await _db(seeded, line.id) for line in (first, second)] + assert [(s.line_status.value, s.line_code) for s in stored if s] == [("ok", CODE), ("ok", CODE)] + assert await _code_total(seeded, document, CODE) == Decimal(3) + + +async def test_code_taken_by_template_with_amount( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + template = await insert_line(seeded, document, code=CODE, row_index=None, amount=Decimal(9)) + unreadable = await insert_line(seeded, document, code=None, row_index=5, status="unreadable", amount=Decimal(1)) + other = await insert_line(seeded, document, code="1100", row_index=6, amount=Decimal(2)) + await seeded.flush() + meta = await _meta(seeded, document) + units, review = meta.units, other.line_status + client = await auth_client(KA_RF) + columns = (await client.get(f"/documents/{document.id}/statement")).json()["columns"] + response = await _patch( + client, + document, + { + "lines": [ + {"line_id": str(unreadable.id), "line_code": CODE}, + {"line_id": str(other.id), "line_status": "detail"}, + ], + "values": [{"line_id": str(template.id), "column_id": columns[0]["id"], "amount": "77"}], + "header": {"units": "руб."}, + }, + ) + assert response.status_code == HTTPStatus.CONFLICT, response.text + detail = response.json()["detail"] + assert detail["code"] == "LINE_CODE_TAKEN" and detail["line_id"] == str(unreadable.id) + assert detail["line_code"] == CODE + stored_unreadable, stored_other = await _db(seeded, unreadable.id), await _db(seeded, other.id) + assert stored_unreadable is not None and stored_unreadable.line_status.value == "unreadable" + assert stored_other is not None and stored_other.line_status == review + assert (await _meta(seeded, document)).units == units + assert await _code_total(seeded, document, CODE) == Decimal(9) + assert await _edit_events(seeded, document) == [] + + +async def test_code_taken_by_empty_template( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + template = await insert_line(seeded, document, code=CODE, row_index=None) + other = await insert_line(seeded, document, code="1100", row_index=6, amount=Decimal(2)) + meta = await _meta(seeded, document) + column = (await repo.form_aggregate(seeded, meta.id))[0][0] + seeded.add(FinancialStatementValue(line_id=template.id, column_id=column.id, amount=None)) + seeded.add( + DocumentIssue( + document_id=document.id, + kind=DocumentIssueKind.warning, + code="X", + severity=IssueSeverity.info, + message="x", + line_ids=[str(template.id), str(other.id)], + source=IssueSource.core, + created_at=NOW, + ) + ) + source = await insert_line(seeded, document, code=None, row_index=3, status="unreadable", amount=Decimal(4)) + client = await auth_client(KA_RF) + response = await _patch(client, document, {"lines": [{"line_id": str(source.id), "line_code": CODE}]}) + assert response.status_code == HTTPStatus.OK, response.text + assert await _db(seeded, template.id) is None + assert ( + await seeded.execute(select(FinancialStatementValue).where(FinancialStatementValue.line_id == template.id)) + ).first() is None + issue = ( + await seeded.execute( + select(DocumentIssue).where(DocumentIssue.document_id == document.id, DocumentIssue.code == "X") + ) + ).scalar_one() + await seeded.refresh(issue) + assert issue.line_ids == [str(other.id)] + stored = await _db(seeded, source.id) + assert stored is not None and stored.line_status.value == "ok" and stored.line_code == CODE + assert [item["line_code"] for item in response.json()["lines"] if item["line_code"] == CODE] == [CODE] + + +async def test_mark_detail(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + missing = await insert_line(seeded, document, code=None, row_index=8, status="missing", amount=Decimal(6)) + client = await auth_client(KA_RF) + unresolved = await client.post(f"/documents/{document.id}/confirm", json={}) + assert unresolved.status_code == HTTPStatus.CONFLICT and unresolved.json()["detail"]["code"] == "LINES_UNRESOLVED" + response = await _patch(client, document, {"lines": [{"line_id": str(missing.id), "line_status": "detail"}]}) + assert response.status_code == HTTPStatus.OK, response.text + stored = await _db(seeded, missing.id) + assert stored is not None and stored.line_status.value == "detail" and stored.line_code is None + again = await client.post(f"/documents/{document.id}/confirm", json={}) + assert again.json().get("detail", {}).get("code") != "LINES_UNRESOLVED" + + +async def test_status_ok_without_code(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + line = await insert_line(seeded, document, code=None, row_index=8, status="missing") + client = await auth_client(KA_RF) + response = await _patch(client, document, {"lines": [{"line_id": str(line.id), "line_status": "ok"}]}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + detail = response.json()["detail"] + assert detail["code"] == "LINE_CODE_REQUIRED" and detail["line_id"] == str(line.id) and detail["line_code"] is None + + +async def test_invalid_line_patch(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + line = await insert_line(seeded, document, code=None, row_index=8, status="missing") + client = await auth_client(KA_RF) + lid = str(line.id) + cases = [ + ([{"line_id": lid}], None), + ([{"line_id": lid, "line_code": "1215", "confirmed": True}], "1215"), + ([{"line_id": lid, "line_code": "1215"}, {"line_id": lid, "line_status": "detail"}], None), + ] + for items, code in cases: + response = await _patch(client, document, {"lines": items}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text + detail = response.json()["detail"] + assert detail["code"] == "INVALID_LINE_PATCH" and detail["line_id"] == lid and detail["line_code"] == code + stored = await _db(seeded, line.id) + assert stored is not None and stored.line_status.value == "missing" + + +async def test_unknown_line(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document, foreign = await two_forms(seeded, fixtures_dir) + other = await insert_line(seeded, foreign, code=None, row_index=8, status="missing") + client = await auth_client(KA_RF) + response = await _patch(client, document, {"lines": [{"line_id": str(other.id), "line_code": CODE}]}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text + detail = response.json()["detail"] + assert detail["code"] == "UNKNOWN_LINE" and detail["line_id"] == str(other.id) and detail["line_code"] == CODE + + +async def test_invalid_line_code(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + line = await insert_line(seeded, document, code=None, row_index=8, status="missing") + client = await auth_client(KA_RF) + for bad in ("123", "123456", "12a4"): + response = await _patch(client, document, {"lines": [{"line_id": str(line.id), "line_code": bad}]}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text + detail = response.json()["detail"] + assert set(detail) == {"code", "message", "line_id", "line_code"} + assert detail["code"] == "INVALID_LINE_CODE" and detail["line_code"] == bad + response = await _patch(client, document, {"lines": [{"line_id": str(line.id), "line_code": "11101"}]}) + assert response.status_code == HTTPStatus.OK, response.text + + +async def test_confirm_code(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + line = await insert_line(seeded, document, code="1120", row_index=9, code_original="1120") + line.line_code_confidence, line.needs_review = Decimal(50), True + await _accept(seeded, document) + client = await auth_client(KA_RF) + response = await _patch(client, document, {"lines": [{"line_id": str(line.id), "confirmed": True}]}) + assert response.status_code == HTTPStatus.OK, response.text + stored = await _db(seeded, line.id) + assert stored is not None and stored.is_confirmed is True and stored.needs_review is False + assert stored.line_code_confidence == Decimal(50) and not stored.line_code_modified + assert response.json()["checks"]["accepted"] is not None + response = await _patch(client, document, {"lines": [{"line_id": str(line.id), "confirmed": False}]}) + stored = await _db(seeded, line.id) + assert stored is not None and stored.is_confirmed is False and stored.needs_review is True + + +async def test_parent_moves_up(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + 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 + top = 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 + first = await insert_line(seeded, document, code="1130", row_index=1) + 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 + stored = [await _db(seeded, line.id) for line in (parent, *details)] + assert [s.parent_row_index for s in stored if s] == [3, 3, 3] + # без строк `ok` выше — None + response = await _patch(client, document, {"lines": [{"line_id": str(top.id), "line_status": "detail"}]}) + assert response.status_code == HTTPStatus.OK + stored = [await _db(seeded, line.id) for line in (top, parent, *details)] + assert [s.parent_row_index for s in stored if s] == [1, 1, 1, 1] + response = await _patch(client, document, {"lines": [{"line_id": str(first.id), "line_status": "detail"}]}) + stored = [await _db(seeded, line.id) for line in (parent, *details)] + assert all(s.parent_row_index is None for s in stored if s) + + +async def test_line_edit_audit_and_accept( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + garbage = await insert_line(seeded, document, code=None, row_index=2, status="unreadable", amount=Decimal(5)) + await _accept(seeded, document) + client = await auth_client(KA_RF) + response = await _patch(client, document, {"lines": [{"line_id": str(garbage.id), "line_code": CODE}]}) + assert response.status_code == HTTPStatus.OK, response.text + assert response.json()["checks"]["accepted"] is None # назначение кода — правка данных + events = await _edit_events(seeded, document) + assert len(events) == 1 + key = f"line:{garbage.id}" + assert events[0].before_value[key]["line_status"] == "unreadable" + assert events[0].after_value[key]["line_status"] == "ok" and events[0].after_value[key]["line_code"] == CODE + assert set(events[0].after_value[key]) == {"line_code", "line_status", "parent_row_index", "is_confirmed"} diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 618fd86..64ce77f 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -1611,7 +1611,7 @@ async def insert_line( # noqa: PLR0913 - строка формы теста: к meta_id=meta.id, line_code=code, line_code_original_value=code_original, - line_status=status, + line_status=StatementLineStatus(status), line_code_text=code_text, line_name="тест", row_index=row_index, diff --git a/api/tests/test_domain_statement_layout.py b/api/tests/test_domain_statement_layout.py index 0cac669..6e824d9 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, order_lines +from src.domain.statement_layout import LayoutLine, nearest_ok_parents, order_lines def L( # noqa: N802 - короткая фабрика строки для таблиц тестов @@ -44,3 +44,14 @@ 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} -- 2.47.2 From b3116acb1745214f1d78230e022028c0e631463a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 17:53:53 +0400 Subject: [PATCH 14/20] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BB=D0=B5=D0=B9,=20=D1=80=D1=83=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=D0=B8=20R-4=E2=80=A6R-8=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D1=80=D1=8B=D1=82=D1=8B=D0=B5=20=D0=B2=D0=BE=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D1=8B=20=D1=87=D0=B0=D1=81=D1=82=D0=B8=20?= =?UTF-8?q?=D0=90=20(I-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- .../unified-normalization-core-a/design.md | 87 ++++++++++++++++++- .../unified-normalization-core-a/proposal.md | 2 + 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/openspec/changes/unified-normalization-core-a/design.md b/openspec/changes/unified-normalization-core-a/design.md index 575b495..2cd981c 100644 --- a/openspec/changes/unified-normalization-core-a/design.md +++ b/openspec/changes/unified-normalization-core-a/design.md @@ -46,5 +46,90 @@ Goals и Non-goals — см. `proposal.md`. | 2026-10-18 | **R-1.** Порядок исполнения части А: A0–A4, затем A6–A13; A5 (контракт 2.0, `recognition_exchange.py`) — последней, после слияния плана 0 (в `origin/main` нет `api/tests/contract_identity.py`) | A5 зависит от теста идентичности плана 0, остальные задачи его результат не потребляют | возможные конфликты слияния в `documents.py` / `recognition_exchange.py` при A5 в конце | | 2026-10-18 | **R-2.** Исполнители задач и их ревьюеры — sonnet; финальное ревью ветки — opus | план даёт прозу, а не готовый код; ревью ветки ловит сквозные расхождения | более слабая модель на задаче — лишние итерации ревью | | 2026-10-18 | **R-3.** Тесты с БД идут на scratch-БД `armcore_pytest_*` compose-Postgres :5432 (устройство conftest); контейнеры не останавливаются и не пересоздаются | общий стенд разработки не должен падать из-за прогонов | недоступный Postgres — задача BLOCKED, а не пропуск тестов | +| 2026-10-18 | **R-4.** A0 (только артефакты OpenSpec) без отдельного ревью сабагентом: достаточно `openspec validate --strict` | кода в задаче нет | неточность формулировок дельт; поймало финальное ревью | +| 2026-10-18 | **R-5.** Ревью A1 и A2 объединено в один пакет после A2 | downgrade-тесты A1 проверяемы только с A2 | находки по A1 пришли на один раунд позже | +| 2026-10-18 | **R-6.** Ревью малых задач объединялось парами (A3+A4, A8+A9, A10+A11, A12+A13) | экономия раундов ревью | находки по первой задаче пары приходят на шаг позже | +| 2026-10-18 | **R-7.** Change `unified-normalization-core-a` архивируется только вместе с `unified-normalization-core-b`; тексты поведения части Б в дельтах А остаются | разбор дельт на два change дороже и не нужен до выкладки обеих частей | если А заархивировать отдельно, `openspec/specs` опишет нереализованное | +| 2026-10-18 | **R-8.** Одна волна правок по итогам финального ревью (sonnet), затем один scoped re-review; M-6, M-8, M-13 не правятся (deferred / rejected) | замечания малы; M-13 (advisory-блокировки purge пофайлово) — окно обслуживания | M-6 (отдельный SUM для `code_amount`) и M-8 остаются техдолгом | +| 2026-10-18 | **A1-1.** `result_mapping.map_file` до A5/A9 присваивает `file_ordinal` порядковым номером создания документов | иначе `uq_document_stored_file_id_file_ordinal` ломает файл с 2+ документами | номера не равны индексу в `documents[]`; заменено в A9/A5 | +| 2026-10-18 | **A1-2.** `organization_name_modified` — nullable boolean (как в DBML); миграция пишет `false` | соответствие DBML v5.19 | нет | +| 2026-10-18 | **A1-3.** Тест-фабрика `tests/factories.py` и `tests/test_models_part_04_06._document` нумеруют `file_ordinal` | уникальность `(stored_file_id, file_ordinal)` | нет | +| 2026-10-18 | **A1-4.** Старый тест «две BALANCE с NULL -> IntegrityError» переписан под DISCLOSURE | по v5.19 для форм это больше не нарушение | нет | +| 2026-10-18 | **A1-5.** Имена FK/PK новых объектов (`fk_financial_statement_value_confirmed_by_app_user`, `fk_document_issue_*`, `pk_document_issue`) — по NAMING_CONVENTION | совпадают с `create_all`, тест схемы зелёный | нет | +| 2026-10-18 | **A2-1.** `line_status` считается потерянным при `<> 'ok'` (колонка NOT NULL default 'ok') | `IS NOT NULL` всегда истинно | шум в журнале отката | +| 2026-10-18 | **A2-2.** Булевы счётчики и `organization_name_modified` считаются как `IS TRUE`; причина отказа «наименование правилось» — `organization_name_modified IS TRUE` | NULL не считается правкой | нет | +| 2026-10-18 | **A2-3.** Причина «строки формы без кода» — по `line_code IS NULL` (любые), «повтор» — только по ненулевым кодам | точность причин отказа отката | нет | +| 2026-10-18 | **A2-4.** Сообщение журнала склеивает счётчики через `; ` | читаемость | нет | +| 2026-10-18 | **A3-1.** Только PATCH, меняющий `crm_id`, пишет `GROUP_CHANGE` (`before/after` всех изменённых полей запроса); PATCH только имени/даты остаётся `MEMBERSHIP_CHANGE`; создание — `MEMBERSHIP_CHANGE` с `crm_id` в payload | P3-18 / Р-41 про смену `crm_id`; минимум изменения поведения | фильтр аудита по действию для правок групп расходится; правка в одну строку | +| 2026-10-18 | **A3-2.** Предпроверка уникальности `crm_id` не учитывает видимость (глобальная уникальность), как для ГК | единый подход | `crm_id` невидимой группы раскрывает существование через 409 (как ГК/ЮЛ) | +| 2026-10-18 | **A3-3.** Before-payload удаления группы включает `crm_id` | полнота аудита | нет | +| 2026-10-18 | **A4-1.** `run` принимает подменяемую фабрику транзакции (по умолчанию `session_transaction`) | тесты идут на `db_session` | нет (боевой путь — по умолчанию) | +| 2026-10-18 | **A4-2.** `PurgePlan` несёт `include_mixed`; `BlockingVersion` по `ex_snapshot_item` имеет статус `EX_SNAPSHOT_ITEM` и `version_id = ex_snapshot.source_version_id` | сигнатура `apply_purge` без флага | нет | +| 2026-10-18 | **A4-3.** Проверка блокировки покрывает ВСЕ документы удаляемых целиком файлов (в том числе OSV/DISCLOSURE; OSV смешанных файлов с `--include-mixed`) | иначе удаление сломало бы FK/заморозку | лишний отказ, если OSV удаляемого файла в верифицированной версии (корректно: она заморожена) | +| 2026-10-18 | **A4-4.** `apply_purge` блокирует все файлы плана (jobs -> file -> documents, по id файла), перепланирует под блокировками и отказывает до первой мутации (`PurgeRefusedError`); версии блокируются позже (file -> doc -> version) | защита от гонки с загрузкой | окно между проверкой и блокировкой версий (T4.3, rejected) | +| 2026-10-18 | **A4-5.** Dry-run с блокировкой печатает блок отказа, но выходит с 0; `--apply` с блокировкой: отчёт в stdout, сообщение в stderr, код 1 | P3-13: 0 = отчёт | нет | +| 2026-10-18 | **A4-6.** `FILE_DELETE.after_value`: для целого файла `{unbound_version_ids}`, для смешанного `{deleted_documents, unbound_version_ids}` | трассируемость очистки | нет | +| 2026-10-18 | **A4-7.** `superseded_with_osv` — файлы, у которых `superseded_by_file_id` указывает на удаляемый файл и которые содержат OSV | сохранение замещения ОСВ | нет | +| 2026-10-18 | **A6-1.** `section_number` возвращает строчные буквы (работает по нормализованному тексту) | внутренний формат | нет | +| 2026-10-18 | **A6-2.** `names()` — имена без повторов в порядке редакций и появления; `edition_codes` — по всем записям | стабильность подсказки | нет | +| 2026-10-18 | **A6-3.** Если «итого по разделу» только у одного из имён, номер у другого `None` — сравнение только по равенству строк | спека | нет | +| 2026-10-18 | **A6-4.** Пустые справочные имена пропускаются | шум | нет | +| 2026-10-18 | **A6-5.** Старые тесты API обновлены под код 1105 в `fsbu4` (yaml дополнен 1105/1130/1140/1530/2460 и вторыми именами) | данные docs | для ГСК-кода 1105 на 66н с датой `fsbu4` появляется пустая строка «Гудвил» (O-2 A6) | +| 2026-10-18 | **A7-1.** `FailedCheck.codes` — поле с default `()`, участвует в сравнении | P3-14 | низкая | +| 2026-10-18 | **A7-2.** `insert_statement` до маппера 2.0 пишет `row_index` = порядковый номер строки черновика | иначе все строки источника стали бы `origin = template` | заменено в A9/части Б | +| 2026-10-18 | **A7-3.** `checks_input` принимает объекты-колонки (id, column_index) — протокол, без импорта моделей в домен | чистый домен | нет | +| 2026-10-18 | **A7-4.** `code_amount`: колонка — первая по `column_index`, затем SUM по ней; нет колонки или непустых — `None` | определённость суммы | M-6: отдельный SUM, deferred в часть Б | +| 2026-10-18 | **A7-5.** В API `checks.failed[].codes` и `line_ids` не выведены в A7 (только домен) | не в брифе A7 | закрыто в A8 | +| 2026-10-18 | **A7-6.** Порядок строк ответа в A7 не менялся | P3-17 — задача A8 | нет | +| 2026-10-18 | **A8-1.** `insert_statement` временно пишет `period_kind` (`absolute` при дате, иначе `none`) | иначе тест брифа на 1.x не проходит до A9 | заменено в A9; совпадает с backfill миграции | +| 2026-10-18 | **A8-2.** `FailedCheckOut.line_ids` — объединение `line_ids` агрегата по `codes` правила; у межформенной проверки — строки только читаемой формы | кода второй формы в агрегате нет | фронт не подсветит строку другой формы | +| 2026-10-18 | **A8-3.** `header.source`: запись с `text: null` отдаётся как есть, нечисловая уверенность -> `null`, не-dict записи пропускаются | T1.1: «нет подсказки» | нет | +| 2026-10-18 | **A8-4.** `order_lines`: `detail` с неизвестным родителем ведёт себя как `detail` без родителя; вложения по `row_index`; равные ключи — порядок входа (по `id`) | P3-17 | нет | +| 2026-10-18 | **A8-5.** `IssueCounts` — dataclass репозитория, сервис переводит в `IssueCountsOut` | слои | нет | +| 2026-10-18 | **A9-1.** `ColumnDraft` вместо кортежа в `StatementDraft.columns` (несёт `period_kind` / `label_confidence`) | нужны поля колонки | правка 4 утверждений в тестах | +| 2026-10-18 | **A9-2.** `header_source.*.confidence` в БД — число как пришло из 1.x (float), не Decimal | JSONB; P3-20 | нет | +| 2026-10-18 | **A9-3.** Повтор формы определяется только при известных годе и квартале у обеих; текст issue без префикса locator, в `metrics.mapping_warnings` — с префиксом | P3-4 | нет | +| 2026-10-18 | **A9-4.** Пустая ячейка (нет значения) -> `needs_review = false` (`value_clean(None, None)` истинно) | формула приёма 1.x | нет | +| 2026-10-18 | **A9-5.** У листа ОСВ issue пишутся и для предупреждения о повторе счёта (относится к первому листу) | P3-5 | нет | +| 2026-10-18 | **A10-1.** Контрагент `UNKNOWN` трактуется как `None`, блокирует только `MISMATCH` | спека: `counterparty_check != MISMATCH` | низкая | +| 2026-10-18 | **A10-2.** `name_mismatches`: строка `ok` с `line_code_confidence = None` считается уверенной (не проверяется); `line_status` сравнивается со `StatementLineStatus.ok` (StrEnum) | спека: «confidence < 100» (T10.1: покрыто комментарием и тестом) | строка без уверенности не даёт `NAME_MISMATCH` | +| 2026-10-18 | **A10-3.** `has_error_warning`: строго `severity == "error"` (регистр важен), не список -> `False` | строгий контракт | нет | +| 2026-10-18 | **A10-4.** `noqa PLR0913` на `form_status_v2` и тестовом хелпере | сигнатура задана планом | нет | +| 2026-10-18 | **A11-1.** Тест `test_disclosure_is_confirmable` удалён: `form_blocker` принимает meta, у расшифровки её нет; не-форма проверяется вызывающими (FORM_TYPES) — **тест возвращён в волне правок (T11.1)** на уровне сервиса | покрытие подтверждения DISCLOSURE/OTHER | нет | +| 2026-10-18 | **A11-2.** `confirmed_twin` при пустом периоде возвращает `None` (`PERIOD_REQUIRED` сработает раньше); первым выбирается близнец с меньшим `file_ordinal` | детерминизм | нет | +| 2026-10-18 | **A11-3.** Дубль ищется только среди `CONFIRMED`; второй `NEEDS_CHECK`-близнец не блокирует | бриф | нет | +| 2026-10-18 | **A11-4.** `line_id` в `extra` — строка (`str(uuid)`), как `document_id` | единообразие | нет | +| 2026-10-18 | **A12-1.** Новый код 422 `DUPLICATE_COLUMN` (колонка дважды в `columns[]`) | в брифе не было | низкая | +| 2026-10-18 | **A12-2.** Подтверждение даты отчёта требует непустого `report_date` (null == null не подтверждение) | смысл подтверждения | низкая | +| 2026-10-18 | **A12-3.** Равное текущему `organization_name` — no-op | подтверждать нечего | нет | +| 2026-10-18 | **A12-4.** Снятие подтверждения ячейки и возврат суммы к исходной: `needs_review` = не(правлена | подтверждена | строка detail | value_clean) — формула приёма 1.x | для 2.0 точное значение даст часть Б | ячейка 2.0 с иным сомнением (знак) может показаться чистой до Б | +| 2026-10-18 | **A12-5.** `recompute` дополнительно сбрасывает `needs_review` у правленых/подтверждённых значений и значений строк `detail` (`settle_edited_values`) | согласованность флагов | T13.3: флаг навсегда `false` у значений detail (часть Б) | +| 2026-10-18 | **A12-6.** Подтверждение ячейки без записи значения в БД — 422 `UNKNOWN_CELL` до записей; колонка не этой формы в `values[]` — `UNKNOWN_CELL`, `UNKNOWN_COLUMN` — только для `columns[]` | существующий тест | нет | +| 2026-10-18 | **A12-7.** `is_current` пересчитывается только при смене дат колонок (самая поздняя дата, меньший `column_index`) | как при приёме | нет | +| 2026-10-18 | **A12-8.** Ключи аудита подтверждений: `value::confirmed`, `column::date_confirmed`, `needs_review_fields`; смена даты колонки — `column:` | единый формат | нет | +| 2026-10-18 | **A12-9.** Смена `report_date` убирает `report_date` из `needs_review_fields` без отдельного ключа аудита | минимум шума | нет | +| 2026-10-18 | **A12-10.** `lines[]` в `StatementPatch` нет в A12 (добавлено в A13) | разбиение задач | нет | +| 2026-10-18 | **A13-1.** Тесты используют код 1215 вместо 1250 из брифа | 1250 есть в источнике ББ Межениновской, а нужен «только шаблонный» код | нет | +| 2026-10-18 | **A13-2.** `tests/test_api_statements.py::insert_line` кладёт `StatementLineStatus`, а не строку | identity map сервиса | нет | +| 2026-10-18 | **A13-3.** Пометка шаблонной строки (`row_index IS NULL`) как `detail` — 422 `INVALID_LINE_PATCH`; подтверждение кода у строки не `ok` — тоже | спека такого перехода не перечисляет; у `detail` нужен `row_index` для родителя | низкая, фронт такого не шлёт | +| 2026-10-18 | **A13-4.** Назначение того же кода строке `ok` с этим кодом — no-op; `detail -> detail` — no-op | нечего менять, аудита нет | T13.1: no-op элемент может дать ложный 409 — недостижимо | +| 2026-10-18 | **A13-5.** Порядок проверок по элементам: действие, повтор, `UNKNOWN_LINE`, `LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, допустимость; затем отдельным проходом `LINE_CODE_TAKEN` (409 после всех 422) | предсказуемость | нет | +| 2026-10-18 | **A13-6.** `needs_review` при снятии подтверждения: `ok` и не `line_code_modified` и уверенность < 100 | правило приёма 1.x; для 2.0 уточнит часть Б | нет | +| 2026-10-18 | **A13-7.** Назначение кода: `confidence = Decimal("100.00")` (ответ отдаёт объект сессии) | без перечитывания | нет | +| 2026-10-18 | **A13-8.** Удаление пустой шаблонной строки пропускается, если она сама — цель элемента запроса | избежать удаления цели | нет | +| 2026-10-18 | **A13-9.** **Отступление от P3-10:** 422 в `values` / `columns` после применённых `lines` откатывает всю транзакцию запроса; отдельной предпроверки адресации `values` для `lines` нет. `UNKNOWN_CELL` по `line_code` видит назначения `lines` этого запроса (они применены и flush-нуты раньше); 409 приоритетнее такого 422 | эквивалентное наблюдаемое поведение при меньшем коде (T13.5) | при ошибке в откате частичная запись — тест M-10 фиксирует отсутствие записей и аудита | +| 2026-10-18 | **A13-10.** `user_id` в `apply_line_edits` принят по интерфейсу, не используется; аудит строк — ключ `line:` с `{line_code, line_status, parent_row_index, is_confirmed}`, в него попадают и `detail` со сменой только родителя | у строки нет автора правки | нет | +| 2026-10-18 | **A13-11.** Пометка шаблонной строки как `detail` = 422 `INVALID_LINE_PATCH` | см. A13-3 | низкая | -Открытые вопросы плана (`O-n`) — в `.superpowers/sdd/2026-10-18-plan-3-arm-core/p3-decisions.md` (ledger, в git не попадает); закрытые владельцем: O-2 (Р-34), O-3 (Р-40), O-5 (Р-35), O-6 (Р-41). Решения исполнителей по ходу задач A1–A13 дописываются сюда контроллером. +## Open questions + +Вопросы владельцу (O-n из плана 3, не закрытые решениями Р-34 / Р-35 / Р-40 / Р-41; нумерация плана). + +- **O-1.** Р-10 для `1.x` (P3-4): вторая форма того же типа хранится и при `1.x` (план) или пропускается, как раньше? +- **O-3 (закрыт Р-40 для четырёхзначных).** `CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` — только для четырёхзначных кодов; пятизначные подстроки Excel `info` не дают. Подтвердить, что сценарий спеки «Нестандартный код Excel» приведён к этому. +- **O-4.** Пересчёт проверок ядра уровня `info` после правки (P3-8) спека не описывает; после смены кода они не пересчитываются в части А (T13.4), пересчёт — в части Б. +- **O-6 (закрыт Р-41).** Смена `crm_id` ГСК/ГСЗ — `GROUP_CHANGE`. +- **O-7.** Конверт с неизвестным MAJOR (`3.0`) inbox отбрасывает с предупреждением в лог (задание закроется по сроку); сценарий «файл получает `ERROR` с `mapping_error`» выполняется для файла результата. Оставить? +- **O-9.** Выкладка: PR части А в `main` без снимка контура до части Б; встроенный фронт `arm-core/web` получает новые типы только через `sync-web` после плана 6. +- **O-10.** Метрики `fields_*` ядро не считает по строкам формы (копирует из конверта, `recognition_exchange.FIELD_COUNTERS`). Подтвердить, что спека («метрики `fields_*`») не требует нового расчёта. +- **Наблюдение.** Хранимая вторая ББ теперь входит в ключи автозамещения: файл с ББ на 30.09.2025 и ББ на 31.12.2024 может заместить более старый файл; его ОФР при этом выпадает с предупреждением OQ-154. diff --git a/openspec/changes/unified-normalization-core-a/proposal.md b/openspec/changes/unified-normalization-core-a/proposal.md index b10fd6e..ffdbc0d 100644 --- a/openspec/changes/unified-normalization-core-a/proposal.md +++ b/openspec/changes/unified-normalization-core-a/proposal.md @@ -20,6 +20,8 @@ - **`crm_id` ГСК/ГСЗ** (A3): `directories-api`. - **Приём конвертов обоих MAJOR, предупреждения файла структурами** (A5): `recognition-job-exchange`, `documents-api` (`JobBrief.warnings`). +**Архивация (R-7).** Change заархивировать только вместе с `unified-normalization-core-b`; дельты содержат поведение части Б. + ## Остаётся части Б (change `unified-normalization-core-b`) Мапперы результата `2.0` (B4, B5), переход на пакет `arm_statements` (пересчёт и §7 через точку входа пакета, `document_issue` видов `relation` / `soft` с `core`, относительные даты, удаление копии словаря §7, B1–B3, B6), `CHECKS_NOT_PASSED` из `document_issue`. До части Б §7 считается ядром на лету (P3-14); код временного пересчёта удаляется в B6. Требования спеки формулируются здесь целиком по docs; пункты, помеченные в таблице покрытия как часть Б, этот change не реализует. -- 2.47.2 From 8296c77fe5f183e1bec8335cd1e31b2957b28eb8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 18:05:28 +0400 Subject: [PATCH 15/20] =?UTF-8?q?fix(api):=20OpenAPI-=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=D1=8B=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA=20PATCH=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D1=8B,=20confirm=20=D0=B8=20crm=5Fid;=20orga?= =?UTF-8?q?nization=5Fname:null=20=E2=80=94=20422=20(I-2,=20M-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- README.md | 10 ++-- .../endpoints/directories/related_groups.py | 7 ++- api/src/api/endpoints/files/documents.py | 4 +- api/src/api/endpoints/files/statements.py | 13 +++-- api/src/domain/statement_mapping.py | 2 +- api/src/repositories/statements.py | 4 +- api/src/schemas/statements.py | 10 +++- api/src/services/result_mapping.py | 19 +++---- api/src/services/statement_line_edits.py | 8 ++- api/src/services/statements.py | 6 ++- api/tests/test_api_statement_lines.py | 53 +++++++++++++++++++ api/tests/test_api_statements.py | 2 +- 12 files changed, 107 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index e9ebc90..bb3cd3e 100644 --- a/README.md +++ b/README.md @@ -941,7 +941,7 @@ email без учёта регистра проверяет только сер | `UNKNOWN_LINE` | 422 | `PATCH …/statement`: `lines[].line_id` не этой формы. `detail.line_id` — чужой id, `detail.line_code` | | `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) | | `PERIOD_REQUIRED` | 409 | подтверждение формы: период не определён — поправьте дату отчёта | -| `LINES_UNRESOLVED` | 409 | подтверждение формы: есть строки `unreadable`/`missing` с непустой ячейкой; `detail.lines[] {line_id, row_index, line_code_text}` по `row_index` — назначьте код или очистите строку | +| `LINES_UNRESOLVED` | 409 | подтверждение формы: есть строки `unreadable`/`missing` с непустой ячейкой; `detail.lines[] {line_id, row_index, line_code_text}` по `row_index` — назначьте код или отметьте строку расшифровкой | | `DUPLICATE_FORM` | 409 | подтверждение формы: в файле уже подтверждена форма того же типа, года и квартала; `detail.document_id` — подтверждённая форма | | `CHECKS_NOT_PASSED` | 409 | подтверждение формы: жёсткие проверки §7 не пройдены и не акцептованы; `detail.failures[] {column_index, relation, expected, actual}` | | `TOO_MANY_IDS` | 422 | `GET /users/brief`: больше 200 id | @@ -1044,7 +1044,7 @@ email без учёта регистра проверяет только сер | `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен | | `make test-integration` | интеграционные тесты: `pytest -m "integration and not e2e"`, нужны PostgreSQL (и NATS) | | `make e2e` | сквозной прогон загрузки ОСВ: `pytest -m e2e`, нужны compose и tabular-parser из профиля `services` (без него — skip) | -| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.18.dbml` | +| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.19.dbml` | | `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate | | `make check` | быстрая проверка без Docker: `lint` + `test` | | `make docker-build` | сборка образа: `docker build -t arm-core .` | @@ -1096,7 +1096,7 @@ queue group `arm-core`, подписчики группы получают со ### Проверка ORM и DBML -Модель данных — `../docs/model/data-model-v5.18.dbml`. ORM обязана совпадать с ней один в один: +Модель данных — `../docs/model/data-model-v5.19.dbml`. ORM обязана совпадать с ней один в один: ```bash make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно @@ -1119,7 +1119,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) + 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; меняется в обоих репозиториях) seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист) seed/dev/ dev-данные: филиалы и пользователи по ролям @@ -1203,7 +1203,7 @@ Dockerfile только для локального compose - [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы; - [`../docs/context/03-architecture.md`](../docs/context/03-architecture.md) — архитектура, соглашения шаблона, NATS и S3; - [`../docs/context/04-data-model-guide.md`](../docs/context/04-data-model-guide.md) и - [`../docs/model/data-model-v5.18.dbml`](../docs/model/data-model-v5.18.dbml) — модель данных; + [`../docs/model/data-model-v5.19.dbml`](../docs/model/data-model-v5.19.dbml) — модель данных; - [`../docs/context/05-domain-rules.md`](../docs/context/05-domain-rules.md) — алгоритмы предметной области; - [`../docs/context/07-open-questions.md`](../docs/context/07-open-questions.md) — открытые вопросы; - [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов; diff --git a/api/src/api/endpoints/directories/related_groups.py b/api/src/api/endpoints/directories/related_groups.py index 2704e83..8d5707f 100644 --- a/api/src/api/endpoints/directories/related_groups.py +++ b/api/src/api/endpoints/directories/related_groups.py @@ -48,7 +48,9 @@ async def list_related_groups(company_group_id: uuid.UUID, service: RelatedGroup async def create_related_group( company_group_id: uuid.UUID, data: RelatedGroupCreate, service: RelatedGroupServiceDep ) -> RelatedGroupOut: - """Создаёт ГСК (`kind = gsk`, код `GSK-NNNNNN`) или ГСЗ (`gsz`, `GSZ-NNNNNN`) в ГК.""" + """Создаёт ГСК (`kind = gsk`, код `GSK-NNNNNN`) или ГСЗ (`gsz`, `GSZ-NNNNNN`) в ГК. + + Занятый `crm_id` — 409 `CRM_ID_EXISTS`.""" return await service.summary(await service.create(company_group_id, data)) @@ -66,7 +68,8 @@ async def get_related_group(group_id: uuid.UUID, service: RelatedGroupServiceDep async def update_related_group( group_id: uuid.UUID, data: RelatedGroupUpdate, service: RelatedGroupServiceDep ) -> RelatedGroupOut: - """Меняет `name`/`created_date`; вид и ГК группы не меняются.""" + """Меняет `name`/`created_date`/`crm_id`; вид и ГК группы не меняются. Занятый `crm_id` — 409 `CRM_ID_EXISTS`; + смена `crm_id` пишется в аудит как `GROUP_CHANGE`.""" return await service.summary(await service.update(group_id, data)) diff --git a/api/src/api/endpoints/files/documents.py b/api/src/api/endpoints/files/documents.py index 981de94..dfef856 100644 --- a/api/src/api/endpoints/files/documents.py +++ b/api/src/api/endpoints/files/documents.py @@ -60,7 +60,9 @@ async def list_entries( status.HTTP_409_CONFLICT: { "model": ErrorDetail, "description": "`ALREADY_CONFIRMED`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_CONFIRMABLE`; для формы ББ/ОФР — " - "`PERIOD_REQUIRED`, `CHECKS_NOT_PASSED` (нарушения проверок — в `detail.failures`)", + "`PERIOD_REQUIRED`, `CHECKS_NOT_PASSED` (нарушения проверок — в `detail.failures`), " + "`LINES_UNRESOLVED` (строки без кода — назначьте код или пометьте расшифровкой), " + "`DUPLICATE_FORM` (уже подтверждена форма того же типа и периода)", }, }, summary="Подтвердить документ", diff --git a/api/src/api/endpoints/files/statements.py b/api/src/api/endpoints/files/statements.py index a3085b7..02bc288 100644 --- a/api/src/api/endpoints/files/statements.py +++ b/api/src/api/endpoints/files/statements.py @@ -28,11 +28,12 @@ EDIT_ERRORS: dict[int | str, dict[str, Any]] = { status.HTTP_409_CONFLICT: { "model": ErrorDetail, "description": "`FILE_SUPERSEDED`, `DOCUMENT_READONLY`, `DOCUMENT_NOT_CONFIRMABLE`, `DOCUMENT_NOT_STATEMENT`, " - "`NOTHING_TO_ACCEPT`", + "`NOTHING_TO_ACCEPT`, `LINE_CODE_TAKEN` (код занят другой строкой формы)", }, status.HTTP_422_UNPROCESSABLE_CONTENT: { "model": ErrorDetail, - "description": "`INVALID_INN`, `UNKNOWN_CELL`, `DUPLICATE_CELL` и ошибки схемы", + "description": "`INVALID_INN`, `UNKNOWN_CELL`, `DUPLICATE_CELL`, `UNKNOWN_COLUMN`, `DUPLICATE_COLUMN`, " + "`LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE` и ошибки схемы", }, } @@ -57,8 +58,14 @@ async def get_statement(document_id: uuid.UUID, service: StatementServiceDep) -> summary="Правка формы ББ/ОФР", ) async def patch_statement(document_id: uuid.UUID, body: StatementPatch, service: StatementServiceDep) -> StatementOut: - """Значения и шапка (`inn`, `report_date`, `units`): аудит, сброс акцепта, пересчёт статуса. + """Правка формы одним запросом, части применяются по порядку `lines` -> `values` -> `columns` -> `header`: + - `lines[]` — строки: назначить код, пометить расшифровкой (`detail`, с родителем), подтвердить код (`confirmed`); + - `values[]` — значения ячеек, в том числе подтверждение (`confirmed`); + - `columns[]` — даты и подтверждение даты колонок; + - `header` — `organization_name`, `inn`, `report_date`, `units` (значение или подтверждение `confirmed`). + + Аудит, сброс акцепта, пересчёт статуса. Ошибка в любой части откатывает весь запрос. Запрос без изменений — 200 без аудита.""" return await service.patch(document_id, body) diff --git a/api/src/domain/statement_mapping.py b/api/src/domain/statement_mapping.py index 0101a49..00a9bc1 100644 --- a/api/src/domain/statement_mapping.py +++ b/api/src/domain/statement_mapping.py @@ -66,7 +66,7 @@ class LineDraft: line_code_confidence: Decimal | None line_name: str line_name_confidence: Decimal | None - row_index: int = 0 # индекс строки в `doc.lines` результата; пропущенный повтор кода индекс не занимает + row_index: int = 0 # индекс строки в `doc.lines` результата — ключ строки формы; повтор кода — свой индекс line_status: str = "ok" line_code_original_value: str | None = None source_ref: str | None = None diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index 217eb97..ecd4c71 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -58,6 +58,7 @@ __all__ = [ "value_flags", ] +# строк на один INSERT: у строки формы 15 колонок, у значения 10; 15 × 1000 < 32767 параметров asyncpg BATCH = 1000 @@ -69,9 +70,6 @@ class IssueCounts: info: int = 0 -# значений на один INSERT: 8 колонок × 1000 < 32767 параметров asyncpg - - async def insert_statement( session: AsyncSession, *, document_id: uuid.UUID, job_id: uuid.UUID, draft: StatementDraft ) -> FinancialStatementMeta: diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index 060936e..b0cb8a6 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -454,7 +454,8 @@ class HeaderPatch(BaseModel): `INVALID_INN` (ИНН нельзя очистить: иначе обходилась бы проверка контрагента). `units: null` — «не менять» (единицы измерения очищать нельзя); непустая строка после обрезки пробелов, до 50 - символов (пробельная строка — 422). `organization_name` — непустая строка после обрезки, до 1000 символов.""" + символов (пробельная строка — 422). `organization_name` — непустая строка после обрезки, до 1000 символов; + `null` — 422 (как `inn`).""" model_config = ConfigDict(extra="forbid") @@ -465,6 +466,13 @@ class HeaderPatch(BaseModel): Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=1000)] | None ) = None + @model_validator(mode="after") + def _organization_name_not_null(self) -> "HeaderPatch": + """`organization_name: null` — 422 (наименование очищать нельзя, как ИНН); «не менять» — отсутствие поля.""" + if "organization_name" in self.model_fields_set and self.organization_name is None: + raise ValueError("organization_name не может быть null") + return self + class ColumnPatch(BaseModel): """Дата колонки: обязательная (`null` — 422 валидации тела).""" diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 28e2543..4b3006f 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -55,6 +55,7 @@ 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_status import duplicate_period_forms from src.models.accounting import ChartOfAccount from src.models.consolidation import Consolidation, ConsolidationVersion from src.models.directories import CompanyGroup, LegalEntity @@ -389,19 +390,15 @@ def _map_statement_docs( # noqa: PLR0913 - все входы именованн updated_at=now, ) forms.append((document, draft)) - same_period: dict[tuple[DocumentType, int, PeriodQuarter], int] = {} - for document, _ in forms: - if document.period_year is not None and document.period_quarter is not None: - key = (document.document_type, document.period_year, document.period_quarter) - same_period[key] = same_period.get(key, 0) + 1 + duplicated = duplicate_period_forms( + (document.id, document.document_type, document.period_year, document.period_quarter) for document, _ in forms + ) repeats: dict[uuid.UUID, str] = {} for document, _ in forms: - if document.period_year is not None and document.period_quarter is not None: - key = (document.document_type, document.period_year, document.period_quarter) - if same_period[key] > 1: - text = f"в файле есть другая форма {document.document_type.value} за тот же период" - repeats[document.id] = text - warnings.append(f"{document.locator}: {text}") + if document.id in duplicated: + text = f"в файле есть другая форма {document.document_type.value} за тот же период" + repeats[document.id] = text + warnings.append(f"{document.locator}: {text}") others = [ Document( id=uuid.uuid4(), diff --git a/api/src/services/statement_line_edits.py b/api/src/services/statement_line_edits.py index e9b39e4..7ef893f 100644 --- a/api/src/services/statement_line_edits.py +++ b/api/src/services/statement_line_edits.py @@ -86,12 +86,15 @@ def _apply_item(line: FinancialStatementLine, item: LinePatch) -> bool: 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 if item.line_status == "detail": 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 @@ -139,6 +142,7 @@ async def apply_line_edits( # noqa: PLR0913 - контракт задачи A13 raise _bad(LINE_CODE_TAKEN, "код занят строкой, созданной правкой, с суммами", item, conflict=True) # запись: пустые шаблонные строки назначаемых кодов уходят, их id — из `document_issue.line_ids` targets = {item.line_id for item in items} + deleted: list[uuid.UUID] = [] old = {line.id: _snapshot(line) for line in lines} for template, _ in templates.values(): if template.id in targets or template.line_code not in codes: @@ -146,7 +150,7 @@ async def apply_line_edits( # noqa: PLR0913 - контракт задачи A13 await repo.delete_line(session, template.id) await repo.remove_line_from_issues(session, document.id, template.id) lines = [line for line in lines if line.id != template.id] - old.pop(template.id, None) + 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() @@ -154,4 +158,6 @@ async def apply_line_edits( # noqa: PLR0913 - контракт задачи A13 now = _snapshot(line) if now != old[line.id]: before[f"line:{line.id}"], after[f"line:{line.id}"] = old[line.id], now + for line_id in deleted: + before[f"line:{line_id}"], after[f"line:{line_id}"] = old[line_id], None return data_edited diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 029e7f2..8a2523d 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -124,7 +124,7 @@ async def form_blocker(session: AsyncSession, document: Document, meta: Financia if lines: return FormBlocker( LINES_UNRESOLVED, - "в форме есть нераспознанные строки со значениями: назначьте код или очистите строку", + "в форме есть нераспознанные строки со значениями: назначьте код или отметьте строку расшифровкой", { "lines": [ {"line_id": str(line_id), "row_index": row_index, "line_code_text": code_text} @@ -269,7 +269,9 @@ class StatementService: """Единая точка пересчёта после правки или акцепта (spec arm-core «Пересчёт после правки»; D-6); `CONFIRMED` не трогает. До части Б `values_ok` — нет `needs_review` у значений строк не `detail`; сигнатура остаётся и в Б. - Предупреждения маппинга файла не хранятся на уровне документа, поэтому передаются пустыми (`warnings=()`). + `warnings=()`: предупреждения приёма (`MAPPING_WARNING`) хранятся как `document_issue` уровня `info` и + определяют статус только при приёме (D-6); после правки аналитика статус строится по данным формы, + проверкам, контрагенту и периоду, а эти записи остаются видимыми в `issues` и статус не удерживают. """ if document.status is DocumentStatus.CONFIRMED: return diff --git a/api/tests/test_api_statement_lines.py b/api/tests/test_api_statement_lines.py index d3a1050..c1ce13f 100644 --- a/api/tests/test_api_statement_lines.py +++ b/api/tests/test_api_statement_lines.py @@ -167,6 +167,10 @@ async def test_code_taken_by_empty_template( stored = await _db(seeded, source.id) assert stored is not None and stored.line_status.value == "ok" and stored.line_code == CODE assert [item["line_code"] for item in response.json()["lines"] if item["line_code"] == CODE] == [CODE] + # T13.2: удаление пустой шаблонной строки — в аудите `line: -> null` + (event,) = await _edit_events(seeded, document) + assert event.before_value[f"line:{template.id}"]["line_code"] == CODE + assert event.after_value[f"line:{template.id}"] is None async def test_mark_detail(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: @@ -277,6 +281,7 @@ async def test_parent_moves_up(seeded: AsyncSession, fixtures_dir: Path, auth_cl stored = [await _db(seeded, line.id) for line in (top, parent, *details)] assert [s.parent_row_index for s in stored if s] == [1, 1, 1, 1] response = await _patch(client, document, {"lines": [{"line_id": str(first.id), "line_status": "detail"}]}) + assert response.status_code == HTTPStatus.OK, response.text stored = [await _db(seeded, line.id) for line in (parent, *details)] assert all(s.parent_row_index is None for s in stored if s) @@ -297,3 +302,51 @@ async def test_line_edit_audit_and_accept( assert events[0].before_value[key]["line_status"] == "unreadable" assert events[0].after_value[key]["line_status"] == "ok" and events[0].after_value[key]["line_code"] == CODE assert set(events[0].after_value[key]) == {"line_code", "line_status", "parent_row_index", "is_confirmed"} + + +async def test_code_change_resets_confirmation_and_parent( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """M-4: смена кода и отметка расшифровкой снимают подтверждение; detail -> ok обнуляет родителя.""" + document = await form(seeded, fixtures_dir) + confirmed = await insert_line(seeded, document, code="1120", row_index=20, code_original="1120") + confirmed.is_confirmed = True + to_detail = await insert_line(seeded, document, code="1130", row_index=21, code_original="1130") + to_detail.is_confirmed = True + detail = await insert_line(seeded, document, code=None, row_index=22, status="detail") + detail.parent_row_index = 21 + client = await auth_client(KA_RF) + body = { + "lines": [ + {"line_id": str(confirmed.id), "line_code": "1121"}, + {"line_id": str(to_detail.id), "line_status": "detail"}, + {"line_id": str(detail.id), "line_code": "1122"}, + ] + } + response = await _patch(client, document, body) + assert response.status_code == HTTPStatus.OK, response.text + first, second, third = [await _db(seeded, line.id) for line in (confirmed, to_detail, detail)] + assert first is not None and first.line_code == "1121" and first.is_confirmed is False + assert second is not None and second.line_status.value == "detail" and second.is_confirmed is False + assert third is not None and third.line_status.value == "ok" and third.parent_row_index is None + + +async def test_values_error_after_lines_persists_nothing( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """M-10 (P3-10, отступление A13-9): 422 в `values` после применённых `lines` откатывает и строки, и аудит.""" + document = await form(seeded, fixtures_dir) + garbage = await insert_line(seeded, document, code=None, row_index=2, status="unreadable", amount=Decimal(5)) + meta = await _meta(seeded, document) + column_id, garbage_id = (await repo.form_aggregate(seeded, meta.id))[0][0].id, garbage.id # до отката сессии + client = await auth_client(KA_RF) + events_before = len(await _edit_events(seeded, document)) + body = { + "lines": [{"line_id": str(garbage_id), "line_code": CODE}], + "values": [{"line_id": str(uuid.uuid4()), "column_id": str(column_id), "amount": "1"}], + } + response = await _patch(client, document, body) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text + stored = await _db(seeded, garbage_id) + assert stored is not None and stored.line_status.value == "unreadable" and stored.line_code is None + assert len(await _edit_events(seeded, document)) == events_before diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 64ce77f..1033560 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -2135,7 +2135,7 @@ async def test_patch_organization_name_invalid_is_422( ) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) - for name in ("", " ", "я" * 1001): + for name in ("", " ", "я" * 1001, None): # M-11: null, как inn: null, не молчаливый no-op response = await client.patch( f"/documents/{document.id}/statement", json={"header": {"organization_name": name}} ) -- 2.47.2 From 16b53b0bdb5aa34b7c57ba5d33fec8fdc982a0c5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 18:05:28 +0400 Subject: [PATCH 16/20] =?UTF-8?q?fix(statements):=20=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D1=81=D0=B2=D0=B5?= =?UTF-8?q?=D0=B6=D0=B5=D0=BC=D1=83=20=D0=BF=D0=BB=D0=B0=D0=BD=D1=83,=20?= =?UTF-8?q?=D0=B0=D1=83=D0=B4=D0=B8=D1=82=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8,=20=D1=81?= =?UTF-8?q?=D0=B1=D1=80=D0=BE=D1=81=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D0=B8=D1=82=D0=B5=D0=BB=D1=8F=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B5=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=20(T4.2,=20T4.4,=20T13.2,=20M-4,=20M-10,=20M-12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- api/scripts/purge_statements.py | 33 +++++++++++++++++++----------- api/tests/test_purge_statements.py | 30 +++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/api/scripts/purge_statements.py b/api/scripts/purge_statements.py index fade70e..062916d 100644 --- a/api/scripts/purge_statements.py +++ b/api/scripts/purge_statements.py @@ -91,11 +91,13 @@ class PurgePlan: class PurgeRefusedError(Exception): - """Очистка отклонена: есть документы, замороженные в верифицированных версиях.""" + """Очистка отклонена: есть документы, замороженные в верифицированных версиях. `plan` — свежий план, + построенный под блокировками (его счётчики и `blocking` описывают состояние базы на момент отказа).""" - def __init__(self, blocking: Sequence[BlockingVersion]) -> None: + def __init__(self, plan: PurgePlan) -> None: super().__init__("очистка отклонена") - self.blocking = list(blocking) + self.plan = plan + self.blocking = list(plan.blocking) def _frozen_version() -> ColumnElement[bool]: @@ -258,15 +260,22 @@ async def apply_purge(session: AsyncSession, plan: PurgePlan, *, now: datetime) Блокирует файлы плана (по `id`), заново проверяет отказ под блокировками и только потом меняет данные: при `PurgeRefusedError` база не изменена.""" - whole = list(plan.form_only_files) + (list(plan.mixed_files) if plan.include_mixed else []) - partial = [] if plan.include_mixed else list(plan.mixed_files) - for file_id in sorted([*whole, *partial]): - await files_repo.lock_jobs(session, file_id) - await files_repo.lock_file(session, file_id) - await files_repo.lock_file_documents(session, file_id) - fresh = await plan_purge(session, include_mixed=plan.include_mixed) + locked: set[uuid.UUID] = set() + + async def lock(file_ids: Sequence[uuid.UUID]) -> None: + for file_id in sorted(set(file_ids) - locked): + await files_repo.lock_jobs(session, file_id) + await files_repo.lock_file(session, file_id) + await files_repo.lock_file_documents(session, file_id) + locked.add(file_id) + + await lock([*plan.form_only_files, *plan.mixed_files]) + fresh = await plan_purge(session, include_mixed=plan.include_mixed) # T4.2: списки файлов — из свежего плана + await lock([*fresh.form_only_files, *fresh.mixed_files]) # файлы, появившиеся после первого плана if fresh.blocking: - raise PurgeRefusedError(fresh.blocking) + raise PurgeRefusedError(fresh) + whole = list(fresh.form_only_files) + (list(fresh.mixed_files) if fresh.include_mixed else []) + partial = [] if fresh.include_mixed else list(fresh.mixed_files) audit = AuditService(session) for file_id in sorted(whole): await _purge_whole_file(session, audit, file_id, now) @@ -323,7 +332,7 @@ async def run(argv: Sequence[str], *, transaction: Transaction = session_transac return EXIT_REFUSED await apply_purge(session, plan, now=utc_now()) except PurgeRefusedError as error: - print(render_report(PurgePlan(blocking=error.blocking)), file=sys.stderr) + print(render_report(error.plan), file=sys.stderr) # T4.4: свежий план с реальными счётчиками print("очистка отклонена, база не изменена", file=sys.stderr) return EXIT_REFUSED print("очистка выполнена") diff --git a/api/tests/test_purge_statements.py b/api/tests/test_purge_statements.py index 594c381..720186f 100644 --- a/api/tests/test_purge_statements.py +++ b/api/tests/test_purge_statements.py @@ -458,3 +458,33 @@ async def test_reupload_after_purge_not_duplicate(world: World, seeded: AsyncSes service = PrimaryFilesService.__new__(PrimaryFilesService) service._session = session # type: ignore[attr-defined] # find_duplicate читает только сессию assert await service.find_duplicate(entity_id, "ab" * 32) is None + + +async def test_apply_uses_fresh_plan_for_file_lists(world: World) -> None: + """T4.2: файл, появившийся после построения плана, удаляется по свежему плану под блокировками.""" + session = world.session + first, first_job = await make_stored_file(session, entity=world.entity, original_name="first.pdf") + await world.form_document(first, first_job, DocumentType.BALANCE) + stale = await purge_statements.plan_purge(session, include_mixed=False) + late, late_job = await make_stored_file(session, entity=world.entity, original_name="late.pdf") + await world.form_document(late, late_job, DocumentType.BALANCE) + first_id, late_id = first.id, late.id + + await purge_statements.apply_purge(session, stale, now=datetime.datetime.now(datetime.UTC)) + + assert await _count(session, StoredFile, StoredFile.id.in_([first_id, late_id])) == 0 + + +async def test_refusal_carries_fresh_plan_counters(world: World) -> None: + """T4.4: отказ под блокировками несёт свежий план — счётчики отчёта не нулевые.""" + objects = await _mixed_and_form_only(world) + stale = await purge_statements.plan_purge(world.session, include_mixed=False) + version = await world.version(ConsolidationStatus.VERIFIED) + await world.bind(objects["income"], version) # type: ignore[arg-type] + + with pytest.raises(purge_statements.PurgeRefusedError) as raised: + await purge_statements.apply_purge(world.session, stale, now=datetime.datetime.now(datetime.UTC)) + + refused = raised.value.plan + assert refused.form_documents > 0 and refused.blocking + assert "документов форм: 3" in purge_statements.render_report(refused) -- 2.47.2 From 1ab7f2238d6ce86ee2010ff602a0038cfb4156f9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 18:05:28 +0400 Subject: [PATCH 17/20] =?UTF-8?q?chore(statements):=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8,=20README,=20?= =?UTF-8?q?=D1=82=D0=B8=D0=BF=D1=8B,=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B8=D1=82=D0=BE=D0=B3=D0=B0=D0=BC=20=D1=84?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E=20(M-1,=20M-2,=20M-3,=20M-5,=20M-7,=20?= =?UTF-8?q?M-9,=20T2.1,=20T4.5,=20T10.1,=20T11.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- api/alembic/versions/0007_dbml_v5_19.py | 1 + api/src/domain/statement_status.py | 5 +-- api/src/repositories/statements.py | 3 +- api/tests/test_auto_confirm.py | 39 +++++++++++++++++++++++ api/tests/test_domain_statement_status.py | 5 +++ 5 files changed, 50 insertions(+), 3 deletions(-) diff --git a/api/alembic/versions/0007_dbml_v5_19.py b/api/alembic/versions/0007_dbml_v5_19.py index c293f8d..ac7e39d 100644 --- a/api/alembic/versions/0007_dbml_v5_19.py +++ b/api/alembic/versions/0007_dbml_v5_19.py @@ -262,6 +262,7 @@ def _log_lost_values() -> None: def downgrade() -> None: + """Откат v5.19. Нужно онлайн-соединение (проверки читают данные): offline-режим `--sql` не поддерживается (T2.1).""" _check_downgrade_allowed() _log_lost_values() op.execute(f"DROP TABLE {S}.document_issue") diff --git a/api/src/domain/statement_status.py b/api/src/domain/statement_status.py index 0181b30..ece8a03 100644 --- a/api/src/domain/statement_status.py +++ b/api/src/domain/statement_status.py @@ -51,13 +51,14 @@ class NameCheckLine: def name_mismatches(lines: Iterable[NameCheckLine], catalog: LineCatalog, form: str) -> list[Hashable]: """Ключи строк `ok` с неуверенным кодом (`line_code_confidence < 100`), без закреплённого кода (`code_fixed`), - чьё наименование не совпало ни с одним справочным именем кода (Р-16). Кода нет в словаре — несовпадение.""" + чьё наименование не совпало ни с одним справочным именем кода (Р-16). Кода нет в словаре — несовпадение. + Строка без уверенности (`None`) считается уверенной и не проверяется: спека требует `confidence < 100`.""" result: list[Hashable] = [] for line in lines: if line.line_status != StatementLineStatus.ok or line.code_fixed: continue confidence = line.line_code_confidence - if confidence is None or confidence >= _CONFIDENT: + if confidence is None or confidence >= _CONFIDENT: # None — уверенной (T10.1) continue if not line.line_code or not names_match(line.line_name, catalog.names(form, line.line_code)): result.append(line.key) diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index ecd4c71..d72c52f 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -10,6 +10,7 @@ from typing import Any from sqlalchemy import delete, exists, func, insert, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql.expression import ScalarSelect from src.domain.statement_aggregate import AggLine, CodeAggregate, aggregate_by_code from src.domain.statement_issues import IssueDraft @@ -213,7 +214,7 @@ async def delete_documents_statements(session: AsyncSession, document_ids: Seque await _delete_statements(session, list(document_ids)) -async def _delete_statements(session: AsyncSession, documents: Any) -> None: +async def _delete_statements(session: AsyncSession, documents: ScalarSelect[uuid.UUID] | list[uuid.UUID]) -> None: await session.execute(delete(DocumentIssue).where(DocumentIssue.document_id.in_(documents))) metas = select(FinancialStatementMeta.id).where(FinancialStatementMeta.document_id.in_(documents)).scalar_subquery() lines = select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id.in_(metas)).scalar_subquery() diff --git a/api/tests/test_auto_confirm.py b/api/tests/test_auto_confirm.py index 05ebed8..45c5e07 100644 --- a/api/tests/test_auto_confirm.py +++ b/api/tests/test_auto_confirm.py @@ -259,3 +259,42 @@ async def test_manual_confirm_of_auto_confirmed_is_409(seeded: AsyncSession, aut assert response.status_code == HTTP_CONFLICT, response.text assert response.json()["detail"]["code"] == "ALREADY_CONFIRMED" assert len(await _confirm_events(seeded)) == 1 + + +async def _synthetic_disclosure(session: AsyncSession, fixtures_dir: Path, *, office: RegionalOffice) -> Document: + """RECOGNIZED `DISCLOSURE` из синтетического снимка st06 (T11.1: форм-проверки к не-форме не применяются).""" + import json # noqa: PLC0415 - нужен только здесь + + path = fixtures_dir / GOLDEN_STATEMENTS / "synthetic" / "st06_header_variants.json" + data = json.loads(path.read_text("utf-8")) + inn = next((d["header"].get("inn") for d in data["documents"] if d.get("header", {}).get("inn")), None) + owner = await make_legal_entity(session, name="ООО Синтетика", inn=inn, office=office) + documents = await map_golden_snapshot(session, entity=owner, path=path, uploaded_by=None) + disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE) + assert disclosure.status is DocumentStatus.RECOGNIZED + return disclosure + + +@pytest.mark.fixtures +async def test_recognized_disclosure_is_auto_confirmed(seeded: AsyncSession, fixtures_dir: Path) -> None: + office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + disclosure = await _synthetic_disclosure(seeded, fixtures_dir, office=office) + file = await seeded.get(StoredFile, disclosure.stored_file_id) + assert file is not None + + assert await auto_confirm(seeded, file=file, documents=[disclosure], now=utc_now()) == [disclosure] + assert disclosure.status is DocumentStatus.CONFIRMED + + +@pytest.mark.fixtures +async def test_recognized_disclosure_is_manually_confirmable( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + disclosure = await _synthetic_disclosure(seeded, fixtures_dir, office=office) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{disclosure.id}/confirm") + + assert response.status_code == 200, response.text # noqa: PLR2004 + assert response.json()["status"] == "CONFIRMED" diff --git a/api/tests/test_domain_statement_status.py b/api/tests/test_domain_statement_status.py index 4c7d758..603cfde 100644 --- a/api/tests/test_domain_statement_status.py +++ b/api/tests/test_domain_statement_status.py @@ -120,3 +120,8 @@ def test_name_mismatches() -> None: _line(10, None, None, "Без кода", status="unreadable"), ] assert name_mismatches(lines, CATALOG, "BALANCE") == [1, 3, 8, 9] + + +def test_name_mismatches_missing_confidence_is_confident() -> None: + """T10.1: `line_code_confidence = None` не даёт `NAME_MISMATCH` (спека: `confidence < 100`).""" + assert name_mismatches([_line(1, "1120", None, "Чужое имя")], CATALOG, "BALANCE") == [] -- 2.47.2 From 17141c0a110a0808006bbfd2f7d9b9b7d55bdd9b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 19:26:50 +0400 Subject: [PATCH 18/20] =?UTF-8?q?feat(contract):=20=D0=BA=D0=BE=D0=BF?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BE=D0=B1=D0=BE=D0=B8=D1=85=20MAJOR,=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B8=20v2.0,=20=D0=BF=D1=80=D0=B8=D1=91?= =?UTF-8?q?=D0=BC=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D1=80=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=201.x/2.0,=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=D0=B0=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Копии api/contracts/ побайтно равны docs/contracts/ (тест идентичности плана 0): схемы v2.0 основные, .v1 рядом, examples/ — v2.0, examples/v1/ — v1.0. Модели result_v2.py и RecognitionResultMessageV2, versions.py выбирает модель по MAJOR. Приём конвертов принимает оба MAJOR; metrics.warnings в recognition_job пишется структурами (строки 1.x оборачиваются в LEGACY_WARNING). JobBrief.warnings отдаёт структуры, мусор пропускается, БД не переписывается. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- api/contracts/examples/result-error.json | 7 +- .../examples/result-message-done.json | 9 +- .../examples/result-message-error.json | 7 +- .../examples/result-message-processing.json | 7 +- .../examples/result-osv-minimal.json | 21 +- .../examples/result-osv-needs-check.json | 46 ++- .../examples/result-statement-excel.json | 97 +++-- .../examples/result-statement-minimal.json | 69 ++-- api/contracts/examples/v1/result-error.json | 12 + .../examples/v1/result-message-done.json | 21 ++ .../examples/v1/result-message-error.json | 11 + .../v1/result-message-processing.json | 7 + .../examples/v1/result-osv-minimal.json | 83 +++++ .../examples/v1/result-osv-needs-check.json | 125 +++++++ .../examples/v1/result-statement-excel.json | 46 +++ .../examples/v1/result-statement-minimal.json | 50 +++ .../recognition-result-message.schema.json | 25 +- .../recognition-result-message.v1.schema.json | 70 ++++ api/contracts/recognition-result.schema.json | 218 ++++++++---- .../recognition-result.v1.schema.json | 220 ++++++++++++ api/src/contract/__init__.py | 19 +- api/src/contract/common.py | 6 +- api/src/contract/message.py | 24 +- api/src/contract/result_v2.py | 256 +++++++++++++ api/src/contract/versions.py | 51 +++ api/src/schemas/files.py | 13 +- api/src/services/documents.py | 27 +- api/src/services/recognition_exchange.py | 38 +- api/tests/test_api_documents.py | 8 +- api/tests/test_api_files_delete.py | 2 +- api/tests/test_api_files_read.py | 42 ++- api/tests/test_contract.py | 336 +++++++++++++++++- api/tests/test_domain_osv_mapping.py | 2 +- api/tests/test_exchange_lifespan_nats.py | 4 +- api/tests/test_factories.py | 2 +- api/tests/test_recognition_inbox.py | 80 ++++- api/tests/test_result_mapping.py | 6 +- 37 files changed, 1864 insertions(+), 203 deletions(-) create mode 100644 api/contracts/examples/v1/result-error.json create mode 100644 api/contracts/examples/v1/result-message-done.json create mode 100644 api/contracts/examples/v1/result-message-error.json create mode 100644 api/contracts/examples/v1/result-message-processing.json create mode 100644 api/contracts/examples/v1/result-osv-minimal.json create mode 100644 api/contracts/examples/v1/result-osv-needs-check.json create mode 100644 api/contracts/examples/v1/result-statement-excel.json create mode 100644 api/contracts/examples/v1/result-statement-minimal.json create mode 100644 api/contracts/recognition-result-message.v1.schema.json create mode 100644 api/contracts/recognition-result.v1.schema.json create mode 100644 api/src/contract/result_v2.py create mode 100644 api/src/contract/versions.py diff --git a/api/contracts/examples/result-error.json b/api/contracts/examples/result-error.json index 54af7cb..b043599 100644 --- a/api/contracts/examples/result-error.json +++ b/api/contracts/examples/result-error.json @@ -1,8 +1,11 @@ { - "schema_version": "1.0", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", - "service": { "name": "tabular-parser", "version": "0.1.0" }, + "service": { + "name": "tabular-parser", + "version": "0.1.0" + }, "status": "error", "error": { "code": "NOT_IMPLEMENTED", diff --git a/api/contracts/examples/result-message-done.json b/api/contracts/examples/result-message-done.json index 6d56f17..4e47bd1 100644 --- a/api/contracts/examples/result-message-done.json +++ b/api/contracts/examples/result-message-done.json @@ -1,14 +1,17 @@ { - "schema_version": "1.0", + "schema_version": "2.0", "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", "status": "done", - "service": { "name": "scan-gateway", "version": "0.1.0" }, + "service": { + "name": "scan-gateway", + "version": "0.1.0" + }, "result_s3": { "bucket": "arm-ka", "key": "results/c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03/result.json" }, - "result_schema_version": "1.0", + "result_schema_version": "2.0", "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", "metrics": { "duration_ms": 18400, diff --git a/api/contracts/examples/result-message-error.json b/api/contracts/examples/result-message-error.json index f5ec77a..6da980c 100644 --- a/api/contracts/examples/result-message-error.json +++ b/api/contracts/examples/result-message-error.json @@ -1,9 +1,12 @@ { - "schema_version": "1.0", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", "status": "error", - "service": { "name": "tabular-parser", "version": "0.1.0" }, + "service": { + "name": "tabular-parser", + "version": "0.1.0" + }, "error": { "code": "NOT_IMPLEMENTED", "message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet" diff --git a/api/contracts/examples/result-message-processing.json b/api/contracts/examples/result-message-processing.json index 2087150..9136790 100644 --- a/api/contracts/examples/result-message-processing.json +++ b/api/contracts/examples/result-message-processing.json @@ -1,7 +1,10 @@ { - "schema_version": "1.0", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", "status": "processing", - "service": { "name": "tabular-parser", "version": "0.1.0" } + "service": { + "name": "tabular-parser", + "version": "0.1.0" + } } diff --git a/api/contracts/examples/result-osv-minimal.json b/api/contracts/examples/result-osv-minimal.json index b0a9e97..f54525f 100644 --- a/api/contracts/examples/result-osv-minimal.json +++ b/api/contracts/examples/result-osv-minimal.json @@ -1,8 +1,11 @@ { - "schema_version": "1.2", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", - "service": { "name": "tabular-parser", "version": "0.1.0" }, + "service": { + "name": "tabular-parser", + "version": "0.1.0" + }, "status": "done", "documents": [ { @@ -24,7 +27,11 @@ "header_variant": 1 }, "group_schema": [ - { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } + { + "type": "COUNTERPARTY", + "name": "Контрагенты", + "has_inn": true + } ], "columns": { "opening_debit": "C", @@ -70,14 +77,18 @@ "turnover_credit": "200.50", "closing_debit": "0.00", "closing_credit": "700.50", - "extra": { "КПП": "352801001" } + "extra": { + "КПП": "352801001" + } } ], "checks": { "total_row_matches_root": true, "balance_identity_violations": [], "parent_sum_violations": [] - } + }, + "status": "done", + "warnings": [] } ] } diff --git a/api/contracts/examples/result-osv-needs-check.json b/api/contracts/examples/result-osv-needs-check.json index 81d64a8..6827c89 100644 --- a/api/contracts/examples/result-osv-needs-check.json +++ b/api/contracts/examples/result-osv-needs-check.json @@ -1,15 +1,15 @@ { - "schema_version": "1.1", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", - "service": { "name": "tabular-parser", "version": "1.0.0" }, + "service": { + "name": "tabular-parser", + "version": "1.0.0" + }, "status": "needs_check", "metrics": { "rows": 3, - "warnings": [ - "sheet=Лист_1 hierarchy=outline", - "§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 3" - ] + "warnings": [] }, "documents": [ { @@ -28,8 +28,15 @@ "header_variant": 1 }, "group_schema": [ - { "type": "ACCOUNT", "name": "Счет" }, - { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } + { + "type": "ACCOUNT", + "name": "Счет" + }, + { + "type": "COUNTERPARTY", + "name": "Контрагенты", + "has_inn": true + } ], "columns": { "opening_debit": "D", @@ -76,7 +83,9 @@ "turnover_credit": "200.50", "closing_debit": "0.00", "closing_credit": "700.50", - "extra": { "КПП": "352801001" } + "extra": { + "КПП": "352801001" + } }, { "row_number": 3, @@ -117,9 +126,24 @@ ], "checks": { "total_row_matches_root": true, - "balance_identity_violations": [3], + "balance_identity_violations": [ + 3 + ], "parent_sum_violations": [] - } + }, + "status": "needs_check", + "warnings": [ + { + "code": "OSV_HIERARCHY_BY_OUTLINE", + "severity": "info", + "message": "иерархия строк — по группировке листа (outline)" + }, + { + "code": "OSV_BALANCE_IDENTITY", + "severity": "info", + "message": "§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 3" + } + ] } ] } diff --git a/api/contracts/examples/result-statement-excel.json b/api/contracts/examples/result-statement-excel.json index 260528b..5e7b311 100644 --- a/api/contracts/examples/result-statement-excel.json +++ b/api/contracts/examples/result-statement-excel.json @@ -1,44 +1,99 @@ { - "schema_version": "1.2", + "schema_version": "2.0", "job_id": "3f1c7a2e-5b64-4d0a-9c1e-8a7b6d5e4f30", "file_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", - "service": { "name": "tabular-parser", "version": "0.3.0" }, - "status": "done", - "metrics": { "duration_ms": 412, "rows": 7, "warnings": ["sheet=Бухгалтерский баланс. Лист 1 значений с дробной частью при единице тыс. руб.: 3"] }, + "service": { "name": "tabular-parser", "version": "0.4.0", "statements_version": "abc1234" }, + "status": "needs_check", + "metrics": { "duration_ms": 412, "rows": 12, "warnings": [] }, "documents": [ { "document_type": "BALANCE", "locator": "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2", - "header": { "form_name": "Бухгалтерский баланс", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "inn": "7700000000", "inn_confidence": 100, "report_date_text": "на 30 сентября 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710001", "okpo": "12345678" }, + "status": "done", + "form_variant": "full", + "warnings": [ + { "code": "FRACTIONAL_IN_THOUSANDS", "severity": "info", "message": "значений с дробной частью при единице тыс. руб.: 6 из 14" } + ], + "header": { + "form_name": "Бухгалтерский баланс", + "organization_name": "ООО \"Пример\"", "organization_name_text": "ООО \"Пример\"", "organization_name_confidence": 100, + "inn": "7700000000", "inn_text": "7700000000", "inn_confidence": 100, + "report_date": "2025-09-30", "report_date_text": "на 30 сентября 2025 г.", "report_date_confidence": 100, + "units": "тыс. руб.", "units_text": "в тыс. рублей", "units_confidence": 100, + "okud": "0710001", "okud_text": "0710001", "okud_confidence": 100, + "okpo": "12345678", "okpo_text": "12345678", "okpo_confidence": 100, + "okei": "384", "okei_text": "384", "okei_confidence": 100, + "needs_review_fields": [] + }, "columns": [ - { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, - { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + { "column_index": 3, "label": "На 30 сентября 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" }, + { "column_index": 4, "label": "На 31 декабря 2024 г.", "label_confidence": 100, "period_date": "2024-12-31", "period_kind": "absolute" } ], "lines": [ - { "line_code": "1110", "line_code_confidence": 100, "line_name": "Нематериальные активы", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }, - { "line_code": "11101", "line_code_confidence": 100, "line_name": "Нематериальные активы, готовые к использованию", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "900000.00", "confidence": 100, "is_recognized": true } ] }, - { "line_code": "1100", "line_code_confidence": 100, "line_name": "Итого по разделу I", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }, - { "line_code": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": null, "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": null, "confidence": 100, "is_recognized": true } ] }, - { "line_code": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] } + { "row_index": 0, "ref": "'Бухгалтерский баланс. Лист 1'!C14", "line_status": "ok", "line_code": "1110", "line_code_text": "1110", "line_code_confidence": 100, "line_name": "Нематериальные активы", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 1, "ref": "'Бухгалтерский баланс. Лист 1'!C15", "line_status": "ok", "line_code": "11101", "line_code_text": "11101", "line_code_confidence": 100, "line_name": "Нематериальные активы, готовые к использованию", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "900000.00", "text": "900 000,00", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 2, "ref": "'Бухгалтерский баланс. Лист 1'!C16", "line_status": "ok", "line_code": "1100", "line_code_text": "1100", "line_code_confidence": 100, "line_name": "Итого по разделу I", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 3, "ref": "'Бухгалтерский баланс. Лист 1'!C17", "line_status": "ok", "line_code": "1200", "line_code_text": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": null, "text": "-", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": null, "text": "", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 4, "ref": "'Бухгалтерский баланс. Лист 1'!C18", "line_status": "ok", "line_code": "1600", "line_code_text": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 5, "ref": "'Бухгалтерский баланс. Лист 2'!C5", "line_status": "ok", "line_code": "1370", "line_code_text": "1370", "line_code_confidence": 100, "line_name": "Нераспределенная прибыль (непокрытый убыток)", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 6, "ref": "'Бухгалтерский баланс. Лист 2'!C6", "line_status": "ok", "line_code": "1300", "line_code_text": "1300", "line_code_confidence": 100, "line_name": "Итого по разделу III", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 7, "ref": "'Бухгалтерский баланс. Лист 2'!C7", "line_status": "ok", "line_code": "1700", "line_code_text": "1700", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] } ], - "checks": { "relations_failed": [], "soft_relations_failed": [ { "column_index": 1, "relation": "1110 = 11101", "expected": "905786.99", "actual": "900000.00" } ], "duplicate_line_codes": [] } + "checks": { + "relations_failed": [], + "soft_relations_failed": [ { "column_index": 4, "relation": "1110 = 11101", "codes": ["1110", "11101"], "expected": "905786.99", "actual": "900000.00" } ] + } }, { "document_type": "INCOME", "locator": "sheet:Отчет о финансовых результатах", - "header": { "form_name": "Отчет о финансовых результатах", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "report_date_text": "за январь - сентябрь 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710002" }, - "columns": [ { "column_index": 0, "label": "За январь - сентябрь 2025 г.", "period_date": "2025-09-30" } ], - "lines": [ - { "line_code": "2110", "line_code_confidence": 100, "line_name": "Выручка", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "1000.00", "confidence": 100, "is_recognized": true } ] }, - { "line_code": "2120", "line_code_confidence": 100, "line_name": "Себестоимость продаж", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "-600.00", "confidence": 100, "is_recognized": true } ] }, - { "line_code": "2100", "line_code_confidence": 100, "line_name": "Валовая прибыль (убыток)", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "400.00", "confidence": 100, "is_recognized": true } ] } + "status": "needs_check", + "form_variant": "full", + "warnings": [ + { "code": "INN_NOT_FOUND", "severity": "info", "message": "в шапке не найден ИНН", "field": "inn" } ], - "checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] } + "header": { + "form_name": "Отчет о финансовых результатах", + "organization_name": "ООО \"Пример\"", "organization_name_text": "ООО \"Пример\"", "organization_name_confidence": 100, + "inn": null, "inn_text": null, "inn_confidence": null, + "report_date": "2025-09-30", "report_date_text": "за январь - сентябрь 2025 г.", "report_date_confidence": 100, + "units": "тыс. руб.", "units_text": "в тыс. рублей", "units_confidence": 100, + "okud": "0710002", "okud_text": "0710002", "okud_confidence": 100, + "needs_review_fields": [] + }, + "columns": [ + { "column_index": 3, "label": "За январь - сентябрь 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" } + ], + "lines": [ + { "row_index": 0, "ref": "'Отчет о финансовых результатах'!C12", "line_status": "ok", "line_code": "2110", "line_code_text": "2110", "line_code_confidence": 100, "line_name": "Выручка", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "1000.00", "text": "1 000", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 1, "ref": "'Отчет о финансовых результатах'!C13", "line_status": "detail", "line_code": null, "line_code_text": "", "line_code_confidence": 100, "line_name": "в том числе от продажи продукции", "line_name_confidence": 100, "parent_ref": "'Отчет о финансовых результатах'!C12", "needs_review": false, + "values": [ { "column_index": 3, "amount": "800.00", "text": "800", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 2, "ref": "'Отчет о финансовых результатах'!C14", "line_status": "ok", "line_code": "2120", "line_code_text": "2120", "line_code_confidence": 100, "line_name": "Себестоимость продаж", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "-600.00", "text": "(600)", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 3, "ref": "'Отчет о финансовых результатах'!C15", "line_status": "ok", "line_code": "2100", "line_code_text": "2100", "line_code_confidence": 100, "line_name": "Валовая прибыль (убыток)", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "400.00", "text": "400", "confidence": 100, "is_recognized": true, "needs_review": true } ] } + ], + "checks": { + "relations_failed": [ { "column_index": 3, "relation": "2200 = 2100 - 2210 - 2220", "codes": ["2200", "2100", "2210", "2220"], "expected": null, "actual": "400.00" } ], + "soft_relations_failed": [] + } }, { "document_type": "DISCLOSURE", "locator": "sheet:Пояснения", - "header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах" }, + "status": "done", + "form_variant": null, + "warnings": [], + "header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах", "needs_review_fields": [] }, "columns": [], "lines": [] } diff --git a/api/contracts/examples/result-statement-minimal.json b/api/contracts/examples/result-statement-minimal.json index 913cfc5..9a3eb1d 100644 --- a/api/contracts/examples/result-statement-minimal.json +++ b/api/contracts/examples/result-statement-minimal.json @@ -1,50 +1,51 @@ { - "schema_version": "1.2", + "schema_version": "2.0", "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", - "service": { "name": "scan-gateway", "version": "0.1.0" }, - "status": "done", + "service": { "name": "scan-gateway", "version": "0.2.0", "statements_version": "abc1234" }, + "status": "needs_check", "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", + "metrics": { "duration_ms": 18400, "pages": 2, "warnings": [] }, "documents": [ { "document_type": "BALANCE", - "locator": "pages:1-2", + "locator": "file:8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json;table:0", + "status": "needs_check", + "form_variant": "full", + "warnings": [ + { "code": "ORGANIZATION_UNCERTAIN", "severity": "info", "message": "организация распознана неуверенно", "field": "organization_name" }, + { "code": "ROW_SPLIT", "severity": "info", "message": "строка с несколькими кодами разлеплена: 1370, 1300", "line_refs": ["row:14#1", "row:14#2"] } + ], "header": { "form_name": "Бухгалтерский баланс", - "organization_name": "ООО Ромашка", - "organization_name_confidence": 97.5, - "inn": "7700000000", - "inn_confidence": 99, - "report_date_text": "30 сентября 2025 г.", - "report_date": "2025-09-30", - "units": "тыс. руб." + "organization_name": "ООО Ромашка", "organization_name_text": "ООО Ромашка", "organization_name_confidence": 97.5, + "inn": "7700000000", "inn_text": "7700000000", "inn_confidence": 100, + "report_date": "2025-09-30", "report_date_text": "30 сентября 2025 г.", "report_date_confidence": 100, + "units": "тыс. руб.", "units_text": "тыс. руб.", "units_confidence": 100, + "needs_review_fields": [] }, "columns": [ - { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, - { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + { "column_index": 2, "label": "На 30 сентября 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" } ], "lines": [ - { - "line_code": "1100", - "line_code_confidence": 99, - "line_name": "Итого по разделу I", - "line_name_confidence": 96, - "values": [ - { "column_index": 0, "amount": "1234.50", "confidence": 95, "is_recognized": true }, - { "column_index": 1, "amount": "1100", "confidence": 93, "is_recognized": true } - ] - }, - { - "line_code": "1600", - "line_code_confidence": 99, - "line_name": "БАЛАНС (актив)", - "line_name_confidence": 98, - "values": [ - { "column_index": 0, "amount": "5678.00", "confidence": 94.5, "is_recognized": true }, - { "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true } - ] - } - ] + { "row_index": 0, "ref": "row:1", "line_status": "ok", "line_code": "1210", "line_code_text": "1210", "line_code_confidence": 100, "line_name": "Запасы", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "100", "text": "100", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 1, "ref": "row:2", "line_status": "ok", "line_code": "1230", "line_code_text": "1230", "line_code_confidence": 100, "line_name": "Дебиторская задолжен-ность", "line_name_confidence": 80, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428311", "string_value": null, "text": "42831 1", "confidence": 100, "is_recognized": false, "needs_review": false } ] }, + { "row_index": 2, "ref": "row:3", "line_status": "unreadable", "line_code": null, "line_code_text": "ИЧ,250", "line_code_confidence": 25, "line_name": "Финансовые вложения", "line_name_confidence": 67, "needs_review": false, + "values": [ { "column_index": 2, "amount": "50", "text": "50", "confidence": 67, "is_recognized": true, "needs_review": true } ] }, + { "row_index": 3, "ref": "row:4", "line_status": "ok", "line_code": "1200", "line_code_text": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 4, "ref": "row:5", "line_status": "ok", "line_code": "1600", "line_code_text": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 5, "ref": "row:14#1", "line_status": "ok", "line_code": "1370", "line_code_text": "1370", "line_code_confidence": 100, "line_name": "Нераспределенная прибыль (непокрытый убыток)", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": false, "needs_review": false } ] }, + { "row_index": 6, "ref": "row:14#2", "line_status": "ok", "line_code": "1300", "line_code_text": "1300", "line_code_confidence": 100, "line_name": "Итого по разделу III", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": false, "needs_review": false } ] }, + { "row_index": 7, "ref": "row:15", "line_status": "ok", "line_code": "1700", "line_code_text": "1700", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] } + ], + "checks": { "relations_failed": [], "soft_relations_failed": [] } } ] } diff --git a/api/contracts/examples/v1/result-error.json b/api/contracts/examples/v1/result-error.json new file mode 100644 index 0000000..54af7cb --- /dev/null +++ b/api/contracts/examples/v1/result-error.json @@ -0,0 +1,12 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "service": { "name": "tabular-parser", "version": "0.1.0" }, + "status": "error", + "error": { + "code": "NOT_IMPLEMENTED", + "message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet" + }, + "documents": [] +} diff --git a/api/contracts/examples/v1/result-message-done.json b/api/contracts/examples/v1/result-message-done.json new file mode 100644 index 0000000..6d56f17 --- /dev/null +++ b/api/contracts/examples/v1/result-message-done.json @@ -0,0 +1,21 @@ +{ + "schema_version": "1.0", + "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", + "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", + "status": "done", + "service": { "name": "scan-gateway", "version": "0.1.0" }, + "result_s3": { + "bucket": "arm-ka", + "key": "results/c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03/result.json" + }, + "result_schema_version": "1.0", + "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", + "metrics": { + "duration_ms": 18400, + "pages": 2, + "fields_total": 4, + "fields_low_confidence": 0, + "fields_unrecognized": 0, + "warnings": [] + } +} diff --git a/api/contracts/examples/v1/result-message-error.json b/api/contracts/examples/v1/result-message-error.json new file mode 100644 index 0000000..f5ec77a --- /dev/null +++ b/api/contracts/examples/v1/result-message-error.json @@ -0,0 +1,11 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "status": "error", + "service": { "name": "tabular-parser", "version": "0.1.0" }, + "error": { + "code": "NOT_IMPLEMENTED", + "message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet" + } +} diff --git a/api/contracts/examples/v1/result-message-processing.json b/api/contracts/examples/v1/result-message-processing.json new file mode 100644 index 0000000..2087150 --- /dev/null +++ b/api/contracts/examples/v1/result-message-processing.json @@ -0,0 +1,7 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "status": "processing", + "service": { "name": "tabular-parser", "version": "0.1.0" } +} diff --git a/api/contracts/examples/v1/result-osv-minimal.json b/api/contracts/examples/v1/result-osv-minimal.json new file mode 100644 index 0000000..b0a9e97 --- /dev/null +++ b/api/contracts/examples/v1/result-osv-minimal.json @@ -0,0 +1,83 @@ +{ + "schema_version": "1.2", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "service": { "name": "tabular-parser", "version": "0.1.0" }, + "status": "done", + "documents": [ + { + "document_type": "OSV", + "locator": "sheet:Лист_1", + "header": { + "organization_name": "ООО Ромашка", + "organization_inn": "7700000000", + "account_code_raw": "60", + "period_text": "1 квартал 2025 г.", + "period_year": 2025, + "period_quarter": "Q1", + "is_cumulative": false, + "period_start": "2025-01-01", + "period_end": "2025-03-31", + "is_from_year_start": true, + "data_type": "BU", + "currency": "RUB", + "header_variant": 1 + }, + "group_schema": [ + { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } + ], + "columns": { + "opening_debit": "C", + "opening_credit": "D", + "turnover_debit": "E", + "turnover_credit": "F", + "closing_debit": "G", + "closing_credit": "H" + }, + "rows": [ + { + "row_number": 1, + "level": 0, + "parent_row_number": null, + "row_value": "60", + "group_type": "ACCOUNT", + "group_name": null, + "account_code_raw": "60", + "counterparty_name": null, + "counterparty_inn": null, + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50" + }, + { + "row_number": 2, + "level": 1, + "parent_row_number": 1, + "row_value": "ООО Лютик, ИНН 7711111111", + "group_type": "COUNTERPARTY", + "group_name": "Контрагенты", + "account_code_raw": "60", + "counterparty_name": "ООО Лютик", + "counterparty_inn": "7711111111", + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50", + "extra": { "КПП": "352801001" } + } + ], + "checks": { + "total_row_matches_root": true, + "balance_identity_violations": [], + "parent_sum_violations": [] + } + } + ] +} diff --git a/api/contracts/examples/v1/result-osv-needs-check.json b/api/contracts/examples/v1/result-osv-needs-check.json new file mode 100644 index 0000000..81d64a8 --- /dev/null +++ b/api/contracts/examples/v1/result-osv-needs-check.json @@ -0,0 +1,125 @@ +{ + "schema_version": "1.1", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "service": { "name": "tabular-parser", "version": "1.0.0" }, + "status": "needs_check", + "metrics": { + "rows": 3, + "warnings": [ + "sheet=Лист_1 hierarchy=outline", + "§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 3" + ] + }, + "documents": [ + { + "document_type": "OSV", + "locator": "sheet:Лист_1", + "header": { + "organization_name": "ООО Ромашка", + "organization_inn": null, + "account_code_raw": "60", + "period_text": "1 квартал 2025 г.", + "period_year": 2025, + "period_quarter": "Q1", + "is_cumulative": false, + "data_type": "BU", + "currency": "RUB", + "header_variant": 1 + }, + "group_schema": [ + { "type": "ACCOUNT", "name": "Счет" }, + { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } + ], + "columns": { + "opening_debit": "D", + "opening_credit": "E", + "turnover_debit": "F", + "turnover_credit": "G", + "closing_debit": "H", + "closing_credit": "I", + "inn": "B" + }, + "rows": [ + { + "row_number": 1, + "level": 0, + "parent_row_number": null, + "row_value": "60", + "group_type": "ACCOUNT", + "group_name": "Счет", + "account_code_raw": "60", + "counterparty_name": null, + "counterparty_inn": null, + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50" + }, + { + "row_number": 2, + "level": 1, + "parent_row_number": 1, + "row_value": "ООО Лютик", + "group_type": "COUNTERPARTY", + "group_name": "Контрагенты", + "account_code_raw": "60", + "counterparty_name": "ООО Лютик", + "counterparty_inn": "7711111111", + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50", + "extra": { "КПП": "352801001" } + }, + { + "row_number": 3, + "level": 2, + "parent_row_number": 2, + "row_value": "Вал.", + "group_type": "OTHER", + "group_name": null, + "account_code_raw": "60", + "counterparty_name": null, + "counterparty_inn": null, + "data_type": "CURRENCY", + "opening_debit": "0.00", + "opening_credit": "400.00", + "turnover_debit": "100.00", + "turnover_credit": "50.00", + "closing_debit": "0.00", + "closing_credit": "400.00" + }, + { + "row_number": 4, + "level": 0, + "parent_row_number": null, + "row_value": "Итого", + "group_type": "TOTAL", + "group_name": null, + "account_code_raw": null, + "counterparty_name": null, + "counterparty_inn": null, + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50" + } + ], + "checks": { + "total_row_matches_root": true, + "balance_identity_violations": [3], + "parent_sum_violations": [] + } + } + ] +} diff --git a/api/contracts/examples/v1/result-statement-excel.json b/api/contracts/examples/v1/result-statement-excel.json new file mode 100644 index 0000000..260528b --- /dev/null +++ b/api/contracts/examples/v1/result-statement-excel.json @@ -0,0 +1,46 @@ +{ + "schema_version": "1.2", + "job_id": "3f1c7a2e-5b64-4d0a-9c1e-8a7b6d5e4f30", + "file_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "service": { "name": "tabular-parser", "version": "0.3.0" }, + "status": "done", + "metrics": { "duration_ms": 412, "rows": 7, "warnings": ["sheet=Бухгалтерский баланс. Лист 1 значений с дробной частью при единице тыс. руб.: 3"] }, + "documents": [ + { + "document_type": "BALANCE", + "locator": "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2", + "header": { "form_name": "Бухгалтерский баланс", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "inn": "7700000000", "inn_confidence": 100, "report_date_text": "на 30 сентября 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710001", "okpo": "12345678" }, + "columns": [ + { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, + { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + ], + "lines": [ + { "line_code": "1110", "line_code_confidence": 100, "line_name": "Нематериальные активы", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "11101", "line_code_confidence": 100, "line_name": "Нематериальные активы, готовые к использованию", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "900000.00", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "1100", "line_code_confidence": 100, "line_name": "Итого по разделу I", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": null, "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": null, "confidence": 100, "is_recognized": true } ] }, + { "line_code": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] } + ], + "checks": { "relations_failed": [], "soft_relations_failed": [ { "column_index": 1, "relation": "1110 = 11101", "expected": "905786.99", "actual": "900000.00" } ], "duplicate_line_codes": [] } + }, + { + "document_type": "INCOME", + "locator": "sheet:Отчет о финансовых результатах", + "header": { "form_name": "Отчет о финансовых результатах", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "report_date_text": "за январь - сентябрь 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710002" }, + "columns": [ { "column_index": 0, "label": "За январь - сентябрь 2025 г.", "period_date": "2025-09-30" } ], + "lines": [ + { "line_code": "2110", "line_code_confidence": 100, "line_name": "Выручка", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "1000.00", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "2120", "line_code_confidence": 100, "line_name": "Себестоимость продаж", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "-600.00", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "2100", "line_code_confidence": 100, "line_name": "Валовая прибыль (убыток)", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "400.00", "confidence": 100, "is_recognized": true } ] } + ], + "checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] } + }, + { + "document_type": "DISCLOSURE", + "locator": "sheet:Пояснения", + "header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах" }, + "columns": [], + "lines": [] + } + ] +} diff --git a/api/contracts/examples/v1/result-statement-minimal.json b/api/contracts/examples/v1/result-statement-minimal.json new file mode 100644 index 0000000..913cfc5 --- /dev/null +++ b/api/contracts/examples/v1/result-statement-minimal.json @@ -0,0 +1,50 @@ +{ + "schema_version": "1.2", + "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", + "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", + "service": { "name": "scan-gateway", "version": "0.1.0" }, + "status": "done", + "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", + "documents": [ + { + "document_type": "BALANCE", + "locator": "pages:1-2", + "header": { + "form_name": "Бухгалтерский баланс", + "organization_name": "ООО Ромашка", + "organization_name_confidence": 97.5, + "inn": "7700000000", + "inn_confidence": 99, + "report_date_text": "30 сентября 2025 г.", + "report_date": "2025-09-30", + "units": "тыс. руб." + }, + "columns": [ + { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, + { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + ], + "lines": [ + { + "line_code": "1100", + "line_code_confidence": 99, + "line_name": "Итого по разделу I", + "line_name_confidence": 96, + "values": [ + { "column_index": 0, "amount": "1234.50", "confidence": 95, "is_recognized": true }, + { "column_index": 1, "amount": "1100", "confidence": 93, "is_recognized": true } + ] + }, + { + "line_code": "1600", + "line_code_confidence": 99, + "line_name": "БАЛАНС (актив)", + "line_name_confidence": 98, + "values": [ + { "column_index": 0, "amount": "5678.00", "confidence": 94.5, "is_recognized": true }, + { "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true } + ] + } + ] + } + ] +} diff --git a/api/contracts/recognition-result-message.schema.json b/api/contracts/recognition-result-message.schema.json index 4d9f401..a78afd8 100644 --- a/api/contracts/recognition-result-message.schema.json +++ b/api/contracts/recognition-result-message.schema.json @@ -1,13 +1,13 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "arm-ka/recognition-result-message/v1", - "title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0", + "$id": "arm-ka/recognition-result-message/v2", + "title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v2.0", "description": "Тело сообщения на subject ответа (настройка окружения NATS_RESULT_SUBJECT, по умолчанию files.tabular_parsed). Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:: — для status = processing; result:: — для done, needs_check и error. Форма запроса : описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).", - "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (2.1) — необязательные поля и новые значения enum; MAJOR (3.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает известный MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения; новый MAJOR — «сначала ядро»: ядро на время перехода принимает конверты (включая ack processing) и прежнего, и нового MAJOR. Changelog: 1.0 — исходный контракт; 2.0 (RSHB/arm-ka-docs#116, change unified-statement-normalization) — metrics.warnings[] стали структурами {code, severity, message, line_refs?, column_index?, field?, locator?} (как metrics.warnings файла результата), производитель пишет в конверт тот же MAJOR, что и в файл результата. Схема 1.0 на окне перехода лежит рядом — recognition-result-message.v1.schema.json (примеры — examples/v1/).", "type": "object", "required": ["schema_version", "job_id", "file_id", "status", "service"], "properties": { - "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" }, + "schema_version": { "type": "string", "pattern": "^2\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v2.0 пишет \"2.0\"" }, "job_id": { "type": "string", "format": "uuid", "description": "recognition_job.id" }, "file_id": { "type": "string", "format": "uuid", "description": "stored_file.id" }, "status": { "enum": ["processing", "done", "needs_check", "error"] }, @@ -47,7 +47,22 @@ "fields_total": { "type": "integer" }, "fields_low_confidence": { "type": "integer" }, "fields_unrecognized": { "type": "integer" }, - "warnings": { "type": "array", "items": { "type": "string" } } + "warnings": { "type": "array", "items": { "$ref": "#/$defs/fileWarning" }, "description": "предупреждения уровня файла — те же, что metrics.warnings файла результата" } + } + } + }, + "$defs": { + "fileWarning": { + "type": "object", + "required": ["code", "severity", "message"], + "properties": { + "code": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" }, + "severity": { "enum": ["error", "info"] }, + "message": { "type": "string" }, + "line_refs": { "type": "array", "items": { "type": "string" } }, + "column_index": { "type": "integer" }, + "field": { "enum": ["organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address"] }, + "locator": { "type": "string" } } } }, diff --git a/api/contracts/recognition-result-message.v1.schema.json b/api/contracts/recognition-result-message.v1.schema.json new file mode 100644 index 0000000..4d9f401 --- /dev/null +++ b/api/contracts/recognition-result-message.v1.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "arm-ka/recognition-result-message/v1", + "title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0", + "description": "Тело сообщения на subject ответа (настройка окружения NATS_RESULT_SUBJECT, по умолчанию files.tabular_parsed). Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:: — для status = processing; result:: — для done, needs_check и error. Форма запроса : описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", + "type": "object", + "required": ["schema_version", "job_id", "file_id", "status", "service"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" }, + "job_id": { "type": "string", "format": "uuid", "description": "recognition_job.id" }, + "file_id": { "type": "string", "format": "uuid", "description": "stored_file.id" }, + "status": { "enum": ["processing", "done", "needs_check", "error"] }, + "service": { + "type": "object", + "required": ["name", "version"], + "properties": { + "name": { "enum": ["tabular-parser", "scan-gateway"] }, + "version": { "type": "string" } + } + }, + "result_s3": { + "type": "object", + "description": "файл результата (recognition-result): results/{file_id}/{job_id}/result.json", + "required": ["bucket", "key"], + "properties": { + "bucket": { "type": "string", "minLength": 1 }, + "key": { "type": "string", "minLength": 1 } + } + }, + "result_schema_version": { "type": "string", "pattern": "^\\d+\\.\\d+$", "description": "schema_version файла результата, MAJOR.MINOR" }, + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { "enum": ["UNSUPPORTED_FORMAT", "UNREADABLE", "TIMEOUT", "SCHEMA_MISMATCH", "NOT_IMPLEMENTED", "FILE_READ_ERROR", "INVALID_FORMAT", "INVALID_DOCUMENT", "RECOGNITION_FAILED", "UNKNOWN_ERROR"] }, + "message": { "type": "string" } + } + }, + "raw_s3_key": { "type": ["string", "null"], "description": "scan-gateway: raw/{job_id}.json — сырой ответ ИС Сканирование; в БД не регистрируется" }, + "metrics": { + "type": "object", + "properties": { + "duration_ms": { "type": "integer" }, + "pages": { "type": "integer" }, + "rows": { "type": "integer" }, + "fields_total": { "type": "integer" }, + "fields_low_confidence": { "type": "integer" }, + "fields_unrecognized": { "type": "integer" }, + "warnings": { "type": "array", "items": { "type": "string" } } + } + } + }, + "allOf": [ + { + "if": { + "properties": { "status": { "enum": ["done", "needs_check"] } }, + "required": ["status"] + }, + "then": { "required": ["result_s3", "result_schema_version"] } + }, + { + "if": { + "properties": { "status": { "const": "error" } }, + "required": ["status"] + }, + "then": { "required": ["error"] } + } + ] +} diff --git a/api/contracts/recognition-result.schema.json b/api/contracts/recognition-result.schema.json index 43781b3..19531dd 100644 --- a/api/contracts/recognition-result.schema.json +++ b/api/contracts/recognition-result.schema.json @@ -1,13 +1,13 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "arm-ka/recognition-result/v1", - "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.2", - "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.", - "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1, 1.2, …) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения.", + "$id": "arm-ka/recognition-result/v2", + "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v2.0", + "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Формы ББ/ОФР нормализуются общим кодом arm_statements (05-domain-rules.md §11): статусы строк, needs_review, проверки §7, статус документа и структурные предупреждения приходят готовыми; ядро хранит их как пришли и пересчитывает тем же кодом только после правок аналитика. Нормализация счетов ОСВ, резолв контрагентов, сверка ИНН и периода — в arm-core.", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (2.1, 2.2, …) — необязательные поля и новые значения enum; MAJOR (3.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает известный MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения. Новый MAJOR — порядок «сначала ядро»: ядро на время перехода принимает и прежний, и новый MAJOR (два маппера), сервисы переходят после выкладки ядра. Changelog: 1.0 — исходный контракт; 1.1 — osvDocument.rows[].extra; 1.2 — DISCLOSURE, statementDocument.checks, границы периода ОСВ; 2.0 (RSHB/arm-ka-docs#116, change unified-statement-normalization) — line_code nullable и line_status (ok | unreadable | missing | detail), row_index, ref, parent_ref, line_code_text, needs_review у значения и строки, values[].text, header.<поле>_text и <поле>_confidence у всех полей шапки, header.needs_review_fields, columns[].period_kind и label_confidence, document.status и document.warnings у всех документов (включая ОСВ), statementDocument.form_variant, metrics.warnings и warnings — структуры с уровнем и привязкой, checks обязателен у BALANCE/INCOME со строками, нарушения с codes, удалён checks.duplicate_line_codes, service.statements_version, header.okei (+ _text, _confidence), смысл is_recognized у OCR отвязан от confidence, statementDocument.form_variant обязателен, OTHER удалён из statementDocument.document_type. Схема 1.2 на окне перехода лежит рядом — recognition-result.v1.schema.json (примеры — examples/v1/); удаляется вместе с маппером 1.x ядра после перехода обоих сервисов.", "type": "object", "required": ["schema_version", "job_id", "file_id", "service", "status", "documents"], "properties": { - "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.2 пишет \"1.2\"" }, + "schema_version": { "type": "string", "pattern": "^2\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v2.0 пишет \"2.0\"" }, "job_id": { "type": "string", "format": "uuid" }, "file_id": { "type": "string", "format": "uuid" }, "service": { @@ -15,10 +15,11 @@ "required": ["name", "version"], "properties": { "name": { "enum": ["tabular-parser", "scan-gateway"] }, - "version": { "type": "string" } + "version": { "type": "string" }, + "statements_version": { "type": "string", "minLength": 1, "description": "коммит репозитория arm-statements, из которого разложена копия общего кода; обязателен, если в documents есть statementDocument" } } }, - "status": { "enum": ["done", "needs_check", "error"] }, + "status": { "enum": ["done", "needs_check", "error"], "description": "done ⇔ все документы done и нет предупреждений файла уровня error; error — документов нет" }, "error": { "type": ["object", "null"], "properties": { @@ -35,7 +36,7 @@ "fields_total": { "type": "integer" }, "fields_low_confidence": { "type": "integer" }, "fields_unrecognized": { "type": "integer" }, - "warnings": { "type": "array", "items": { "type": "string" } } + "warnings": { "type": "array", "items": { "$ref": "#/$defs/fileWarning" }, "description": "предупреждения уровня файла (ошибочный json набора ИС, потерянная таблица, формат файла)" } } }, "raw_s3_key": { "type": ["string", "null"], "description": "scan-gateway: raw/{job_id}.json — сырой ответ ИС Сканирование; в БД не регистрируется" }, @@ -44,31 +45,68 @@ "items": { "oneOf": [ { "$ref": "#/$defs/osvDocument" }, { "$ref": "#/$defs/statementDocument" } ] } } }, - "if": { - "properties": { "status": { "enum": ["done", "needs_check"] } }, - "required": ["status"] - }, - "then": { - "properties": { "documents": { "minItems": 1 } } - }, + "allOf": [ + { + "if": { + "properties": { "status": { "enum": ["done", "needs_check"] } }, + "required": ["status"] + }, + "then": { + "properties": { "documents": { "minItems": 1 } } + } + }, + { + "if": { + "properties": { "documents": { "contains": { "properties": { "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE"] } }, "required": ["document_type"] } } }, + "required": ["documents"] + }, + "then": { + "properties": { "service": { "required": ["name", "version", "statements_version"] } } + } + } + ], "$defs": { "money": { "type": ["string", "null"], "pattern": "^-?\\d+(\\.\\d{1,2})?$", "description": "Decimal как строка, чтобы не терять копейки в JSON" }, + "confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "documentStatus": { "enum": ["done", "needs_check"] }, + "headerField": { "enum": ["organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address"] }, + "warning": { + "type": "object", + "required": ["code", "severity", "message"], + "properties": { + "code": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$", "examples": ["COLUMN_WITHOUT_DATE", "ROW_SPLIT"], "description": "код по таблице 05-domain-rules.md §11; не перечисление — новый код не меняет MAJOR" }, + "severity": { "enum": ["error", "info"], "description": "error → документ needs_check; info на статус не влияет" }, + "message": { "type": "string" }, + "line_refs": { "type": "array", "items": { "type": "string" }, "description": "ref строк документа — только строк из statementDocument.lines[]; строки ролей title / header / numbering в line_refs не попадают (о них — message)" }, + "column_index": { "type": "integer", "description": "столбец сетки кандидата (как columns[].column_index); у COLUMN_SKIPPED и других предупреждений о столбце вне columns[] — номер этого столбца" }, + "field": { "$ref": "#/$defs/headerField" } + } + }, + "fileWarning": { + "allOf": [ { "$ref": "#/$defs/warning" } ], + "properties": { + "locator": { "type": "string", "examples": ["file:aep_3_ofr.json", "sheet:Лист3"], "description": "место в файле, к которому относится предупреждение" } + } + }, "failedRelation": { "type": "object", - "required": ["column_index", "relation", "expected", "actual"], + "required": ["column_index", "relation", "codes", "expected", "actual"], "properties": { "column_index": { "type": "integer" }, "relation": { "type": "string", "examples": ["1600 = 1100 + 1200"] }, - "expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения как в файле" }, - "actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам" } + "codes": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^\\d{4,5}$" }, "description": "коды цели и членов соотношения: нарушение подсвечивает все строки этих кодов в колонке" }, + "expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения (цель) по строкам ok; null — цели нет" }, + "actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам ok" } } }, "osvDocument": { "type": "object", - "required": ["document_type", "locator", "header", "group_schema", "rows"], + "required": ["document_type", "locator", "header", "group_schema", "rows", "status", "warnings"], "properties": { "document_type": { "const": "OSV" }, "locator": { "type": "string", "examples": ["sheet:Лист_1"] }, + "status": { "$ref": "#/$defs/documentStatus", "description": "done — контрольные суммы §1.4 листа сошлись, иначе needs_check (05-domain-rules.md §1.6)" }, + "warnings": { "type": "array", "items": { "$ref": "#/$defs/warning" }, "description": "предупреждения листа ОСВ; все уровня info, код с префиксом OSV_" }, "header": { "type": "object", "required": ["organization_name", "account_code_raw", "period_text"], @@ -142,79 +180,131 @@ } } }, + "statementValue": { + "type": "object", + "required": ["column_index", "amount", "text", "confidence", "is_recognized", "needs_review"], + "properties": { + "column_index": { "type": "integer", "description": "колонка значений — columns[].column_index" }, + "amount": { "$ref": "#/$defs/money" }, + "string_value": { "type": ["string", "null"], "description": "текст ячейки, только когда amount = null и текст непуст и не прочерк" }, + "text": { "type": ["string", "null"], "description": "сырой текст источника всегда (у Excel — как показывает числовой формат); null — позиции нет" }, + "confidence": { "$ref": "#/$defs/confidence", "description": "уверенность источника (у Excel 100; позиции нет — 100)" }, + "is_recognized": { "type": "boolean", "description": "текст разобран как число без выброшенных и исправленных символов; от confidence не зависит" }, + "needs_review": { "type": "boolean", "description": "сводный признак: нуждается в покрытии §7 и не покрыто, не подтверждён знак, или в несошедшемся жёстком соотношении (§11)" } + } + }, + "statementLine": { + "type": "object", + "required": ["row_index", "ref", "line_status", "line_code", "line_code_text", "line_name", "needs_review", "values"], + "properties": { + "row_index": { "type": "integer", "minimum": 0, "description": "порядковый номер строки в документе с 0, по порядку сетки после разлепления" }, + "ref": { "type": "string", "minLength": 1, "examples": ["row:8", "row:14#2", "Баланс!C27", "'Бухгалтерский баланс. Лист 1'!C14"], "description": "положение в источнике; уникален в документе. OCR — row:[#k]; Excel — <лист>! по правилу Excel: имя листа в одинарных кавычках, если в нём есть что-то кроме букв, цифр и _ или оно начинается с цифры; апостроф внутри имени удваивается" }, + "line_status": { "enum": ["ok", "unreadable", "missing", "detail"] }, + "line_code": { "type": ["string", "null"], "pattern": "^\\d{4,5}$", "description": "разобранный код; null ⇔ line_status ≠ ok" }, + "line_code_text": { "type": ["string", "null"], "description": "сырой текст ячейки кода" }, + "line_code_confidence": { "$ref": "#/$defs/confidence", "description": "от источника; у кода с заменой двойника — не выше 99" }, + "line_name": { "type": ["string", "null"], "description": "распознанное наименование как в источнике (не из справочника)" }, + "line_name_confidence": { "$ref": "#/$defs/confidence" }, + "parent_ref": { "type": ["string", "null"], "description": "ref строки-родителя у detail" }, + "needs_review": { "type": "boolean", "description": "неуверенный код не подтверждён структурой или повтор кода с неуверенной копией" }, + "values": { "type": "array", "items": { "$ref": "#/$defs/statementValue" } } + }, + "allOf": [ + { + "if": { "properties": { "line_status": { "const": "ok" } }, "required": ["line_status"] }, + "then": { "properties": { "line_code": { "type": "string" } } }, + "else": { "properties": { "line_code": { "type": "null" } } } + }, + { + "if": { "properties": { "line_status": { "const": "detail" } }, "required": ["line_status"] }, + "then": { "required": ["parent_ref"], "properties": { "parent_ref": { "type": "string" } } } + } + ] + }, "statementDocument": { "type": "object", - "required": ["document_type", "locator", "header", "columns", "lines"], + "required": ["document_type", "form_variant", "locator", "header", "columns", "lines", "status", "warnings"], "properties": { - "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE", "OTHER"] }, - "locator": { "type": "string", "examples": ["pages:1-2"] }, + "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE"], "description": "OTHER в v2.0 не порождается: лист «прочее» — предупреждение файла SHEET_OTHER, документа нет (значение OTHER — только в схеме 1.x)" }, + "locator": { "type": "string", "examples": ["file:aep_3.json;table:0", "sheet:Баланс,Баланс (2)"] }, + "status": { "$ref": "#/$defs/documentStatus" }, + "form_variant": { "enum": ["full", "simplified", null], "description": "по структуре кодов строк ok (§11); null — DISCLOSURE; обязателен" }, + "warnings": { "type": "array", "items": { "$ref": "#/$defs/warning" } }, "header": { "type": "object", "properties": { - "form_name": { "type": "string" }, + "form_name": { "type": ["string", "null"], "description": "заголовок формы как в источнике" }, "organization_name": { "type": ["string", "null"] }, - "organization_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "inn": { "type": ["string", "null"] }, - "inn_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "report_date_text": { "type": ["string", "null"] }, + "organization_name_text": { "type": ["string", "null"] }, + "organization_name_confidence": { "$ref": "#/$defs/confidence" }, + "inn": { "type": ["string", "null"], "pattern": "^(\\d{10}|\\d{12})$" }, + "inn_text": { "type": ["string", "null"] }, + "inn_confidence": { "$ref": "#/$defs/confidence" }, "report_date": { "type": ["string", "null"], "format": "date" }, + "report_date_text": { "type": ["string", "null"] }, + "report_date_confidence": { "$ref": "#/$defs/confidence" }, "units": { "type": ["string", "null"], "examples": ["тыс. руб."] }, + "units_text": { "type": ["string", "null"] }, + "units_confidence": { "$ref": "#/$defs/confidence" }, "okud": { "type": ["string", "null"] }, + "okud_text": { "type": ["string", "null"] }, + "okud_confidence": { "$ref": "#/$defs/confidence" }, "okpo": { "type": ["string", "null"] }, + "okpo_text": { "type": ["string", "null"] }, + "okpo_confidence": { "$ref": "#/$defs/confidence" }, "okopf": { "type": ["string", "null"] }, + "okopf_text": { "type": ["string", "null"] }, + "okopf_confidence": { "$ref": "#/$defs/confidence" }, "okfs": { "type": ["string", "null"] }, - "address": { "type": ["string", "null"] } + "okfs_text": { "type": ["string", "null"] }, + "okfs_confidence": { "$ref": "#/$defs/confidence" }, + "okei": { "type": ["string", "null"], "examples": ["384"], "description": "код ОКЕИ единицы (384 — тыс. руб., 383 — руб., 385 — млн руб.); запасной источник units (§11.2)" }, + "okei_text": { "type": ["string", "null"] }, + "okei_confidence": { "$ref": "#/$defs/confidence" }, + "address": { "type": ["string", "null"] }, + "address_text": { "type": ["string", "null"] }, + "address_confidence": { "$ref": "#/$defs/confidence" }, + "needs_review_fields": { "type": "array", "items": { "$ref": "#/$defs/headerField" }, "uniqueItems": true, "description": "поля шапки, значение которых требует проверки и держит документ в needs_check (сейчас — только неуверенная дата отчёта, не подтверждённая датами колонок); сомнения в ИНН и организации — предупреждения info с field, сюда не входят (§11.9)" } } }, "columns": { "type": "array", "items": { "type": "object", - "required": ["column_index", "label"], + "required": ["column_index", "label", "period_kind"], "properties": { - "column_index": { "type": "integer" }, - "label": { "type": "string", "examples": ["На 30 сентября 2025 г."] }, - "period_date": { "type": ["string", "null"], "format": "date" } - } - } - }, - "lines": { - "type": "array", - "items": { - "type": "object", - "required": ["line_code", "line_name", "values"], - "properties": { - "line_code": { "type": "string" }, - "line_code_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "line_name": { "type": "string" }, - "line_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "values": { - "type": "array", - "items": { - "type": "object", - "required": ["column_index"], - "properties": { - "column_index": { "type": "integer" }, - "amount": { "$ref": "#/$defs/money" }, - "string_value": { "type": ["string", "null"] }, - "confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "is_recognized": { "type": "boolean" } - } - } - } + "column_index": { "type": "integer", "minimum": 0, "description": "номер столбца в сетке кандидата шага 1 (у OCR — column_index ИС, не обязательно подряд; у Excel — номер столбца листа первого сегмента, с 0); уникален в документе; колонки сегментов без шапки сопоставляются с колонками первого сегмента по порядку и несут их номер" }, + "label": { "type": "string", "examples": ["На 30 сентября 2025 г.", "На отчетную дату"] }, + "label_confidence": { "$ref": "#/$defs/confidence", "description": "у Excel 100; подписи нет — null" }, + "period_date": { "type": ["string", "null"], "format": "date", "description": "конец периода колонки (period_end); у относительной подписи — от эффективной даты документа" }, + "period_kind": { "enum": ["absolute", "relative:current", "relative:prev_year_end", "relative:prev_prev_year_end", "relative:prev_period", "none"] } } } }, + "lines": { "type": "array", "items": { "$ref": "#/$defs/statementLine" }, "description": "у документа без строк (DISCLOSURE, форма без столбца кодов) — пустой массив" }, "checks": { "type": "object", - "description": "Результаты самопроверки формы по соотношениям §7 (tabular-parser). Отсутствие объекта — «не проверялось» (OCR-путь); пустые массивы — «проверено, сошлось».", + "required": ["relations_failed", "soft_relations_failed"], + "description": "Самопроверка формы по соотношениям §7 общим кодом (оба конвейера). Есть у BALANCE/INCOME со строками, нет у документа без строк. Пустые массивы — «проверено, сошлось».", "properties": { - "relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения жёстких соотношений: документ needs_check" }, - "soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения мягких соотношений (OQ-12, подстроки «в том числе») и жёстких в пределах округления итогов (OQ-138): только предупреждение" }, - "duplicate_line_codes": { "type": "array", "items": { "type": "string" }, "description": "коды строк, встретившиеся в документе больше одного раза (осталась первая строка)" } + "relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "жёсткие соотношения, не сошедшиеся вне допуска ±1 и округления 0,001 %: документ needs_check" }, + "soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "мягкие соотношения (OQ-12, подстроки) и жёсткие в пределах округления (OQ-138): info" } } } - } + }, + "allOf": [ + { + "if": { + "properties": { "document_type": { "enum": ["BALANCE", "INCOME"] }, "lines": { "minItems": 1 } }, + "required": ["document_type", "lines"] + }, + "then": { "required": ["checks"] } + }, + { + "if": { "properties": { "lines": { "maxItems": 0 } }, "required": ["lines"] }, + "then": { "not": { "required": ["checks"] } } + } + ] } } } diff --git a/api/contracts/recognition-result.v1.schema.json b/api/contracts/recognition-result.v1.schema.json new file mode 100644 index 0000000..43781b3 --- /dev/null +++ b/api/contracts/recognition-result.v1.schema.json @@ -0,0 +1,220 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "arm-ka/recognition-result/v1", + "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.2", + "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1, 1.2, …) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения.", + "type": "object", + "required": ["schema_version", "job_id", "file_id", "service", "status", "documents"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.2 пишет \"1.2\"" }, + "job_id": { "type": "string", "format": "uuid" }, + "file_id": { "type": "string", "format": "uuid" }, + "service": { + "type": "object", + "required": ["name", "version"], + "properties": { + "name": { "enum": ["tabular-parser", "scan-gateway"] }, + "version": { "type": "string" } + } + }, + "status": { "enum": ["done", "needs_check", "error"] }, + "error": { + "type": ["object", "null"], + "properties": { + "code": { "enum": ["UNSUPPORTED_FORMAT", "UNREADABLE", "TIMEOUT", "SCHEMA_MISMATCH", "NOT_IMPLEMENTED", "FILE_READ_ERROR", "INVALID_FORMAT", "INVALID_DOCUMENT", "RECOGNITION_FAILED", "UNKNOWN_ERROR"] }, + "message": { "type": "string" } + } + }, + "metrics": { + "type": "object", + "properties": { + "duration_ms": { "type": "integer" }, + "pages": { "type": "integer" }, + "rows": { "type": "integer" }, + "fields_total": { "type": "integer" }, + "fields_low_confidence": { "type": "integer" }, + "fields_unrecognized": { "type": "integer" }, + "warnings": { "type": "array", "items": { "type": "string" } } + } + }, + "raw_s3_key": { "type": ["string", "null"], "description": "scan-gateway: raw/{job_id}.json — сырой ответ ИС Сканирование; в БД не регистрируется" }, + "documents": { + "type": "array", + "items": { "oneOf": [ { "$ref": "#/$defs/osvDocument" }, { "$ref": "#/$defs/statementDocument" } ] } + } + }, + "if": { + "properties": { "status": { "enum": ["done", "needs_check"] } }, + "required": ["status"] + }, + "then": { + "properties": { "documents": { "minItems": 1 } } + }, + "$defs": { + "money": { "type": ["string", "null"], "pattern": "^-?\\d+(\\.\\d{1,2})?$", "description": "Decimal как строка, чтобы не терять копейки в JSON" }, + "failedRelation": { + "type": "object", + "required": ["column_index", "relation", "expected", "actual"], + "properties": { + "column_index": { "type": "integer" }, + "relation": { "type": "string", "examples": ["1600 = 1100 + 1200"] }, + "expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения как в файле" }, + "actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам" } + } + }, + "osvDocument": { + "type": "object", + "required": ["document_type", "locator", "header", "group_schema", "rows"], + "properties": { + "document_type": { "const": "OSV" }, + "locator": { "type": "string", "examples": ["sheet:Лист_1"] }, + "header": { + "type": "object", + "required": ["organization_name", "account_code_raw", "period_text"], + "properties": { + "organization_name": { "type": "string", "description": "как в r1 файла" }, + "organization_inn": { "type": ["string", "null"] }, + "account_code_raw": { "type": "string", "examples": ["60", "58.03"] }, + "period_text": { "type": "string", "examples": ["1 квартал 2025 г.", "2024 г."] }, + "period_year": { "type": ["integer", "null"] }, + "period_quarter": { "enum": ["Q1", "Q2", "Q3", "Q4", null] }, + "is_cumulative": { "type": ["boolean", "null"], "description": "по тексту; ядро может переопределить по данным (OQ-02)" }, + "period_start": { "type": ["string", "null"], "format": "date", "description": "начало периода по шапке: первое число квартала/года либо начало диапазона месяцев (OQ-131)" }, + "period_end": { "type": ["string", "null"], "format": "date", "description": "конец периода по шапке" }, + "is_from_year_start": { "type": ["boolean", "null"], "description": "period_start — 1 января; is_cumulative не переопределяет" }, + "data_type": { "enum": ["BU", "NU", "SUM", "CURRENCY", "QUANTITY", null] }, + "currency": { "type": ["string", "null"], "examples": ["RUB", "руб."] }, + "header_variant": { "enum": [1, 2] } + } + }, + "group_schema": { + "type": "array", + "description": "порядок группировок из шапки, после первого ACCOUNT", + "items": { + "type": "object", + "required": ["type"], + "properties": { + "type": { "enum": ["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"] }, + "name": { "type": "string", "description": "исходный текст из шапки" }, + "has_inn": { "type": "boolean" } + } + } + }, + "columns": { + "type": "object", + "description": "диагностика: какие колонки листа были приняты за шесть сумм и ИНН", + "additionalProperties": { "type": "string" } + }, + "rows": { + "type": "array", + "items": { + "type": "object", + "required": ["row_number", "level", "row_value", "group_type"], + "properties": { + "row_number": { "type": "integer" }, + "level": { "type": "integer" }, + "parent_row_number": { "type": ["integer", "null"] }, + "row_value": { "type": "string" }, + "group_type": { "enum": ["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER", "TOTAL"] }, + "group_name": { "type": ["string", "null"] }, + "account_code_raw": { "type": ["string", "null"], "description": "код текущей ветки, как в файле (60.1)" }, + "counterparty_name": { "type": ["string", "null"] }, + "counterparty_inn": { "type": ["string", "null"] }, + "data_type": { "enum": ["BU", "NU", "SUM", "CURRENCY", "QUANTITY", null] }, + "opening_debit": { "$ref": "#/$defs/money" }, + "opening_credit": { "$ref": "#/$defs/money" }, + "turnover_debit": { "$ref": "#/$defs/money" }, + "turnover_credit": { "$ref": "#/$defs/money" }, + "closing_debit": { "$ref": "#/$defs/money" }, + "closing_credit": { "$ref": "#/$defs/money" }, + "extra": { "type": ["object", "null"], "description": "нераспознанные колонки внутри таблицы: заголовок колонки → текст ячейки (§1.2)", "additionalProperties": { "type": ["string", "null"] } } + } + } + }, + "checks": { + "type": "object", + "properties": { + "total_row_matches_root": { "type": "boolean" }, + "balance_identity_violations": { "type": "array", "items": { "type": "integer" }, "description": "row_number строк, где opening−turnover≠closing" }, + "parent_sum_violations": { "type": "array", "items": { "type": "integer" } } + } + } + } + }, + "statementDocument": { + "type": "object", + "required": ["document_type", "locator", "header", "columns", "lines"], + "properties": { + "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE", "OTHER"] }, + "locator": { "type": "string", "examples": ["pages:1-2"] }, + "header": { + "type": "object", + "properties": { + "form_name": { "type": "string" }, + "organization_name": { "type": ["string", "null"] }, + "organization_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "inn": { "type": ["string", "null"] }, + "inn_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "report_date_text": { "type": ["string", "null"] }, + "report_date": { "type": ["string", "null"], "format": "date" }, + "units": { "type": ["string", "null"], "examples": ["тыс. руб."] }, + "okud": { "type": ["string", "null"] }, + "okpo": { "type": ["string", "null"] }, + "okopf": { "type": ["string", "null"] }, + "okfs": { "type": ["string", "null"] }, + "address": { "type": ["string", "null"] } + } + }, + "columns": { + "type": "array", + "items": { + "type": "object", + "required": ["column_index", "label"], + "properties": { + "column_index": { "type": "integer" }, + "label": { "type": "string", "examples": ["На 30 сентября 2025 г."] }, + "period_date": { "type": ["string", "null"], "format": "date" } + } + } + }, + "lines": { + "type": "array", + "items": { + "type": "object", + "required": ["line_code", "line_name", "values"], + "properties": { + "line_code": { "type": "string" }, + "line_code_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "line_name": { "type": "string" }, + "line_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "values": { + "type": "array", + "items": { + "type": "object", + "required": ["column_index"], + "properties": { + "column_index": { "type": "integer" }, + "amount": { "$ref": "#/$defs/money" }, + "string_value": { "type": ["string", "null"] }, + "confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "is_recognized": { "type": "boolean" } + } + } + } + } + } + }, + "checks": { + "type": "object", + "description": "Результаты самопроверки формы по соотношениям §7 (tabular-parser). Отсутствие объекта — «не проверялось» (OCR-путь); пустые массивы — «проверено, сошлось».", + "properties": { + "relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения жёстких соотношений: документ needs_check" }, + "soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения мягких соотношений (OQ-12, подстроки «в том числе») и жёстких в пределах округления итогов (OQ-138): только предупреждение" }, + "duplicate_line_codes": { "type": "array", "items": { "type": "string" }, "description": "коды строк, встретившиеся в документе больше одного раза (осталась первая строка)" } + } + } + } + } + } +} diff --git a/api/src/contract/__init__.py b/api/src/contract/__init__.py index 79922fd..2708680 100644 --- a/api/src/contract/__init__.py +++ b/api/src/contract/__init__.py @@ -1,4 +1,4 @@ -"""Контракт обмена с сервисами распознавания v1.0 (схемы — `api/contracts/`, копии `docs/contracts/`). +"""Контракт обмена с сервисами распознавания, MAJOR 1 и 2 (схемы — `api/contracts/`, копии `docs/contracts/`). Публичный API: pydantic-модели запроса, конверта и файла результата, subject'ы NATS, идентификаторы сообщений. """ @@ -12,7 +12,7 @@ from src.contract.common import ( ServiceInfo, ServiceName, ) -from src.contract.message import MessageError, MessageStatus, RecognitionResultMessage +from src.contract.message import MessageError, MessageStatus, RecognitionResultMessage, RecognitionResultMessageV2 from src.contract.request import FileType, RecognitionRequest from src.contract.result import ( OsvDocument, @@ -20,6 +20,14 @@ from src.contract.result import ( ResultStatus, StatementDocument, ) +from src.contract.result_v2 import ( + DocumentWarning, + FileWarning, + MetricsV2, + OsvDocumentV2, + RecognitionResultV2, + StatementDocumentV2, +) from src.contract.subjects import ( IMAGE_REQUEST_SUBJECT, NATS_MSG_ID_HEADER, @@ -41,21 +49,28 @@ __all__ = [ "RESULT_SUBJECT", "TABULAR_REQUEST_SUBJECT", "ContractModel", + "DocumentWarning", "ErrorCode", + "FileWarning", "FileType", "MessageError", "MessageStatus", "Metrics", + "MetricsV2", "OsvDocument", + "OsvDocumentV2", "Pipeline", "RecognitionRequest", "RecognitionResult", "RecognitionResultMessage", + "RecognitionResultMessageV2", + "RecognitionResultV2", "ResultStatus", "S3Ref", "ServiceInfo", "ServiceName", "StatementDocument", + "StatementDocumentV2", "ack_msg_id", "request_msg_id", "request_subject", diff --git a/api/src/contract/common.py b/api/src/contract/common.py index d0e27a8..72110ce 100644 --- a/api/src/contract/common.py +++ b/api/src/contract/common.py @@ -1,4 +1,4 @@ -"""Общие основы моделей контракта v1.0: базовый класс, версия, типы UUID / деньги / время, метрики и ошибки. +"""Общие основы моделей контракта (MAJOR 1 и 2): базовый класс, версия, типы UUID / деньги / время, метрики и ошибки. Модели повторяют JSON Schema (`api/contracts/`) буквально: схемы не содержат `additionalProperties: false` (кроме значений `columns` ОСВ), поэтому незнакомые поля принимаются и отбрасываются (`extra="ignore"`) — @@ -30,7 +30,8 @@ from pydantic import ( CONTRACT_VERSION = "1.0" # Шаблоны версий — буквально из схем -SCHEMA_VERSION_PATTERN = r"^1\.\d+$" # свой MAJOR, любой MINOR +SCHEMA_VERSION_PATTERN = r"^1\.\d+$" # свой MAJOR (модели v1), любой MINOR +SCHEMA_VERSION_V2_PATTERN = r"^2\.\d+$" # модели v2.0 RESULT_SCHEMA_VERSION_PATTERN = r"^\d+\.\d+$" # recognition-result-message.result_schema_version T = TypeVar("T") @@ -174,6 +175,7 @@ HeaderVariant = Annotated[Literal[1, 2], BeforeValidator(int_like)] NonEmptyStr = Annotated[str, Field(min_length=1)] SchemaVersion = Annotated[str, Field(pattern=SCHEMA_VERSION_PATTERN)] +SchemaVersionV2 = Annotated[str, Field(pattern=SCHEMA_VERSION_V2_PATTERN)] ResultSchemaVersion = Annotated[str, Field(pattern=RESULT_SCHEMA_VERSION_PATTERN)] # Уверенность распознавания, проценты (число 0–100) Confidence = Annotated[float, Field(ge=0, le=100)] | None diff --git a/api/src/contract/message.py b/api/src/contract/message.py index 6e07b12..a371cab 100644 --- a/api/src/contract/message.py +++ b/api/src/contract/message.py @@ -1,4 +1,4 @@ -"""Конверт сообщения на `files.tabular_parsed`: `recognition-result-message.schema.json` v1.0. +"""Конверт сообщения на `files.tabular_parsed`: `recognition-result-message.schema.json` (v2.0) и `.v1` (v1.0). `processing` — ack «взял в работу» (до чтения файла из S3); `done | needs_check | error` — итог. Условная обязательность — буквально из `allOf` схемы: для `done | needs_check` обязательны `result_s3` и @@ -19,8 +19,10 @@ from src.contract.common import ( ResultSchemaVersion, S3Ref, SchemaVersion, + SchemaVersionV2, ServiceInfo, ) +from src.contract.result_v2 import MetricsV2 class MessageStatus(StrEnum): @@ -40,8 +42,9 @@ class MessageError(ContractModel): message: str -class RecognitionResultMessage(ContractModel): - schema_version: SchemaVersion +class _MessageBase(ContractModel): + """Поля конверта, общие для MAJOR 1 и 2 (различаются `schema_version` и тип `metrics`) и условия по статусу.""" + job_id: ContractUUID # recognition_job.id file_id: ContractUUID # stored_file.id status: LaxMessageStatus @@ -50,7 +53,6 @@ class RecognitionResultMessage(ContractModel): result_schema_version: OptionalNotNull[ResultSchemaVersion] = None # schema_version файла результата error: OptionalNotNull[MessageError] = None raw_s3_key: str | None = None # scan-gateway: raw/{job_id}.json; в БД не регистрируется - metrics: OptionalNotNull[Metrics] = None @model_validator(mode="after") def _check_status_requirements(self) -> Self: @@ -61,3 +63,17 @@ class RecognitionResultMessage(ContractModel): elif self.status is MessageStatus.ERROR and self.error is None: raise ValueError("при status = error обязательно поле error") return self + + +class RecognitionResultMessage(_MessageBase): + """Конверт v1.x: `metrics.warnings` — строки.""" + + schema_version: SchemaVersion + metrics: OptionalNotNull[Metrics] = None + + +class RecognitionResultMessageV2(_MessageBase): + """Конверт v2.0: `metrics.warnings` — структуры `{code, severity, message, ...}`.""" + + schema_version: SchemaVersionV2 + metrics: OptionalNotNull[MetricsV2] = None diff --git a/api/src/contract/result_v2.py b/api/src/contract/result_v2.py new file mode 100644 index 0000000..d98ce37 --- /dev/null +++ b/api/src/contract/result_v2.py @@ -0,0 +1,256 @@ +"""Файл результата в S3, контракт v2.0: `recognition-result.schema.json` (RSHB/arm-ka-docs#116). + +Отличия от v1.x (`result.py`): статусы строк и документов, `row_index` / `ref` / `parent_ref`, сырой текст значений и +шапки с уверенностью по каждому полю, структурные предупреждения (файла и документа), `checks` с `codes`, обязательный +`form_variant`, `service.statements_version`, ОСВ несёт `status` и `warnings`. Поля и обязательность — буквально из +схемы; модели v1 остаются в `result.py` на окне перехода. Суммы — `Decimal` в модели и строка в JSON. +""" + +from typing import Annotated, Literal, Self + +from pydantic import Field, model_validator + +from src.contract.common import ( + Confidence, + ContractDate, + ContractModel, + ContractUUID, + IntLike, + LaxServiceName, + Metrics, + Money, + NonEmptyStr, + OptionalNotNull, + SchemaVersionV2, +) +from src.contract.result import LaxResultStatus, OsvDocument, ResultError, ResultStatus + +__all__ = [ + "DocumentWarning", + "FailedRelationV2", + "FileWarning", + "MetricsV2", + "OsvDocumentV2", + "RecognitionResultV2", + "ServiceInfoV2", + "StatementChecksV2", + "StatementColumnV2", + "StatementDocumentV2", + "StatementHeaderV2", + "StatementLineV2", + "StatementValueV2", +] + +WarningSeverity = Literal["error", "info"] +HeaderField = Literal[ + "organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address" +] +DocumentStatusV2 = Literal["done", "needs_check"] +WarningCode = Annotated[str, Field(pattern=r"^[A-Z][A-Z0-9_]*$")] +LineCode = Annotated[str, Field(pattern=r"^\d{4,5}$")] +NonNegativeInt = Annotated[IntLike, Field(ge=0)] + + +# --- предупреждения --------------------------------------------------------------------------------------------------- + + +class DocumentWarning(ContractModel): + """Предупреждение документа (`$defs.warning`); код — не перечисление (новый код не меняет MAJOR).""" + + code: WarningCode + severity: WarningSeverity + message: str + line_refs: OptionalNotNull[list[str]] = None + column_index: OptionalNotNull[IntLike] = None + field: OptionalNotNull[HeaderField] = None + + +class FileWarning(DocumentWarning): + """Предупреждение файла (`$defs.fileWarning`): то же и `locator` — место в файле.""" + + locator: OptionalNotNull[str] = None + + +class MetricsV2(Metrics): + """Метрики v2.0: `warnings` — структуры, а не строки.""" + + warnings: OptionalNotNull[list[FileWarning]] = None # type: ignore[assignment] + + +class ServiceInfoV2(ContractModel): + """`service {name, version, statements_version?}`.""" + + name: LaxServiceName + version: str + statements_version: OptionalNotNull[NonEmptyStr] = None # коммит arm-statements; обязателен при формах + + +# --- ОСВ -------------------------------------------------------------------------------------------------------------- + + +class OsvDocumentV2(OsvDocument): + """Поля `OsvDocument` + `status` (контрольные суммы §1.4) и `warnings` (уровня info, коды `OSV_*`).""" + + status: DocumentStatusV2 + warnings: list[DocumentWarning] + + +# --- ББ / ОФР / раскрытия --------------------------------------------------------------------------------------------- + + +class StatementHeaderV2(ContractModel): + """Шапка: значение, сырой текст `<поле>_text` и уверенность `<поле>_confidence` по каждому полю.""" + + form_name: str | None = None + organization_name: str | None = None + organization_name_text: str | None = None + organization_name_confidence: Confidence = None + inn: Annotated[str, Field(pattern=r"^(\d{10}|\d{12})$")] | None = None + inn_text: str | None = None + inn_confidence: Confidence = None + report_date: ContractDate | None = None + report_date_text: str | None = None + report_date_confidence: Confidence = None + units: str | None = None + units_text: str | None = None + units_confidence: Confidence = None + okud: str | None = None + okud_text: str | None = None + okud_confidence: Confidence = None + okpo: str | None = None + okpo_text: str | None = None + okpo_confidence: Confidence = None + okopf: str | None = None + okopf_text: str | None = None + okopf_confidence: Confidence = None + okfs: str | None = None + okfs_text: str | None = None + okfs_confidence: Confidence = None + okei: str | None = None + okei_text: str | None = None + okei_confidence: Confidence = None + address: str | None = None + address_text: str | None = None + address_confidence: Confidence = None + needs_review_fields: list[HeaderField] = Field(default_factory=list) + + @model_validator(mode="after") + def _check_unique_review_fields(self) -> Self: + if len(set(self.needs_review_fields)) != len(self.needs_review_fields): + raise ValueError("needs_review_fields: значения не должны повторяться (uniqueItems)") + return self + + +class StatementColumnV2(ContractModel): + column_index: NonNegativeInt + label: str + label_confidence: Confidence = None + period_date: ContractDate | None = None + period_kind: Literal[ + "absolute", + "relative:current", + "relative:prev_year_end", + "relative:prev_prev_year_end", + "relative:prev_period", + "none", + ] + + +class StatementValueV2(ContractModel): + column_index: IntLike + amount: Money # обязательно, null — позиции нет / не число + string_value: str | None = None + text: str | None # сырой текст источника; null — позиции нет + confidence: Confidence + is_recognized: bool + needs_review: bool + + +class StatementLineV2(ContractModel): + row_index: NonNegativeInt + ref: NonEmptyStr + line_status: Literal["ok", "unreadable", "missing", "detail"] + line_code: LineCode | None # null ⇔ line_status ≠ ok + line_code_text: str | None + line_code_confidence: Confidence = None + line_name: str | None + line_name_confidence: Confidence = None + parent_ref: str | None = None + needs_review: bool + values: list[StatementValueV2] + + @model_validator(mode="after") + def _check_status_requirements(self) -> Self: + """`ok` ⇒ `line_code` строка, иначе `null`; `detail` ⇒ `parent_ref` строка — буквально из `allOf` схемы.""" + if self.line_status == "ok": + if self.line_code is None: + raise ValueError("при line_status = ok line_code обязателен") + elif self.line_code is not None: + raise ValueError(f"при line_status = {self.line_status} line_code должен быть null") + if self.line_status == "detail" and self.parent_ref is None: + raise ValueError("при line_status = detail обязателен parent_ref") + return self + + +class FailedRelationV2(ContractModel): + """Нарушенное соотношение §7 по колонке; `codes` — коды цели и членов (подсветка строк).""" + + column_index: IntLike + relation: str + codes: Annotated[list[LineCode], Field(min_length=1)] + expected: Money + actual: Money + + +class StatementChecksV2(ContractModel): + """Самопроверка формы: пустые списки — «проверено, сошлось»; `duplicate_line_codes` в v2.0 удалён.""" + + relations_failed: list[FailedRelationV2] + soft_relations_failed: list[FailedRelationV2] + + +class StatementDocumentV2(ContractModel): + document_type: Literal["BALANCE", "INCOME", "DISCLOSURE"] # OTHER в v2.0 не порождается + locator: str + status: DocumentStatusV2 + form_variant: Literal["full", "simplified"] | None # обязателен; null — DISCLOSURE + warnings: list[DocumentWarning] + header: StatementHeaderV2 + columns: list[StatementColumnV2] + lines: list[StatementLineV2] + checks: OptionalNotNull[StatementChecksV2] = None + + @model_validator(mode="after") + def _check_checks_presence(self) -> Self: + """BALANCE / INCOME со строками ⇒ `checks` обязателен; документ без строк ⇒ `checks` отсутствует.""" + if self.lines: + if self.document_type in ("BALANCE", "INCOME") and self.checks is None: + raise ValueError(f"у {self.document_type} со строками обязательно поле checks") + elif self.checks is not None: + raise ValueError("у документа без строк поле checks не допускается") + return self + + +ResultDocumentV2 = Annotated[OsvDocumentV2 | StatementDocumentV2, Field(discriminator="document_type")] + + +class RecognitionResultV2(ContractModel): + schema_version: SchemaVersionV2 + job_id: ContractUUID + file_id: ContractUUID + service: ServiceInfoV2 + status: LaxResultStatus + error: ResultError | None = None + metrics: OptionalNotNull[MetricsV2] = None + raw_s3_key: str | None = None + documents: list[ResultDocumentV2] + + @model_validator(mode="after") + def _check_conditions(self) -> Self: + """`done | needs_check` ⇒ минимум один документ; есть форма ⇒ `service.statements_version` обязателен.""" + if self.status in (ResultStatus.DONE, ResultStatus.NEEDS_CHECK) and not self.documents: + raise ValueError(f"при status = {self.status.value} список documents не может быть пустым") + has_statement = any(isinstance(document, StatementDocumentV2) for document in self.documents) + if has_statement and self.service.statements_version is None: + raise ValueError("при наличии форм ББ / ОФР / раскрытий обязателен service.statements_version") + return self diff --git a/api/src/contract/versions.py b/api/src/contract/versions.py new file mode 100644 index 0000000..1444164 --- /dev/null +++ b/api/src/contract/versions.py @@ -0,0 +1,51 @@ +"""Выбор модели по MAJOR `schema_version` на окне перехода v1 → v2 (ядро принимает оба MAJOR). + +`schema_version` — `MAJOR.MINOR`. Известны MAJOR 1 и 2; иной MAJOR, неверный формат или отсутствие поля — +`pydantic.ValidationError` (разбор отдан модели v1, чей шаблон `^1\\.\\d+$` такое не принимает). +""" + +import json +import re +from typing import Any + +from src.contract.message import RecognitionResultMessage, RecognitionResultMessageV2 +from src.contract.result import RecognitionResult +from src.contract.result_v2 import RecognitionResultV2 + +_VERSION = re.compile(r"(\d+)\.\d+", re.ASCII) +MAJOR_V2 = 2 + +__all__ = ["major", "parse_message", "parse_result"] + + +def major(schema_version: str) -> int: + """MAJOR из `MAJOR.MINOR`; иная запись — `ValueError`.""" + match = _VERSION.fullmatch(schema_version) + if match is None: + raise ValueError(f"schema_version {schema_version!r}: ожидается MAJOR.MINOR") + return int(match.group(1)) + + +def _is_v2(data: Any) -> bool: + version = data.get("schema_version") if isinstance(data, dict) else None + if not isinstance(version, str): + return False + try: + return major(version) == MAJOR_V2 + except ValueError: + return False + + +def parse_message(data: dict[str, Any]) -> RecognitionResultMessage | RecognitionResultMessageV2: + """Конверт `recognition-result-message`: MAJOR 1 → модель v1, MAJOR 2 → модель v2, иное — `ValidationError`.""" + if _is_v2(data): + return RecognitionResultMessageV2.model_validate(data) + return RecognitionResultMessage.model_validate(data) + + +def parse_result(content: bytes) -> RecognitionResult | RecognitionResultV2: + """Файл результата: те же правила MAJOR; нечитаемый JSON — `ValueError` (`json.JSONDecodeError`).""" + data = json.loads(content) + if _is_v2(data): + return RecognitionResultV2.model_validate(data) + return RecognitionResult.model_validate(data) diff --git a/api/src/schemas/files.py b/api/src/schemas/files.py index 1aab6ef..1379775 100644 --- a/api/src/schemas/files.py +++ b/api/src/schemas/files.py @@ -7,6 +7,7 @@ import datetime import uuid +from typing import Literal from pydantic import BaseModel, ConfigDict @@ -47,6 +48,16 @@ class UploadResult(BaseModel): rejected: list[RejectedOut] +class FileWarningOut(BaseModel): + """Предупреждение файла от сервиса распознавания (`metrics.warnings`): код, уровень, текст и место в файле. + Старые строковые предупреждения (контракт 1.x) отдаются с кодом `LEGACY_WARNING` уровня `info`.""" + + code: str + severity: Literal["error", "info"] + message: str + locator: str | None + + class JobBrief(BaseModel): """Текущее задание распознавания файла: статус, причина ошибки сервиса (`error_code`/`error_message`) и маппинга (`mapping_error` — `metrics.mapping_error`: файл в `ERROR` после маппинга при задании `done`), предупреждения @@ -57,7 +68,7 @@ class JobBrief(BaseModel): error_code: str | None error_message: str | None mapping_error: str | None - warnings: list[str] + warnings: list[FileWarningOut] mapping_warnings: list[str] finished_at: datetime.datetime | None diff --git a/api/src/services/documents.py b/api/src/services/documents.py index dfa82b9..a66739c 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -30,13 +30,14 @@ from src.repositories import files as files_repo from src.repositories import statements as statements_repo from src.schemas.comments import CommentAuthorOut, CommentIn, CommentOut from src.schemas.documents import DocumentOut, EntriesPageParams, GroupOut -from src.schemas.files import DocumentBrief, FileOut, JobBrief +from src.schemas.files import DocumentBrief, FileOut, FileWarningOut, JobBrief from src.schemas.issues import IssueCountsOut, IssueOut, issue_out from src.services import consolidation_documents from src.services.result_mapping import MAPPING_ERROR_KEY, MAPPING_WARNINGS_KEY DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего OBJECT_TYPE = "document" +LEGACY_WARNING_CODE = "LEGACY_WARNING" # строка предупреждения контракта 1.x в ответе API WARNINGS_KEY = "warnings" # `recognition_job.metrics.warnings` — предупреждения сервиса распознавания FILE_SUPERSEDED = "FILE_SUPERSEDED" ALREADY_CONFIRMED = "ALREADY_CONFIRMED" @@ -55,6 +56,28 @@ def _strings(value: Any) -> list[str]: return [item for item in value if isinstance(item, str)] if isinstance(value, list) else [] +def file_warnings(value: Any) -> list[FileWarningOut]: + """Предупреждения файла из `recognition_job.metrics.warnings`. Строка (задание контракта 1.x) — `LEGACY_WARNING` + уровня info; объект с `code`, `severity` ∈ {error, info} и `message` — как есть; всё прочее (и значение, не + являющееся списком) пропускается. Данные в БД не переписываются.""" + if not isinstance(value, list): + return [] + result: list[FileWarningOut] = [] + for item in value: + if isinstance(item, str): + result.append(FileWarningOut(code=LEGACY_WARNING_CODE, severity="info", message=item, locator=None)) + elif isinstance(item, dict): + code, severity, message, locator = (item.get(k) for k in ("code", "severity", "message", "locator")) + if ( + isinstance(code, str) + and severity in ("error", "info") + and isinstance(message, str) + and (locator is None or isinstance(locator, str)) + ): + result.append(FileWarningOut(code=code, severity=severity, message=message, locator=locator)) + return result + + def job_brief(job: RecognitionJob) -> JobBrief: """Текущее задание файла для карточки: статус, ошибки сервиса и маппинга, предупреждения.""" metrics = _metrics(job) @@ -65,7 +88,7 @@ def job_brief(job: RecognitionJob) -> JobBrief: error_code=job.error_code, error_message=job.error_message, mapping_error=mapping_error if isinstance(mapping_error, str) else None, - warnings=_strings(metrics.get(WARNINGS_KEY)), + warnings=file_warnings(metrics.get(WARNINGS_KEY)), mapping_warnings=_strings(metrics.get(MAPPING_WARNINGS_KEY)), finished_at=job.finished_at, ) diff --git a/api/src/services/recognition_exchange.py b/api/src/services/recognition_exchange.py index 6c08514..fc69e77 100644 --- a/api/src/services/recognition_exchange.py +++ b/api/src/services/recognition_exchange.py @@ -66,15 +66,16 @@ from src.contract import ( NATS_MSG_ID_HEADER, REQUEST_SUBJECTS, MessageStatus, - Metrics, RecognitionRequest, RecognitionResultMessage, + RecognitionResultMessageV2, S3Ref, ack_msg_id, request_msg_id, request_subject, result_msg_id, ) +from src.contract.versions import parse_message from src.core.config import Settings from src.core.nats import NatsPublishError from src.core.storage import ObjectStorage @@ -83,6 +84,10 @@ from src.models.files import RecognitionJob, S3Object, StoredFile from src.services.file_status import set_file_status_for_job, set_file_status_for_jobs from src.services.result_mapping import run_mapping_loop +# Конверт любого принятого MAJOR (общие поля одинаковы; различается `metrics.warnings`) +ResultMessage = RecognitionResultMessage | RecognitionResultMessageV2 +LEGACY_WARNING_CODE = "LEGACY_WARNING" # строка предупреждения конверта 1.x, обёрнутая в структуру + WATCHDOG_PERIOD_SECONDS = 30 # период вахтёра сроков (spec «Таймаут ожидания результата») PUBLISH_BATCH_SIZE = 50 # заданий за один проход публикатора (design D-6, LIMIT 50) # Срок ответа сервиса, если задание создано без `reply_deadline`: OCR — 20 мин (решение владельца 09.10.2026; note DBML @@ -567,8 +572,8 @@ def merge_metrics(existing: Any, incoming: Mapping[str, Any] | None) -> Any: return {**base, **incoming} -def _parse_message(msg: InboundMessage) -> RecognitionResultMessage | None: - """Конверт `recognition-result-message` v1.0 или `None` (warning в лог без тела сообщения).""" +def _parse_message(msg: InboundMessage) -> ResultMessage | None: + """Конверт `recognition-result-message` MAJOR 1 или 2 (`parse_message`) или `None` (warning в лог без тела).""" try: data = json.loads(msg.data) except ValueError: # JSONDecodeError и UnicodeDecodeError @@ -581,7 +586,7 @@ def _parse_message(msg: InboundMessage) -> RecognitionResultMessage | None: ) return None try: - return RecognitionResultMessage.model_validate(data) + return parse_message(data) except ValidationError as exc: raw_job_id = data.get("job_id") # job_id невалидного сообщения — данные отправителя: repr экранирует переводы строк и управляющие символы @@ -613,7 +618,7 @@ async def _lock_job(session: AsyncSession, job_id: uuid.UUID) -> RecognitionJob async def _apply_ack( - session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage, msg_id: str, now: datetime + session: AsyncSession, job: RecognitionJob, message: ResultMessage, msg_id: str, now: datetime ) -> None: """Ack (`status = processing`): `published` → `processing` и файл `UPLOADED → PROCESSING` (R-5); повтор для `processing` — тихо; иначе — warning (запоздавший ack после `timeout`/`dlq` файл не трогает).""" @@ -645,7 +650,7 @@ async def _apply_ack( ) -async def _register_result_object(session: AsyncSession, message: RecognitionResultMessage, now: datetime) -> uuid.UUID: +async def _register_result_object(session: AsyncSession, message: ResultMessage, now: datetime) -> uuid.UUID: """Файл результата в реестре `s3_object` (`bucket_role = results`, `created_by = service.name`). Уникальность `(bucket, key)`: строка с этим ключом уже есть — переиспользуется (дубль не вставляется, не падаем). @@ -681,7 +686,7 @@ async def _register_result_object(session: AsyncSession, message: RecognitionRes async def _apply_result( - session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage, msg_id: str, now: datetime + session: AsyncSession, job: RecognitionJob, message: ResultMessage, msg_id: str, now: datetime ) -> bool: """Результат (`done | needs_check | error`): дубликат и финальный статус — без изменений (`False`); иначе — регистрация файла результата, обновление задания и статуса файла (`True`, дальше — commit).""" @@ -710,7 +715,7 @@ async def _apply_result( job.service_version = message.service.version job.finished_at = now if message.metrics is not None: - job.metrics = merge_metrics(job.metrics, _metrics_json(message.metrics)) + job.metrics = merge_metrics(job.metrics, _metrics_json(message)) for name in FIELD_COUNTERS: value = getattr(message.metrics, name) if value is not None: @@ -742,13 +747,20 @@ async def _apply_result_file_status(session: AsyncSession, job: RecognitionJob, return await set_file_status_for_job(session, job, FileStatus.PROCESSING, now=now, only_from={FileStatus.UPLOADED}) -def _metrics_json(metrics: Metrics) -> dict[str, Any]: - return metrics.model_dump(mode="json", exclude_none=True) +def _metrics_json(message: ResultMessage) -> dict[str, Any]: + """Метрики конверта для `recognition_job.metrics`. `warnings` всегда списком структур `{code, severity, message, + locator}`: у 2.0 — как пришли, у 1.x каждая строка — `LEGACY_WARNING` уровня info.""" + assert message.metrics is not None + metrics = message.metrics.model_dump(mode="json", exclude_none=True) + if not isinstance(message, RecognitionResultMessageV2) and "warnings" in metrics: + metrics["warnings"] = [ + {"code": LEGACY_WARNING_CODE, "severity": "info", "message": text, "locator": None} + for text in metrics["warnings"] + ] + return metrics -async def _apply_message( - session: AsyncSession, message: RecognitionResultMessage, header_id: str | None, clock: Clock -) -> None: +async def _apply_message(session: AsyncSession, message: ResultMessage, header_id: str | None, clock: Clock) -> None: job = await _lock_job(session, message.job_id) if job is None: logger.warning( diff --git a/api/tests/test_api_documents.py b/api/tests/test_api_documents.py index af40e4b..cb868f8 100644 --- a/api/tests/test_api_documents.py +++ b/api/tests/test_api_documents.py @@ -232,7 +232,9 @@ async def test_entries_expose_match_kind(seeded: AsyncSession, auth_client: Auth seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done ) result = json.loads( - (Path(__file__).resolve().parents[1] / "contracts" / "examples" / "result-osv-minimal.json").read_text("utf-8") + (Path(__file__).resolve().parents[1] / "contracts" / "examples" / "v1" / "result-osv-minimal.json").read_text( + "utf-8" + ) ) (document,) = await make_entries_from_result(seeded, file, job, result) await seeded.execute( @@ -256,7 +258,9 @@ async def test_entries_pagination_and_limits(seeded: AsyncSession, auth_client: seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done ) result = json.loads( - (Path(__file__).resolve().parents[1] / "contracts" / "examples" / "result-osv-minimal.json").read_text("utf-8") + (Path(__file__).resolve().parents[1] / "contracts" / "examples" / "v1" / "result-osv-minimal.json").read_text( + "utf-8" + ) ) (document,) = await make_entries_from_result(seeded, file, job, result) client = await auth_client(KA_RF) diff --git a/api/tests/test_api_files_delete.py b/api/tests/test_api_files_delete.py index 9dd722e..26ad2d2 100644 --- a/api/tests/test_api_files_delete.py +++ b/api/tests/test_api_files_delete.py @@ -63,7 +63,7 @@ KA_RF = "ka_rf@armka.local" # Ростовский филиал, DOCUMENT_VIEW RKA = "rka@armka.local" # Ростовский филиал, только DOCUMENT_VIEW CLIENT_MANAGER = "client_manager@armka.local" # Краснодарский филиал ROSTOV = "Ростовский региональный филиал" -MINIMAL_RESULT = Path(__file__).resolve().parents[1] / "contracts" / "examples" / "result-osv-minimal.json" +MINIMAL_RESULT = Path(__file__).resolve().parents[1] / "contracts" / "examples" / "v1" / "result-osv-minimal.json" LOCK_WAIT_SECONDS = 10.0 pytestmark = pytest.mark.integration diff --git a/api/tests/test_api_files_read.py b/api/tests/test_api_files_read.py index bc35c0f..a8e0ad8 100644 --- a/api/tests/test_api_files_read.py +++ b/api/tests/test_api_files_read.py @@ -136,7 +136,9 @@ async def test_list_processed_with_document_and_error(seeded: AsyncSession, auth "error_code": None, "error_message": None, "mapping_error": None, - "warnings": ["sheet=Лист_1 hierarchy=outline"], + "warnings": [ + {"code": "LEGACY_WARNING", "severity": "info", "message": "sheet=Лист_1 hierarchy=outline", "locator": None} + ], "mapping_warnings": ["sheet=Лист_1: сироты"], "finished_at": "2026-10-01T09:00:00Z", } @@ -231,6 +233,44 @@ async def test_file_card_equals_list_item(seeded: AsyncSession, auth_client: Aut assert card.json()["job"]["mapping_warnings"] == [] +_LEGACY = "sheet=ОСВ 60 строки без родителя" + + +async def test_job_brief_converts_legacy_strings(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Старое задание со строковыми предупреждениями: структуры `LEGACY_WARNING`, данные в БД не переписаны.""" + entity = await _rostov_entity(seeded) + file, job = await _file(seeded, entity, minutes=1, metrics={"warnings": [_LEGACY]}) + client = await auth_client(KA_RF) + + card = await client.get(f"/files/{file.id}") + + assert card.status_code == HTTP_OK, card.text + assert card.json()["job"]["warnings"] == [ + {"code": "LEGACY_WARNING", "severity": "info", "message": _LEGACY, "locator": None} + ] + await seeded.refresh(job) + assert job.metrics == {"warnings": [_LEGACY]} + + +async def test_job_brief_mixed_and_garbage_warnings(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Смесь строк и объектов: строки — `LEGACY_WARNING`, объекты без обязательных полей и прочий мусор пропускаются; + `metrics.warnings` не список — пустой ответ (Review Focus 5).""" + entity = await _rostov_entity(seeded) + structured = {"code": "JSON_FAILED", "severity": "error", "message": "m", "locator": "x"} + mixed, _ = await _file(seeded, entity, minutes=1, metrics={"warnings": ["a", structured, 5, {"code": "X"}]}) + not_list, _ = await _file(seeded, entity, minutes=2, metrics={"warnings": "строка"}) + client = await auth_client(KA_RF) + + mixed_card = await client.get(f"/files/{mixed.id}") + not_list_card = await client.get(f"/files/{not_list.id}") + + assert mixed_card.json()["job"]["warnings"] == [ + {"code": "LEGACY_WARNING", "severity": "info", "message": "a", "locator": None}, + structured, + ] + assert not_list_card.json()["job"]["warnings"] == [] + + async def test_file_card_without_current_job(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: entity = await _rostov_entity(seeded) file, _ = await _file(seeded, entity, minutes=1, status=FileStatus.UPLOADED) diff --git a/api/tests/test_contract.py b/api/tests/test_contract.py index eb01593..a6a64df 100644 --- a/api/tests/test_contract.py +++ b/api/tests/test_contract.py @@ -1,8 +1,8 @@ -"""Контракт обмена с сервисами распознавания v1.0 (spec recognition-job-exchange, design D-6). +"""Контракт обмена с сервисами распознавания, MAJOR 1 и 2 (spec recognition-job-exchange, design D-6). Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/` по правилу MAJOR (S-53); -каждый пример проходит и JSON Schema, и pydantic-модель (с round-trip); модели и схемы выносят один и тот же -вердикт на негативных кейсах. +каждый пример проходит и JSON Schema, и pydantic-модель (с round-trip): `examples/*.json` — схемы и модели v2.0, +`examples/v1/*.json` — схемы `.v1` и модели v1; модели и схемы выносят один и тот же вердикт на негативных кейсах. """ import copy @@ -29,17 +29,26 @@ from src.contract import ( RecognitionRequest, RecognitionResult, RecognitionResultMessage, + RecognitionResultMessageV2, + RecognitionResultV2, ack_msg_id, request_msg_id, request_subject, result_msg_id, ) +from src.contract.versions import major, parse_message, parse_result from tests.contract_identity import identity_problems CONTRACTS_DIR = Path(__file__).resolve().parents[1] / "contracts" EXAMPLES_DIR = CONTRACTS_DIR / "examples" +V1_EXAMPLES_DIR = EXAMPLES_DIR / "v1" +# Таблица согласованности ниже — про MAJOR 1: схемы `.v1` и модели v1; MAJOR 2 — отдельные тесты в конце файла. SCHEMA_FILES = { "request": "recognition-request.schema.json", + "message": "recognition-result-message.v1.schema.json", + "result": "recognition-result.v1.schema.json", +} +SCHEMA_FILES_V2 = { "message": "recognition-result-message.schema.json", "result": "recognition-result.schema.json", } @@ -48,6 +57,10 @@ MODELS: dict[str, type[ContractModel]] = { "message": RecognitionResultMessage, "result": RecognitionResult, } +MODELS_V2: dict[str, type[ContractModel]] = { + "message": RecognitionResultMessageV2, + "result": RecognitionResultV2, +} EXAMPLE_KIND = { "request-image.json": "request", "request-tabular.json": "request", @@ -68,14 +81,21 @@ def _load(path: Path) -> Any: def _example(name: str) -> dict[str, Any]: + """Пример MAJOR 1: запросы лежат в `examples/`, результаты и конверты v1 — в `examples/v1/`.""" + data = _load((EXAMPLES_DIR if name.startswith("request-") else V1_EXAMPLES_DIR) / name) + assert isinstance(data, dict) + return data + + +def _example_v2(name: str) -> dict[str, Any]: data = _load(EXAMPLES_DIR / name) assert isinstance(data, dict) return data -def _build_validators() -> dict[str, Draft202012Validator]: - """Валидаторы по трём схемам; `$ref` между схемами разрешаются локальным реестром (без сети).""" - schemas = {kind: _load(CONTRACTS_DIR / file_name) for kind, file_name in SCHEMA_FILES.items()} +def _build_validators(schema_files: dict[str, str] = SCHEMA_FILES) -> dict[str, Draft202012Validator]: + """Валидаторы по схемам; `$ref` между схемами разрешаются локальным реестром (без сети).""" + schemas = {kind: _load(CONTRACTS_DIR / file_name) for kind, file_name in schema_files.items()} registry: Registry = Registry() for schema in schemas.values(): registry = registry.with_resource(schema["$id"], Resource.from_contents(schema)) @@ -86,6 +106,7 @@ def _build_validators() -> dict[str, Draft202012Validator]: VALIDATORS = _build_validators() +VALIDATORS_V2 = _build_validators(SCHEMA_FILES_V2) def _schema_ok(kind: str, payload: dict[str, Any]) -> bool: @@ -119,9 +140,12 @@ def test_vendored_contracts_are_identical_to_docs() -> None: def test_example_table_covers_all_example_files() -> None: - files = {p.name for p in EXAMPLES_DIR.glob("*.json")} - assert files == set(EXAMPLE_KIND) - assert len(files) == EXAMPLES_COUNT + v1_files = {p.name for p in V1_EXAMPLES_DIR.glob("*.json")} + requests = {p.name for p in EXAMPLES_DIR.glob("request-*.json")} + assert v1_files | requests == set(EXAMPLE_KIND) + assert len(v1_files | requests) == EXAMPLES_COUNT + # v2.0: те же имена в `examples/` + assert {p.name for p in EXAMPLES_DIR.glob("*.json")} == set(EXAMPLE_KIND) # --- примеры: JSON Schema, модель, round-trip --------------------------------------------------------------------- @@ -649,3 +673,297 @@ def test_integer_valued_float_is_normalized_to_int() -> None: assert type(wire["documents"][0]["rows"][0]["level"]) is int assert type(wire["documents"][0]["header"]["header_variant"]) is int VALIDATORS["result"].validate(wire) + + +# --- MAJOR 2: примеры `examples/*.json` против схем и моделей v2.0 ---------------------------------------------------- + +V2_EXAMPLES = sorted(name for name, kind in EXAMPLE_KIND.items() if kind != "request") + + +@pytest.mark.parametrize("name", V2_EXAMPLES) +def test_example_v2_passes_json_schema(name: str) -> None: + VALIDATORS_V2[EXAMPLE_KIND[name]].validate(_example_v2(name)) + + +@pytest.mark.parametrize("name", V2_EXAMPLES) +def test_example_v2_passes_model_and_round_trips(name: str) -> None: + kind = EXAMPLE_KIND[name] + original = _example_v2(name) + model = MODELS_V2[kind].model_validate(original) + assert model.model_dump(mode="json", exclude_unset=True) == original + wire = model.to_wire() + assert wire == original + VALIDATORS_V2[kind].validate(wire) + assert MODELS_V2[kind].model_validate(wire) == model + + +def test_v2_models_expose_json_schema() -> None: + for model in MODELS_V2.values(): + assert model.model_json_schema()["type"] == "object" + + +def test_v2_statement_amounts_are_decimal_and_warnings_are_structures() -> None: + result = RecognitionResultV2.model_validate(_example_v2("result-statement-excel.json")) + balance = result.documents[0] + assert balance.lines[0].values[0].amount == Decimal("586260.26") # type: ignore[union-attr] + assert balance.warnings[0].code == "FRACTIONAL_IN_THOUSANDS" + assert balance.warnings[0].severity == "info" + assert result.documents[1].lines[1].line_status == "detail" # type: ignore[union-attr] + + +# (идентификатор, пример-основа, мутация, ожидаемый вердикт) — модель v2 и схема v2 согласованы +CASES_V2: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [ + ("v2/message/base", "result-message-done.json", lambda p: None, True), + ("v2/message/schema_version-1.0", "result-message-done.json", _set("schema_version", "1.0"), False), + ("v2/message/schema_version-2.3", "result-message-done.json", _set("schema_version", "2.3"), True), + ( + "v2/message/warning-ok", + "result-message-done.json", + _set( + "metrics.warnings", [{"code": "JSON_FAILED", "severity": "error", "message": "m", "locator": "file:a.json"}] + ), + True, + ), + ("v2/message/warning-string", "result-message-done.json", _set("metrics.warnings", ["строка"]), False), + ( + "v2/message/warning-bad-severity", + "result-message-done.json", + _set("metrics.warnings", [{"code": "X", "severity": "warn", "message": "m"}]), + False, + ), + ( + "v2/message/warning-bad-code", + "result-message-done.json", + _set("metrics.warnings", [{"code": "x-1", "severity": "info", "message": "m"}]), + False, + ), + ( + "v2/message/warning-no-message", + "result-message-done.json", + _set("metrics.warnings", [{"code": "X", "severity": "info"}]), + False, + ), + ("v2/result/statement-base", "result-statement-excel.json", lambda p: None, True), + ("v2/result/schema_version-1.2", "result-statement-excel.json", _set("schema_version", "1.2"), False), + ("v2/result/no-statements_version", "result-statement-excel.json", _drop("service.statements_version"), False), + ( + "v2/result/osv-no-statements_version-ok", + "result-osv-minimal.json", + lambda p: p["service"].pop("statements_version", None), + True, + ), + ("v2/result/type-OTHER", "result-statement-excel.json", _set("documents.0.document_type", "OTHER"), False), + ("v2/result/no-form_variant", "result-statement-excel.json", _drop("documents.0.form_variant"), False), + ("v2/result/form_variant-bad", "result-statement-excel.json", _set("documents.0.form_variant", "x"), False), + ("v2/result/no-status", "result-statement-excel.json", _drop("documents.0.status"), False), + ("v2/result/status-error", "result-statement-excel.json", _set("documents.0.status", "error"), False), + ("v2/result/no-warnings", "result-statement-excel.json", _drop("documents.0.warnings"), False), + ("v2/result/no-checks", "result-statement-excel.json", _drop("documents.0.checks"), False), + ( + "v2/result/checks-on-empty-lines", + "result-statement-excel.json", + _set("documents.2.checks", {"relations_failed": [], "soft_relations_failed": []}), + False, + ), + ("v2/result/line-no-ref", "result-statement-excel.json", _drop("documents.0.lines.0.ref"), False), + ("v2/result/line-empty-ref", "result-statement-excel.json", _set("documents.0.lines.0.ref", ""), False), + ( + "v2/result/line-row_index-negative", + "result-statement-excel.json", + _set("documents.0.lines.0.row_index", -1), + False, + ), + ("v2/result/line-status-bad", "result-statement-excel.json", _set("documents.0.lines.0.line_status", "x"), False), + ("v2/result/line-code-1-digit", "result-statement-excel.json", _set("documents.0.lines.0.line_code", "1"), False), + ("v2/result/ok-without-code", "result-statement-excel.json", _set("documents.0.lines.0.line_code", None), False), + ( + "v2/result/unreadable-with-code", + "result-statement-excel.json", + _set("documents.0.lines.0.line_status", "unreadable"), + False, + ), + ( + "v2/result/unreadable-null-code", + "result-statement-excel.json", + lambda p: p["documents"][0]["lines"][0].update(line_status="unreadable", line_code=None), + True, + ), + ( + "v2/result/detail-without-parent_ref", + "result-statement-excel.json", + _drop("documents.1.lines.1.parent_ref"), + False, + ), + ( + "v2/result/line-needs_review-missing", + "result-statement-excel.json", + _drop("documents.0.lines.0.needs_review"), + False, + ), + ( + "v2/result/value-text-missing", + "result-statement-excel.json", + _drop("documents.0.lines.0.values.0.text"), + False, + ), + ( + "v2/result/value-needs_review-missing", + "result-statement-excel.json", + _drop("documents.0.lines.0.values.0.needs_review"), + False, + ), + ( + "v2/result/value-amount-float", + "result-statement-excel.json", + _set("documents.0.lines.0.values.0.amount", 1.5), + False, + ), + ( + "v2/result/column-no-period_kind", + "result-statement-excel.json", + _drop("documents.0.columns.0.period_kind"), + False, + ), + ( + "v2/result/column-period_kind-bad", + "result-statement-excel.json", + _set("documents.0.columns.0.period_kind", "abs"), + False, + ), + ( + "v2/result/column-index-negative", + "result-statement-excel.json", + _set("documents.0.columns.0.column_index", -1), + False, + ), + ("v2/result/header-inn-bad", "result-statement-excel.json", _set("documents.0.header.inn", "123"), False), + ("v2/result/header-inn-null", "result-statement-excel.json", _set("documents.0.header.inn", None), True), + ( + "v2/result/header-needs_review_fields-bad", + "result-statement-excel.json", + _set("documents.0.header.needs_review_fields", ["zzz"]), + False, + ), + ( + "v2/result/header-needs_review_fields-dup", + "result-statement-excel.json", + _set("documents.0.header.needs_review_fields", ["inn", "inn"]), + False, + ), + ( + "v2/result/header-needs_review_fields-ok", + "result-statement-excel.json", + _set("documents.0.header.needs_review_fields", ["report_date"]), + True, + ), + ( + "v2/result/relation-no-codes", + "result-statement-excel.json", + _drop("documents.0.checks.soft_relations_failed.0.codes"), + False, + ), + ( + "v2/result/relation-empty-codes", + "result-statement-excel.json", + _set("documents.0.checks.soft_relations_failed.0.codes", []), + False, + ), + ( + "v2/result/relation-bad-code", + "result-statement-excel.json", + _set("documents.0.checks.soft_relations_failed.0.codes", ["12"]), + False, + ), + ( + "v2/result/checks-no-relations_failed", + "result-statement-excel.json", + _drop("documents.0.checks.relations_failed"), + False, + ), + ( + "v2/result/doc-warning-field-bad", + "result-statement-excel.json", + _set("documents.1.warnings.0.field", "zzz"), + False, + ), + ( + "v2/result/doc-warning-line_refs-ok", + "result-statement-excel.json", + _set("documents.0.warnings.0.line_refs", ["row:1"]), + True, + ), + ("v2/result/osv-base", "result-osv-needs-check.json", lambda p: None, True), + ("v2/result/osv-no-status", "result-osv-needs-check.json", _drop("documents.0.status"), False), + ("v2/result/osv-no-warnings", "result-osv-needs-check.json", _drop("documents.0.warnings"), False), + ("v2/result/osv-status-bad", "result-osv-needs-check.json", _set("documents.0.status", "error"), False), + ("v2/result/error-base", "result-error.json", lambda p: None, True), + ("v2/result/done-empty-documents", "result-osv-minimal.json", _set("documents", []), False), + ( + "v2/result/metrics-warning-string", + "result-osv-needs-check.json", + _set("metrics.warnings", ["строка"]), + False, + ), +] + + +@pytest.mark.parametrize(("case_id", "example", "mutate", "expected_valid"), CASES_V2, ids=[c[0] for c in CASES_V2]) +def test_model_and_schema_agree_v2( + case_id: str, example: str, mutate: Callable[[dict[str, Any]], None], expected_valid: bool +) -> None: + kind = EXAMPLE_KIND[example] + payload = copy.deepcopy(_example_v2(example)) + mutate(payload) + schema_verdict = VALIDATORS_V2[kind].is_valid(payload) + try: + MODELS_V2[kind].model_validate(payload) + model_verdict = True + except ValidationError: + model_verdict = False + assert schema_verdict == expected_valid, f"{case_id}: схема вынесла {schema_verdict}, ожидали {expected_valid}" + assert model_verdict == schema_verdict, f"{case_id}: модель {model_verdict}, схема {schema_verdict}" + + +# --- версии: разбор по MAJOR --------------------------------------------------------------------------------------- + + +@pytest.mark.parametrize(("version", "expected"), [("1.0", 1), ("1.7", 1), ("2.0", 2), ("2.13", 2), ("10.1", 10)]) +def test_major(version: str, expected: int) -> None: + assert major(version) == expected + + +@pytest.mark.parametrize("bad", ["", "1", "x.y", "1.0.1", "v2.0", " 2.0"]) +def test_major_rejects_malformed_version(bad: str) -> None: + with pytest.raises(ValueError, match="schema_version"): + major(bad) + + +def test_parse_message_by_major() -> None: + v1 = parse_message(_example("result-message-done.json")) + assert type(v1) is RecognitionResultMessage + v2 = parse_message(_example_v2("result-message-done.json")) + assert type(v2) is RecognitionResultMessageV2 + payload = _example_v2("result-message-done.json") + payload["schema_version"] = "3.0" + with pytest.raises(ValidationError): + parse_message(payload) + payload["schema_version"] = "banana" + with pytest.raises(ValidationError): + parse_message(payload) + no_version = _example_v2("result-message-done.json") + del no_version["schema_version"] + with pytest.raises(ValidationError): + parse_message(no_version) + + +def test_parse_result_by_major() -> None: + v1 = parse_result((V1_EXAMPLES_DIR / "result-statement-minimal.json").read_bytes()) + assert type(v1) is RecognitionResult + v2 = parse_result((EXAMPLES_DIR / "result-statement-excel.json").read_bytes()) + assert type(v2) is RecognitionResultV2 + payload = _example_v2("result-statement-excel.json") + payload["schema_version"] = "3.0" + with pytest.raises(ValidationError): + parse_result(json.dumps(payload).encode("utf-8")) + with pytest.raises(ValidationError): + parse_result(b"[1, 2]") diff --git a/api/tests/test_domain_osv_mapping.py b/api/tests/test_domain_osv_mapping.py index 0c9d372..de5b659 100644 --- a/api/tests/test_domain_osv_mapping.py +++ b/api/tests/test_domain_osv_mapping.py @@ -31,7 +31,7 @@ from src.models.enums import DocumentCheckResult, DocumentStatus, OsvDataType, O API_DIR = Path(__file__).resolve().parent.parent SEED_DIR = API_DIR / "seed" -MINIMAL_EXAMPLE = API_DIR / "contracts" / "examples" / "result-osv-minimal.json" +MINIMAL_EXAMPLE = API_DIR / "contracts" / "examples" / "v1" / "result-osv-minimal.json" GOLDEN_OSV_66 = Path("golden") / "expected_osv_parse" / "rb" / "2024-FY" / "ОСВ_66 за 2024.json" OWNER_INN = "3528052718" diff --git a/api/tests/test_exchange_lifespan_nats.py b/api/tests/test_exchange_lifespan_nats.py index ac97ea1..01ad623 100644 --- a/api/tests/test_exchange_lifespan_nats.py +++ b/api/tests/test_exchange_lifespan_nats.py @@ -34,7 +34,7 @@ from src.models.enums import RecognitionStatus, S3BucketRole from src.models.files import RecognitionJob, S3Object from src.services.recognition_exchange import handle_message from tests.factories import CommittedJobs -from tests.test_recognition_inbox import MESSAGE_VALIDATOR, envelope +from tests.test_recognition_inbox import MESSAGE_VALIDATORS, envelope pytestmark = pytest.mark.integration @@ -92,7 +92,7 @@ class _Service: pytest.fail(f"NATS недоступен ({url}, .env.test): {type(exc).__name__}", pytrace=False) async def send(self, body: dict[str, Any], msg_id: str) -> None: - MESSAGE_VALIDATOR.validate(body) + MESSAGE_VALIDATORS[str(body["schema_version"]).split(".")[0]].validate(body) payload = json.dumps(body, ensure_ascii=False).encode("utf-8") await self.nc.publish(self.result_subject, payload, headers={NATS_MSG_ID_HEADER: msg_id}) await self.nc.flush() diff --git a/api/tests/test_factories.py b/api/tests/test_factories.py index c840027..d84b23a 100644 --- a/api/tests/test_factories.py +++ b/api/tests/test_factories.py @@ -222,7 +222,7 @@ async def test_make_document_defaults_and_uniqueness(db_session: AsyncSession) - await make_document(db_session, file=file, job=job) -MINIMAL_RESULT = Path(__file__).resolve().parents[1] / "contracts" / "examples" / "result-osv-minimal.json" +MINIMAL_RESULT = Path(__file__).resolve().parents[1] / "contracts" / "examples" / "v1" / "result-osv-minimal.json" @pytest.mark.integration diff --git a/api/tests/test_recognition_inbox.py b/api/tests/test_recognition_inbox.py index c578302..289587a 100644 --- a/api/tests/test_recognition_inbox.py +++ b/api/tests/test_recognition_inbox.py @@ -49,8 +49,15 @@ from src.services.recognition_exchange import ( from tests.factories import CommittedJobs API_DIR = Path(__file__).resolve().parents[1] -MESSAGE_SCHEMA = json.loads((API_DIR / "contracts" / "recognition-result-message.schema.json").read_text("utf-8")) -MESSAGE_VALIDATOR = Draft202012Validator(MESSAGE_SCHEMA, format_checker=FormatChecker()) +MESSAGE_VALIDATORS = { + major: Draft202012Validator( + json.loads((API_DIR / "contracts" / file_name).read_text("utf-8")), format_checker=FormatChecker() + ) + for major, file_name in ( + ("1", "recognition-result-message.v1.schema.json"), + ("2", "recognition-result-message.schema.json"), + ) +} SERVICE = {"name": "tabular-parser", "version": "0.1.0"} BUCKET = "arm-ka" @@ -82,10 +89,13 @@ def _result_key(file_id: uuid.UUID, job_id: uuid.UUID) -> str: return f"results/{file_id}/{job_id}/result.json" -def envelope(job_id: uuid.UUID, file_id: uuid.UUID, status: str, **extra: Any) -> dict[str, Any]: - """Конверт `recognition-result-message` v1.0; для done/needs_check по умолчанию — `result_s3` и версия схемы.""" +def envelope( + job_id: uuid.UUID, file_id: uuid.UUID, status: str, *, version: str = "1.0", **extra: Any +) -> dict[str, Any]: + """Конверт `recognition-result-message` (по умолчанию 1.0); для done/needs_check по умолчанию — `result_s3` и + версия схемы результата того же MAJOR.""" body: dict[str, Any] = { - "schema_version": "1.0", + "schema_version": version, "job_id": str(job_id), "file_id": str(file_id), "status": status, @@ -93,7 +103,7 @@ def envelope(job_id: uuid.UUID, file_id: uuid.UUID, status: str, **extra: Any) - } if status in ("done", "needs_check"): body["result_s3"] = {"bucket": BUCKET, "key": _result_key(file_id, job_id)} - body["result_schema_version"] = "1.0" + body["result_schema_version"] = version body.update(extra) return body @@ -101,7 +111,7 @@ def envelope(job_id: uuid.UUID, file_id: uuid.UUID, status: str, **extra: Any) - def fake_msg(body: Any, msg_id: str | None, *, header: str = NATS_MSG_ID_HEADER, validate: bool = True) -> Any: """Msg-подобный объект nats-py: `.data`, `.headers`, `.subject`. Тело по умолчанию проверяется JSON Schema.""" if validate: - MESSAGE_VALIDATOR.validate(body) + MESSAGE_VALIDATORS[str(body["schema_version"]).split(".")[0]].validate(body) data = body if isinstance(body, bytes) else json.dumps(body, ensure_ascii=False).encode("utf-8") headers = None if msg_id is None else {header: msg_id} return SimpleNamespace(data=data, headers=headers, subject="files.tabular_parsed") @@ -223,7 +233,7 @@ _FILE = uuid.UUID("22222222-2222-4222-8222-222222222222") b"[1, 2]", b'"text"', json.dumps({"schema_version": "1.0", "status": "done"}).encode(), # нет job_id - json.dumps(envelope(_JOB, _FILE, "done", schema_version="2.0")).encode(), # чужой MAJOR + json.dumps(envelope(_JOB, _FILE, "done", schema_version="3.0")).encode(), # неизвестный MAJOR json.dumps({k: v for k, v in envelope(_JOB, _FILE, "done").items() if k != "result_s3"}).encode(), json.dumps(envelope(_JOB, _FILE, "error")).encode(), # error без поля error json.dumps(envelope(_JOB, _FILE, "finished")).encode(), # статус вне enum @@ -317,6 +327,54 @@ async def test_ack_moves_published_job_to_processing(session_factory, jobs: Comm assert _logged(logs, "ack", "INFO") +@pytest.mark.integration +async def test_inbox_ack_v2_moves_job_to_processing(session_factory, jobs: CommittedJobs): + """Ack конверта 2.0 принимается так же, как 1.0 (ядро на окне перехода держит оба MAJOR).""" + job_id = await jobs.make_queued_job(status=RecognitionStatus.published, request_msg_id=f"{uuid.uuid4()}:1") + file_id = await _file_id(jobs, job_id) + + await _handle( + session_factory, fake_msg(envelope(job_id, file_id, "processing", version="2.0"), ack_msg_id(job_id, 1)) + ) + + after = await _row(session_factory, job_id) + assert after["status"] is RecognitionStatus.processing + assert (after["service_name"], after["service_version"]) == ("tabular-parser", "0.1.0") + + +@pytest.mark.integration +async def test_inbox_v1_warnings_wrapped(session_factory, jobs: CommittedJobs): + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) + file_id = await _file_id(jobs, job_id) + metrics = {"warnings": ["sheet=ОСВ 60 строки без родителя"]} + + await _handle( + session_factory, fake_msg(envelope(job_id, file_id, "done", metrics=metrics), result_msg_id(job_id, 1)) + ) + + job = await _row(session_factory, job_id) + assert job["status"] is RecognitionStatus.done + assert job["metrics"]["warnings"] == [ + {"code": "LEGACY_WARNING", "severity": "info", "message": "sheet=ОСВ 60 строки без родителя", "locator": None} + ] + + +@pytest.mark.integration +async def test_inbox_v2_warnings_kept(session_factory, jobs: CommittedJobs): + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) + file_id = await _file_id(jobs, job_id) + warning = {"code": "JSON_FAILED", "severity": "error", "message": "ошибочный json", "locator": "file:a.json"} + body = envelope(job_id, file_id, "needs_check", version="2.0", metrics={"rows": 3, "warnings": [warning]}) + + await _handle(session_factory, fake_msg(body, result_msg_id(job_id, 1))) + + job = await _row(session_factory, job_id) + assert job["status"] is RecognitionStatus.needs_check + assert job["result_schema_version"] == "2.0" + assert job["metrics"] == {"rows": 3, "warnings": [warning]} + assert job["fields_total"] is None + + @pytest.mark.integration async def test_repeated_ack_changes_nothing(session_factory, jobs: CommittedJobs): job_id = await jobs.make_queued_job(status=RecognitionStatus.published) @@ -381,7 +439,9 @@ async def test_done_result_registers_s3_object_and_updates_job(session_factory, assert job["finished_at"] == moment assert job["result_schema_version"] == "1.1" assert (job["service_name"], job["service_version"]) == ("tabular-parser", "0.1.0") - assert job["metrics"] == {"republish_count": 1, **metrics} + # предупреждения 1.x ложатся структурами (LEGACY_WARNING), остальные метрики — как пришли + legacy = [{"code": "LEGACY_WARNING", "severity": "info", "message": "лист без шапки", "locator": None}] + assert job["metrics"] == {"republish_count": 1, **metrics, "warnings": legacy} assert (job["fields_total"], job["fields_low_confidence"], job["fields_unrecognized"]) == (300, 4, 2) assert (job["error_code"], job["error_message"]) == (None, None) assert s3["bucket_role"] is S3BucketRole.results @@ -943,7 +1003,7 @@ class _FakeService: await self._nc.flush() async def send(self, body: dict[str, Any], msg_id: str) -> None: - MESSAGE_VALIDATOR.validate(body) + MESSAGE_VALIDATORS[str(body["schema_version"]).split(".")[0]].validate(body) await self._nc.publish(self.result_subject, json.dumps(body).encode(), headers={NATS_MSG_ID_HEADER: msg_id}) await self._nc.flush() diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index 871b466..d0c7615 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -3,7 +3,7 @@ D-9; рулинги R-3, R-4, R-5; Review Focus 1, 2). Интеграционные тесты — на отдельной временной БД (`committed_sessionmaker`: цикл работает в собственных транзакциях), результат лежит в `FakeStorage` под `results/{file_id}/{job_id}/result.json`. Синтетические результаты строятся из -примера контракта `contracts/examples/result-osv-minimal.json`; сценарий «Результат смапплен» и замер скорости — на +примера контракта `contracts/examples/v1/result-osv-minimal.json`; сценарий «Результат смапплен» и замер скорости — на golden-снимках парсера (маркер `fixtures`). """ @@ -89,7 +89,7 @@ from tests.factories import ( from tests.test_recognition_inbox import envelope, fake_msg API_DIR = Path(__file__).resolve().parents[1] -MINIMAL_RESULT = API_DIR / "contracts" / "examples" / "result-osv-minimal.json" +MINIMAL_RESULT = API_DIR / "contracts" / "examples" / "v1" / "result-osv-minimal.json" GOLDEN_OSV_66 = Path("golden") / "expected_osv_parse" / "rb" / "2024-FY" / "ОСВ_66 за 2024.json" GOLDEN_OSV_60 = Path("golden") / "expected_osv_parse" / "rb" / "2024-FY" / "ОСВ_60 за 2024.json" BUCKET = "arm-ka" @@ -1307,7 +1307,7 @@ async def test_replaced_document_is_unbound_from_versions_in_work( # --- OQ-154: предупреждение о документах заменённого файла, которых нет в новом ----------------------------------- -STATEMENT_MINIMAL = API_DIR / "contracts" / "examples" / "result-statement-minimal.json" +STATEMENT_MINIMAL = API_DIR / "contracts" / "examples" / "v1" / "result-statement-minimal.json" def _statement_result(*document_types: str) -> dict[str, Any]: -- 2.47.2 From 70abf87809a4d610d410bb062822a70f8268a0da Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 19:29:24 +0400 Subject: [PATCH 19/20] =?UTF-8?q?docs(readme):=20JobBrief.warnings=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80?= =?UTF-8?q?=D1=8B=20(A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bb3cd3e..63a44eb 100644 --- a/README.md +++ b/README.md @@ -529,7 +529,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл | `GET /legal-entities/{legal_entity_id}/files` | `DOCUMENT_VIEW` | файлы ЮЛ, новые первыми (`limit`/`offset`; `include_superseded=true` — с заменёнными) | | `POST /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` | `DOCUMENT_EDIT` | загрузка в консолидацию (v5.18): файл получает `consolidation_id`; версии консолидации «в работе» с ЮЛ в периметре `INITIATED` → `DOCUMENTS_PROCESSING` | | `GET /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` | `DOCUMENT_VIEW` | рабочий стол консолидации для ЮЛ: файлы с её `consolidation_id` + файлы с документами, привязанными к её версиям (`limit`/`offset`/`include_superseded`) | -| `GET /files/{file_id}` | `DOCUMENT_VIEW` | карточка файла: статус, `job` (`error_code`, `error_message`, `mapping_error`, `warnings`, `mapping_warnings`), документы | +| `GET /files/{file_id}` | `DOCUMENT_VIEW` | карточка файла: статус, `job` (`error_code`, `error_message`, `mapping_error`, `warnings` — список объектов `{code, severity, message, locator}`, строки старых заданий отдаются как `LEGACY_WARNING`, `severity` — `error` или `info`; `mapping_warnings` — список строк), документы | | `DELETE /files/{file_id}` | `DOCUMENT_EDIT` | удалить файл (см. «Удаление и замена»), 204 | | `GET /documents/{document_id}` | `DOCUMENT_VIEW` | документ: статус, проверки, `locator`, `group_schema` листа, `warnings`, `file_id` | | `GET /documents/{document_id}/entries` | `DOCUMENT_VIEW` | строки ОСВ по `row_number` (`limit` ≤ 2000, по умолчанию 500); дерево — по `parent_entry_id` | -- 2.47.2 From c2ee31ddbb1646e5c1f6d6fb51357d36a369155b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 19:29:55 +0400 Subject: [PATCH 20/20] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20A5=20=D0=B8=20=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=D1=8B=D0=B5=20=D0=B2=D0=BE=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D1=8B=20=D1=87=D0=B0=D1=81=D1=82=D0=B8=20=D0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- .../changes/unified-normalization-core-a/design.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openspec/changes/unified-normalization-core-a/design.md b/openspec/changes/unified-normalization-core-a/design.md index 2cd981c..ede0fd1 100644 --- a/openspec/changes/unified-normalization-core-a/design.md +++ b/openspec/changes/unified-normalization-core-a/design.md @@ -74,6 +74,12 @@ Goals и Non-goals — см. `proposal.md`. | 2026-10-18 | **A6-2.** `names()` — имена без повторов в порядке редакций и появления; `edition_codes` — по всем записям | стабильность подсказки | нет | | 2026-10-18 | **A6-3.** Если «итого по разделу» только у одного из имён, номер у другого `None` — сравнение только по равенству строк | спека | нет | | 2026-10-18 | **A6-4.** Пустые справочные имена пропускаются | шум | нет | +| 2026-10-18 | **A5-1.** `FileWarning` наследует `DocumentWarning` (есть также `line_refs`/`column_index`/`field`): схема fileWarning = warning + locator | надмножество брифа, без дублирования полей | нет | +| 2026-10-18 | **A5-2.** `MetricsV2` наследует `Metrics` (переопределяет `warnings`); ветвление в `_metrics_json` идёт по классу конверта, не метрик | изоляция v1/v2 при общей базе метрик | v2-конверт обернул бы уже готовые структуры; покрыто `test_inbox_v2_warnings_kept` | +| 2026-10-18 | **A5-3.** `parse_message`/`parse_result`: не-2 MAJOR отдаётся модели v1, чей шаблон `^1\.\d+$` даёт `ValidationError` (3.0, «banana», нет поля); нечитаемый JSON в `parse_result` — `json.JSONDecodeError` (ValueError), не `ValidationError` | единая точка отказа для неподдерживаемых версий | вызывающий код, ждущий только `ValidationError`, пропустит ошибку чтения JSON (см. открытый вопрос) | +| 2026-10-18 | **A5-4.** У v2-предупреждения без `locator` в метрики пишется как пришло (ключа нет), у LEGACY — `locator: None` | не домысливать данные, не менять пришедшее | потребитель обязан читать `locator` как необязательный (`file_warnings` так и делает) | +| 2026-10-18 | **A5-5.** `RecognitionResultMessageV2` не наследует v1; общие поля вынесены в `_MessageBase` | изоляция `isinstance` между версиями | нет | +| 2026-10-18 | **A5-6.** `file_warnings`: `locator` не-строка/не-null -> объект пропускается | защита от мусора во входящем конверте | потеря предупреждения с некорректным locator | | 2026-10-18 | **A6-5.** Старые тесты API обновлены под код 1105 в `fsbu4` (yaml дополнен 1105/1130/1140/1530/2460 и вторыми именами) | данные docs | для ГСК-кода 1105 на 66н с датой `fsbu4` появляется пустая строка «Гудвил» (O-2 A6) | | 2026-10-18 | **A7-1.** `FailedCheck.codes` — поле с default `()`, участвует в сравнении | P3-14 | низкая | | 2026-10-18 | **A7-2.** `insert_statement` до маппера 2.0 пишет `row_index` = порядковый номер строки черновика | иначе все строки источника стали бы `origin = template` | заменено в A9/части Б | @@ -133,3 +139,7 @@ Goals и Non-goals — см. `proposal.md`. - **O-9.** Выкладка: PR части А в `main` без снимка контура до части Б; встроенный фронт `arm-core/web` получает новые типы только через `sync-web` после плана 6. - **O-10.** Метрики `fields_*` ядро не считает по строкам формы (копирует из конверта, `recognition_exchange.FIELD_COUNTERS`). Подтвердить, что спека («метрики `fields_*`») не требует нового расчёта. - **Наблюдение.** Хранимая вторая ББ теперь входит в ключи автозамещения: файл с ББ на 30.09.2025 и ББ на 31.12.2024 может заместить более старый файл; его ОФР при этом выпадает с предупреждением OQ-154. +- **A5-O1.** `parse_result` на битом JSON бросает `JSONDecodeError`/`UnicodeDecodeError`, а не `ValidationError`: в части Б при переключении `_load_result` ловить `ValueError`. +- **A5-O2.** `statement-code-orders.yaml` не скопирован в `api/contracts` (тест идентичности его не покрывает); при необходимости ядру — скопировать в части Б. +- **A5-O3.** `frontend-api-guide` в docs должен описывать `JobBrief.warnings` как объекты (docs, план 6). +- **Нумерация.** Пропуски в нумерации O-n (O-2, O-5, O-8) закрыты планом/рулингами Р-n. -- 2.47.2