139 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Design
## Context
Мотивация и объём — proposal.md. Сейчас upload создаёт recognition_job в queued, а recognition_exchange.py
публикует outbox, принимает ack/результаты и закрывает просроченные задания. attempt при повторной публикации
не растёт. Механизма ручной новой попытки нет. file_status.py уже защищает файл от событий нетекущего задания.
DocumentService умеет confirm; привязки централизованы в consolidation_documents.py. Модель и миграции — v5.19.
Основание для ОСВ — DBML v5.20 и §9.5 п.12; для повтора — AURORA-1442, DBML v5.21,
§9.4 и §9.5 п.13. FILE_REPROCESS уже опубликован в модели v5.21; изменение enum в v5.20 не требуется.
Пакет по consolidation_id и периметру выбранной версии дополнительно согласован пользователем.
Этот change содержит только план: соседний docs не изменён.
## Goals / Non-Goals
**Goals:** атомарные правки и повтор, сохранение первичных данных и истории, отсутствие дублей при гонках,
единая проверка допустимости для одиночного и пакетного повтора, совместимость с действующим outbox.
**Non-Goals:** новые фоновые воркеры, доставка через JetStream, вычисляемые ORM-таблицы, изменение сумм и
субсчетов ОСВ. Денежные значения сохраняют Decimal/numeric(18,2), рубли; правка шапки их не пересчитывает.
## Decisions
### D1. Сервис ручного повтора и API
Новый services/file_retry.py, фабрика DI и тонкие маршруты:
- POST /api/v1/files/{file_id}/retry → 202 FileOut.
- POST /api/v1/consolidations/versions/{version_id}/files/retry → 202 RetryBatchOut.
- PATCH /api/v1/documents/{id}/osv-header → 200 DocumentOut (endpoints/files/documents.py).
Первые два маршрута не требуют чтения/записи S3 и синхронной публикации NATS: достаточно транзакции outbox.
Для файла повторно используется тот же s3_object; path загрузки содержит id первой попытки, это допустимо:
ключ исходника берётся из реестра, не конструируется из id новой попытки.
Ответы и коды отказов определены в дельтах primary-files/documents-api; новые типы DTO — в schemas/files.py
и schemas/documents.py. Названия retry-маршрутов и 202 — проектное решение, в исходной задаче они не заданы.
### D2. Новая попытка вместо обновления прежнего job
Создать новый UUID job, queued, attempt + 1; перенести file_id, pipeline, max_attempts.
schema_version брать из CONTRACT_VERSION, request_subject — из текущих настроек NATS, как при загрузке;
версию контракта и subject прежнего задания не копировать. Ошибки, метрики, result_s3_object_id, service metadata, message ids
и времена старой попытки не копируются. requested_at — сейчас, reply_deadline формируется публикатором.
Переключить current_recognition_job_id и вернуть файл из ERROR в UPLOADED. Прежний job неизменен.
Это соответствует уточнённой задаче и не требует изменения дедупа парсера по старому job_id (OQ-73).
Автоматический republish остаётся прежним job/attempt; это другая операция.
### D3. Пакет, частичные отказы и версии
Отбор repositories/files.py::retry_candidates выбирает только ERROR-файлы выбранной консолидации
по stored_file.consolidation_id, с ЮЛ из периметра версии и policy ЮЛ, до любых блокировок.
Остальные статусы не блокируются и не попадают в skipped; после блокировки статус ERROR перепроверяется.
Файл без документов не отбирается через consolidation_document. Дедуп по file_id.
Согласованный пользователем общий набор у нескольких версий сохраняется; повтор не создаёт версионной копии файла.
Проектное решение для смешанного пакета: общие ошибки прав/видимости/статуса версии отклоняют запрос целиком;
непригодность конкретного файла возвращается в skipped, остальные принимаются. accepted и skipped стабильны
по file_id. Пустой пакет — 202. Неожиданная ошибка БД откатывает весь запрос, а не маскируется как skipped.
Одна транзакция на запрос, сервис не вызывает commit. Новых переходов статуса версии от ручного retry нет.
Допустимые версии — IN_WORK_STATUSES; брать пользовательскую consolidation_lock не требуется.
Для одиночного файла с consolidation_id проверить наличие такой версии с его ЮЛ; файл без контекста — по policy ЮЛ.
Это консервативное проектное правило для отсутствующего version_id, его нужно явно перенести в §9.4.
### D4. Блокировки и гонки
Retry следует порядку работающего обмена: текущее задание FOR UPDATE → файл FOR UPDATE → версии по id.
В одиночном повторе только рабочие версии берутся FOR SHARE: статус и периметр защищены от изменений,
обычное чтение не блокируется, VERIFIED/CANCELLED вообще не блокируются.
После блокировки файла перечитать current_recognition_job_id; если изменился, одиночный запрос получает
FILE_NOT_RETRYABLE, пакет пропускает файл. Не захватывать новое текущее задание, уже удерживая файл:
это инвертирует порядок обмена. Лимит и отсутствие документов проверять заново под блокировкой.
Для пакета сначала блокировать все отобранные прежние jobs по id, затем файлы по id, затем версии по id;
после захвата версий перечитать статус и состав периметра. Не начинать пакет с FOR UPDATE версии.
OpenAPI одиночного повтора описывает FILE_NOT_RETRYABLE/MAX_ATTEMPTS_REACHED/VERSION_NOT_EDITABLE;
для пакетного 409 только VERSION_NOT_EDITABLE, остальные причины возвращаются в skipped.
Для PATCH — файл → документ FOR NO KEY UPDATE → существующая advisory-блокировка привязок ГК → версии
по id, как при confirm/delete. Проверить связанные статусы под блокировками и не использовать
lock_editable_version, требующий пользовательскую consolidation_lock. Сериализация на файле защищает
счёт от параллельного PATCH; уникальный индекс остаётся последней защитой, IntegrityError переводится
в DUPLICATE_ACCOUNT через SAVEPOINT с повторным чтением конфликтующего document_id.
### D5. Шапка и подтверждение
OsvHeaderPatch использует StrictInt для period_year, StrictStr для period_quarter/osv_account_code,
StrictBool для is_cumulative; каждое поле допускает null, отсутствие определяется exclude_unset.
Pydantic возвращает стандартный 422 для неверного типа до вызова обработчика. Порядок доменных
ошибок §9.5 п.12 применяется к телу с корректными типами; границы года и набор кварталов проверяет домен.
Чистая функция domain/osv_header.py строит итоговую шапку, нормализует счёт существующей
normalize_account_code, различает отсутствующие и переданные поля, проверяет целостность периода.
ORM-операции — отдельный services/osv_header.py с доступом из DocumentService/DI.
Пустое тело или нормализованное равенство текущему — no-op; проверки доступа и конфликтов сохраняются.
Перед первой фактической правкой сохранить все четыре исходных поля JSON, используя сериализуемые
строки enum. На каждой правке сравнивать итог с неизменным original_values. При возврате исходного
is_modified снимается, original_values и история остаются. Не создавать новый document/file_ordinal.
У CONFIRMED снять подтверждение и только рабочие привязки; CANCELLED сохранить. Другие статусы не менять.
period_check/counterparty_check и document_issue не пересчитываются этим PATCH.
Повторный confirm использует существующие правила привязки по исправленным полям, автозамена файлов не вызывается.
### D6. Аудит и согласование модели
FILE_REPROCESS — значение audit_action из DBML v5.21; раздел PRIMARY_DOCUMENTS, объект stored_file,
одно событие на принятый файл. before_value — прежнее задание: job_id, attempt, status задания, error_code;
after_value — новое задание: job_id, attempt. Для пакета в after_value добавить consolidation_id.
Сохранить автора, IP и User-Agent. Статус файла в этот payload не подставляется.
Автоматический обмен по OQ-55 по-прежнему без такого аудита. PATCH — RECOGNIZED_DATA_EDIT, RECOGNITION,
объект document, изменённые поля и сброшенное подтверждение, unbound_version_ids. Всё в транзакции мутации.
Переносить аудит повтора на FILE_UPLOAD отвергнуто: физической загрузки нет.
## Risks / Trade-offs
- Поздний ответ старого job → существующая защита финального статуса и current job, регрессия inbox и маппера.
- Взаимоблокировка с confirm/delete/status → единый порядок и интеграционные тесты на отдельных соединениях.
- Пакет охватывает общие файлы консолидации → явно описанный состав и повторная проверка текущего job.
- Расхождение с источником истины → сверка ORM с опубликованной DBML v5.21; исходная v5.20 остаётся неизменной.
- Папка ../fixtures сейчас недоступна → до golden/e2e обеспечить доступ к репозиторию реальных ОСВ РБ;
отсутствие данных не считать успешной проверкой. Гонки и статусы проверяются фабриками БД без поддельных форматов.
- Временный флаг периода может маскировать исправление периода → тесты с флагом false и true, сам флаг не удалять.
- Откат после возврата к исходному удаляет original_values при is_modified=false → следовать утверждённому
условию v5.20; историю правок сохраняет audit_event, исходник/JSON остаются в S3; ограничение описать в README.
## Migration Plan
1. Использовать опубликованные DBML v5.20/v5.21 и §9.5 п.12–13. В docs уточнить согласованный отбор пакета
и описание API; повторно вводить FILE_REPROCESS в модель не требуется.
2. Миграция 0008_dbml_v5_20 после 0007: только четыре колонки document; старые строки получают
is_modified=false, остальные NULL. Существующие документы и привязки не меняются.
3. Отдельная миграция 0009_dbml_v5_21 после 0008: только FILE_REPROCESS в audit_action,
после FILE_REPLACE как в DBML. Итоговые DBML_VERSION, путь Makefile и сверка схемы — v5.21.
4. Сначала схема, затем приложение; перезапуск фоновых задач обычным порядком. Старые job и файлы не мигрировать.
5. Downgrade 0009 → 0008 запрещён до DDL при наличии FILE_REPROCESS в аудите, события не переписываются.
На совместимых данных восстанавливается enum v5.20 без FILE_REPROCESS. Downgrade 0008 → 0007
отдельно запрещён при document.is_modified=true; иначе удаляются только четыре колонки.
Проверить оба перехода по отдельности, полный цикл на чистой/непустой БД и нестандартный DB_SCHEMA.
6. Обновить frontend-api-guide и README; UI не выкладывается этой задачей.