From 70c5743c776b60f66bdb9f8b55a751acdfa7c347 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 18:20:08 +0400 Subject: [PATCH] =?UTF-8?q?=D0=90=D1=80=D1=85=D0=B8=D0=B2=20OpenSpec=20ret?= =?UTF-8?q?ry-recognition-and-edit-osv-header=20(PR=20#161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/audit-trail/spec.md | 0 .../specs/data-model/spec.md | 0 .../specs/documents-api/spec.md | 0 .../specs/primary-files/spec.md | 0 .../specs/recognition-job-exchange/spec.md | 0 .../tasks.md | 0 openspec/specs/audit-trail/spec.md | 34 ++++++ openspec/specs/data-model/spec.md | 47 +++++++- openspec/specs/documents-api/spec.md | 109 ++++++++++++++++++ openspec/specs/primary-files/spec.md | 62 +++++++++- .../specs/recognition-job-exchange/spec.md | 38 +++++- 14 files changed, 281 insertions(+), 9 deletions(-) rename openspec/changes/{retry-recognition-and-edit-osv-header => archive/2026-10-23-retry-recognition-and-edit-osv-header}/.openspec.yaml (100%) rename openspec/changes/{retry-recognition-and-edit-osv-header => archive/2026-10-23-retry-recognition-and-edit-osv-header}/design.md (100%) rename openspec/changes/{retry-recognition-and-edit-osv-header => archive/2026-10-23-retry-recognition-and-edit-osv-header}/proposal.md (100%) rename openspec/changes/{retry-recognition-and-edit-osv-header => archive/2026-10-23-retry-recognition-and-edit-osv-header}/specs/audit-trail/spec.md (100%) rename openspec/changes/{retry-recognition-and-edit-osv-header => archive/2026-10-23-retry-recognition-and-edit-osv-header}/specs/data-model/spec.md (100%) rename openspec/changes/{retry-recognition-and-edit-osv-header => archive/2026-10-23-retry-recognition-and-edit-osv-header}/specs/documents-api/spec.md (100%) rename openspec/changes/{retry-recognition-and-edit-osv-header => archive/2026-10-23-retry-recognition-and-edit-osv-header}/specs/primary-files/spec.md (100%) rename openspec/changes/{retry-recognition-and-edit-osv-header => archive/2026-10-23-retry-recognition-and-edit-osv-header}/specs/recognition-job-exchange/spec.md (100%) rename openspec/changes/{retry-recognition-and-edit-osv-header => archive/2026-10-23-retry-recognition-and-edit-osv-header}/tasks.md (100%) diff --git a/openspec/changes/retry-recognition-and-edit-osv-header/.openspec.yaml b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/.openspec.yaml similarity index 100% rename from openspec/changes/retry-recognition-and-edit-osv-header/.openspec.yaml rename to openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/.openspec.yaml diff --git a/openspec/changes/retry-recognition-and-edit-osv-header/design.md b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/design.md similarity index 100% rename from openspec/changes/retry-recognition-and-edit-osv-header/design.md rename to openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/design.md diff --git a/openspec/changes/retry-recognition-and-edit-osv-header/proposal.md b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/proposal.md similarity index 100% rename from openspec/changes/retry-recognition-and-edit-osv-header/proposal.md rename to openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/proposal.md diff --git a/openspec/changes/retry-recognition-and-edit-osv-header/specs/audit-trail/spec.md b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/specs/audit-trail/spec.md similarity index 100% rename from openspec/changes/retry-recognition-and-edit-osv-header/specs/audit-trail/spec.md rename to openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/specs/audit-trail/spec.md diff --git a/openspec/changes/retry-recognition-and-edit-osv-header/specs/data-model/spec.md b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/specs/data-model/spec.md similarity index 100% rename from openspec/changes/retry-recognition-and-edit-osv-header/specs/data-model/spec.md rename to openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/specs/data-model/spec.md diff --git a/openspec/changes/retry-recognition-and-edit-osv-header/specs/documents-api/spec.md b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/specs/documents-api/spec.md similarity index 100% rename from openspec/changes/retry-recognition-and-edit-osv-header/specs/documents-api/spec.md rename to openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/specs/documents-api/spec.md diff --git a/openspec/changes/retry-recognition-and-edit-osv-header/specs/primary-files/spec.md b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/specs/primary-files/spec.md similarity index 100% rename from openspec/changes/retry-recognition-and-edit-osv-header/specs/primary-files/spec.md rename to openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/specs/primary-files/spec.md diff --git a/openspec/changes/retry-recognition-and-edit-osv-header/specs/recognition-job-exchange/spec.md b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/specs/recognition-job-exchange/spec.md similarity index 100% rename from openspec/changes/retry-recognition-and-edit-osv-header/specs/recognition-job-exchange/spec.md rename to openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/specs/recognition-job-exchange/spec.md diff --git a/openspec/changes/retry-recognition-and-edit-osv-header/tasks.md b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/tasks.md similarity index 100% rename from openspec/changes/retry-recognition-and-edit-osv-header/tasks.md rename to openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/tasks.md diff --git a/openspec/specs/audit-trail/spec.md b/openspec/specs/audit-trail/spec.md index 47a617f..5c626e4 100644 --- a/openspec/specs/audit-trail/spec.md +++ b/openspec/specs/audit-trail/spec.md @@ -179,3 +179,37 @@ credit_account_ids, cells}`; для `PRESET` в `after_value` SHALL быть д #### Scenario: Нет счёта статьи - **WHEN** выгрузка отвечает 422 `RESULT_ACCOUNTS_MISSING` - **THEN** событие `EXPORT` не записано + +### Requirement: Аудит ручной повторной обработки файла +Каждая принятая ручная попытка SHALL писать одно audit_event с action = FILE_REPROCESS, +section = PRIMARY_DOCUMENTS, object_type = stored_file, object_id = file_id, user_id инициатора, +IP и User-Agent запроса. before_value SHALL содержать прежние job_id, attempt, status задания, error_code; +after_value — новые job_id, attempt, а при пакетном повторе также consolidation_id. Событие SHALL фиксироваться в транзакции создания попытки. + +#### Scenario: Пакетный повтор +- **WHEN** пакет принял два файла ОСВ РБ и пропустил третий +- **THEN** два FILE_REPROCESS с разными object_id и прежними/новыми job_id, с consolidation_id в after_value; + before_value.status — статус прежнего задания (timeout или dlq), error_code — TIMEOUT или NO_ACK; для skipped события нет + +#### Scenario: Отказ или откат +- **WHEN** повтор отклонён либо транзакция создания задания откатывается +- **THEN** FILE_REPROCESS для него отсутствует + +#### Scenario: Автоматическая повторная публикация +- **WHEN** outbox повторно публикует прежнее задание без ack, не создавая новую попытку +- **THEN** FILE_REPROCESS не пишется + +### Requirement: Аудит уточнения шапки ОСВ +Фактический PATCH шапки ОСВ SHALL писать одно RECOGNIZED_DATA_EDIT раздела RECOGNITION, +object_type = document, object_id документа, user_id инициатора. before/after SHALL содержать изменённые +поля и при сбросе подтверждения status, confirmed_by/at; after_value.unbound_version_ids SHALL содержать +версии снятых привязок. Запрос без изменений MUST NOT писать аудит. + +#### Scenario: Подтверждённая ОСВ исправлена +- **WHEN** меняется счёт CONFIRMED ОСВ РБ, привязанной к версии DATA_CORRECTION +- **THEN** одно RECOGNIZED_DATA_EDIT с прежним/новым счётом и подтверждением, after_value содержит + unbound_version_ids, пользователь, IP и User-Agent сохранены + +#### Scenario: Возврат к исходному +- **WHEN** аналитик возвращает исходные значения после предыдущей правки +- **THEN** новое RECOGNIZED_DATA_EDIT отражает эту правку; старые события не меняются diff --git a/openspec/specs/data-model/spec.md b/openspec/specs/data-model/spec.md index 4af8cc9..388d7e9 100644 --- a/openspec/specs/data-model/spec.md +++ b/openspec/specs/data-model/spec.md @@ -6,8 +6,8 @@ ## Requirements -### Requirement: Схема БД равна DBML v5.19 -ORM-метаданные SHALL содержать ровно 44 таблицы и 33 enum'а из `../docs/model/data-model-v5.19.dbml` в схеме PostgreSQL +### Requirement: Схема БД равна DBML v5.21 +ORM-метаданные SHALL содержать ровно 44 таблицы и 33 enum'а из `../docs/model/data-model-v5.21.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, значения @@ -31,9 +31,12 @@ 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. +Схема v5.20 SHALL добавлять в конец `document` колонки `is_modified` (`boolean NOT NULL DEFAULT false`), `original_values` +(`jsonb`, nullable), `modified_by` (`uuid`, nullable, FK → `app_user.id`) и `modified_at` (`timestamptz`, nullable); схема v5.21 SHALL +добавлять значение `FILE_REPROCESS` в enum `audit_action`. Состав и порядок колонок — буквально по DBML v5.21. #### Scenario: Сверка ORM с DBML -- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.19.dbml` (`make dbml-check`) +- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.21.dbml` (`make dbml-check`) - **THEN** вывод пуст и код возврата 0 #### Scenario: Лишняя колонка в ORM @@ -64,7 +67,7 @@ DBML, MUST NOT существовать. `stored_file`; `alembic downgrade 0005_dbml_v5_17` удаляет индекс и колонку, повторный `upgrade` их возвращает #### Scenario: Сверка с DBML v5.19 -- **WHEN** выполняется `make dbml-check` по `../docs/model/data-model-v5.19.dbml` +- **WHEN** выполняется `make dbml-check` по `../docs/model/data-model-v5.21.dbml` - **THEN** таблицы, колонки, enum'ы, ключи и индексы ORM совпадают с DBML без расхождений ### Requirement: Миграции применяются с нуля и откатываются @@ -99,11 +102,11 @@ enum'ы, таблицы, ограничения, индексы и послед - **THEN** сгенерированная ревизия не содержит операций (пустые `upgrade`/`downgrade`) ### Requirement: Версия DBML зафиксирована в коде -Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.19"`); скрипт сверки SHALL сравнивать её с версией в шапке файла +Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.21"`); скрипт сверки SHALL сравнивать её с версией в шапке файла DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна. #### Scenario: Новая версия DBML без обновления ORM -- **WHEN** в `docs/model/` появляется `data-model-v5.20.dbml`, а константа в ORM — `5.19` +- **WHEN** в `docs/model/` появляется `data-model-v5.22.dbml`, а константа в ORM — `5.21` - **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц ### Requirement: Деньги и проценты — точные типы @@ -161,3 +164,35 @@ organization_name`, `document.file_ordinal` — по `(created_at, id)` внут #### Scenario: Откат после подтверждений - **WHEN** в БД нет несовместимых данных, но у ячеек есть `is_confirmed = true` и записи `document_issue` - **THEN** `downgrade` проходит, удаляет колонки и таблицу и пишет в журнал число удалённых подтверждений и записей + +### Requirement: Миграция DBML v5.20 +Одна добавочная миграция после 0007_dbml_v5_19 SHALL добавить четыре колонки document по DBML v5.20; существующие записи получают is_modified = false и остальные поля NULL. Миграция MUST NOT менять документы, их строки, подтверждения и привязки. Downgrade SHALL до любых изменений проверить отсутствие document.is_modified = true и при наличии остановиться с ошибкой и числом записей. + +#### Scenario: Обновление существующих ОСВ +- **WHEN** миграция применяется к базе с подтверждёнными ОСВ РБ +- **THEN** добавлены четыре поля, данные ОСВ, id, file_ordinal, подтверждения и привязки сохранены; сверка с DBML v5.20 проходит + +#### Scenario: Защита от потери исправлений +- **WHEN** хотя бы один документ имеет is_modified = true и запрошен downgrade до v5.19 +- **THEN** откат прекращается до DDL с причиной и числом записей, схема и данные не меняются + +#### Scenario: Пустая база и нестандартная схема +- **WHEN** выполняется upgrade head, downgrade base, upgrade head на пустой базе с настроенным DB_SCHEMA +- **THEN** цикл успешен, ORM соответствует DBML v5.20, autogenerate пуст + +### Requirement: Миграция DBML v5.21 и действие аудита повторной обработки +Отдельная миграция v5.21 после миграции v5.20 SHALL добавить только FILE_REPROCESS в enum audit_action +по DBML v5.21. Миграция SHALL сохранять все существующие значения и события. Downgrade MUST NOT удалять либо +переписывать audit_event; при наличии FILE_REPROCESS он SHALL завершаться ошибкой до изменения схемы. + +#### Scenario: Схема после дополнения модели +- **WHEN** миграция v5.21 применена к БД v5.20 +- **THEN** событие нового типа записывается, прежние события читаются без изменений, сверка DBML успешна + +#### Scenario: Откат после ручного повтора +- **WHEN** существует audit_event с FILE_REPROCESS и выполняется downgrade с v5.21 к v5.20 +- **THEN** ошибка с причиной и количеством несовместимых событий до DDL, события и схема сохранены + +#### Scenario: Независимый откат enum +- **WHEN** в БД v5.21 нет FILE_REPROCESS и выполняется downgrade к v5.20 +- **THEN** удалено только новое значение enum; четыре поля document, исходные значения и привязки сохранены diff --git a/openspec/specs/documents-api/spec.md b/openspec/specs/documents-api/spec.md index 74fb008..9cf7aca 100644 --- a/openspec/specs/documents-api/spec.md +++ b/openspec/specs/documents-api/spec.md @@ -568,3 +568,112 @@ locator?}` (`fileWarning` контракта): у `2.0` — как пришли, #### Scenario: Нарушение с пустой целью в ответе - **WHEN** у формы запись `relation` по `1700 = 1300 + 1400 + 1500` с `expected = NULL` - **THEN** элемент `checks.failed` имеет `expected = null`, `actual` — сумма членов + +### Requirement: Уточнение шапки ОСВ +`PATCH /api/v1/documents/{id}/osv-header` SHALL требовать `DOCUMENT_EDIT` и видимость ЮЛ, принимать частичный набор +`period_year`, `period_quarter`, `is_cumulative`, `osv_account_code` и возвращать карточку документа с +`is_modified`, `original_values`, `modified_by`, `modified_at`. Отсутствующие поля SHALL сохраняться. +Правила — §9.5 п.12. Чужой и несуществующий документ SHALL возвращать одинаковый 404. + +#### Scenario: Изменение только счёта +- **WHEN** аналитик меняет счёт ОСВ РБ с 60 на 62, не передавая период +- **THEN** меняется только счёт; даже неопределённый квартал остаётся прежним + +#### Scenario: Нет доступа +- **WHEN** пользователь без DOCUMENT_EDIT правит шапку либо документ находится вне видимости ЮЛ +- **THEN** без права возвращается 403, при наличии права и отсутствии видимости — 404; данные не меняются + +### Requirement: Проверки правки шапки ОСВ +После проверки типов тела запроса Pydantic PATCH SHALL проверять последовательно: тип OSV (иначе 409 DOCUMENT_NOT_OSV), файл не SUPERSEDED (иначе 409 +FILE_SUPERSEDED), корректность периода (422 INVALID_PERIOD), счёта (422 INVALID_ACCOUNT_CODE), отсутствие +другой ОСВ с тем же нормализованным счётом в файле (409 DUPLICATE_ACCOUNT с detail.document_id), +редактируемость привязанных версий (409 VERSION_NOT_EDITABLE с detail.version_ids). Все проверки SHALL +предшествовать записи. + +#### Scenario: Частичная правка периода +- **WHEN** передано хотя бы одно поле периода +- **THEN** итоговые год, квартал и is_cumulative обязательны: год 1900–2099, квартал Q1–Q4, признак boolean; + для Q1 признак true; иначе 422 INVALID_PERIOD без изменений + +#### Scenario: Нормализация и коллизия +- **WHEN** передано 60.1, а у другой ОСВ этого файла уже счёт 60.01 +- **THEN** нормализация даёт 60.01, возвращается 409 DUPLICATE_ACCOUNT с id другого документа + +#### Scenario: Пустой счёт +- **WHEN** передан пустой счёт +- **THEN** 422 INVALID_ACCOUNT_CODE, прежний счёт сохранён + +#### Scenario: Порядок конфликтов +- **WHEN** у заменённого файла документ BALANCE и некорректный период в теле +- **THEN** возвращается DOCUMENT_NOT_OSV; у OSV того же файла — FILE_SUPERSEDED + +### Requirement: Исходная шапка и признак исправления +При первой фактической правке ядро SHALL сохранить прежние значения всех четырёх полей в document.original_values; +при последующих правках снимок MUST NOT меняться. Новые значения SHALL записываться в ту же строку document, +is_modified SHALL отражать отличие от снимка, modified_by/at — последнюю правку. document.id, file_ordinal, +строки osv_entry, файл и JSON результата в S3 MUST NOT изменяться. + +#### Scenario: Две правки +- **WHEN** счёт ОСВ РБ меняется 60 → 62 → 76 +- **THEN** текущий счёт 76, original_values.osv_account_code = 60, в original_values также исходный период; + is_modified = true, id и количество документов и строк ОСВ прежние + +#### Scenario: Возврат к исходному +- **WHEN** все четыре поля возвращены к значениям original_values +- **THEN** is_modified = false, original_values остаётся заполненным, последняя правка атрибутирована + +#### Scenario: Запрос без изменений +- **WHEN** нормализованная итоговая шапка совпадает с текущей +- **THEN** 200 с карточкой без аудита, создания original_values, сброса подтверждения и изменения modified_by/at + +### Requirement: Снятие подтверждения ОСВ после уточнения +Фактическая правка CONFIRMED ОСВ SHALL установить NEEDS_CHECK, очистить confirmed_by/at и снять привязки +consolidation_document из всех версий «в работе», сохранив привязки CANCELLED и статусы версий. +Привязка к иной версии SHALL запретить правку. Неподтверждённый документ SHALL сохранить статус. +consolidation_lock MUST NOT требоваться; автоподтверждение после PATCH MUST NOT запускаться. + +#### Scenario: Исправление автоподтверждённой ОСВ +- **WHEN** ОСВ РБ привязана к версии DATA_CORRECTION, аналитик исправляет период +- **THEN** NEEDS_CHECK, confirmed_by/at = null, привязка снята, версия остаётся DATA_CORRECTION; + повторное ручное подтверждение привязывает документ по новому периоду и счёту + +#### Scenario: Завершённая версия +- **WHEN** документ привязан к VERIFIED, даже если одновременно привязан к версии «в работе» +- **THEN** 409 VERSION_NOT_EDITABLE со списком запрещающих version_ids, ни одна привязка и шапка не меняются + +#### Scenario: Отменённая версия +- **WHEN** подтверждённый документ связан только с CANCELLED и версиями «в работе» +- **THEN** правка разрешена без consolidation_lock, привязки CANCELLED остаются, остальные снимаются + +### Requirement: Производные данные после уточнения ОСВ +Подходящесть версиям, чек-лист и колонка «ОСВ» SHALL отражать новую шапку при следующем чтении; +period_check, counterparty_check и строки osv_entry SHALL сохраняться. Правка MUST NOT запускать автозамену +другого файла; замещение привязки с тем же ключом происходит при повторном подтверждении по §3 п.1. +Перезагрузка файла MUST NOT переносить уточнения старых документов. Флаг CONSOLIDATION_PERIOD_FROM_VERSION сохраняется. + +#### Scenario: Смена счёта шапки +- **WHEN** счёт ОСВ РБ исправлен с 60 на 62 +- **THEN** колонка «ОСВ» и подходящие слоты чек-листа учитывают 62; account_code/account_id/account_root + строк ОСВ, period_check и counterparty_check не изменились, другой файл ОСВ 62 не заменён + +#### Scenario: Перезагрузка +- **WHEN** новый загруженный файл распознан с исходным счётом 60 +- **THEN** новые документы созданы из нового результата, исправление 62 предыдущего документа не переносится + +### Requirement: Строгие типы шапки ОСВ +PATCH SHALL принимать period_year как integer, period_quarter и osv_account_code как string, +is_cumulative как boolean, без приведения типов. Отсутствующие поля не меняются; null SHALL передаваться +в доменную проверку явно. Неверные типы SHALL отклоняться стандартным 422 валидации тела до доменных +проверок. Границы года, допустимые кварталы и совместимость полей SHALL проверяться доменом. + +#### Scenario: Неверный тип года +- **WHEN** передан period_year как строка, дробное число, boolean, массив или объект +- **THEN** стандартный 422 с detail-массивом ошибок валидации, доменная обработка не запускается + +#### Scenario: Год вне диапазона +- **WHEN** у доступного незаменённого документа OSV передан period_year = 2100 +- **THEN** тип integer принят, домен возвращает 422 INVALID_PERIOD + +#### Scenario: Неизвестный квартал +- **WHEN** у доступного незаменённого документа OSV передан period_quarter = "Q0" +- **THEN** тип string принят, домен возвращает 422 INVALID_PERIOD diff --git a/openspec/specs/primary-files/spec.md b/openspec/specs/primary-files/spec.md index 15fd526..69fb719 100644 --- a/openspec/specs/primary-files/spec.md +++ b/openspec/specs/primary-files/spec.md @@ -60,7 +60,7 @@ Query-параметр `consolidation_version_id` MUST NOT приниматьс - **THEN** `rejected[0].code = DUPLICATE_FILE`, `rejected[0].file_id` — прежний файл; новых записей нет ### Requirement: Жизненный цикл статуса файла -`stored_file.status` SHALL меняться только по заданию `current_recognition_job_id`: `UPLOADED` при загрузке; +`stored_file.status` SHALL меняться только по заданию `current_recognition_job_id`: `UPLOADED` при загрузке и при принятом ручном повторе с новым текущим заданием; `PROCESSING` при ack сервиса; `ERROR` при финальной ошибке задания (`error`, `timeout`, `dlq`) и при невозможности маппинга; `PROCESSED` после успешного маппинга; `SUPERSEDED` при автозамене. Карточка файла SHALL показывать причину `ERROR` (`error_code`/`error_message` задания или ошибку маппинга). @@ -238,3 +238,63 @@ advisory-блокировка привязок ГК → версии по `id`. #### Scenario: Очистка и повторная загрузка - **WHEN** очистка выполнена, ядро v5.19 выложено, аналитик загружает тот же PDF с ББ и ОФР - **THEN** дедуп не мешает (файла больше нет), формы создаются по мапперу 1.x или 2.0 — смотря что шлёт сервис + +### Requirement: Ручной повтор обработки файла +`POST /api/v1/files/{file_id}/retry` SHALL требовать DOCUMENT_EDIT и видимость ЮЛ. Повтор разрешён для +текущего задания tabular в timeout/TIMEOUT или dlq/NO_ACK при attempt < max_attempts, отсутствии документов +и файле ERROR. Успех SHALL вернуть 202 с карточкой файла и новым текущим заданием. Несуществующий или чужой файл — 404. + +#### Scenario: После таймаута +- **WHEN** табличный файл ОСВ РБ завершился timeout/TIMEOUT, attempt = 1, max_attempts = 3 +- **THEN** 202, создано новое задание attempt = 2, прежнее осталось, в карточке новое текущее задание + +#### Scenario: Неподходящее состояние +- **WHEN** повтор запрошен для OCR, done, needs_check, error сервиса, ошибки маппинга либо файла с документами +- **THEN** 409 FILE_NOT_RETRYABLE без нового задания и аудита + +#### Scenario: Лимит исчерпан +- **WHEN** подходящее задание имеет attempt = max_attempts +- **THEN** 409 MAX_ATTEMPTS_REACHED, состояние не меняется + +### Requirement: Пакет повторной обработки версии +`POST /api/v1/consolidations/versions/{version_id}/files/retry` SHALL выбирать видимые ERROR-файлы +консолидации по периметру версии. Другие статусы не входят в пакет и не блокируются. +Ответ — 202 с accepted[{file_id, job_id, attempt}] и skipped[{file_id, code}]. В skipped SHALL входить +только ERROR-файлы, которые нельзя повторить (FILE_NOT_RETRYABLE, MAX_ATTEMPTS_REACHED). +Файлы без контекста консолидации MUST NOT включаться по совпадению ЮЛ. + +#### Scenario: Смешанный пакет +- **WHEN** у версии есть файл timeout/TIMEOUT, OCR, успешный файл и файл с исчерпанным лимитом +- **THEN** новый job создаётся только для первого; OCR-файл в skipped с `FILE_NOT_RETRYABLE`, файл с исчерпанным + лимитом — с `MAX_ATTEMPTS_REACHED`; успешный (`PROCESSED`) файл не попадает ни в accepted, ни в skipped + +#### Scenario: Периметр и контекст +- **WHEN** файл загружен в другую консолидацию либо его ЮЛ не входит в выбранную версию +- **THEN** файл не участвует ни в accepted, ни в skipped этой версии + +#### Scenario: Общие файлы разных версий +- **WHEN** две версии одной консолидации содержат одно ЮЛ и вызывают пакетный повтор +- **THEN** они используют общий набор файлов консолидации; уже повторяемый файл второй раз не запускается + +#### Scenario: Пустой пакет +- **WHEN** подходящих файлов нет +- **THEN** 202 с пустым accepted; в skipped — только файлы в `ERROR`, которые повторить нельзя (без них список пуст); + аудита повторов нет + +### Requirement: Ограничения версии при повторе +Повтор в контексте версии SHALL разрешаться только для версий «в работе» (INITIATED, DOCUMENTS_PROCESSING, +DATA_CORRECTION). Иначе SHALL возвращаться 409 VERSION_NOT_EDITABLE. consolidation_lock MUST NOT требоваться. +Одиночный повтор файла с consolidation_id SHALL требовать наличия версии «в работе» с ЮЛ файла в периметре; +без consolidation_id SHALL применяться только policy ЮЛ. Повтор MUST NOT изменять статусы версий. + +#### Scenario: Проверенная версия +- **WHEN** пакет запрошен для VERIFIED +- **THEN** 409 VERSION_NOT_EDITABLE, ни один файл не изменён + +#### Scenario: Консолидация уже закрыта для обработки +- **WHEN** одиночный повтор файла консолидации запрошен без версии «в работе» с его ЮЛ +- **THEN** 409 VERSION_NOT_EDITABLE; другая версия консолидации не открывает недоступный пакет версии + +#### Scenario: Сохранение статуса +- **WHEN** пакет допустимой версии INITIATED принят без пользовательской блокировки +- **THEN** попытки созданы, INITIATED сохраняется; дальнейшие переходы происходят по существующим событиям diff --git a/openspec/specs/recognition-job-exchange/spec.md b/openspec/specs/recognition-job-exchange/spec.md index f2662ca..3a63913 100644 --- a/openspec/specs/recognition-job-exchange/spec.md +++ b/openspec/specs/recognition-job-exchange/spec.md @@ -82,8 +82,7 @@ ### Requirement: Таймаут ожидания результата Фоновый вахтёр SHALL с периодом 30 с переводить задания `processing` и `published` с `reply_deadline < now()` в `status = timeout`, `error_code = TIMEOUT`, `finished_at = now()`, а их файлы (по `current_recognition_job_id`) — в -`stored_file.status = ERROR`. Публикатор при переводе задания в `dlq` SHALL так же переводить файл в `ERROR`. Replay -задания (новая попытка, `attempt + 1`) — вне этого среза. +`stored_file.status = ERROR`. Публикатор при переводе задания в `dlq` SHALL так же переводить файл в `ERROR`. Ручной повтор после TIMEOUT/NO_ACK SHALL создавать новое задание по требованию «Новая попытка обработки файла». #### Scenario: Сервис взял, но не ответил - **WHEN** задание в `processing` и `reply_deadline` прошёл без результата @@ -162,3 +161,38 @@ Inbox SHALL принимать конверты (включая ack `processing` #### Scenario: Результат 2.0 разбирает маппер 2.0 - **WHEN** inbox принял файл результата со `schema_version = "2.0"` - **THEN** документы создаёт маппер результата `2.0`; результат `1.x` по-прежнему разбирает маппер `1.x` + +### Requirement: Новая попытка обработки файла +Ручной повтор SHALL создать recognition_job с новым id, attempt = прежний attempt + 1, тем же file_id, +pipeline и max_attempts, статусом queued. Предыдущая запись SHALL сохраняться без изменений. +Файл SHALL ссылаться на новое задание и получить UPLOADED. Ссылка S3 на исходник и consolidation_id сохраняются; +новых документов, файлов и копий исходника SHALL NOT создаваться. + +#### Scenario: Повтор после NO_ACK +- **WHEN** tabular-задание ОСВ РБ завершилось dlq/NO_ACK с attempt = 1 +- **THEN** новое задание queued имеет attempt = 2, файл UPLOADED с прежним id и объектом S3, + current_recognition_job_id указывает на новый id + +### Requirement: Изоляция новой попытки +Новое задание SHALL начинаться без ack, результата, ошибок, метрик и времён старого задания, со свежим +requested_at. Публикатор SHALL вычислить новый reply_deadline и Nats-Msg-Id для нового job_id/attempt. +Запоздавшие ack/результаты прежней финальной попытки MUST NOT менять файл, создавать документы или +мешать обработке нового задания. + +#### Scenario: Старый ответ +- **WHEN** после создания попытки 2 пришли ack и done для попытки 1 в timeout +- **THEN** текущее задание, статус файла и документы не меняются; ответ логируется и игнорируется + +#### Scenario: Публикация после недоступности NATS +- **WHEN** ручной повтор принят, а NATS временно недоступен +- **THEN** задание остаётся queued; после восстановления публикуется со свежим сроком и новым Nats-Msg-Id + +### Requirement: Конкурентные запросы повтора +Проверка текущего задания и лимита, создание новой попытки и переключение файла SHALL быть атомарны. +Два запроса к одной неуспешной попытке MUST NOT создавать два новых задания. При rollback новое задание, +переключение файла и аудит SHALL откатываться вместе. + +#### Scenario: Двойное нажатие +- **WHEN** два параллельных запроса повторяют attempt = 1 +- **THEN** создан ровно один job attempt = 2; второй одиночный запрос получает 409 FILE_NOT_RETRYABLE, + а пакетный отмечает файл skipped; attempt = 3 не создаётся -- 2.47.2