Архив OpenSpec retry-recognition-and-edit-osv-header (PR #161) #176

Merged
aep merged 1 commits from chore/161-archive-retry-recognition into main 2026-10-23 17:20:25 +03:00
14 changed files with 281 additions and 9 deletions
Showing only changes of commit 70c5743c77 - Show all commits

View File

@ -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 отражает эту правку; старые события не меняются

View File

@ -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, исходные значения и привязки сохранены

View File

@ -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

View File

@ -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 сохраняется; дальнейшие переходы происходят по существующим событиям

View File

@ -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 не создаётся