diff --git a/openspec/changes/unified-normalization-core-a/design.md b/openspec/changes/archive/2026-10-19-unified-normalization-core-a/design.md similarity index 100% rename from openspec/changes/unified-normalization-core-a/design.md rename to openspec/changes/archive/2026-10-19-unified-normalization-core-a/design.md diff --git a/openspec/changes/unified-normalization-core-a/proposal.md b/openspec/changes/archive/2026-10-19-unified-normalization-core-a/proposal.md similarity index 100% rename from openspec/changes/unified-normalization-core-a/proposal.md rename to openspec/changes/archive/2026-10-19-unified-normalization-core-a/proposal.md diff --git a/openspec/changes/unified-normalization-core-a/specs/data-model/spec.md b/openspec/changes/archive/2026-10-19-unified-normalization-core-a/specs/data-model/spec.md similarity index 100% rename from openspec/changes/unified-normalization-core-a/specs/data-model/spec.md rename to openspec/changes/archive/2026-10-19-unified-normalization-core-a/specs/data-model/spec.md diff --git a/openspec/changes/unified-normalization-core-a/specs/directories-api/spec.md b/openspec/changes/archive/2026-10-19-unified-normalization-core-a/specs/directories-api/spec.md similarity index 100% rename from openspec/changes/unified-normalization-core-a/specs/directories-api/spec.md rename to openspec/changes/archive/2026-10-19-unified-normalization-core-a/specs/directories-api/spec.md diff --git a/openspec/changes/unified-normalization-core-a/specs/documents-api/spec.md b/openspec/changes/archive/2026-10-19-unified-normalization-core-a/specs/documents-api/spec.md similarity index 100% rename from openspec/changes/unified-normalization-core-a/specs/documents-api/spec.md rename to openspec/changes/archive/2026-10-19-unified-normalization-core-a/specs/documents-api/spec.md diff --git a/openspec/changes/unified-normalization-core-a/specs/osv-result-mapping/spec.md b/openspec/changes/archive/2026-10-19-unified-normalization-core-a/specs/osv-result-mapping/spec.md similarity index 100% rename from openspec/changes/unified-normalization-core-a/specs/osv-result-mapping/spec.md rename to openspec/changes/archive/2026-10-19-unified-normalization-core-a/specs/osv-result-mapping/spec.md diff --git a/openspec/changes/unified-normalization-core-a/specs/primary-files/spec.md b/openspec/changes/archive/2026-10-19-unified-normalization-core-a/specs/primary-files/spec.md similarity index 100% rename from openspec/changes/unified-normalization-core-a/specs/primary-files/spec.md rename to openspec/changes/archive/2026-10-19-unified-normalization-core-a/specs/primary-files/spec.md diff --git a/openspec/changes/unified-normalization-core-a/specs/recognition-job-exchange/spec.md b/openspec/changes/archive/2026-10-19-unified-normalization-core-a/specs/recognition-job-exchange/spec.md similarity index 100% rename from openspec/changes/unified-normalization-core-a/specs/recognition-job-exchange/spec.md rename to openspec/changes/archive/2026-10-19-unified-normalization-core-a/specs/recognition-job-exchange/spec.md diff --git a/openspec/changes/unified-normalization-core-a/specs/statement-checks/spec.md b/openspec/changes/archive/2026-10-19-unified-normalization-core-a/specs/statement-checks/spec.md similarity index 100% rename from openspec/changes/unified-normalization-core-a/specs/statement-checks/spec.md rename to openspec/changes/archive/2026-10-19-unified-normalization-core-a/specs/statement-checks/spec.md diff --git a/openspec/changes/unified-normalization-core-a/tasks.md b/openspec/changes/archive/2026-10-19-unified-normalization-core-a/tasks.md similarity index 100% rename from openspec/changes/unified-normalization-core-a/tasks.md rename to openspec/changes/archive/2026-10-19-unified-normalization-core-a/tasks.md diff --git a/openspec/changes/unified-normalization-core-b/design.md b/openspec/changes/archive/2026-10-19-unified-normalization-core-b/design.md similarity index 100% rename from openspec/changes/unified-normalization-core-b/design.md rename to openspec/changes/archive/2026-10-19-unified-normalization-core-b/design.md diff --git a/openspec/changes/unified-normalization-core-b/proposal.md b/openspec/changes/archive/2026-10-19-unified-normalization-core-b/proposal.md similarity index 100% rename from openspec/changes/unified-normalization-core-b/proposal.md rename to openspec/changes/archive/2026-10-19-unified-normalization-core-b/proposal.md diff --git a/openspec/changes/unified-normalization-core-b/specs/documents-api/spec.md b/openspec/changes/archive/2026-10-19-unified-normalization-core-b/specs/documents-api/spec.md similarity index 100% rename from openspec/changes/unified-normalization-core-b/specs/documents-api/spec.md rename to openspec/changes/archive/2026-10-19-unified-normalization-core-b/specs/documents-api/spec.md diff --git a/openspec/changes/unified-normalization-core-b/specs/osv-result-mapping/spec.md b/openspec/changes/archive/2026-10-19-unified-normalization-core-b/specs/osv-result-mapping/spec.md similarity index 100% rename from openspec/changes/unified-normalization-core-b/specs/osv-result-mapping/spec.md rename to openspec/changes/archive/2026-10-19-unified-normalization-core-b/specs/osv-result-mapping/spec.md diff --git a/openspec/changes/unified-normalization-core-b/specs/recognition-job-exchange/spec.md b/openspec/changes/archive/2026-10-19-unified-normalization-core-b/specs/recognition-job-exchange/spec.md similarity index 100% rename from openspec/changes/unified-normalization-core-b/specs/recognition-job-exchange/spec.md rename to openspec/changes/archive/2026-10-19-unified-normalization-core-b/specs/recognition-job-exchange/spec.md diff --git a/openspec/changes/unified-normalization-core-b/specs/statement-checks/spec.md b/openspec/changes/archive/2026-10-19-unified-normalization-core-b/specs/statement-checks/spec.md similarity index 100% rename from openspec/changes/unified-normalization-core-b/specs/statement-checks/spec.md rename to openspec/changes/archive/2026-10-19-unified-normalization-core-b/specs/statement-checks/spec.md diff --git a/openspec/changes/unified-normalization-core-b/tasks.md b/openspec/changes/archive/2026-10-19-unified-normalization-core-b/tasks.md similarity index 100% rename from openspec/changes/unified-normalization-core-b/tasks.md rename to openspec/changes/archive/2026-10-19-unified-normalization-core-b/tasks.md diff --git a/openspec/specs/data-model/spec.md b/openspec/specs/data-model/spec.md index dfa5ade..4af8cc9 100644 --- a/openspec/specs/data-model/spec.md +++ b/openspec/specs/data-model/spec.md @@ -6,8 +6,8 @@ ## Requirements -### Requirement: Схема БД равна DBML v5.18 -ORM-метаданные SHALL содержать ровно 43 таблицы и 28 enum'ов из `../docs/model/data-model-v5.18.dbml` в схеме PostgreSQL +### 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, значения @@ -30,9 +30,10 @@ SHALL добавлять обе колонки (существующие зап привязанным к версии, — консолидация этой версии; при привязках к версиям разных консолидаций — самый ранний период (год, затем квартал); файлы без привязок остаются `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.18.dbml` (`make dbml-check`) +- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.19.dbml` (`make dbml-check`) - **THEN** вывод пуст и код возврата 0 #### Scenario: Лишняя колонка в ORM @@ -62,6 +63,10 @@ DBML, MUST NOT существовать. - **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: Миграции применяются с нуля и откатываются Цепочка ревизий `0001_dbml_v5_13` → `0002_dbml_v5_14` → `0003_dbml_v5_15` → `0004_dbml_v5_16` → `0005_dbml_v5_17` → `0006_dbml_v5_18` SHALL создавать схему из `DB_SCHEMA` (если нет), все @@ -94,11 +99,11 @@ enum'ы, таблицы, ограничения, индексы и послед - **THEN** сгенерированная ревизия не содержит операций (пустые `upgrade`/`downgrade`) ### Requirement: Версия DBML зафиксирована в коде -Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.18"`); скрипт сверки SHALL сравнивать её с версией в шапке файла +Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.19"`); скрипт сверки SHALL сравнивать её с версией в шапке файла DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна. #### Scenario: Новая версия DBML без обновления ORM -- **WHEN** в `docs/model/` появляется `data-model-v5.19.dbml`, а константа в ORM — `5.18` +- **WHEN** в `docs/model/` появляется `data-model-v5.20.dbml`, а константа в ORM — `5.19` - **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц ### Requirement: Деньги и проценты — точные типы @@ -108,3 +113,51 @@ ORM SHALL отдавать их как `Decimal`, `float` в моделях MUST #### Scenario: Чтение денежной колонки - **WHEN** из `osv_entry` читается строка со `closing_debit = 106311116.46` - **THEN** значение имеет тип `Decimal` и равно `Decimal("106311116.46")` + +### 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/specs/directories-api/spec.md b/openspec/specs/directories-api/spec.md index 245fa18..6016d84 100644 --- a/openspec/specs/directories-api/spec.md +++ b/openspec/specs/directories-api/spec.md @@ -143,39 +143,6 @@ SHALL установить `company_group_id = NULL`, при `CLOSE` — ещё - **WHEN** ЮЛ в ГСК A и ГСЗ C; `PATCH` с `gsk_id = B`, `gsz_id = null` - **THEN** членства в A и C закрыты, открыто членство в B; повтор того же тела ничего не меняет -### Requirement: ГСК и ГСЗ — группы и состав -`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`. `GET …/related-groups` и -`GET …/related-groups/{id}` SHALL отдавать группы с числом текущих членов и списком членов. Тела `POST` и `PATCH` SHALL -принимать необязательный `crm_id` («ID ГСК/ГСЗ в CRM» форм 51-03/51-04/77-02) и отбрасывать его: колонки у `related_group` -нет (arm-core #112); в ответах `crm_id` всегда `null`, в аудит и в список изменений `PATCH` не попадает. - -#### Scenario: `crm_id` ГСК принимается и не сохраняется -- **WHEN** ГСК создаётся с `crm_id = "3-9CGSK01"`, затем `PATCH` передаёт только `crm_id` -- **THEN** создание — 201 с `crm_id = null`; `PATCH` — 200 без изменений и без события аудита - -#### 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` - ### Requirement: Права и аудит на всех маршрутах справочников Каждый маршрут SHALL требовать соответствующее право (`DIRECTORY_VIEW` для чтения, `DIRECTORY_EDIT` для изменений, `DIRECTORY_DELETE` для удаления ГСК/ГСЗ и алиасов) и применять политику видимости (`directory-policy`); каждая мутация @@ -204,3 +171,39 @@ SHALL выполняться в одной транзакции с запись #### Scenario: Нет права на справочник - **WHEN** пользователь без `DIRECTORY_VIEW` запрашивает список счетов - **THEN** ответ 403 + +### 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/specs/documents-api/spec.md b/openspec/specs/documents-api/spec.md index 952e1ad..74fb008 100644 --- a/openspec/specs/documents-api/spec.md +++ b/openspec/specs/documents-api/spec.md @@ -41,11 +41,11 @@ v5.18 — консолидация, в контексте которой фай или `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`; непройденные неакцептованные жёсткие проверки §7 — 409 +`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`, `CHECKS_NOT_PASSED`. Подтверждение берёт блокировки в порядке «файл → +`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`. @@ -97,6 +97,18 @@ v5.18 — консолидация, в контексте которой фай - **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: Подхват документов при создании версии и расширении периметра При создании версии и при добавлении ЮЛ в периметр ядро SHALL привязать уже подтверждённые документы этого ЮЛ из незаменённых файлов, подходящие версии (условия — как при подтверждении); на один ключ (`document_type`, @@ -118,11 +130,27 @@ v5.18 — консолидация, в контексте которой фай `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[]` по `line_code` (`id`, `origin`, `line_code`, `line_code_confidence`, +`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. +`soft_failed`, `cross_form`, `accepted`: `by`, `at`, `comment` или `null`). Элемент нарушения в `checks` — `{column_index, +relation, expected, actual, codes, line_ids}`; `expected` и `actual` SHALL быть `null`, когда сторона соотношения пуста +(пустая цель при непустых членах — `expected = null`, пакет `arm_statements`, P1-21), см. требование «Блок `checks` +ответа формы из `document_issue`». Документ другого типа — 409 `DOCUMENT_NOT_STATEMENT`; чужой или несуществующий — 404. + +**Состав ответа v2.0.** `StatementOut` SHALL отдавать: у документа `form_variant`; у шапки `needs_review_fields`, `source` (`header_source`; +`confidence` — строкой Decimal, как прочие уверенности API), `organization_name_modified` и `okei`; у колонок `period_kind`, `label_confidence`; у строк `id`, `origin` (`template` ⇔ +`row_index = null`), `row_index`, `source_ref`, `line_status`, `line_code`, `line_code_text`, `line_code_confidence`, +`line_name` (из файла), `reference_name` (справочное имя по коду — первое имя кода в словаре — или `null`), +`parent_row_index`, `parent_code`, `needs_review`, `is_confirmed`, `line_code_modified`; у значений `text` +(`source_text`), `needs_review`, `is_confirmed` и прежние поля; `issues[]` — записи `document_issue` с `line_ids`, +`column_id`, `column_index`, `field`, `codes`; `checks` — нарушения из `document_issue` с `codes` и `line_ids`; +`edition_lines[] {code, name}` — справочник строк для подсказки кода: коды словаря имён формы в редакции ответа по одному +(имя — первое), у формы без редакции — коды всех редакций по одному. `DocumentBrief` (`FileOut.documents[]`) и +`DocumentOut` SHALL отдавать `issue_counts {error, info}` — число записей `document_issue` документа по уровню. Порядок +`lines` — по возрастанию кода, как сейчас (`schemas/statements.py`), одинаковые коды — по `row_index`; строки +`unreadable` / `missing` — после ближайшей предыдущей по `row_index` строки, размещённой по коду, без такой — в начале; +`detail` — после родителя. Разделы и размещение итогов на экране («как на бумаге», Р-14) строит экран. Форма SHALL достраиваться в ответе по справочнику строк редакции `edition` (`api/rules/statement_lines.yaml`, свой список кодов и наименований для `BALANCE` и `INCOME` каждой редакции): строки справочника, кода которых нет в форме, @@ -130,9 +158,8 @@ v5.18 — консолидация, в контексте которой фай 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 = "source"`, если пришла из источника, и `"template"`, если создана правкой -аналитика (`line_code_original_value IS NULL`). Строки источника, которых нет в справочнике, отдаются как есть. -Достроенные строки и значения MUST NOT записываться в БД при чтении. +достраиваются. Строка из БД имеет `origin = "template"` ⇔ `row_index IS NULL` (создана правкой аналитика), иначе `"source"`. Строки источника, которых нет в справочнике, отдаются как есть. +Достроенные строки и значения MUST NOT записываться в БД при чтении и MUST NOT участвовать в проверках §7. #### Scenario: Форма для экрана - **WHEN** запрошена форма `BALANCE` из `mezheninovskaya/2025-Q3/1_Форма1_бух_09` @@ -142,8 +169,10 @@ SHALL быть значение по каждой колонке формы — #### 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) + `id = null`, `amount = null`, `is_recognized = null`; достройка при чтении не меняет статус документа и проверки: + `checks.failed` содержит соотношения с пустой целью 1700 (`1700 = 1300 + 1400 + 1500`, `expected = null`, `actual` — + сумма членов) и `1600 = 1700` (цель 1600 заполнена, 1700 пуста) — такие же, как без достройки; подтверждение формы + требует правки строки 1700 или акцепта проверок #### Scenario: Недостающая ячейка - **WHEN** у строки 1150 в БД есть значение только для колонки 0, а у формы две колонки @@ -151,12 +180,17 @@ SHALL быть значение по каждой колонке формы — #### Scenario: Нет даты периода - **WHEN** у формы OCR не определена дата периода (`edition = null`) -- **THEN** `lines` — только строки из БД, у каждой значения по всем колонкам +- **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[]` @@ -170,10 +204,51 @@ SHALL быть значение по каждой колонке формы — `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`, + создаётся `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`, далее @@ -215,6 +290,45 @@ SHALL быть значение по каждой колонке формы — - **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` + ### Requirement: Акцепт проверок формы `POST /api/v1/documents/{document_id}/statement/checks/accept` (`DOCUMENT_EDIT`, policy) с `{comment}` (1–2000 символов) SHALL выполнять акцепт (`statement-checks`), пересчитывать статус и писать `audit_event(RECOGNIZED_DATA_EDIT, @@ -274,3 +388,183 @@ SHALL отдавать комментарии по `created_at`: `{id, text, aut #### Scenario: Чужая консолидация - **WHEN** `ka_rf` запрашивает стол консолидации ГК другого филиала и несуществующий id - **THEN** оба ответа 404 с одинаковым телом + +### 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` +и статус **точкой входа пакета `arm_statements` для пересчёта** по сохранённым данным (`statement-normalization`; +конвенция знака — по `source_text`); временного пересчёта ядра MUST NOT быть. Правила: `is_modified` или `is_confirmed` +значения ⇒ `needs_review = false`; `line_code_modified` или `is_confirmed` строки ⇒ `needs_review` строки = `false`; +значения строк `detail` — `needs_review = false` (Р-23), у строки, выведенной из `detail` назначением кода, +`needs_review` её значений считается по общему правилу; акцепт ⇒ жёсткие нарушения не учитываются; правленые ячейки в +голосование знака не входят. Результат пишется: `needs_review` строк и значений, `form_variant`, `parent_row_index` всех +строк `detail` (ближайшая строка `ok` выше по `row_index`; нет такой — `null`, строка остаётся `detail`), записи +`document_issue` видов `relation` / `soft` (заменяются целиком независимо от источника), статус. Относительные даты +колонок (`period_kind`, `statement-normalization`) пересчитываются от даты отчёта после правки `header.report_date` и +`columns[].period_date`. Отличие `statements_version` задания от версии копии ядра — `document_issue` +`STATEMENTS_VERSION_MISMATCH` (`info`); у задания без версии (`1.x`) запись не пишется. Записи `core` +`CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION` (только четырёхзначные коды), `NAME_MISMATCH` и +`STATEMENTS_VERSION_MISMATCH` пересчитываются заменой; `NAME_MISMATCH` не ставится строке с назначенным аналитиком кодом; +`MAPPING_WARNING`, `RESULT_STATUS_INCONSISTENT`, `LEGACY_WARNING` сохраняются. Один PATCH — один пересчёт и одна запись +аудита `RECOGNIZED_DATA_EDIT`. Акцепт SHALL сбрасываться правкой данных — суммы, кода или статуса строки, даты +колонки, поля шапки; подтверждение или снятие подтверждения (`confirmed`) и подтверждение даты отчёта или даты колонки +тем же значением акцепт MUST NOT сбрасывать (Р-25, S-47). + +#### Scenario: Подтверждение ячейки без правки +- **WHEN** аналитик подтверждает ячейку с `needs_review = true` без изменения суммы +- **THEN** `is_confirmed = true`, `confirmed_by/at`, `is_modified` не ставится, `needs_review = false`, аудит; акцепт + проверок, если был, сохраняется + +#### Scenario: Снятие подтверждения +- **WHEN** аналитик снимает подтверждение с ячейки (`confirmed: false`), которая не покрыта соотношением +- **THEN** `is_confirmed = false`, `needs_review = true` после пересчёта, аудит + +#### Scenario: Родитель уходит в расшифровки +- **WHEN** у строки 1150 две расшифровки (`detail`, `parent_row_index` = 1150), аналитик отмечает саму 1150 расшифровкой +- **THEN** после пересчёта родитель всех трёх строк `detail` — ближайшая строка `ok` выше 1150; строк над ними нет — `null` + (на экране — в начале раздела) + +#### Scenario: Правка исправляет нарушение +- **WHEN** форма принята с `RELATION_FAILED` по `1200 = 1210 + … + 1260`, аналитик правит сумму 1230 так, что + соотношение сходится +- **THEN** запись `relation` по 1200 заменена (её больше нет), статус пересчитан точкой входа пакета + +#### Scenario: После PATCH остаётся RELATION_FAILED +- **WHEN** аналитик правит сумму, но соотношение всё ещё не сходится +- **THEN** запись `RELATION_FAILED` пересоздана с новыми `expected` / `actual`; `POST …/confirm` без акцепта — 409 + `CHECKS_NOT_PASSED` + +### Requirement: Словари ядра и достройка шаблонных строк +Копия словаря §7 ядра (`api/rules/statement_checks.yaml`) SHALL быть удалена: проверки ядро считает копией пакета +`arm_statements`. `api/rules/statement_lines.yaml` SHALL оставаться только словарём имён (код → наименования по +редакциям): у кода может быть несколько имён (синонимы и законные повторы кода в бланке, Р-20), загрузчик повтор кода в +редакции MUST NOT запрещать; сверка наименования — с любым именем кода; шаблонная строка берёт первое имя. Порядок и +состав редакций — из копии перечня кодов пакета (`code_orders.editions`); код словаря имён, которого нет в перечне своей +редакции, — ошибка проверки (тест согласованности, `sync-statements --check`). В редакции `fsbu4` словарь имён SHALL быть +дополнен вторыми именами законных повторов 1220 «НДС, начисленный по отгрузке», 1520 «Обязательство по аренде», 2410 +«Налог при упрощенной системе налогообложения» (Р-20) и кодами печатной формы 1С 1105 «Гудвил», 1130 «Нематериальные +поисковые активы», 1140 «Материальные поисковые активы», 1530 «Доходы будущих периодов», 2460 «Прочее» (имена — по +`fixtures/ocr` и шаблонам банка); без этого законные повторы РБ, Счастливочной и aep_6 и любой неуверенный 1105 дают +`NAME_MISMATCH`. Шаблонные строки достраиваются по словарю имён (одна на код, первое имя); код, у которого есть хотя бы +одна строка `ok` из файла, шаблоном MUST NOT достраиваться; строки `unreadable` / `missing` не заменяются шаблонной строкой +молча. + +#### Scenario: Код словаря вне перечня +- **WHEN** в `statement_lines.yaml` у редакции есть код, которого нет в перечне этой редакции копии пакета +- **THEN** проверка согласованности словаря с перечнем падает + +#### 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` + +### Requirement: `CHECKS_NOT_PASSED` из `document_issue` +Нарушения для 409 `CHECKS_NOT_PASSED` при `POST /documents/{id}/confirm` формы ББ/ОФР (порядок проверок и условия — +«Подтверждение документа») SHALL читаться **только из записей `document_issue` вида `relation`** без акцепта +(акцептованная форма нарушенной не считается); пересчёта §7 в запросе подтверждения нет. `detail.failures[]` — +`{column_index, relation, expected, actual}`: `expected` и `actual` — строки Decimal или `null` (пустая цель при +непустых членах — `expected = null`); `column_index` — индекс колонки записи, а если колонку определить нельзя +(у записи нет ни `column_id`, ни `column_index`) — `null`. + +#### Scenario: Жёсткое нарушение из document_issue +- **WHEN** у формы есть запись `relation` без акцепта и нет строк `unreadable` / `missing` со значениями +- **THEN** 409 `CHECKS_NOT_PASSED`, `detail.failures[]` — по записи на нарушение + +#### Scenario: Нарушение с пустой целью +- **WHEN** у формы нет строки 1700 (потеряна источником), 1300 + 1400 + 1500 заполнены +- **THEN** 409 `CHECKS_NOT_PASSED`; в `detail.failures[]` нарушение `1700 = 1300 + 1400 + 1500` с `expected = null` + +### Requirement: Блок `checks` ответа формы из `document_issue` +Блок `checks` ответа `GET …/statement` SHALL строиться из записей `document_issue` видов `relation` (`failed`) и `soft` +(`soft_failed`) с `codes`, `expected`, `actual`, `line_ids`; собственного расчёта §7 при чтении нет. Элемент нарушения — +`{column_index, relation, expected, actual, codes, line_ids}`: `expected` и `actual` — `Decimal` или `null` +(`expected = null` — цель соотношения пуста при непустых членах); `column_index` — индекс колонки формы по `column_id` +записи, иначе `column_index` записи, иначе `-1` (колонки нет: межформенная проверка `cross_form` всегда `-1`). +Фактическое различие с `detail.failures[]` 409 (там отсутствующий индекс — `null`, а не `-1`) сохраняется как есть +(P3-60). Межформенная проверка `cross_form` считается по агрегату формы по коду. + +#### Scenario: checks из document_issue +- **WHEN** у формы две записи `relation` и одна `soft` +- **THEN** `checks.failed` содержит две записи, `checks.soft_failed` — одну; расчёт §7 при запросе не выполняется + +#### Scenario: Нарушение с пустой целью в ответе +- **WHEN** у формы запись `relation` по `1700 = 1300 + 1400 + 1500` с `expected = NULL` +- **THEN** элемент `checks.failed` имеет `expected = null`, `actual` — сумма членов diff --git a/openspec/specs/osv-result-mapping/spec.md b/openspec/specs/osv-result-mapping/spec.md index 2462769..4e7946b 100644 --- a/openspec/specs/osv-result-mapping/spec.md +++ b/openspec/specs/osv-result-mapping/spec.md @@ -141,11 +141,11 @@ LOCKED`), проверять, что `result_s3_object` относится к б предупреждением; `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`, `address` — как пришли; `statement_type` — `balance`/`income`. Колонки — по `columns[]`, +`okpo`, `okopf`, `okfs`, `okei`, `address` — как пришли (шапка 1.x — по полям 1.x); `statement_type` — `balance`/`income`. Колонки — по `columns[]`, `is_current = true` у колонки с самой поздней `period_date`. Строки — по `lines[]` с исходниками кода и названия; повтор -кода — первая строка и предупреждение. Значение SHALL создаваться на каждую пару «строка × колонка»: из результата — -`amount`, `string_value`, `confidence`, `is_recognized`; отсутствующая ячейка — все поля `NULL`; `is_modified = false`, -`original_amount = NULL`. `checks` результата MUST NOT использоваться для статуса. `counterparty_check` — тем же +кода сохраняется как отдельная строка (Р-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 @@ -165,8 +165,7 @@ LOCKED`), проверять, что `result_s3_object` относится к б - **THEN** `period_year = 2025`, `period_quarter = NULL`, предупреждение маппинга, документ `NEEDS_CHECK` ### Requirement: Повтор формы в файле и прочие документы -Второй документ `BALANCE` или `INCOME` в одном файле MUST NOT создаваться: предупреждение маппинга, первый документ — -`NEEDS_CHECK`. Документы `DISCLOSURE` и `OTHER` результата SHALL давать по одному `document` каждого типа на файл без +Каждый `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 создаваться с шапкой и колонками (если колонки есть в результате). @@ -178,11 +177,13 @@ LOCKED`), проверять, что `result_s3_object` относится к б - **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` SHALL быть `RECOGNIZED`, только если одновременно: задание `done`; нет непройденных -неакцептованных жёстких проверок §7 (`statement-checks`); `counterparty_check ≠ MISMATCH`; `period_year` и -`period_quarter` определены; у каждого значения `is_modified = true` либо (`is_recognized` не `false` и `confidence` -равна 100 или `NULL`); нет предупреждений маппинга по документу; есть хотя бы одна строка. Иначе `NEEDS_CHECK`. Статус +Документ `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 @@ -255,3 +256,220 @@ stored_file.uploaded_by` и `comment = «автоподтверждение до - **WHEN** документ ОСВ 60 РБ за 2025 Q1 из файла A привязан к версии 2025 Q1; загружен и смаплен файл B того же ключа (чистый), A заменён автозаменой - **THEN** привязка документа A снята, документ B `CONFIRMED` и привязан к версии + +### Requirement: Хранение результата формы v2.0 (DBML v5.19) +Маппер результата `2.0` SHALL писать документ формы как пришёл: строки, коды и значения не сливаются и не +дедуплицируются (повтор кода — отдельные строки), каждая строка результата — `financial_statement_line` (`row_index`, +`source_ref` = `ref`, `line_status`, `line_code`, `line_code_original_value` = `line_code` результата, `line_code_text`, +`line_code_confidence`, `line_name`, `line_name_confidence`, `parent_row_index` — `row_index` строки с `ref = parent_ref`, +`needs_review`, `is_confirmed = false`); каждая пара «строка × колонка» — одна `financial_statement_value` (`amount`, +`string_value`, `source_text` = `values[].text`, `confidence`, `is_recognized`, `needs_review`; значения, которого нет в +результате, — пустая запись с `needs_review = false`); колонки — `financial_statement_column` с `period_kind`, +`label_confidence`; шапка — `financial_statement_meta` с `form_variant`, `needs_review_fields` и `header_source` +(раздел «Шапка формы»); предупреждения и нарушения документа — `document_issue` (источник `service`); версия общего кода — +`recognition_job.statements_version`. Единственные случаи, когда элемент результата не записывается, — нарушение +уникальности ключей хранения, и каждый из них MUST фиксироваться `document_issue` `MAPPING_WARNING` (`info`, источник +`core`): значение с `column_index` вне колонок формы; повтор `column_index` колонки — остаётся первая колонка, остальные +пропускаются; повтор `row_index` или `ref` у строк — остаётся первая строка, остальные пропускаются; `detail` с `parent_ref`, +которого нет, хранится с `parent_row_index = null`. Уникальность `(meta_id, line_code)` снята; `(meta_id, source_ref)` +уникален там, где `source_ref` не `null`. Каждый `BALANCE` / `INCOME` результата SHALL становиться отдельным `document`; +вторая форма того же типа в файле хранится (Р-10); ОСВ по счёту, `DISCLOSURE` и `OTHER` — по-прежнему один документ на тип +(и счёт) на файл. **Каждый** создаваемый `document` (ОСВ, формы, `DISCLOSURE`, `OTHER`) при приёме результата любого +`MAJOR` (мапперы `1.x` и `2.0`) SHALL получать `file_ordinal` = индекс документа в `documents[]` результата (с 0); +документ, который не создаётся (второй лист ОСВ с тем же счётом, второй `DISCLOSURE`), номер не занимает, но и не +сдвигает остальные — номера уникальны в файле и могут идти с пропусками. + +#### Scenario: Книга из двух ОСВ +- **WHEN** результат содержит две ОСВ по счетам 60 и 62 (`documents[0]`, `documents[1]`), принятый маппером `1.x` или `2.0` +- **THEN** два документа с `file_ordinal` 0 и 1; уникальность `(stored_file_id, file_ordinal)` не нарушена, файл не `ERROR` + +#### Scenario: PDF с ББ и ОФР по 1.x +- **WHEN** результат `1.2` содержит `BALANCE` и `INCOME` +- **THEN** документы с `file_ordinal` 0 и 1 + +#### Scenario: Повтор кода сохраняется +- **WHEN** в результате две строки `ok` с кодом 2410 (пустая и с `(649)`) +- **THEN** в БД две строки 2410 с разными `row_index` и `source_ref`, обе со своими значениями + +#### Scenario: Форма 2.0 хранится как пришла +- **WHEN** результат `2.0` содержит две строки с кодом 1230 и строку без кода (`ref` с `parent_ref`) +- **THEN** создано три строки с их `row_index`, `source_ref` и `parent_row_index`; повтор кода не сливается + +#### Scenario: Строка без кода +- **WHEN** в результате строка `unreadable` с `line_code_text = "ИЧ,250"` и суммой +- **THEN** строка хранится с `line_code = null`, `line_code_original_value = null`, `line_status = unreadable`, + `line_code_text = "ИЧ,250"` и `row_index` из результата + +#### Scenario: Два баланса одного периода в одном файле +- **WHEN** результат содержит два `BALANCE` с одной парой `period_year`, `period_quarter` (лист-продолжение другого + типа или двойная печать) +- **THEN** создаются два документа с разными `file_ordinal`; ни один не автоподтверждается (Р-18) + +#### Scenario: Два баланса разных периодов в одном файле +- **WHEN** результат содержит `BALANCE` на 30.09.2025 и `BALANCE` на 31.12.2024 +- **THEN** два документа с разными `file_ordinal`; каждый получает статус по своим условиям, правило Р-18 их не касается + +#### Scenario: Повтор column_index колонки +- **WHEN** в `columns[]` результата два элемента с `column_index = 1` +- **THEN** сохраняется первая колонка, вторая пропускается, у документа `MAPPING_WARNING` + +#### Scenario: Повтор ref строки +- **WHEN** в результате две строки с одним `ref` +- **THEN** сохраняется первая, вторая пропускается с `MAPPING_WARNING`; приём не падает + +### Requirement: Признак строки, созданной правкой +Строка формы 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`; собственного +пересчёта §7 ядро MUST NOT выполнять. Условие D-6 «проверки сошлись» для `1.x` SHALL браться из этого расчёта; +`needs_review` значения при приёме `1.x` = **НЕ** `values_ok` (`values_ok` — `is_recognized` не `false` и `confidence` +равна 100 или пуста), `needs_review` строки — `false`. Предупреждения маппера `1.x` по документу (повтор кода, значение +вне колонок) — `document_issue` `MAPPING_WARNING` (`info`, источник `core`); строки `metrics.warnings` результата `1.x` — +в `recognition_job.metrics.warnings` структурами `{code: "LEGACY_WARNING", severity: "info", message}`. После правки такая +форма пересчитывается общим кодом, как форма 2.0; `MAPPING_WARNING` и `LEGACY_WARNING` статус не держат. Формы, +существовавшие в БД до миграции v5.19, §7 не пересчитываются (требования «Миграция DBML v5.19» и «Очистка форм перед +выкладкой»). + +#### Scenario: Полярность needs_review при приёме 1.x +- **WHEN** в результате `1.2` у значения `confidence = 100`, `is_recognized = true`, у другого — `confidence = 80` +- **THEN** у первого `needs_review = false`, у второго — `true` + +#### Scenario: Форма 1.x с нарушением +- **WHEN** результат `1.2` содержит ББ с несошедшимся `1600 = 1700` +- **THEN** у формы запись `document_issue` `relation` `RELATION_FAILED` с `codes = ["1600", "1700"]` (источник `core`), + документ `NEEDS_CHECK`; `POST …/confirm` без акцепта — 409 `CHECKS_NOT_PASSED` + +#### Scenario: 1.x-документ после PATCH +- **WHEN** форма принята по результату `1.2`, аналитик правит сумму 1230 так, что `1200 = 1210 + … + 1260` сходится +- **THEN** ядро пересчитывает §7 и статус общим кодом, запись `relation` по 1200 заменена (её больше нет), статус + пересчитан; `STATEMENTS_VERSION_MISMATCH` не пишется (у задания 1.x версии нет) + +### Requirement: Проверки ядра при приёме результата 2.0 +При приёме результата `2.0` ядро SHALL писать в `document_issue` формы предупреждения и `checks` результата (источник +`service`) и проверки ядра (источник `core`): `CODE_NOT_IN_DICTIONARY` и `CODE_NOT_IN_EDITION` (редакция — `edition` точки +входа пакета, а не по дате; одна запись на код документа, `line_ids` — все строки `ok` кода; **только для кодов из ровно +четырёх цифр** — пятизначные подстроки Excel не проверяются; запись не пишется, если пакет не определил редакцию), +`NAME_MISMATCH`, `RESULT_STATUS_INCONSISTENT` (согласованность `done` — вызовом `engine.contract_document_status` +пакета) и `STATEMENTS_VERSION_MISMATCH` (при отличии `statements_version` задания от версии копии ядра; у `1.x` не +пишется). Проверки §7, `needs_review` и предупреждения сервиса MUST NOT пересчитываться при загрузке. Статус документа +формы — по требованию «Условие `RECOGNIZED` при приёме 2.0». + +#### Scenario: Пятизначный код не даёт info +- **WHEN** в ББ строка с кодом `12301` (подстрока Excel), которого нет в словаре +- **THEN** `CODE_NOT_IN_DICTIONARY` и `CODE_NOT_IN_EDITION` для него не пишутся + +#### Scenario: Четырёхзначный код вне перечня +- **WHEN** в ББ строка 1120 на бланке `fsbu4`, где такого кода нет +- **THEN** запись `CODE_NOT_IN_EDITION` (`info`, `core`) с `line_ids` строк `ok` этого кода + +#### Scenario: Версия копии отличается +- **WHEN** результат `2.0` принят с `statements_version`, отличной от версии копии ядра +- **THEN** запись `STATEMENTS_VERSION_MISMATCH` (`info`, `core`) уже при приёме + +### Requirement: Ведомость ОСВ по контракту 2.0 +Для листа ОСВ результата `2.0` ядро SHALL создавать документ ОСВ по `osvDocument`, предупреждения листа +(`osvDocument.warnings`, `OSV_*`) писать в `document_issue` (вид `warning`, источник `service`), а статус документа +определять по требованию «Предупреждения файла и ОСВ в API, статус ОСВ при приёме 2.0». Статус ОСВ MUST NOT зависеть от +статуса соседнего документа книги и от статуса конверта. + +#### Scenario: ОСВ в книге с ОФР, требующим проверки +- **WHEN** книга из ОСВ (`status = done`, контрольные суммы сошлись) и ОФР (`needs_check`) +- **THEN** документ ОСВ `RECOGNIZED`, документ ОФР `NEEDS_CHECK` diff --git a/openspec/specs/primary-files/spec.md b/openspec/specs/primary-files/spec.md index 0ac3258..15fd526 100644 --- a/openspec/specs/primary-files/spec.md +++ b/openspec/specs/primary-files/spec.md @@ -191,3 +191,50 @@ advisory-блокировка привязок ГК → версии по `id`. #### Scenario: Загрузка не требует блокировки версии - **WHEN** версию держит другой пользователь (`consolidation_lock`), ЮЛ в периметре - **THEN** 201; версия `INITIATED` → `DOCUMENTS_PROCESSING` + +### 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/specs/recognition-job-exchange/spec.md b/openspec/specs/recognition-job-exchange/spec.md index 1b3c408..f2662ca 100644 --- a/openspec/specs/recognition-job-exchange/spec.md +++ b/openspec/specs/recognition-job-exchange/spec.md @@ -131,3 +131,34 @@ Stream'ы и consumer'ы JetStream MUST NOT требоваться. Фоновы #### Scenario: JWT не раскрывается - **WHEN** заданы `NATS_JWT` и `NATS_SEED`, а валидация настроек падает на другом поле - **THEN** текст ошибки и лог не содержат значений JWT и seed; `repr` настроек показывает их скрытыми + +### Requirement: Приём конвертов и результатов обоих MAJOR контракта +Inbox SHALL принимать конверты (включая ack `processing`) и файлы результата и `MAJOR` 1, и `MAJOR` 2 («сначала ядро», +`recognition-contract`): версия определяется по `schema_version`, конверт валидируется моделью своего `MAJOR`, неизвестный +`MAJOR` (например, `3.0`) отбрасывается с предупреждением в лог (задание закроется по сроку). Файл результата +обрабатывается маппером своего `MAJOR` (`2.0` — маппер результата `2.0`, `1.x` — маппер `1.x`); файл с неизвестным +`MAJOR` получает `ERROR` с `mapping_error`. Ядро SHALL держать копии схем и примеров обоих `MAJOR` (`api/contracts/`), +сверяемые с `docs/contracts/` общим правилом. Предупреждения уровня файла SHALL записываться в +`recognition_job.metrics.warnings` структурами `{code, severity, message, locator?}`: у `2.0` — как пришли, у `1.x` — +каждая строка как `{code: "LEGACY_WARNING", severity: "info", message: <строка>, locator: null}`. +`recognition_job.statements_version` SHALL сохранять `service.statements_version` результата `2.0` (у `1.x` — `NULL`). + +#### Scenario: Ack нового сервиса +- **WHEN** сервис публикует ack `processing` со `schema_version = "2.0"` +- **THEN** задание переходит в `processing`, как для ack `1.0` + +#### Scenario: Предупреждения 1.x оборачиваются +- **WHEN** конверт `1.0` несёт `metrics.warnings = ["sheet=ОСВ 60 строки без родителя"]` +- **THEN** в `recognition_job.metrics.warnings` — `{code: "LEGACY_WARNING", severity: "info", message: "sheet=ОСВ 60 строки без родителя", locator: null}` + +#### Scenario: Предупреждения 2.0 сохраняются как пришли +- **WHEN** конверт `2.0` несёт предупреждение `JSON_FAILED` уровня `error` с `locator` +- **THEN** структура сохранена без изменений + +#### Scenario: Неизвестный MAJOR +- **WHEN** приходит конверт со `schema_version = "3.0"` +- **THEN** конверт отбрасывается с предупреждением в лог, задание не меняется + +#### Scenario: Результат 2.0 разбирает маппер 2.0 +- **WHEN** inbox принял файл результата со `schema_version = "2.0"` +- **THEN** документы создаёт маппер результата `2.0`; результат `1.x` по-прежнему разбирает маппер `1.x` diff --git a/openspec/specs/statement-checks/spec.md b/openspec/specs/statement-checks/spec.md index e4870e1..2949952 100644 --- a/openspec/specs/statement-checks/spec.md +++ b/openspec/specs/statement-checks/spec.md @@ -8,18 +8,18 @@ ### Requirement: Соотношения §7 по колонкам Проверки SHALL считаться чистой функцией по текущим `financial_statement_value.amount` документа для каждой колонки -отдельно по словарю `api/rules/statement_checks.yaml` (копия словаря tabular-parser): соотношение проверяется, только -если присутствует его левая часть; отсутствующий код и пустая сумма — 0; выполнено, если `|левая − правая| ≤ tolerance` +отдельно по словарю §7 копии пакета `arm_statements` (`statement-normalization`; своей копии словаря ядро не держит): +соотношение **посчитано**, если непуста цель (левая часть) или хотя бы один член (есть строка `ok` с кодом и непустой +суммой); если пусто всё — соотношение пропускается. В посчитанном соотношении отсутствующий код и пустая сумма в +арифметике — 0, но пустая цель при непустых членах и непустая цель при пустых членах — нарушение (пустая цель даёт +`expected = null`, P1-21 пакета). Выполнено, если `|левая − правая| ≤ tolerance` (1, в единицах формы) хотя бы в одном варианте правой части — как записано или с кодами `abs_terms` по модулю. Нарушение жёсткого (`strict`) соотношения, не превышающее `rounding_tolerance_percent` (0,001 %) от наибольшего по модулю числа соотношения, SHALL считаться мягким («в пределах округления»). Мягкие: соотношения `strict: false` и «сумма пятизначных подстрок = четырёхзначный родитель». Результат — `{failed: [...], soft_failed: [...]}`, элемент — `{column_index, -relation, expected, actual}`; MUST NOT храниться в БД. +relation, expected, actual}`, где `expected` и `actual` могут быть `null` (`expected = null` — пустая цель); не хранится как есть: нарушения при приёме и пересчёте записываются в `document_issue`. -Присутствующими SHALL считаться строки формы из БД, кроме строк, созданных правкой аналитика -(`line_code_original_value IS NULL`), у которых все суммы пустые; строки, достроенные в ответе по справочнику -(`documents-api`, `id = null`), в проверках MUST NOT участвовать. Созданная правкой строка с хотя бы одной непустой -суммой проверяется по всем колонкам, пустые суммы — 0. +Присутствующими SHALL считаться суммы **агрегата формы по коду** (требование «Агрегат формы по коду»): строки `ok` формы из БД, кроме строк, созданных правкой аналитика (`row_index IS NULL`), у которых все суммы пустые; строки `detail`, `unreadable`, `missing` и строки, достроенные в ответе по справочнику (`documents-api`, `id = null`), в проверках MUST NOT участвовать. Созданная правкой строка с хотя бы одной непустой суммой проверяется по всем колонкам; в колонке, где её сумма пуста, она — пустая цель (или член с суммой 0). Повтор кода в форме суммируется. Результат нарушения несёт `codes` (коды цели и членов соотношения) и `line_ids` (все строки `ok` этих кодов). Соотношения считает точка входа пакета для пересчёта по агрегату формы; запись в БД — только в виде `document_issue` при приёме и пересчёте, `api/rules/statement_checks.yaml` и код его загрузки в ядре MUST NOT существовать. #### Scenario: Сходящийся баланс - **WHEN** проверяется форма `mezheninovskaya/2025-Q3/1_Форма1_бух_09` @@ -38,24 +38,40 @@ relation, expected, actual}`; MUST NOT храниться в БД. - **THEN** проверка по текущим суммам: `failed` пуст #### Scenario: Пустая шаблонная строка не проверяется -- **WHEN** в форме нет строки 1700 (в ответе она достроена пустой), 1300 + 1400 + 1500 = 2 371 333 в колонке 0 -- **THEN** соотношения с целью 1700 (`1700 = 1300 + 1400 + 1500`) не проверяются; `1600 = 1700` (цель 1600 есть в - форме, 1700 = 0) нарушено — как и без достройки: потерянная строка «БАЛАНС» сама по себе даёт `NEEDS_CHECK` +- **WHEN** в форме нет строки 1700 (в ответе она достроена пустой и как значение в проверках не участвует), 1300 + + 1400 + 1500 = 2 371 333 в колонке 0 +- **THEN** сама строка 1700 не проверяется, но соотношение `1700 = 1300 + 1400 + 1500` посчитано и нарушено: `expected = null`, `actual = 2371333`; + `1600 = 1700` (цель 1600 есть в форме, 1700 пуста) тоже нарушено; потерявшая итоговую строку форма блокирует + подтверждение (`CHECKS_NOT_PASSED`) до правки строки или акцепта (P3-30) + +#### Scenario: Все стороны пусты +- **WHEN** в колонке нет ни цели, ни членов соотношения +- **THEN** соотношение пропускается, нарушения нет #### Scenario: Заполненная шаблонная строка проверяется - **WHEN** аналитик заполнил 1700 = 2 371 333 только в колонке 0, а в колонке 1 1300 + 1400 + 1500 = 2 000 000 -- **THEN** в колонке 0 соотношение выполнено; в колонке 1 `failed` содержит `1700 = 1300 + 1400 + 1500` с `expected 0`, - `actual 2000000` +- **THEN** в колонке 0 соотношение выполнено; в колонке 1 `failed` содержит `1700 = 1300 + 1400 + 1500` с + `expected = null` (1700 в колонке 1 пуста), `actual = 2000000` #### Scenario: Очищенная шаблонная строка - **WHEN** затем аналитик очищает 1700 в колонке 0 (`amount = null`) -- **THEN** строка 1700 снова не участвует в проверках +- **THEN** строка 1700 снова не участвует в проверках как значение: пустая цель при непустых членах — нарушение + `expected = null` в каждой колонке (прежнее «очищенная строка не цель» не действует, P3-30) + +#### Scenario: Повтор кода суммируется +- **WHEN** в ББ две строки 1230 со значениями 100 и 50 в колонке 0 +- **THEN** соотношения берут 1230 = 150 + +#### Scenario: Копии словаря §7 в ядре нет +- **WHEN** проверяется репозиторий ядра после перехода на пакет +- **THEN** файла `api/rules/statement_checks.yaml` нет, `load_check_rules` / `run_checks` в `api/src` не встречаются; + допуск `tolerance` берётся из правил пакета ### Requirement: Межформенная проверка Для документа `BALANCE` или `INCOME` SHALL считаться мягкая проверка `1370(колонка отчётной даты ББ) − 1370(колонка 31 декабря прошлого года ББ) = 2400(колонка отчётного периода ОФР)` с допуском `tolerance`, если найдена форма-пара: документ другого типа формы того же ЮЛ, `period_year` и `period_quarter`, файла не `SUPERSEDED` (подтверждённый, затем -самый поздний по `created_at`), и все три колонки. Нарушение SHALL попадать в `cross_form` и MUST NOT блокировать +самый поздний по `created_at`), и все три колонки. Значение `1370` / `2400` берётся по агрегату формы по коду (сумма строк `ok` с этим кодом). Нарушение SHALL попадать в `cross_form` и MUST NOT блокировать подтверждение. #### Scenario: Пара найдена @@ -66,6 +82,10 @@ relation, expected, actual}`; MUST NOT храниться в БД. - **WHEN** у ЮЛ есть только ББ за период - **THEN** `cross_form` — `null` +#### Scenario: Межформенная проверка по агрегату +- **WHEN** в ББ две строки 1370 (сумма 500 и 300 на отчётную дату) +- **THEN** межформенная проверка берёт 1370 = 800 + ### Requirement: Акцепт непройденных проверок Акцепт SHALL записывать `financial_statement_meta.checks_accepted_by`, `checks_accepted_at`, `checks_accepted_comment`; документ с акцептом SHALL считаться прошедшим жёсткие проверки для статуса и подтверждения. @@ -74,3 +94,14 @@ relation, expected, actual}`; MUST NOT храниться в БД. #### Scenario: Акцепт и правка - **WHEN** аналитик акцептовал нарушение 1600 и затем поправил любое значение - **THEN** поля акцепта пусты, при непройденной проверке документ `NEEDS_CHECK` + +### 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