diff --git a/CLAUDE.md b/CLAUDE.md index 15bec43..f4ebad5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,7 +175,7 @@ - NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`. ## Правила репозитория -- ORM 1:1 с `../docs/model/data-model-v5.19.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. +- ORM 1:1 с `../docs/model/data-model-v5.22.dbml` (текущая версия; v5.22 структуру не меняла; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19` + `0008_dbml_v5_20` + `0009_dbml_v5_21`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. - Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring. - Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц. - Фронт (`web/`) разрабатывается здесь (с 21.10.2026): фронтовые задачи — worktree arm-core и PR в `main`, как и diff --git a/Makefile b/Makefile index b5bb7c9..8026686 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ VENV := api/.venv BIN := $(CURDIR)/$(VENV)/bin COMPOSE_FILE ?= ../docs/dev/docker-compose.yml # DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/ -DBML ?= ../../docs/model/data-model-v5.19.dbml +DBML ?= ../../docs/model/data-model-v5.22.dbml .PHONY: venv lint test test-integration e2e dbml-check migration-cycle check check-infra docker-build web-check statements-check check-full up migrate seed seed-dev diff --git a/README.md b/README.md index ec0e19f..9118a25 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,71 @@ PostgreSQL 16, core NATS без JetStream), `web/` — фронтенд (React + статусы, привязка подтверждённых документов к версии, чек-лист, сводная ОСВ, ручное сопоставление контрагента, экспорт xlsx. Спецификация — `openspec/changes/slice-4-consolidation/`; раздел ниже: «Консолидация (Срез 4)». +## Повторная обработка и уточнение ОСВ (AURORA-1442, DBML v5.20–v5.21) + +Все маршруты ниже требуют `DOCUMENT_EDIT` и видимость ЮЛ; чужой объект отвечает тем же 404, что несуществующий. + +| Запрос (префикс `/api/v1`) | Успех | Назначение | +|---|---|---| +| `POST /files/{file_id}/retry` | 202 `FileOut` | Новая попытка обработки одного файла | +| `POST /consolidations/versions/{version_id}/files/retry` | 202 `{accepted, skipped}` | Подходящие файлы консолидации по периметру выбранной версии | +| `PATCH /documents/{document_id}/osv-header` | 200 `DocumentOut` | Уточнение периода и счёта ОСВ | + +Ручной повтор разрешён для файла `ERROR`, только если его текущее задание `tabular` завершилось +`timeout/TIMEOUT` или `dlq/NO_ACK`, документов нет и `attempt < max_attempts`. Создаётся новый job +`queued`, `attempt + 1`; файл становится `UPLOADED`, исходный S3-объект сохраняется, старый job остаётся +в истории. NATS не требуется в момент запроса: публикация выполняется существующим outbox после commit. +OCR, `error` сервиса и ошибки маппинга не повторяются. Ошибки: 409 `FILE_NOT_RETRYABLE`, +`MAX_ATTEMPTS_REACHED`, `VERSION_NOT_EDITABLE`. + +Пакет включает только файлы `ERROR` с `stored_file.consolidation_id` выбранной консолидации и ЮЛ из периметра выбранной +версии; файлы другой консолидации и загрузки без контекста не включаются. Версия должна быть «в работе» +(`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`), пользовательская блокировка не нужна. +Версии одной консолидации могут видеть общий файл. Другие статусы файлов не блокируются и не попадают +в ответ. Неподходящие `ERROR`-файлы пропускаются: +`accepted: [{file_id, job_id, attempt}]`, `skipped: [{file_id, code}]`. Пустой пакет также отвечает 202. +Для одиночного файла консолидации требуется хотя бы одна рабочая версия с ЮЛ файла в периметре. +Повтор не меняет статусы версий. Два конкурентных запроса не создают две новые попытки. + +`PATCH …/osv-header` принимает любые из четырёх полей; отсутствующие не меняются. Пример: + +```json +{"period_year": 2025, "period_quarter": "Q3", "is_cumulative": true, "osv_account_code": "60.1"} +``` + +Типы тела строгие: `period_year` — integer, `period_quarter` и `osv_account_code` — string, +`is_cumulative` — boolean; приведения строк и чисел нет. Неверный тип — стандартный 422 валидации +до доменных проверок. Явный `null` передаётся в домен, отсутствующие поля не меняются. +Счёт нормализуется (`60.1` → `60.01`). Если передано поле периода, итоговые три поля периода обязательны: +год 1900–2099, квартал Q1–Q4, boolean; у Q1 `is_cumulative=true`. Правка только счёта неполный период не трогает. +Новые значения сохраняются в прежнем `document`; перед первой фактической правкой все четыре исходных +значения сохраняются в `original_values`, неизменном при следующих правках. `is_modified` показывает отличие +текущих значений от оригинала, `modified_by/at` — последнюю правку. Возврат к оригиналу снимает признак, +но сохраняет снимок. Файл, JSON результата, `file_ordinal` и строки `osv_entry` не меняются. + +Правка `CONFIRMED` сбрасывает статус в `NEEDS_CHECK` и очищает `confirmed_by/at`; привязки к рабочим версиям +снимаются, `CANCELLED` сохраняются, статусы версий не меняются. Нужно новое ручное подтверждение; +автоподтверждения и автозамены файлов нет. Колонка «ОСВ», подходящесть версиям и чек-лист читают актуальные +поля; `period_check/counterparty_check` не меняются. Флаг `CONSOLIDATION_PERIOD_FROM_VERSION` остаётся. +Запрос без фактических изменений — 200 без аудита, снимка и сброса подтверждения. + +Ошибки PATCH по порядку: 409 `DOCUMENT_NOT_OSV`, `FILE_SUPERSEDED`; 422 `INVALID_PERIOD`, +`INVALID_ACCOUNT_CODE`; 409 `DUPLICATE_ACCOUNT` (`detail.document_id`); 409 `VERSION_NOT_EDITABLE` +(`detail.version_ids`). Последняя означает привязку к версии вне рабочих статусов и `CANCELLED`. + +Аудит: `FILE_REPROCESS`, раздел `PRIMARY_DOCUMENTS`, объект `stored_file`, на каждый принятый файл. +`before_value={job_id, attempt, status, error_code}` описывает прежнее задание; +`after_value={job_id, attempt}` — новое; для пакета добавляется `consolidation_id`. +Пропущенные файлы и автоматические republish таких событий не пишут. +Правка ОСВ — одно `RECOGNIZED_DATA_EDIT` (`RECOGNITION`, `document`) с изменёнными полями, +сброшенным подтверждением и `after_value.unbound_version_ids`. Аудит и мутация фиксируются вместе. + +Поставка: сначала `0008_dbml_v5_20` (четыре поля document), затем `0009_dbml_v5_21` (только enum +`FILE_REPROCESS`), затем приложение. Итоговая сверка — DBML v5.21. Откат 0009 → 0008 запрещён при +наличии `FILE_REPROCESS` в аудите; откат 0008 → 0007 — при `document.is_modified=true`. +При возврате к исходным значениям признак false, откат v5.20 удалит снимок; файл/JSON в S3 и аудит сохраняются. +UI повторной обработки и правки ОСВ поставляется отдельными задачами. + ## Предварительные требования - **Python 3.11** (версия платформы) и **[`uv`](https://docs.astral.sh/uv/)** — `make venv` создаёт окружение и ставит @@ -1004,7 +1069,7 @@ email без учёта регистра проверяет только сер | `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно | | `DB_SCHEMA` | `dkb` | схема PostgreSQL модели данных: MetaData ORM, `search_path` движка приложения, `CREATE SCHEMA` и таблица версий Alembic, схема в миграциях. Имя — строчный идентификатор `[a-z_][a-z0-9_]{0,62}`, не `pg_*` (подставляется в DDL без кавычек; иное — ошибка при старте). **Одно значение для приложения и для `alembic upgrade`:** сменить схему у уже мигрированной БД нельзя (таблицы остаются в старой); в контуре — `OPENBAO__APP__DB_SCHEMA`, в миграционном задании тоже. Читается при импорте моделей, поэтому скрипты и тесты видят его через те же `.env`/окружение | | `FORWARDED_ALLOW_IPS` | `127.0.0.1` | доверенные прокси для `X-Forwarded-For`/`X-Forwarded-Proto` (`ProxyHeadersMiddleware`): адреса через запятую или `*`. Адрес клиента (`audit_event.ip_address`) берётся из заголовка только от них, от остальных заголовок игнорируется. На платформе — адреса ingress/балансировщика (OQ-65) | -| `CORS_ORIGINS` | пусто | при `DEBUG=false` — источники, которым разрешены кросс-доменные запросы: строка через запятую (`https://a.example.ru,https://b.example.ru`) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); `allow_credentials` — только для явного списка без `*`. При `DEBUG=true` не используется (всегда `*` без credentials: токен передаётся заголовком, cookie не нужны) | +| `CORS_ORIGINS` | пусто | при `DEBUG=false` — источники, которым разрешены кросс-доменные запросы: строка через запятую (`https://a.example.ru,https://b.example.ru`) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); `allow_credentials` — только для явного списка без `*`. При `DEBUG=true` не используется (всегда `*` без credentials: токен передаётся заголовком, cookie не нужны). В обоих режимах фронту открыт заголовок `Content-Disposition` (`Access-Control-Expose-Headers`) — имя файла экспорта (#173) | | `SERVER__HOST`, `SERVER__PORT` | `0.0.0.0`, `8000` | адрес и порт сервера (`run_server.py`); в `.env.example` — `127.0.0.1` (dev-запуск только на loopback) | | `ROOT_PATH` | `/` | корневой путь за прокси (должен совпадать с `REACT_APP_ROOT_PATH` фронта) | | `VERSION` | `1.0.0` | версия в `/healthcheck` и OpenAPI | @@ -1062,7 +1127,7 @@ email без учёта регистра проверяет только сер | `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен | | `make test-integration` | интеграционные тесты: `pytest -m "integration and not e2e"`, нужны PostgreSQL (и NATS) | | `make e2e` | сквозной прогон загрузки ОСВ: `pytest -m e2e`, нужны compose и tabular-parser из профиля `services` (без него — skip) | -| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.19.dbml` | +| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.22.dbml` | | `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate | | `make check` | быстрая проверка без Docker: `lint` + `test` | | `make docker-build` | сборка образа: `docker build -t arm-core .` | @@ -1114,7 +1179,7 @@ queue group `arm-core`, подписчики группы получают со ### Проверка ORM и DBML -Модель данных — `../docs/model/data-model-v5.19.dbml`. ORM обязана совпадать с ней один в один: +Модель данных — `../docs/model/data-model-v5.22.dbml`. ORM обязана совпадать с ней один в один: ```bash make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно @@ -1137,7 +1202,7 @@ make migration-cycle # проверить цикл upgr ``` api/ run_server.py точка входа (uvicorn; хост и порт — из настроек) - alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16, 0005_dbml_v5_17.py — v5.17, 0006_dbml_v5_18.py — v5.18, 0007_dbml_v5_19.py — v5.19) + alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16, 0005_dbml_v5_17.py — v5.17, 0006_dbml_v5_18.py — v5.18, 0007_dbml_v5_19.py — v5.19, 0008_dbml_v5_20.py — v5.20, 0009_dbml_v5_21.py — v5.21) rules/ statement_lines.yaml — справочник имён строк форм ББ/ОФР по редакциям (словарь проверок §7 — в пакете arm_statements) seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист) seed/dev/ dev-данные: филиалы и пользователи по ролям @@ -1221,7 +1286,7 @@ Dockerfile только для локального compose - [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы; - [`../docs/context/03-architecture.md`](../docs/context/03-architecture.md) — архитектура, соглашения шаблона, NATS и S3; - [`../docs/context/04-data-model-guide.md`](../docs/context/04-data-model-guide.md) и - [`../docs/model/data-model-v5.19.dbml`](../docs/model/data-model-v5.19.dbml) — модель данных; + [`../docs/model/data-model-v5.22.dbml`](../docs/model/data-model-v5.22.dbml) — модель данных; - [`../docs/context/05-domain-rules.md`](../docs/context/05-domain-rules.md) — алгоритмы предметной области; - [`../docs/context/07-open-questions.md`](../docs/context/07-open-questions.md) — открытые вопросы; - [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов; diff --git a/api/alembic/versions/0008_dbml_v5_20.py b/api/alembic/versions/0008_dbml_v5_20.py new file mode 100644 index 0000000..9e8edb2 --- /dev/null +++ b/api/alembic/versions/0008_dbml_v5_20.py @@ -0,0 +1,28 @@ +"""DBML v5.20: исходная шапка ОСВ рядом с исправленными данными.""" + +from alembic import op +from src.models.base import SCHEMA + +revision = "0008_dbml_v5_20" +down_revision = "0007_dbml_v5_19" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(f"ALTER TABLE {SCHEMA}.document ADD COLUMN is_modified boolean NOT NULL DEFAULT false") + op.execute(f"ALTER TABLE {SCHEMA}.document ADD COLUMN original_values jsonb") + op.execute( + f"ALTER TABLE {SCHEMA}.document ADD COLUMN modified_by uuid " + f"CONSTRAINT fk_document_modified_by_app_user REFERENCES {SCHEMA}.app_user(id)" + ) + op.execute(f"ALTER TABLE {SCHEMA}.document ADD COLUMN modified_at timestamptz") + + +def downgrade() -> None: + op.execute(f"""DO $$ DECLARE n bigint; BEGIN + SELECT count(*) INTO n FROM {SCHEMA}.document WHERE is_modified; + IF n > 0 THEN RAISE EXCEPTION 'откат v5.20 невозможен: исправленные документы: %', n; END IF; + END $$""") + for name in ("modified_at", "modified_by", "original_values", "is_modified"): + op.execute(f"ALTER TABLE {SCHEMA}.document DROP COLUMN {name}") diff --git a/api/alembic/versions/0009_dbml_v5_21.py b/api/alembic/versions/0009_dbml_v5_21.py new file mode 100644 index 0000000..f7e45c9 --- /dev/null +++ b/api/alembic/versions/0009_dbml_v5_21.py @@ -0,0 +1,28 @@ +"""DBML v5.21: FILE_REPROCESS; аудит не удаляется при откате.""" + +from alembic import op +from src.models.base import SCHEMA + +revision = "0009_dbml_v5_21" +down_revision = "0008_dbml_v5_20" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(f"ALTER TYPE {SCHEMA}.audit_action ADD VALUE 'FILE_REPROCESS' AFTER 'FILE_REPLACE'") + + +def downgrade() -> None: + op.execute(f"""DO $$ DECLARE n bigint; labels text; BEGIN + SELECT count(*) INTO n FROM {SCHEMA}.audit_event WHERE action::text = 'FILE_REPROCESS'; + IF n > 0 THEN RAISE EXCEPTION 'откат v5.21 невозможен: события FILE_REPROCESS: %', n; END IF; + SELECT string_agg(quote_literal(e.enumlabel), ',' ORDER BY e.enumsortorder) INTO labels + FROM pg_enum e JOIN pg_type t ON t.oid=e.enumtypid JOIN pg_namespace ns ON ns.oid=t.typnamespace + WHERE ns.nspname='{SCHEMA}' AND t.typname='audit_action' AND e.enumlabel <> 'FILE_REPROCESS'; + ALTER TYPE {SCHEMA}.audit_action RENAME TO audit_action_v5_21; + EXECUTE 'CREATE TYPE {SCHEMA}.audit_action AS ENUM (' || labels || ')'; + ALTER TABLE {SCHEMA}.audit_event ALTER COLUMN action TYPE {SCHEMA}.audit_action + USING action::text::{SCHEMA}.audit_action; + DROP TYPE {SCHEMA}.audit_action_v5_21; + END $$""") diff --git a/api/scripts/dbml_parity.py b/api/scripts/dbml_parity.py index 4ff47a4..a758501 100644 --- a/api/scripts/dbml_parity.py +++ b/api/scripts/dbml_parity.py @@ -57,7 +57,7 @@ from sqlalchemy.sql.functions import FunctionElement #: Эталонная схема модели. Имя схемы в БД конфигурируемо (`DB_SCHEMA`), CLI сверяет ORM, загруженную с этим значением. SCHEMA = "dkb" -DBML_FILE_NAME = "data-model-v5.19.dbml" +DBML_FILE_NAME = "data-model-v5.22.dbml" ORM = "ORM" DBML = "DBML" ABSENT = "нет" diff --git a/api/src/api/dependencies/files.py b/api/src/api/dependencies/files.py index 63afe17..150256a 100644 --- a/api/src/api/dependencies/files.py +++ b/api/src/api/dependencies/files.py @@ -9,6 +9,8 @@ from src.api.dependencies.db import SessionDep from src.api.dependencies.directories import SettingsDep, get_scope from src.domain.policy import VisibilityScope from src.services.documents import DocumentService +from src.services.file_retry import FileRetryService +from src.services.osv_header import OsvHeaderService from src.services.primary_files import PrimaryFilesService from src.services.statements import StatementService from src.services.statements_engine import StatementsEngine, default_engine @@ -60,3 +62,26 @@ def get_statement_service( StatementServiceDep = Annotated[StatementService, Depends(get_statement_service)] + + +def get_file_retry_service( + session: SessionDep, + app_settings: SettingsDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + scope: Annotated[VisibilityScope, Depends(get_scope)], +) -> FileRetryService: + return FileRetryService(session, audit, scope, app_settings) + + +FileRetryServiceDep = Annotated[FileRetryService, Depends(get_file_retry_service)] + + +def get_osv_header_service( + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + scope: Annotated[VisibilityScope, Depends(get_scope)], +) -> OsvHeaderService: + return OsvHeaderService(session, audit, scope) + + +OsvHeaderServiceDep = Annotated[OsvHeaderService, Depends(get_osv_header_service)] diff --git a/api/src/api/endpoints/files/__init__.py b/api/src/api/endpoints/files/__init__.py index 1e2a4d0..70e2d42 100644 --- a/api/src/api/endpoints/files/__init__.py +++ b/api/src/api/endpoints/files/__init__.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from src.api.endpoints.files import comments, consolidation_files, documents, read, statements, upload +from src.api.endpoints.files import comments, consolidation_files, documents, read, retry, statements, upload router = APIRouter(tags=["files"]) router.include_router(upload.router) @@ -11,3 +11,5 @@ router.include_router(consolidation_files.router) router.include_router(documents.router) router.include_router(comments.router) router.include_router(statements.router) + +router.include_router(retry.router) diff --git a/api/src/api/endpoints/files/documents.py b/api/src/api/endpoints/files/documents.py index dfef856..e72e9be 100644 --- a/api/src/api/endpoints/files/documents.py +++ b/api/src/api/endpoints/files/documents.py @@ -11,9 +11,9 @@ from typing import Annotated, Any from fastapi import APIRouter, Depends, Query, status from src.api.dependencies.auth import require -from src.api.dependencies.files import DocumentServiceDep +from src.api.dependencies.files import DocumentServiceDep, OsvHeaderServiceDep from src.schemas.common import ErrorDetail, Page -from src.schemas.documents import DocumentOut, EntriesPageParams, EntryOut +from src.schemas.documents import DocumentOut, EntriesPageParams, EntryOut, OsvHeaderPatch router = APIRouter(prefix="/documents") @@ -70,3 +70,22 @@ async def list_entries( async def confirm_document(document_id: uuid.UUID, service: DocumentServiceDep) -> DocumentOut: """`RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED`; аудит `DOCUMENT_CONFIRM`.""" return await service.confirm(document_id) + + +@router.patch( + "/{document_id}/osv-header", + summary="Уточнить период и счёт ОСВ", + response_model=DocumentOut, + dependencies=EDIT, + responses={ + 404: NOT_FOUND[status.HTTP_404_NOT_FOUND], + 409: { + "model": ErrorDetail, + "description": "DOCUMENT_NOT_OSV, FILE_SUPERSEDED, DUPLICATE_ACCOUNT, VERSION_NOT_EDITABLE", + }, + 422: {"model": ErrorDetail, "description": "INVALID_PERIOD, INVALID_ACCOUNT_CODE"}, + }, +) +async def patch_osv_header(document_id: uuid.UUID, body: OsvHeaderPatch, service: OsvHeaderServiceDep) -> DocumentOut: + """Уточнить период/счёт ОСВ, сохранив исходные значения и сняв подтверждение.""" + return await service.patch(document_id, body) diff --git a/api/src/api/endpoints/files/retry.py b/api/src/api/endpoints/files/retry.py new file mode 100644 index 0000000..b547232 --- /dev/null +++ b/api/src/api/endpoints/files/retry.py @@ -0,0 +1,40 @@ +"""AURORA-1442: ручной повтор файла/пакета.""" + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends + +from src.api.dependencies.auth import require +from src.api.dependencies.files import FileRetryServiceDep +from src.schemas.common import ErrorDetail +from src.schemas.files import FileOut, RetryBatchOut + +router = APIRouter(dependencies=[Depends(require("DOCUMENT_EDIT"))]) +FILE_RESPONSES: dict[int | str, dict[str, Any]] = { + 404: {"model": ErrorDetail, "description": "Нет или вне видимости"}, + 409: {"model": ErrorDetail, "description": "FILE_NOT_RETRYABLE, MAX_ATTEMPTS_REACHED, VERSION_NOT_EDITABLE"}, +} + + +BATCH_RESPONSES: dict[int | str, dict[str, Any]] = { + 404: {"model": ErrorDetail, "description": "Нет или вне видимости"}, + 409: {"model": ErrorDetail, "description": "VERSION_NOT_EDITABLE"}, +} + + +@router.post("/files/{file_id}/retry", response_model=FileOut, status_code=202, responses=FILE_RESPONSES) +async def retry_file(file_id: uuid.UUID, service: FileRetryServiceDep) -> FileOut: + """Новая попытка tabular после TIMEOUT или NO_ACK, до max_attempts.""" + return await service.retry(file_id) + + +@router.post( + "/consolidations/versions/{version_id}/files/retry", + response_model=RetryBatchOut, + status_code=202, + responses=BATCH_RESPONSES, +) +async def retry_version_files(version_id: uuid.UUID, service: FileRetryServiceDep) -> RetryBatchOut: + """Повтор подходящих файлов консолидации по периметру выбранной версии.""" + return await service.retry_version(version_id) diff --git a/api/src/app.py b/api/src/app.py index 8ccb8b0..5a2366f 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -203,19 +203,24 @@ async def lifespan(app: FastAPI): await _shutdown(app) +#: заголовки ответа, которые браузер отдаёт JS с другого источника: имя файла экспорта (xlsx, csv, скан) +EXPOSE_HEADERS = ["Content-Disposition"] + + def cors_options(app_settings: Settings) -> dict[str, Any] | None: """Источники CORS (OQ-65): DEBUG — `*` без credentials (ревью R-11; токен — в заголовке, cookie не нужны); иначе — `CORS_ORIGINS`. Пустой `CORS_ORIGINS` вне DEBUG — `None`: middleware не подключается, браузер кросс-доменные запросы не выполнит. `allow_credentials` вне DEBUG — только для явного списка без `*` (спецификация CORS запрещает `*` с credentials). + `Content-Disposition` открыт фронту: без него имя файла экспорта с другого источника недоступно (arm-core #173). """ if app_settings.DEBUG: - return {"allow_origins": ["*"], "allow_credentials": False} + return {"allow_origins": ["*"], "allow_credentials": False, "expose_headers": EXPOSE_HEADERS} origins = list(app_settings.CORS_ORIGINS) if not origins: return None - return {"allow_origins": origins, "allow_credentials": "*" not in origins} + return {"allow_origins": origins, "allow_credentials": "*" not in origins, "expose_headers": EXPOSE_HEADERS} #: пути документации API: при DEBUG=false маршрутов нет, а SPA-заглушка отвечает на них 404, а не index.html diff --git a/api/src/domain/osv_header.py b/api/src/domain/osv_header.py new file mode 100644 index 0000000..a4f90c1 --- /dev/null +++ b/api/src/domain/osv_header.py @@ -0,0 +1,34 @@ +"""Проверка итоговой шапки ОСВ, §9.5 п.12; отсутствующее поле не меняется.""" + +from collections.abc import Mapping + +from src.common.exceptions import DomainValidationError +from src.domain.osv_mapping import normalize_account_code +from src.models.enums import PeriodQuarter + +HEADER_FIELDS = ("period_year", "period_quarter", "is_cumulative", "osv_account_code") +MIN_YEAR = 1900 +MAX_YEAR = 2099 + + +def corrected_header(current: Mapping[str, object], patch: Mapping[str, object]) -> dict[str, object]: + """Проверить и нормализовать итог, не изменяя входные отображения.""" + result = dict(current) + result.update(patch) + if any(key in patch for key in HEADER_FIELDS[:3]): + year, quarter, cumulative = (result[key] for key in HEADER_FIELDS[:3]) + if ( + type(year) is not int + or not MIN_YEAR <= year <= MAX_YEAR + or not isinstance(quarter, str) + or quarter not in {q.value for q in PeriodQuarter} + or type(cumulative) is not bool + or (quarter == "Q1" and cumulative is not True) + ): + raise DomainValidationError("INVALID_PERIOD", "Укажите год 1900–2099, квартал и признак нарастающего итога") + if "osv_account_code" in patch: + account = patch["osv_account_code"] + if not isinstance(account, str) or not account.strip(): + raise DomainValidationError("INVALID_ACCOUNT_CODE", "Счёт ОСВ не может быть пустым") + result["osv_account_code"] = normalize_account_code(account) + return result diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index 277ec44..a05af08 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -1,4 +1,4 @@ -"""ORM-модели. Источник истины — docs/model/data-model-v5.19.dbml; заполняется в Срезе 0. +"""ORM-модели. Источник истины — docs/model/data-model-v5.22.dbml; заполняется в Срезе 0. Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic). """ @@ -7,7 +7,7 @@ from . import access, accounting, audit_ex, consolidation, directories, files, k from .base import Base, UUIDPrimaryMixin #: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm). -DBML_VERSION = "5.19" +DBML_VERSION = "5.22" __all__ = [ "DBML_VERSION", diff --git a/api/src/models/enums.py b/api/src/models/enums.py index 1e529a3..e303ec2 100644 --- a/api/src/models/enums.py +++ b/api/src/models/enums.py @@ -328,6 +328,7 @@ class AuditAction(enum.StrEnum): FILE_UPLOAD = "FILE_UPLOAD" FILE_DELETE = "FILE_DELETE" FILE_REPLACE = "FILE_REPLACE" + FILE_REPROCESS = "FILE_REPROCESS" DOCUMENT_CONFIRM = "DOCUMENT_CONFIRM" CONSOLIDATION_LOCK = "CONSOLIDATION_LOCK" CONSOLIDATION_LOCK_TAKEOVER = "CONSOLIDATION_LOCK_TAKEOVER" diff --git a/api/src/models/files.py b/api/src/models/files.py index 7006ef1..e4d7495 100644 --- a/api/src/models/files.py +++ b/api/src/models/files.py @@ -137,6 +137,11 @@ class Document(UUIDPrimaryMixin, Base): # v5.19: индекс документа в documents[] результата (с 0); несозданный документ номер не занимает file_ordinal: Mapped[int] = mapped_column(SmallInteger, nullable=False, server_default=text("0")) + is_modified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + original_values: Mapped[Any | None] = mapped_column(nullable_jsonb()) + modified_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + modified_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + class RecognitionJob(UUIDPrimaryMixin, Base): """Единственная точка обмена с NATS: запись = outbox (queued -> published), результат — inbox (дедуп).""" diff --git a/api/src/repositories/files.py b/api/src/repositories/files.py index e51cd35..b839304 100644 --- a/api/src/repositories/files.py +++ b/api/src/repositories/files.py @@ -18,7 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.domain.policy import VisibilityScope, legal_entities_visible from src.models.access import AppUser from src.models.audit_ex import Comment -from src.models.consolidation import ConsolidationDocument, ConsolidationVersion +from src.models.consolidation import ConsolidationDocument, ConsolidationMember, ConsolidationVersion from src.models.directories import LegalEntity from src.models.enums import DocumentStatus, FileStatus, S3BucketRole from src.models.files import Document, RecognitionJob, S3Object, StoredFile @@ -298,3 +298,22 @@ async def list_desk_files( # noqa: PLR0913 - фильтр и пагинация ) files = list((await session.execute(page_stmt)).scalars().all()) return files, total + + +async def retry_candidates( + session: AsyncSession, scope: VisibilityScope, consolidation_id: uuid.UUID, version_id: uuid.UUID +) -> list[StoredFile]: + """ERROR-файлы консолидации по видимому периметру версии; без блокировки нормальных файлов.""" + members = select(ConsolidationMember.legal_entity_id).where( + ConsolidationMember.consolidation_version_id == version_id + ) + stmt = ( + _visible_files(select(StoredFile), scope) + .where( + StoredFile.consolidation_id == consolidation_id, + StoredFile.legal_entity_id.in_(members), + StoredFile.status == FileStatus.ERROR, + ) + .order_by(StoredFile.id) + ) + return list((await session.scalars(stmt)).all()) diff --git a/api/src/schemas/documents.py b/api/src/schemas/documents.py index f100e84..cff93b4 100644 --- a/api/src/schemas/documents.py +++ b/api/src/schemas/documents.py @@ -9,8 +9,9 @@ import datetime import uuid from decimal import Decimal +from typing import Any -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr from src.models.enums import ( CounterpartyMatchKind, @@ -54,6 +55,10 @@ class DocumentOut(BaseModel): confirmed_at: datetime.datetime | None group_schema: list[GroupOut] | None warnings: list[str] + is_modified: bool = False + original_values: dict[str, Any] | None = None + modified_by: uuid.UUID | None = None + modified_at: datetime.datetime | None = None comments_count: int = 0 # число комментариев документа (D-11) issues: list[IssueOut] = Field(default_factory=list) # v5.19: записи `document_issue` документа (любого типа) issue_counts: IssueCountsOut = Field(default_factory=IssueCountsOut) # P3-23 @@ -93,3 +98,14 @@ class EntriesPageParams(BaseModel): limit: int = Field(DEFAULT_ENTRIES_LIMIT, ge=1, le=MAX_ENTRIES_LIMIT) offset: int = Field(0, ge=0) + + +class OsvHeaderPatch(BaseModel): + """Строгие типы проверяет Pydantic, допустимость значений — домен.""" + + model_config = ConfigDict(extra="forbid") + + period_year: StrictInt | None = None + period_quarter: StrictStr | None = None + is_cumulative: StrictBool | None = None + osv_account_code: StrictStr | None = None diff --git a/api/src/schemas/files.py b/api/src/schemas/files.py index 1379775..c19bea4 100644 --- a/api/src/schemas/files.py +++ b/api/src/schemas/files.py @@ -109,3 +109,19 @@ class FileOut(BaseModel): consolidation_id: uuid.UUID | None # v5.18: консолидация, в контексте которой загружен файл; None — без контекста job: JobBrief | None documents: list[DocumentBrief] + + +class RetryAccepted(BaseModel): + file_id: uuid.UUID + job_id: uuid.UUID + attempt: int + + +class RetrySkipped(BaseModel): + file_id: uuid.UUID + code: str + + +class RetryBatchOut(BaseModel): + accepted: list[RetryAccepted] + skipped: list[RetrySkipped] diff --git a/api/src/services/documents.py b/api/src/services/documents.py index a66739c..d2b58fb 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -177,6 +177,10 @@ def document_out( # noqa: PLR0913, PLR0917 - карточка собирает group_schema=_group_schema(job, document.locator), # ASSUMPTION(R-6): в DBML нет привязки предупреждения к документу — отдаются предупреждения маппинга файла warnings=_strings(_metrics(job).get(MAPPING_WARNINGS_KEY)), + is_modified=document.is_modified, + original_values=document.original_values, + modified_by=document.modified_by, + modified_at=document.modified_at, comments_count=comments_count, issues=list(issues), issue_counts=_counts_out(issue_counts), diff --git a/api/src/services/file_retry.py b/api/src/services/file_retry.py new file mode 100644 index 0000000..19fffce --- /dev/null +++ b/api/src/services/file_retry.py @@ -0,0 +1,187 @@ +"""Ручные новые попытки tabular; очередь и исходный объект S3 переиспользуются.""" + +import uuid +from typing import Any + +from sqlalchemy import exists, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.common.clock import utc_now +from src.common.exceptions import ConflictError, NotFoundError +from src.contract import CONTRACT_VERSION, request_subject +from src.core.config import Settings, get_settings +from src.domain.policy import IN_WORK_STATUSES, VisibilityScope +from src.models.consolidation import ConsolidationMember, ConsolidationVersion +from src.models.enums import AuditAction, AuditSection, FileStatus, RecognitionPipeline, RecognitionStatus +from src.models.files import Document, RecognitionJob, StoredFile +from src.repositories import consolidations as versions_repo +from src.repositories import files as files_repo +from src.schemas.files import FileOut, RetryAccepted, RetryBatchOut, RetrySkipped +from src.services.documents import file_out + + +class FileRetryService: + def __init__( + self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope, settings: Settings | None = None + ) -> None: + self.session, self.audit, self.scope = session, audit, scope + self.settings = settings if settings is not None else get_settings() + + async def _lock_files(self, files: list[StoredFile]) -> list[tuple[StoredFile, RecognitionJob | None]]: + expected = {file.id: file.current_recognition_job_id for file in files} + jobs = list( + ( + await self.session.scalars( + select(RecognitionJob) + .where(RecognitionJob.id.in_([jid for jid in expected.values() if jid is not None])) + .order_by(RecognitionJob.id) + .with_for_update() + .execution_options(populate_existing=True) + ) + ).all() + ) + by_id = {job.id: job for job in jobs} + locked = list( + ( + await self.session.scalars( + select(StoredFile) + .where(StoredFile.id.in_(expected)) + .order_by(StoredFile.id) + .with_for_update() + .execution_options(populate_existing=True) + ) + ).all() + ) + return [ + ( + file, + by_id.get(file.current_recognition_job_id) + if file.current_recognition_job_id is not None and file.current_recognition_job_id == expected[file.id] + else None, + ) + for file in locked + ] + + async def _reason(self, file: StoredFile, job: RecognitionJob | None) -> str | None: + if ( + job is None + or file.status is not FileStatus.ERROR + or job.pipeline is not RecognitionPipeline.tabular + or (job.status, job.error_code) + not in {(RecognitionStatus.timeout, "TIMEOUT"), (RecognitionStatus.dlq, "NO_ACK")} + or await self.session.scalar(select(exists().where(Document.stored_file_id == file.id))) + ): + return "FILE_NOT_RETRYABLE" + if job.attempt >= job.max_attempts: + return "MAX_ATTEMPTS_REACHED" + return None + + async def _new(self, file: StoredFile, job: RecognitionJob, consolidation_id: uuid.UUID | None) -> RecognitionJob: + now = utc_now() + new = RecognitionJob( + file_id=file.id, + pipeline=job.pipeline, + request_subject=request_subject(job.pipeline.value, self.settings.OPENBAO.NATS.request_subjects()), + schema_version=CONTRACT_VERSION, + status=RecognitionStatus.queued, + attempt=job.attempt + 1, + max_attempts=job.max_attempts, + requested_at=now, + ) + self.session.add(new) + await self.session.flush() + file.current_recognition_job_id = new.id + file.status, file.updated_at = FileStatus.UPLOADED, now + after: dict[str, Any] = {"job_id": str(new.id), "attempt": new.attempt} + if consolidation_id is not None: + after["consolidation_id"] = str(consolidation_id) + await self.audit.record( + action=AuditAction.FILE_REPROCESS, + section=AuditSection.PRIMARY_DOCUMENTS, + object_type="stored_file", + object_id=file.id, + before_value={ + "job_id": str(job.id), + "attempt": job.attempt, + "status": job.status.value, + "error_code": job.error_code, + }, + after_value=after, + ) + return new + + async def retry(self, file_id: uuid.UUID) -> FileOut: + visible = await files_repo.get_file(self.session, self.scope, file_id) + if visible is None: + raise NotFoundError("файл не найден") + locked = await self._lock_files([visible]) + if not locked: + raise NotFoundError("файл не найден") + file, job = locked[0] + reason = await self._reason(file, job) + if reason: + raise ConflictError(reason, "Повторная обработка файла недоступна") + if file.consolidation_id is not None: + versions = list( + ( + await self.session.scalars( + select(ConsolidationVersion) + .where( + ConsolidationVersion.consolidation_id == file.consolidation_id, + ConsolidationVersion.status.in_(IN_WORK_STATUSES), + ) + .order_by(ConsolidationVersion.id) + .with_for_update(read=True) + .execution_options(populate_existing=True) + ) + ).all() + ) + allowed = [v.id for v in versions] + member = await self.session.scalar( + select( + exists().where( + ConsolidationMember.consolidation_version_id.in_(allowed), + ConsolidationMember.legal_entity_id == file.legal_entity_id, + ) + ) + ) + if not member: + raise ConflictError("VERSION_NOT_EDITABLE", "Нет версии в работе с организацией файла") + assert job is not None + new = await self._new(file, job, None) + await self.session.flush() + return file_out(file, new, []) + + async def retry_version(self, version_id: uuid.UUID) -> RetryBatchOut: + found = await versions_repo.get_version(self.session, self.scope, version_id) + if found is None: + raise NotFoundError("версия не найдена") + _, consolidation = found + members = select(ConsolidationMember.legal_entity_id).where( + ConsolidationMember.consolidation_version_id == version_id + ) + candidates = await files_repo.retry_candidates(self.session, self.scope, consolidation.id, version_id) + locked = await self._lock_files(candidates) + version = await versions_repo.lock_version(self.session, version_id) + if version is None: + raise NotFoundError("версия не найдена") + if version.status not in IN_WORK_STATUSES: + raise ConflictError("VERSION_NOT_EDITABLE", "Версия не в работе") + current_members = set((await self.session.scalars(members)).all()) + result = RetryBatchOut(accepted=[], skipped=[]) + for file, job in locked: + if ( + file.legal_entity_id not in current_members + or file.consolidation_id != consolidation.id + or file.status is not FileStatus.ERROR + ): + continue + reason = await self._reason(file, job) + if reason: + result.skipped.append(RetrySkipped(file_id=file.id, code=reason)) + continue + assert job is not None + new = await self._new(file, job, consolidation.id) + result.accepted.append(RetryAccepted(file_id=file.id, job_id=new.id, attempt=new.attempt)) + return result diff --git a/api/src/services/osv_header.py b/api/src/services/osv_header.py new file mode 100644 index 0000000..1850d39 --- /dev/null +++ b/api/src/services/osv_header.py @@ -0,0 +1,104 @@ +"""Уточнение ОСВ в транзакции запроса с сохранением первички и аудитом.""" + +import uuid + +from sqlalchemy import delete, select +from sqlalchemy.exc import IntegrityError + +from src.common.clock import utc_now +from src.common.exceptions import ConflictError, NotFoundError +from src.domain.osv_header import HEADER_FIELDS, corrected_header +from src.domain.policy import IN_WORK_STATUSES +from src.models.consolidation import ConsolidationDocument +from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, DocumentStatus, DocumentType, FileStatus +from src.models.files import Document +from src.repositories import files as files_repo +from src.schemas.documents import DocumentOut, OsvHeaderPatch +from src.services import consolidation_documents +from src.services.documents import DocumentService + + +class OsvHeaderService(DocumentService): + """Разделяет видимость, карточку и транзакцию с обычным сервисом документов.""" + + async def _twin(self, document: Document, account: object) -> uuid.UUID | None: + return await self._session.scalar( + select(Document.id).where( + Document.stored_file_id == document.stored_file_id, + Document.document_type == DocumentType.OSV, + Document.osv_account_code == account, + Document.id != document.id, + ) + ) + + async def patch(self, document_id: uuid.UUID, body: OsvHeaderPatch) -> DocumentOut: + visible = await self._visible(document_id) + file = await files_repo.lock_file(self._session, visible.stored_file_id) + document = await files_repo.lock_document(self._session, document_id) + if file is None or document is None: + raise NotFoundError("документ не найден") + if document.document_type is not DocumentType.OSV: + raise ConflictError("DOCUMENT_NOT_OSV", "Документ не является ОСВ") + if file.status is FileStatus.SUPERSEDED: + raise ConflictError("FILE_SUPERSEDED", "Файл заменён новой версией") + current = {key: getattr(document, key) for key in HEADER_FIELDS} + result = corrected_header(current, body.model_dump(exclude_unset=True)) + twin = await self._twin(document, result["osv_account_code"]) + if twin is not None: + raise ConflictError("DUPLICATE_ACCOUNT", "Счёт уже есть в файле", document_id=str(twin)) + versions = await consolidation_documents.bound_versions(self._session, documents=[document]) + blocked = [ + str(vid) for vid, status in versions if status not in IN_WORK_STATUSES | {ConsolidationStatus.CANCELLED} + ] + if blocked: + raise ConflictError("VERSION_NOT_EDITABLE", "Документ используется в закрытой версии", version_ids=blocked) + changes = {key: value for key, value in result.items() if value != current[key]} + if not changes: + return await self._card(document, file) + before = {key: current[key] for key in changes} + after = dict(changes) + try: + async with self._session.begin_nested(): + if document.original_values is None: + document.original_values = current + for key, value in changes.items(): + setattr(document, key, value) + document.is_modified = result != document.original_values + document.modified_by = self._audit.user_id + document.modified_at = document.updated_at = utc_now() + if document.status is DocumentStatus.CONFIRMED: + before.update( + status=document.status.value, + confirmed_by=str(document.confirmed_by) if document.confirmed_by else None, + confirmed_at=document.confirmed_at.isoformat() if document.confirmed_at else None, + ) + document.status = DocumentStatus.NEEDS_CHECK + document.confirmed_by = document.confirmed_at = None + after.update(status=document.status.value, confirmed_by=None, confirmed_at=None) + working = [vid for vid, status in versions if status in IN_WORK_STATUSES] + await self._session.execute( + delete(ConsolidationDocument).where( + ConsolidationDocument.document_id == document.id, + ConsolidationDocument.consolidation_version_id.in_(working), + ) + ) + after["unbound_version_ids"] = [str(vid) for vid in working] + await self._session.flush() + except IntegrityError: + # SAVEPOINT rollback expires the changed ORM object; reload asynchronously before accessing fields. + document = await files_repo.lock_document(self._session, document_id) + if document is None: + raise + twin = await self._twin(document, result["osv_account_code"]) + if twin is None: + raise + raise ConflictError("DUPLICATE_ACCOUNT", "Счёт уже есть в файле", document_id=str(twin)) from None + await self._audit.record( + action=AuditAction.RECOGNIZED_DATA_EDIT, + section=AuditSection.RECOGNITION, + object_type="document", + object_id=document.id, + before_value=before, + after_value=after, + ) + return await self._card(document, file) diff --git a/api/tests/test_api_documents.py b/api/tests/test_api_documents.py index 899eed2..06c2734 100644 --- a/api/tests/test_api_documents.py +++ b/api/tests/test_api_documents.py @@ -140,6 +140,10 @@ async def test_document_card(seeded: AsyncSession, auth_client: AuthClientFactor # R-6: предупреждения маппинга файла целиком, без фильтра по листу "warnings": ["sheet=Лист_1: повтор", "sheet=Лист_2: сироты"], "comments_count": 0, + "is_modified": False, + "original_values": None, + "modified_by": None, + "modified_at": None, "issues": [], "issue_counts": {"error": 0, "info": 0}, } diff --git a/api/tests/test_api_file_retry.py b/api/tests/test_api_file_retry.py new file mode 100644 index 0000000..6c6c4d6 --- /dev/null +++ b/api/tests/test_api_file_retry.py @@ -0,0 +1,421 @@ +"""Регрессии AURORA-1442: новая попытка после сбоя обмена, без повторной загрузки.""" + +import asyncio +from datetime import timedelta +from http import HTTPStatus + +import pytest +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.api.dependencies.directories import get_app_settings +from src.common.exceptions import ConflictError +from src.contract import CONTRACT_VERSION +from src.domain.policy import VisibilityScope +from src.models.audit_ex import AuditEvent +from src.models.consolidation import Consolidation, ConsolidationMember, ConsolidationVersion +from src.models.enums import ConsolidationStatus, FileStatus, RecognitionPipeline, RecognitionStatus +from src.models.files import RecognitionJob, StoredFile +from src.services.audit import AuditService +from src.services.file_retry import FileRetryService +from src.services.recognition_exchange import handle_message, publish_pending +from tests.conftest import AuthClientFactory +from tests.factories import make_document, make_stored_file, make_version +from tests.test_api_consolidation_members import _user, _version +from tests.test_api_files_read import KA_RF, _rostov_entity +from tests.test_recognition_exchange import FakeNats +from tests.test_recognition_inbox import envelope, fake_msg + +pytestmark = pytest.mark.integration + + +@pytest.mark.parametrize("state,code", [(RecognitionStatus.timeout, "TIMEOUT"), (RecognitionStatus.dlq, "NO_ACK")]) +async def test_retry_preserves_old_job_and_records_audit( + seeded: AsyncSession, auth_client: AuthClientFactory, state: RecognitionStatus, code: str +) -> None: + entity = await _rostov_entity(seeded) + file, old = await make_stored_file(seeded, entity=entity, status=FileStatus.ERROR, job_status=state) + old.max_attempts = 3 + old.error_code = code + old.metrics = {"republish_count": 3} + source = file.s3_object_id + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.post(f"/files/{file.id}/retry") + assert response.status_code == HTTPStatus.ACCEPTED, response.text + await seeded.refresh(file) + await seeded.refresh(old) + new = await seeded.get(RecognitionJob, file.current_recognition_job_id) + assert new is not None and new.id != old.id + assert (new.status, new.attempt, new.max_attempts) == (RecognitionStatus.queued, 2, 3) + assert new.error_code is None and new.metrics is None and new.result_s3_object_id is None + assert file.s3_object_id == source and file.status == FileStatus.UPLOADED + assert old.status == state and old.error_code == code + event = ( + await seeded.execute( + select(AuditEvent).where(AuditEvent.object_id == file.id, AuditEvent.action == "FILE_REPROCESS") + ) + ).scalar_one() + assert event.before_value == {"job_id": str(old.id), "attempt": 1, "status": state.value, "error_code": code} + assert event.after_value == {"job_id": str(new.id), "attempt": 2} + again = await client.post(f"/files/{file.id}/retry") + assert again.status_code == HTTPStatus.CONFLICT + + +@pytest.mark.parametrize("reason", ["limit", "ocr", "service_error", "document"]) +async def test_retry_rejects_ineligible_file(seeded: AsyncSession, auth_client: AuthClientFactory, reason: str) -> None: + entity = await _rostov_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.timeout + ) + job.error_code = "TIMEOUT" + job.max_attempts = 3 + if reason == "limit": + job.attempt = 3 + elif reason == "ocr": + job.pipeline = RecognitionPipeline.image + elif reason == "service_error": + job.status = RecognitionStatus.error + else: + await make_document(seeded, file=file, job=job) + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.post(f"/files/{file.id}/retry") + assert response.status_code == HTTPStatus.CONFLICT, response.text + expected = "MAX_ATTEMPTS_REACHED" if reason == "limit" else "FILE_NOT_RETRYABLE" + assert response.json()["detail"]["code"] == expected + await seeded.refresh(file) + assert file.current_recognition_job_id == job.id + + +async def test_batch_filters_perimeter_and_skips_ineligible( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + + version, inside, outside, _ = await _version(seeded, members=1, spare=1, locked_by=None) + good, job = await make_stored_file( + seeded, entity=inside[0], status=FileStatus.ERROR, job_status=RecognitionStatus.dlq + ) + job.error_code, job.max_attempts = "NO_ACK", 3 + good.consolidation_id = version.consolidation_id + bad, _ = await make_stored_file(seeded, entity=inside[0]) + bad.consolidation_id = version.consolidation_id + excluded, _ = await make_stored_file(seeded, entity=outside[0]) + excluded.consolidation_id = version.consolidation_id + desk, _ = await make_stored_file(seeded, entity=inside[0]) + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.post(f"/consolidations/versions/{version.id}/files/retry") + assert response.status_code == HTTPStatus.ACCEPTED, response.text + body = response.json() + assert [row["file_id"] for row in body["accepted"]] == [str(good.id)] + assert body["skipped"] == [] + event = ( + await seeded.execute( + select(AuditEvent).where(AuditEvent.object_id == good.id, AuditEvent.action == "FILE_REPROCESS") + ) + ).scalar_one() + assert event.after_value["consolidation_id"] == str(version.consolidation_id) + assert str(desk.id) not in str(body) + + +async def test_batch_rejects_verified_and_returns_empty_for_empty_perimeter( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + + version, _, _, _ = await _version(seeded, members=1, locked_by=None) + client = await auth_client(KA_RF) + url = f"/consolidations/versions/{version.id}/files/retry" + response = await client.post(url) + assert response.status_code == HTTPStatus.ACCEPTED + assert response.json() == {"accepted": [], "skipped": []} + version.status = ConsolidationStatus.VERIFIED + await seeded.flush() + response = await client.post(url) + assert response.status_code == HTTPStatus.CONFLICT + assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE" + + +async def test_concurrent_retries_create_only_one_attempt(committed_sessionmaker) -> None: + + async with committed_sessionmaker() as session, session.begin(): + entity = await _rostov_entity(session) + file, job = await make_stored_file( + session, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.timeout + ) + job.error_code, job.max_attempts = "TIMEOUT", 3 + user = await _user(session, KA_RF) + file_id, user_id = file.id, user.id + + async def retry(): + async with committed_sessionmaker() as session: + try: + async with session.begin(): + audit = RequestAudit(AuditService(session), user_id, None, None) + return await FileRetryService(session, audit, VisibilityScope(None)).retry(file_id) + except ConflictError as error: + return error.code + + results = await asyncio.wait_for(asyncio.gather(retry(), retry()), timeout=10) + assert sum(isinstance(result, str) for result in results) == 1 + assert "FILE_NOT_RETRYABLE" in results + async with committed_sessionmaker() as session: + jobs = list((await session.scalars(select(RecognitionJob).where(RecognitionJob.file_id == file_id))).all()) + assert sorted(job.attempt for job in jobs) == [1, 2] + + +async def test_retry_outbox_and_late_old_result(committed_sessionmaker) -> None: + + async with committed_sessionmaker() as session, session.begin(): + entity = await _rostov_entity(session) + file, old = await make_stored_file( + session, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.timeout + ) + old.error_code, old.max_attempts = "TIMEOUT", 3 + user = await _user(session, KA_RF) + file_id, old_id = file.id, old.id + audit = RequestAudit(AuditService(session), user.id, None, None) + card = await FileRetryService(session, audit, VisibilityScope(None)).retry(file.id) + assert card.job is not None + new_id = card.job.id + + for state in ("processing", "done"): + await handle_message(fake_msg(envelope(old_id, file_id, state), None), session_factory=committed_sessionmaker) + offline = FakeNats(connected=False) + await publish_pending(committed_sessionmaker, offline, ack_timeout=timedelta(seconds=30), max_republish=3) + async with committed_sessionmaker() as session: + new = await session.get(RecognitionJob, new_id) + assert new is not None and new.status is RecognitionStatus.queued + assert new.reply_deadline is None and new.result_s3_object_id is None + online = FakeNats() + await publish_pending(committed_sessionmaker, online, ack_timeout=timedelta(seconds=30), max_republish=3) + async with committed_sessionmaker() as session: + new = await session.get(RecognitionJob, new_id) + assert new is not None and new.status is RecognitionStatus.published + assert new.reply_deadline is not None and new.request_msg_id == f"{new_id}:2" + old = await session.get(RecognitionJob, old_id) + assert old is not None and old.status is RecognitionStatus.timeout + + +async def test_retry_rolls_back_job_pointer_and_audit(committed_sessionmaker) -> None: + async with committed_sessionmaker() as session, session.begin(): + entity = await _rostov_entity(session) + file, job = await make_stored_file( + session, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.timeout + ) + job.error_code, job.max_attempts = "TIMEOUT", 3 + user = await _user(session, KA_RF) + file_id, job_id, user_id = file.id, job.id, user.id + with pytest.raises(RuntimeError, match="rollback"): + async with committed_sessionmaker() as session, session.begin(): + audit = RequestAudit(AuditService(session), user_id, None, None) + await FileRetryService(session, audit, VisibilityScope(None)).retry(file_id) + raise RuntimeError("rollback") + async with committed_sessionmaker() as session: + jobs = list((await session.scalars(select(RecognitionJob).where(RecognitionJob.file_id == file_id))).all()) + assert [job.id for job in jobs] == [job_id] + events = list((await session.scalars(select(AuditEvent).where(AuditEvent.object_id == file_id))).all()) + assert events == [] + + +async def test_concurrent_batch_retry(committed_sessionmaker) -> None: + async with committed_sessionmaker() as session, session.begin(): + version, members, _, _ = await _version(session, members=1, locked_by=None) + file, job = await make_stored_file( + session, entity=members[0], status=FileStatus.ERROR, job_status=RecognitionStatus.timeout + ) + file.consolidation_id = version.consolidation_id + job.error_code, job.max_attempts = "TIMEOUT", 3 + user = await _user(session, KA_RF) + version_id, user_id = version.id, user.id + + async def retry(): + async with committed_sessionmaker() as session, session.begin(): + audit = RequestAudit(AuditService(session), user_id, None, None) + return await FileRetryService(session, audit, VisibilityScope(None)).retry_version(version_id) + + results = await asyncio.wait_for(asyncio.gather(retry(), retry()), timeout=10) + assert sorted(len(result.accepted) for result in results) == [0, 1] + assert all(result.skipped == [] for result in results) + + +@pytest.mark.parametrize("change", ["status", "perimeter"]) +async def test_batch_rechecks_version_after_waiting_for_job( + committed_sessionmaker, monkeypatch: pytest.MonkeyPatch, change: str +) -> None: + + async with committed_sessionmaker() as session, session.begin(): + version, members, _, _ = await _version(session, members=1, locked_by=None) + file, job = await make_stored_file( + session, entity=members[0], status=FileStatus.ERROR, job_status=RecognitionStatus.timeout + ) + file.consolidation_id = version.consolidation_id + job.error_code, job.max_attempts = "TIMEOUT", 3 + user = await _user(session, KA_RF) + version_id, job_id, user_id = version.id, job.id, user.id + reached = asyncio.Event() + original = FileRetryService._lock_files + + async def signal(self, files): + reached.set() + return await original(self, files) + + monkeypatch.setattr(FileRetryService, "_lock_files", signal) + + async def retry(): + async with committed_sessionmaker() as session: + try: + async with session.begin(): + audit = RequestAudit(AuditService(session), user_id, None, None) + return await FileRetryService(session, audit, VisibilityScope(None)).retry_version(version_id) + except ConflictError as error: + return error.code + + async with committed_sessionmaker() as holder, holder.begin(): + await holder.execute(select(RecognitionJob).where(RecognitionJob.id == job_id).with_for_update()) + pending = asyncio.create_task(retry()) + await asyncio.wait_for(reached.wait(), timeout=10) + version = await holder.get(ConsolidationVersion, version_id) + assert version is not None + if change == "status": + version.status = ConsolidationStatus.VERIFIED + else: + await holder.execute( + delete(ConsolidationMember).where(ConsolidationMember.consolidation_version_id == version_id) + ) + result = await asyncio.wait_for(pending, timeout=10) + if change == "status": + assert result == "VERSION_NOT_EDITABLE" + else: + assert result.accepted == [] and result.skipped == [] + + +async def test_batch_mixed_package_skips_ocr_and_exhausted_files( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Смешанный пакет: подходящий файл принят, OCR в ERROR и исчерпанный лимит попыток — в skipped с кодами.""" + version, inside, _, _ = await _version(seeded, members=1, locked_by=None) + good, good_job = await make_stored_file( + seeded, entity=inside[0], status=FileStatus.ERROR, job_status=RecognitionStatus.timeout + ) + good_job.error_code, good_job.max_attempts = "TIMEOUT", 3 + ocr, ocr_job = await make_stored_file( + seeded, entity=inside[0], status=FileStatus.ERROR, job_status=RecognitionStatus.timeout + ) + ocr_job.pipeline, ocr_job.error_code, ocr_job.max_attempts = RecognitionPipeline.image, "TIMEOUT", 3 + spent, spent_job = await make_stored_file( + seeded, entity=inside[0], status=FileStatus.ERROR, job_status=RecognitionStatus.timeout + ) + spent_job.error_code, spent_job.max_attempts, spent_job.attempt = "TIMEOUT", 3, 3 + for item in (good, ocr, spent): + item.consolidation_id = version.consolidation_id + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.post(f"/consolidations/versions/{version.id}/files/retry") + assert response.status_code == HTTPStatus.ACCEPTED, response.text + body = response.json() + assert [row["file_id"] for row in body["accepted"]] == [str(good.id)] + skipped = {row["file_id"]: row["code"] for row in body["skipped"]} + assert skipped == {str(ocr.id): "FILE_NOT_RETRYABLE", str(spent.id): "MAX_ATTEMPTS_REACHED"} + for skipped_file, skipped_job in ((ocr, ocr_job), (spent, spent_job)): + await seeded.refresh(skipped_file) + assert skipped_file.current_recognition_job_id == skipped_job.id and skipped_file.status == FileStatus.ERROR + events = await seeded.scalars( + select(AuditEvent).where(AuditEvent.object_id == skipped_file.id, AuditEvent.action == "FILE_REPROCESS") + ) + assert list(events.all()) == [] + + +async def test_batch_no_ack_at_attempt_limit_is_max_attempts_reached( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """NO_ACK (dlq) на пределе попыток в пакете даёт MAX_ATTEMPTS_REACHED, новое задание не создаётся.""" + version, inside, _, _ = await _version(seeded, members=1, locked_by=None) + file, job = await make_stored_file( + seeded, entity=inside[0], status=FileStatus.ERROR, job_status=RecognitionStatus.dlq + ) + job.error_code, job.max_attempts, job.attempt = "NO_ACK", 3, 3 + file.consolidation_id = version.consolidation_id + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.post(f"/consolidations/versions/{version.id}/files/retry") + assert response.status_code == HTTPStatus.ACCEPTED, response.text + assert response.json() == {"accepted": [], "skipped": [{"file_id": str(file.id), "code": "MAX_ATTEMPTS_REACHED"}]} + jobs = await seeded.scalars(select(RecognitionJob).where(RecognitionJob.file_id == file.id)) + assert [item.id for item in jobs.all()] == [job.id] + + +async def test_retry_uses_current_contract_and_subject( + seeded: AsyncSession, auth_client: AuthClientFactory, monkeypatch: pytest.MonkeyPatch +) -> None: + entity = await _rostov_entity(seeded) + file, old = await make_stored_file( + seeded, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.timeout + ) + old.error_code, old.max_attempts = "TIMEOUT", 3 + old.schema_version, old.request_subject = "0.9", "old.tabular.subject" + monkeypatch.setattr(get_app_settings().OPENBAO.NATS, "NATS_TABULAR_REQUEST_SUBJECT", "current.tabular.subject") + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.post(f"/files/{file.id}/retry") + assert response.status_code == HTTPStatus.ACCEPTED, response.text + await seeded.refresh(file) + new = await seeded.get(RecognitionJob, file.current_recognition_job_id) + assert new is not None + assert new.schema_version == CONTRACT_VERSION and new.request_subject == "current.tabular.subject" + await seeded.refresh(old) + assert old.schema_version == "0.9" and old.request_subject == "old.tabular.subject" + + +@pytest.mark.parametrize( + "normal_status", [FileStatus.UPLOADED, FileStatus.PROCESSING, FileStatus.PROCESSED, FileStatus.SUPERSEDED] +) +async def test_batch_does_not_lock_normal_files(committed_sessionmaker, normal_status: FileStatus) -> None: + async with committed_sessionmaker() as session, session.begin(): + version, members, _, _ = await _version(session, members=1, locked_by=None) + normal, job = await make_stored_file( + session, entity=members[0], status=normal_status, job_status=RecognitionStatus.processing + ) + normal.consolidation_id = version.consolidation_id + user = await _user(session, KA_RF) + version_id, file_id, job_id, user_id = version.id, normal.id, job.id, user.id + + async def retry(): + async with committed_sessionmaker() as session, session.begin(): + audit = RequestAudit(AuditService(session), user_id, None, None) + return await FileRetryService(session, audit, VisibilityScope(None)).retry_version(version_id) + + async with committed_sessionmaker() as holder, holder.begin(): + await holder.execute(select(RecognitionJob).where(RecognitionJob.id == job_id).with_for_update()) + await holder.execute(select(StoredFile).where(StoredFile.id == file_id).with_for_update()) + result = await asyncio.wait_for(retry(), timeout=5) + assert result.accepted == [] and result.skipped == [] + + +@pytest.mark.parametrize("closed_status", [ConsolidationStatus.VERIFIED, ConsolidationStatus.CANCELLED]) +async def test_single_retry_does_not_lock_closed_versions(committed_sessionmaker, closed_status) -> None: + async with committed_sessionmaker() as session, session.begin(): + working, members, _, _ = await _version(session, members=1, locked_by=None) + consolidation = await session.get(Consolidation, working.consolidation_id) + user = await _user(session, KA_RF) + closed = await make_version( + session, consolidation=consolidation, version_no=2, members=members, initiator=user, status=closed_status + ) + file, job = await make_stored_file( + session, entity=members[0], status=FileStatus.ERROR, job_status=RecognitionStatus.timeout + ) + file.consolidation_id = working.consolidation_id + job.error_code, job.max_attempts = "TIMEOUT", 3 + file_id, user_id, closed_id = file.id, user.id, closed.id + + async def retry(): + async with committed_sessionmaker() as session, session.begin(): + audit = RequestAudit(AuditService(session), user_id, None, None) + return await FileRetryService(session, audit, VisibilityScope(None)).retry(file_id) + + async with committed_sessionmaker() as holder, holder.begin(): + await holder.execute(select(ConsolidationVersion).where(ConsolidationVersion.id == closed_id).with_for_update()) + card = await asyncio.wait_for(retry(), timeout=5) + assert card.status == FileStatus.UPLOADED diff --git a/api/tests/test_api_osv_header.py b/api/tests/test_api_osv_header.py new file mode 100644 index 0000000..c426a43 --- /dev/null +++ b/api/tests/test_api_osv_header.py @@ -0,0 +1,228 @@ +"""Правки шапки ОСВ с сохранением первичного снимка и повторным подтверждением.""" + +import asyncio +import json +import uuid +from http import HTTPStatus +from pathlib import Path +from typing import Any + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.contract.versions import parse_result +from src.core.config import get_settings +from src.domain.policy import VisibilityScope +from src.models.audit_ex import AuditEvent +from src.models.consolidation import Consolidation, ConsolidationDocument +from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus, PeriodQuarter +from src.models.files import Document, StoredFile +from src.schemas.documents import OsvHeaderPatch +from src.services.audit import AuditService +from src.services.documents import DocumentService +from src.services.osv_header import OsvHeaderService +from tests.conftest import AuthClientFactory +from tests.factories import make_document, make_stored_file, make_version +from tests.fixtures_helpers import map_golden_snapshot +from tests.test_api_consolidation_members import _user, _version +from tests.test_api_files_read import KA_RF, _rostov_entity + +pytestmark = [pytest.mark.integration, pytest.mark.fixtures] + + +def _result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> Any: + data = json.loads(path.read_text()) + data.update(job_id=str(job_id), file_id=str(file_id)) + data["service"]["version"] = "golden" + return parse_result(json.dumps(data).encode()) + + +async def test_header_keeps_original_and_entries( + seeded: AsyncSession, auth_client: AuthClientFactory, fixtures_dir: Path +) -> None: + entity = await _rostov_entity(seeded) + snapshots = sorted((fixtures_dir / "golden/expected_osv_parse/rb").rglob("*.json")) + assert snapshots, "Нужны реальные golden-снимки ОСВ РБ" + documents = await map_golden_snapshot( + seeded, entity=entity, path=snapshots[0], uploaded_by=None, result_builder=_result + ) + document = documents[0] + file = await seeded.get(StoredFile, document.stored_file_id) + assert file is not None + file.status = FileStatus.PROCESSED + document.status = DocumentStatus.CONFIRMED + original = { + key: getattr(document, key) for key in ("period_year", "period_quarter", "is_cumulative", "osv_account_code") + } + await seeded.flush() + client = await auth_client(KA_RF) + url = f"/documents/{document.id}" + entries = (await client.get(f"{url}/entries")).json() + ordinal = document.file_ordinal + response = await client.patch(f"{url}/osv-header", json={"osv_account_code": "99.1"}) + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + assert body["osv_account_code"] == "99.01" + assert body["original_values"] == original + assert body["is_modified"] is True and body["status"] == "NEEDS_CHECK" + response = await client.patch(f"{url}/osv-header", json={"osv_account_code": original["osv_account_code"]}) + assert response.status_code == HTTPStatus.OK, response.text + assert response.json()["is_modified"] is False + assert response.json()["original_values"] == original + await seeded.refresh(document) + assert document.file_ordinal == ordinal + assert (await client.get(f"{url}/entries")).json() == entries + + +async def test_header_unbinds_working_version_but_keeps_cancelled( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + + version, members, _, _ = await _version(seeded, members=1, locked_by=None) + file, job = await make_stored_file(seeded, entity=members[0], status=FileStatus.PROCESSED) + doc = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED) + consolidation = await seeded.get(Consolidation, version.consolidation_id) + assert consolidation is not None + cancelled = await make_version( + seeded, + consolidation=consolidation, + version_no=2, + members=members, + initiator=await _user(seeded, KA_RF), + status=ConsolidationStatus.CANCELLED, + ) + seeded.add_all( + [ConsolidationDocument(consolidation_version_id=v.id, document_id=doc.id) for v in (version, cancelled)] + ) + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{doc.id}/osv-header", json={"osv_account_code": "62"}) + assert response.status_code == HTTPStatus.OK, response.text + bound = list( + ( + await seeded.scalars( + select(ConsolidationDocument.consolidation_version_id).where( + ConsolidationDocument.document_id == doc.id + ) + ) + ).all() + ) + assert bound == [cancelled.id] + assert response.json()["status"] == "NEEDS_CHECK" + + +async def test_header_validation_order_duplicate_and_noop(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + + entity = await _rostov_entity(seeded) + file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED) + doc = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED) + twin = await make_document(seeded, file=file, job=job, account="62") + client = await auth_client(KA_RF) + url = f"/documents/{doc.id}/osv-header" + response = await client.patch(url, json={"period_quarter": None, "osv_account_code": "62"}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "INVALID_PERIOD" + response = await client.patch(url, json={"osv_account_code": "62"}) + assert response.status_code == HTTPStatus.CONFLICT + assert response.json()["detail"]["document_id"] == str(twin.id) + response = await client.patch(url, json={"osv_account_code": "60"}) + assert response.status_code == HTTPStatus.OK + assert response.json()["status"] == "CONFIRMED" + assert response.json()["original_values"] is None + assert ( + await seeded.scalar( + select(func.count()) + .select_from(AuditEvent) + .where(AuditEvent.object_id == doc.id, AuditEvent.action == "RECOGNIZED_DATA_EDIT") + ) + == 0 + ) + + +async def test_header_type_validation_precedes_domain_checks( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + entity = await _rostov_entity(seeded) + file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED) + doc = await make_document(seeded, file=file, job=job) + doc.document_type = DocumentType.BALANCE + doc.osv_account_code = None + await seeded.flush() + client = await auth_client(KA_RF) + url = f"/documents/{doc.id}/osv-header" + response = await client.patch(url, json={"period_year": "abc"}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert isinstance(response.json()["detail"], list) + assert response.json()["detail"][0]["loc"] == ["body", "period_year"] + response = await client.patch(url, json={"period_year": 2100}) + assert response.status_code == HTTPStatus.CONFLICT + assert response.json()["detail"]["code"] == "DOCUMENT_NOT_OSV" + + +@pytest.mark.parametrize("period_from_version", [False, True]) +async def test_corrected_account_in_version_card( + seeded: AsyncSession, + auth_client: AuthClientFactory, + monkeypatch: pytest.MonkeyPatch, + period_from_version: bool, +) -> None: + + monkeypatch.setattr(get_settings(), "CONSOLIDATION_PERIOD_FROM_VERSION", period_from_version) + version, members, _, _ = await _version(seeded, members=1, locked_by=None) + file, job = await make_stored_file(seeded, entity=members[0], status=FileStatus.PROCESSED) + file.consolidation_id = version.consolidation_id + doc = await make_document(seeded, file=file, job=job, year=2024, quarter=PeriodQuarter.Q3) + old_checks = doc.period_check, doc.counterparty_check + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{doc.id}/osv-header", json={"osv_account_code": "62"}) + assert response.status_code == HTTPStatus.OK, response.text + card = (await client.get(f"/consolidations/versions/{version.id}")).json() + assert card["members"][0]["osv_accounts"] == ["62"] + await seeded.refresh(doc) + assert (doc.period_check, doc.counterparty_check) == old_checks + response = await client.post(f"/documents/{doc.id}/confirm") + assert response.status_code == HTTPStatus.OK, response.text + assert ( + await seeded.scalar( + select(ConsolidationDocument.document_id).where( + ConsolidationDocument.consolidation_version_id == version.id + ) + ) + == doc.id + ) + + +async def test_concurrent_header_patch_and_confirm(committed_sessionmaker) -> None: + + async with committed_sessionmaker() as session, session.begin(): + version, members, _, _ = await _version(session, members=1, locked_by=None) + file, job = await make_stored_file(session, entity=members[0], status=FileStatus.PROCESSED) + doc = await make_document(session, file=file, job=job, year=2024, quarter=PeriodQuarter.Q3) + user = await _user(session, KA_RF) + doc_id, user_id, version_id = doc.id, user.id, version.id + + async def change(confirm: bool): + async with committed_sessionmaker() as session, session.begin(): + audit = RequestAudit(AuditService(session), user_id, None, None) + if confirm: + return await DocumentService(session, audit, VisibilityScope(None)).confirm(doc_id) + return await OsvHeaderService(session, audit, VisibilityScope(None)).patch( + doc_id, OsvHeaderPatch(osv_account_code="62") + ) + + await asyncio.wait_for(asyncio.gather(change(True), change(False)), timeout=10) + async with committed_sessionmaker() as session: + doc = await session.get(Document, doc_id) + assert doc is not None and doc.osv_account_code == "62" + bound = await session.scalar( + select(ConsolidationDocument.document_id).where( + ConsolidationDocument.consolidation_version_id == version_id + ) + ) + if doc.status is DocumentStatus.CONFIRMED: + assert bound == doc_id + else: + assert doc.status is DocumentStatus.NEEDS_CHECK and bound is None diff --git a/api/tests/test_api_osv_header_synthetic.py b/api/tests/test_api_osv_header_synthetic.py new file mode 100644 index 0000000..67e704b --- /dev/null +++ b/api/tests/test_api_osv_header_synthetic.py @@ -0,0 +1,280 @@ +"""Правка шапки ОСВ на синтетических документах из фабрик (без golden-файлов), §9.5 п.12–13.""" + +import datetime +from http import HTTPStatus + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.audit_ex import AuditEvent +from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationVersion +from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus +from src.models.files import Document, StoredFile +from src.models.osv import OsvEntry +from tests.conftest import AuthClientFactory +from tests.factories import make_document, make_osv_entry, make_stored_file, make_version +from tests.test_api_consolidation_members import _user, _version +from tests.test_api_files_read import KA_RF, _rostov_entity + +pytestmark = pytest.mark.integration + +ORIGINAL = {"period_year": 2025, "period_quarter": "Q1", "is_cumulative": True, "osv_account_code": "60"} + + +async def _events(session: AsyncSession, document: Document) -> list[AuditEvent]: + rows = await session.scalars( + select(AuditEvent).where(AuditEvent.object_id == document.id, AuditEvent.action == "RECOGNIZED_DATA_EDIT") + ) + return list(rows.all()) + + +async def _bound(session: AsyncSession, document: Document) -> set[object]: + rows = await session.scalars( + select(ConsolidationDocument.consolidation_version_id).where(ConsolidationDocument.document_id == document.id) + ) + return set(rows.all()) + + +async def _simple(session: AsyncSession, status: DocumentStatus = DocumentStatus.RECOGNIZED) -> Document: + entity = await _rostov_entity(session) + file, job = await make_stored_file(session, entity=entity, status=FileStatus.PROCESSED) + return await make_document(session, file=file, job=job, status=status) + + +async def test_conflict_order_type_then_superseded(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """DOCUMENT_NOT_OSV раньше FILE_SUPERSEDED, а тот раньше проверок периода и счёта.""" + entity = await _rostov_entity(seeded) + file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.SUPERSEDED) + balance = await make_document(seeded, file=file, job=job) + balance.document_type = DocumentType.BALANCE + balance.osv_account_code = None + osv = await make_document(seeded, file=file, job=job, account="62") + await seeded.flush() + client = await auth_client(KA_RF) + bad_body = {"period_quarter": "Q9", "osv_account_code": " "} + response = await client.patch(f"/documents/{balance.id}/osv-header", json=bad_body) + assert response.status_code == HTTPStatus.CONFLICT + assert response.json()["detail"]["code"] == "DOCUMENT_NOT_OSV" + response = await client.patch(f"/documents/{osv.id}/osv-header", json=bad_body) + assert response.status_code == HTTPStatus.CONFLICT + assert response.json()["detail"]["code"] == "FILE_SUPERSEDED" + await seeded.refresh(osv) + assert osv.osv_account_code == "62" and osv.original_values is None + + +async def test_conflict_order_period_account_duplicate_version( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Порядок: INVALID_PERIOD, INVALID_ACCOUNT_CODE, DUPLICATE_ACCOUNT, VERSION_NOT_EDITABLE; запись после всех.""" + version, members, _, _ = await _version(seeded, members=1, locked_by=None) + file, job = await make_stored_file(seeded, entity=members[0], status=FileStatus.PROCESSED) + doc = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED) + twin = await make_document(seeded, file=file, job=job, account="62.01") + consolidation = await seeded.get(Consolidation, version.consolidation_id) + assert consolidation is not None + verified = await make_version( + seeded, + consolidation=consolidation, + version_no=2, + members=members, + initiator=await _user(seeded, KA_RF), + status=ConsolidationStatus.VERIFIED, + ) + seeded.add_all( + [ConsolidationDocument(consolidation_version_id=v.id, document_id=doc.id) for v in (version, verified)] + ) + await seeded.flush() + client = await auth_client(KA_RF) + url = f"/documents/{doc.id}/osv-header" + + # период проверяется раньше счёта и коллизии + response = await client.patch( + url, json={"period_quarter": "Q1", "is_cumulative": False, "osv_account_code": "62.1"} + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "INVALID_PERIOD" + # пустой счёт — раньше коллизии и версий + response = await client.patch(url, json={"osv_account_code": " "}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "INVALID_ACCOUNT_CODE" + # коллизия по нормализованному счёту раньше VERSION_NOT_EDITABLE + response = await client.patch(url, json={"osv_account_code": "62.1"}) + assert response.status_code == HTTPStatus.CONFLICT + assert response.json()["detail"]["code"] == "DUPLICATE_ACCOUNT" + assert response.json()["detail"]["document_id"] == str(twin.id) + # без коллизии — запрещающая версия + response = await client.patch(url, json={"osv_account_code": "70"}) + assert response.status_code == HTTPStatus.CONFLICT + assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE" + assert response.json()["detail"]["version_ids"] == [str(verified.id)] + + await seeded.refresh(doc) + assert doc.osv_account_code == "60" and doc.status is DocumentStatus.CONFIRMED + assert doc.original_values is None and doc.is_modified is False + assert await _bound(seeded, doc) == {version.id, verified.id} + assert await _events(seeded, doc) == [] + + +async def test_noop_patch_changes_nothing(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Совпадающая шапка: 200, без аудита, original_values, сброса подтверждения и modified_by/at.""" + version, members, _, _ = await _version(seeded, members=1, locked_by=None) + file, job = await make_stored_file(seeded, entity=members[0], status=FileStatus.PROCESSED) + doc = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED) + seeded.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=doc.id)) + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{doc.id}/osv-header", json=ORIGINAL) + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + assert body["status"] == "CONFIRMED" and body["is_modified"] is False and body["original_values"] is None + await seeded.refresh(doc) + assert doc.modified_by is None and doc.modified_at is None + assert await _bound(seeded, doc) == {version.id} + assert await _events(seeded, doc) == [] + + +async def test_original_saved_once_and_is_modified_resets(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Снимок original_values — при первой правке и больше не меняется; is_modified зависит от отличия от снимка.""" + doc = await _simple(seeded) + client = await auth_client(KA_RF) + url = f"/documents/{doc.id}/osv-header" + user = await _user(seeded, KA_RF) + + body = (await client.patch(url, json={"osv_account_code": "62"})).json() + assert body["osv_account_code"] == "62" and body["is_modified"] is True + assert body["original_values"] == ORIGINAL + body = (await client.patch(url, json={"osv_account_code": "76"})).json() + assert body["osv_account_code"] == "76" and body["is_modified"] is True + assert body["original_values"] == ORIGINAL + + body = (await client.patch(url, json={"period_year": 2024, "period_quarter": "Q3", "is_cumulative": False})).json() + assert (body["period_year"], body["period_quarter"], body["is_cumulative"]) == (2024, "Q3", False) + assert body["original_values"] == ORIGINAL + + # возврат всех четырёх полей к снимку + response = await client.patch(url, json=ORIGINAL) + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + assert body["is_modified"] is False and body["original_values"] == ORIGINAL + await seeded.refresh(doc) + assert doc.modified_by == user.id and doc.modified_at is not None + # по одному событию на фактическую правку (4), старые не переписываются + events = await _events(seeded, doc) + assert len(events) == len(("62", "76", "период", "возврат")) + afters = [e.after_value or {} for e in events] + assert sorted(a["osv_account_code"] for a in afters if "osv_account_code" in a) == ["60", "62", "76"] + + # частичный возврат: только счёт — снова изменён + body = (await client.patch(url, json={"period_year": 2023})).json() + assert body["is_modified"] is True and body["original_values"] == ORIGINAL + + +async def test_confirmed_reset_unbinds_only_working_versions_with_single_audit( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """CONFIRMED -> NEEDS_CHECK, снятие привязок «в работе», CANCELLED остаётся, одно событие аудита.""" + version, members, _, _ = await _version(seeded, members=1, locked_by=None) + file, job = await make_stored_file(seeded, entity=members[0], status=FileStatus.PROCESSED) + doc = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED) + user = await _user(seeded, KA_RF) + confirmed_at = datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.UTC) + doc.confirmed_by, doc.confirmed_at = user.id, confirmed_at + consolidation = await seeded.get(Consolidation, version.consolidation_id) + assert consolidation is not None + second = await make_version( + seeded, + consolidation=consolidation, + version_no=2, + members=members, + initiator=user, + status=ConsolidationStatus.INITIATED, + ) + cancelled = await make_version( + seeded, + consolidation=consolidation, + version_no=3, + members=members, + initiator=user, + status=ConsolidationStatus.CANCELLED, + ) + seeded.add_all( + [ConsolidationDocument(consolidation_version_id=v.id, document_id=doc.id) for v in (version, second, cancelled)] + ) + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{doc.id}/osv-header", json={"osv_account_code": "62"}) + assert response.status_code == HTTPStatus.OK, response.text + assert response.json()["status"] == "NEEDS_CHECK" + + await seeded.refresh(doc) + assert doc.status is DocumentStatus.NEEDS_CHECK and doc.confirmed_by is None and doc.confirmed_at is None + assert await _bound(seeded, doc) == {cancelled.id} + statuses = { + v.id: v.status + for v in ( + await seeded.scalars( + select(ConsolidationVersion).where(ConsolidationVersion.id.in_([version.id, second.id, cancelled.id])) + ) + ).all() + } + assert statuses == { + version.id: ConsolidationStatus.DATA_CORRECTION, + second.id: ConsolidationStatus.INITIATED, + cancelled.id: ConsolidationStatus.CANCELLED, + } + + (event,) = await _events(seeded, doc) + assert event.user_id == user.id and event.object_type == "document" + assert event.before_value["osv_account_code"] == "60" + assert event.before_value["status"] == "CONFIRMED" + assert event.before_value["confirmed_by"] == str(user.id) + assert event.before_value["confirmed_at"] is not None + assert event.after_value["osv_account_code"] == "62" + assert event.after_value["status"] == "NEEDS_CHECK" + assert event.after_value["confirmed_by"] is None and event.after_value["confirmed_at"] is None + assert sorted(event.after_value["unbound_version_ids"]) == sorted([str(version.id), str(second.id)]) + + +async def test_unconfirmed_document_keeps_status_and_audit_has_no_reset_fields( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Неподтверждённый документ сохраняет статус; в аудите нет status и unbound_version_ids.""" + doc = await _simple(seeded, DocumentStatus.NEEDS_CHECK) + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{doc.id}/osv-header", json={"osv_account_code": "62"}) + assert response.status_code == HTTPStatus.OK, response.text + assert response.json()["status"] == "NEEDS_CHECK" + (event,) = await _events(seeded, doc) + assert event.before_value == {"osv_account_code": "60"} + assert event.after_value == {"osv_account_code": "62"} + + +async def test_duplicate_account_after_normalization_and_entries_untouched( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """DUPLICATE_ACCOUNT по нормализованному счёту; успешная правка не меняет osv_entry.""" + entity = await _rostov_entity(seeded) + file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED) + doc = await make_document(seeded, file=file, job=job) + twin = await make_document(seeded, file=file, job=job, account="60.01") + entry = await make_osv_entry( + seeded, recognition_job_id=job.id, document_id=doc.id, owner_legal_entity_id=entity.id, row_number=1 + ) + before = {c.name: getattr(entry, c.name) for c in OsvEntry.__table__.columns} + count = await seeded.scalar(select(func.count()).select_from(OsvEntry)) + client = await auth_client(KA_RF) + url = f"/documents/{doc.id}/osv-header" + response = await client.patch(url, json={"osv_account_code": "60.1"}) + assert response.status_code == HTTPStatus.CONFLICT + assert response.json()["detail"]["code"] == "DUPLICATE_ACCOUNT" + assert response.json()["detail"]["document_id"] == str(twin.id) + await seeded.refresh(doc) + assert doc.osv_account_code == "60" and doc.original_values is None + + response = await client.patch(url, json={"osv_account_code": "61"}) + assert response.status_code == HTTPStatus.OK, response.text + await seeded.refresh(entry) + assert {c.name: getattr(entry, c.name) for c in OsvEntry.__table__.columns} == before + assert await seeded.scalar(select(func.count()).select_from(OsvEntry)) == count + assert (await seeded.get(StoredFile, file.id)) is not None diff --git a/api/tests/test_app.py b/api/tests/test_app.py index 76cdaec..d8eaf6e 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -642,20 +642,31 @@ async def test_cors_prod_wildcard_without_credentials(tmp_path: Any) -> None: assert "access-control-allow-credentials" not in response.headers +@pytest.mark.parametrize("update", [{"DEBUG": True}, {"DEBUG": False, "CORS_ORIGINS": [ORIGIN]}]) +async def test_cors_exposes_content_disposition(tmp_path: Any, update: dict[str, Any]) -> None: + """Фронт на другом источнике читает имя файла экспорта из Content-Disposition (qa-guide W35, arm-core #173).""" + response = await _request(_app_with(tmp_path, **update), "GET", "/healthcheck", headers={"Origin": ORIGIN}) + + assert response.headers["access-control-expose-headers"] == "Content-Disposition" + + def test_cors_options_rules() -> None: base = app_module.settings assert app_module.cors_options(base.model_copy(update={"DEBUG": True, "CORS_ORIGINS": []})) == { "allow_origins": ["*"], "allow_credentials": False, + "expose_headers": ["Content-Disposition"], } assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": []})) is None assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": [ORIGIN]})) == { "allow_origins": [ORIGIN], "allow_credentials": True, + "expose_headers": ["Content-Disposition"], } assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": ["*", ORIGIN]})) == { "allow_origins": ["*", ORIGIN], "allow_credentials": False, + "expose_headers": ["Content-Disposition"], } diff --git a/api/tests/test_check_dbml_script.py b/api/tests/test_check_dbml_script.py index dcc623c..126ece8 100644 --- a/api/tests/test_check_dbml_script.py +++ b/api/tests/test_check_dbml_script.py @@ -140,18 +140,18 @@ def test_enum_values_and_sets_are_compared(dbml_file: Path) -> None: def test_newer_dbml_version_fails_before_comparing_tables(tmp_path: Path, dbml_file: Path) -> None: lines = dbml_file.read_text(encoding="utf-8").splitlines() - newer = tmp_path / "data-model-v5.20.dbml" - # шапка 5.19 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора - newer.write_text(lines[0].replace("v5.19", "v5.20") + "\nTable broken {{{\n", encoding="utf-8") - assert run_check(newer) == (1, ["версия DBML 5.20 не совпадает с версией ORM 5.19: обновите модели"]) + newer = tmp_path / "data-model-v5.23.dbml" + # шапка 5.22 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора + newer.write_text(lines[0].replace("v5.22", "v5.23") + "\nTable broken {{{\n", encoding="utf-8") + assert run_check(newer) == (1, ["версия DBML 5.23 не совпадает с версией ORM 5.22: обновите модели"]) def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None: - newer = tmp_path / "data-model-v5.20.dbml" - newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.19", "v5.20", 1), encoding="utf-8") + newer = tmp_path / "data-model-v5.23.dbml" + newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.22", "v5.23", 1), encoding="utf-8") result = _run_cli(str(newer)) assert result.returncode == 1 - assert result.stdout == "версия DBML 5.20 не совпадает с версией ORM 5.19: обновите модели\n" + assert result.stdout == "версия DBML 5.23 не совпадает с версией ORM 5.22: обновите модели\n" def test_header_without_version_is_usage_error(tmp_path: Path) -> None: diff --git a/api/tests/test_domain_osv_header.py b/api/tests/test_domain_osv_header.py new file mode 100644 index 0000000..69030e7 --- /dev/null +++ b/api/tests/test_domain_osv_header.py @@ -0,0 +1,47 @@ +"""Частичные правки шапки ОСВ и доменные ошибки до записи в БД.""" + +import pytest + +from src.common.exceptions import DomainValidationError +from src.domain.osv_header import corrected_header + +CURRENT = {"period_year": 2025, "period_quarter": "Q3", "is_cumulative": True, "osv_account_code": "60"} + + +@pytest.mark.parametrize( + "patch", + [ + {"period_year": 1899}, + {"period_year": 2100}, + {"period_year": True}, + {"period_year": "2025"}, + {"period_quarter": "Q0"}, + {"period_quarter": []}, + {"period_quarter": None}, + {"is_cumulative": None}, + {"is_cumulative": "true"}, + {"period_quarter": "Q1", "is_cumulative": False}, + ], +) +def test_invalid_period(patch: dict[str, object]) -> None: + with pytest.raises(DomainValidationError) as error: + corrected_header(CURRENT, patch) + assert error.value.code == "INVALID_PERIOD" + + +@pytest.mark.parametrize("year", [1900, 2099]) +def test_valid_year_boundaries(year: int) -> None: + assert corrected_header(CURRENT, {"period_year": year})["period_year"] == year + + +def test_account_only_does_not_require_period() -> None: + current = dict(CURRENT, period_quarter=None) + assert corrected_header(current, {"osv_account_code": "60.1"}) == dict(current, osv_account_code="60.01") + assert corrected_header(CURRENT, {}) == CURRENT + + +@pytest.mark.parametrize("account", [None, "", " ", 60, []]) +def test_empty_or_invalid_account(account: object) -> None: + with pytest.raises(DomainValidationError) as error: + corrected_header(CURRENT, {"osv_account_code": account}) + assert error.value.code == "INVALID_ACCOUNT_CODE" diff --git a/api/tests/test_enums.py b/api/tests/test_enums.py index 66b2738..cce314c 100644 --- a/api/tests/test_enums.py +++ b/api/tests/test_enums.py @@ -1,4 +1,4 @@ -"""Enum'ы ORM против блоков `Enum {}` DBML v5.19: имена, значения и их порядок (без БД).""" +"""Enum'ы ORM против блоков `Enum {}` DBML v5.22: имена, значения и их порядок (без БД).""" import enum import re @@ -15,7 +15,7 @@ EXPECTED_ENUM_COUNT = 33 def test_dbml_version_format() -> None: assert re.fullmatch(r"\d+\.\d+", DBML_VERSION) - assert DBML_VERSION == "5.19" + assert DBML_VERSION == "5.22" assert f"v{DBML_VERSION}" in dbml_path().name diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 82aca1d..897bcf8 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -37,7 +37,9 @@ from tests.test_models_part_07_09 import ( _check_active_lock_is_unique, ) -HEAD = "0007_dbml_v5_19" +V5_19 = "0007_dbml_v5_19" +V5_20 = "0008_dbml_v5_20" +HEAD = "0009_dbml_v5_21" V5_18 = "0006_dbml_v5_18" V5_17 = "0005_dbml_v5_17" V5_16 = "0004_dbml_v5_16" @@ -911,7 +913,7 @@ async def _expect_downgrade_0007_refused(scratch_db_url: str, mutate_sql: str, r await alembic(scratch_db_url, "downgrade", V5_18) assert f"откат v5.19 невозможен: {reason}: 1" in str(error.value) async with engine.connect() as conn: - assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == ["0007_dbml_v5_19"] + assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [HEAD] assert await _count(conn, "financial_statement_line") == lines_before # данные не изменены assert await _count(conn, "document") == documents_before assert await _scalars( @@ -1295,7 +1297,9 @@ def test_offline_sql_creates_schema_before_version_table() -> None: (V5_16, V5_15), (V5_17, V5_16), (V5_18, V5_17), - (HEAD, V5_18), + (V5_19, V5_18), + (V5_20, V5_19), + (HEAD, V5_20), ): assert ( f"UPDATE dkb.alembic_version SET version_num='{new_version}' " @@ -1308,3 +1312,57 @@ def test_cluster_commands_use_service_database() -> None: assert service_url().database == "postgres" base = base_url() assert (service_url().host, service_url().port, service_url().username) == (base.host, base.port, base.username) + + +@pytest.mark.integration +async def test_v5_21_downgrade_preserves_document_fields(scratch_db_url: str) -> None: + engine = create_async_engine(scratch_db_url) + try: + await alembic(scratch_db_url, "upgrade", "head") + await alembic(scratch_db_url, "downgrade", V5_20) + async with engine.connect() as conn: + names = await _scalars( + conn, + "SELECT column_name FROM information_schema.columns WHERE table_schema='dkb' AND table_name='document'", + ) + assert {"original_values", "is_modified", "modified_by", "modified_at"} <= set(names) + labels = await _scalars(conn, "SELECT unnest(enum_range(NULL::dkb.audit_action))::text") + assert "FILE_REPROCESS" not in labels + await engine.dispose() + await alembic(scratch_db_url, "upgrade", "head") + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO dkb.audit_event " + "(id, occurred_at, action, section, object_type) " + "VALUES (:id, now(), 'FILE_REPROCESS', 'PRIMARY_DOCUMENTS', 'stored_file')" + ), + {"id": uuid.uuid4()}, + ) + with pytest.raises(AssertionError, match="откат v5.21 невозможен"): + await alembic(scratch_db_url, "downgrade", V5_20) + async with engine.connect() as conn: + assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [HEAD] + assert await _count(conn, "audit_event") == 1 + finally: + await engine.dispose() + + +@pytest.mark.integration +async def test_v5_20_downgrade_refuses_modified_document(scratch_db_url: str) -> None: + engine = create_async_engine(scratch_db_url) + ids = {k: uuid.uuid4() for k in _SEED_KEYS} + try: + await alembic(scratch_db_url, "upgrade", V5_18) + async with engine.begin() as conn: + await _seed_v5_18_statements(conn, ids) + await alembic(scratch_db_url, "upgrade", V5_20) + async with engine.begin() as conn: + await conn.execute(text("UPDATE dkb.document SET is_modified=true")) + with pytest.raises(AssertionError, match="откат v5.20 невозможен"): + await alembic(scratch_db_url, "downgrade", V5_19) + async with engine.connect() as conn: + assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [V5_20] + assert await conn.scalar(text("SELECT count(*) FROM dkb.document WHERE is_modified")) + finally: + await engine.dispose() diff --git a/api/tests/test_models_part_07_09.py b/api/tests/test_models_part_07_09.py index 2e46db0..775d9dd 100644 --- a/api/tests/test_models_part_07_09.py +++ b/api/tests/test_models_part_07_09.py @@ -277,6 +277,7 @@ def test_all_nullable_jsonb_columns_are_listed() -> None: "recognition_job.group_schema", "recognition_job.metrics", "osv_entry.original_values", + "document.original_values", "financial_statement_meta.needs_review_fields", "financial_statement_meta.header_source", "document_issue.line_ids", diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index c78f8e5..7ec8a95 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -53,6 +53,9 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files": {"get", "post"}, # v5.18 "/api/v1/files/{file_id}": {"get", "delete"}, "/api/v1/files/{file_id}/content": {"get"}, + "/api/v1/files/{file_id}/retry": {"post"}, + "/api/v1/consolidations/versions/{version_id}/files/retry": {"post"}, + "/api/v1/documents/{document_id}/osv-header": {"patch"}, "/api/v1/documents/{document_id}": {"get"}, "/api/v1/documents/{document_id}/entries": {"get"}, "/api/v1/documents/{document_id}/confirm": {"post"}, @@ -287,3 +290,12 @@ def test_version_member_declares_type_and_osv_accounts(schema: dict[str, Any]) - """front-api-gaps D-3: участник периметра — тип ЮЛ и счета ОСВ к периоду версии.""" member = schema["components"]["schemas"]["MemberOut"] assert {"entity_type", "osv_accounts", "checklist"} <= set(member["required"]) + + +def test_retry_routes_declare_different_conflicts(schema: dict[str, Any]) -> None: + single = schema["paths"]["/api/v1/files/{file_id}/retry"]["post"]["responses"]["409"]["description"] + batch = schema["paths"]["/api/v1/consolidations/versions/{version_id}/files/retry"]["post"]["responses"]["409"][ + "description" + ] + assert "FILE_NOT_RETRYABLE" in single and "MAX_ATTEMPTS_REACHED" in single + assert batch == "VERSION_NOT_EDITABLE" diff --git a/api/tests/test_osv_header_schema.py b/api/tests/test_osv_header_schema.py new file mode 100644 index 0000000..1e22dae --- /dev/null +++ b/api/tests/test_osv_header_schema.py @@ -0,0 +1,52 @@ +"""Типы PATCH проверяются до домена, значения и частичная шапка — в домене.""" + +import pytest +from pydantic import ValidationError + +from src.schemas.documents import OsvHeaderPatch + + +@pytest.mark.parametrize("year", ["abc", "2025", 2025.5, 2025.0, True, [], {}]) +def test_year_type_is_strict(year: object) -> None: + with pytest.raises(ValidationError) as error: + OsvHeaderPatch.model_validate({"period_year": year}) + assert error.value.errors()[0]["loc"] == ("period_year",) + + +@pytest.mark.parametrize( + "field,value", + [ + ("period_quarter", 3), + ("period_quarter", []), + ("osv_account_code", 60), + ("osv_account_code", {}), + ("is_cumulative", "true"), + ("is_cumulative", 1), + ("is_cumulative", 0), + ], +) +def test_other_types_are_strict(field: str, value: object) -> None: + with pytest.raises(ValidationError): + OsvHeaderPatch.model_validate({field: value}) + + +def test_domain_values_are_not_rejected_by_schema() -> None: + patch = {"period_year": 2100, "period_quarter": "Q0", "is_cumulative": False, "osv_account_code": ""} + assert OsvHeaderPatch.model_validate(patch).model_dump(exclude_unset=True) == patch + + +def test_null_and_absent_fields_remain_distinct() -> None: + assert OsvHeaderPatch().model_dump(exclude_unset=True) == {} + assert OsvHeaderPatch(period_year=None).model_dump(exclude_unset=True) == {"period_year": None} + + +def test_openapi_schema_describes_field_types() -> None: + properties = OsvHeaderPatch.model_json_schema()["properties"] + expected = { + "period_year": "integer", + "period_quarter": "string", + "is_cumulative": "boolean", + "osv_account_code": "string", + } + for name, kind in expected.items(): + assert properties[name]["anyOf"] == [{"type": kind}, {"type": "null"}] diff --git a/openspec/changes/archive/2026-10-23-document-switcher-history/.openspec.yaml b/openspec/changes/archive/2026-10-23-document-switcher-history/.openspec.yaml new file mode 100644 index 0000000..f56fe13 --- /dev/null +++ b/openspec/changes/archive/2026-10-23-document-switcher-history/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-23 diff --git a/openspec/changes/archive/2026-10-23-document-switcher-history/proposal.md b/openspec/changes/archive/2026-10-23-document-switcher-history/proposal.md new file mode 100644 index 0000000..955ac8e --- /dev/null +++ b/openspec/changes/archive/2026-10-23-document-switcher-history/proposal.md @@ -0,0 +1,35 @@ +## Why + +AURORA-1692: на экране документа аналитик перебирает документы организации через выпадающий список «Документ» в шапке, +каждый выбор добавляет запись в историю браузера, и «Назад» ведёт к предыдущему выбранному документу, а не к экрану +консолидации, откуда документ открыли. В ветке `AURORA-1692` (PR RSHB/arm-core#171, автор — Степанов) выбор документа +открывается заменой текущей записи истории. Переключателя нет в спеке `document-review-ui`; спеку ведёт ревьюер +коммитом в ветку автора (правило владельца). + +## What Changes + +Репозиторий — только arm-core (`web/`), эндпоинты без изменений. + +- Выбор в списке «Документ» шапки экрана документа открывает `/documents/:document_id` выбранного документа с заменой + текущей записи истории (`navigate(…, { replace: true })`), без новой записи. +- Дельта `document-review-ui`: ADDED требование «Переключатель документа» — состав списка (как в коде main) и поведение + истории. + +## Non-goals + +- Переход «Открыть подтверждённую форму» с дубля формы (`StatementReview`) и открытие документа из списка файлов + (`FilesList`) — остаются обычными переходами с новой записью истории. +- Изменения бэкенда и DBML. + +## Capabilities + +### New Capabilities + +### Modified Capabilities + +- `document-review-ui`: ADDED требование «Переключатель документа». + +## Impact + +- `web/src/layouts/DocumentPage.tsx` — одна строка, `onChange` списка «Документ». +- Тестов экрана `DocumentPage` нет; поведение проверяется вручную по сценариям спеки. diff --git a/openspec/changes/archive/2026-10-23-document-switcher-history/specs/document-review-ui/spec.md b/openspec/changes/archive/2026-10-23-document-switcher-history/specs/document-review-ui/spec.md new file mode 100644 index 0000000..95a671c --- /dev/null +++ b/openspec/changes/archive/2026-10-23-document-switcher-history/specs/document-review-ui/spec.md @@ -0,0 +1,18 @@ +## ADDED Requirements + +### Requirement: Переключатель документа +В шапке экрана документа SHALL быть выпадающий список «Документ» с документами файлов того же ЮЛ в той же консолидации, +что и файл открытого документа (файл вне консолидации — все файлы ЮЛ): у пункта имя файла, вторая строка (тип · формат · +дата) и статус; пункты, которые нельзя открыть, неактивны. Выбор пункта SHALL открывать `/documents/:document_id` +выбранного документа заменой текущей записи истории браузера, без новой записи: кнопка «Назад» экрана и браузера +возвращает на экран, с которого открыт первый документ, а не к предыдущему выбранному. + +#### Scenario: Назад после перебора документов +- **WHEN** аналитик открыл документ из панели «Организации» консолидации, выбрал в списке «Документ» подряд ещё два + документа и нажал «Назад» +- **THEN** открыт экран консолидации, с которого был открыт первый документ + +#### Scenario: Документ открыт прямой ссылкой +- **WHEN** документ открыт прямой ссылкой в новой вкладке, аналитик выбрал в списке «Документ» другой документ и нажал + «Назад» экрана +- **THEN** открыт список групп компаний `/groups` diff --git a/openspec/changes/archive/2026-10-23-document-switcher-history/tasks.md b/openspec/changes/archive/2026-10-23-document-switcher-history/tasks.md new file mode 100644 index 0000000..bbc97ec --- /dev/null +++ b/openspec/changes/archive/2026-10-23-document-switcher-history/tasks.md @@ -0,0 +1,8 @@ +## 1. Автор (RSHB/arm-core#171, AURORA-1692) + +- [x] 1.1 `web/src/layouts/DocumentPage.tsx`: выбор в списке «Документ» — `navigate(…, { replace: true })`. + +## 2. Ревьюер + +- [x] 2.1 Change `document-switcher-history`: proposal, дельта `document-review-ui` (ADDED «Переключатель документа»). +- [x] 2.2 Проверки фронта на ветке: `tsc`, `eslint`, `prettier --check`, `vitest`. diff --git a/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/.openspec.yaml b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/.openspec.yaml new file mode 100644 index 0000000..7cc41bb --- /dev/null +++ b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-22 diff --git a/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/design.md b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/design.md new file mode 100644 index 0000000..48ee033 --- /dev/null +++ b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/design.md @@ -0,0 +1,138 @@ +# 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 не выкладывается этой задачей. diff --git a/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/proposal.md b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/proposal.md new file mode 100644 index 0000000..6835903 --- /dev/null +++ b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/proposal.md @@ -0,0 +1,60 @@ +# Proposal + +## Why + +AURORA-1442: после TIMEOUT/NO_ACK аналитик не может повторить обработку уже загруженного файла; +ошибочный период или счёт ОСВ нельзя уточнить через API. DBML v5.20 и решение OQ-161 от 21.10.2026 уже задают +хранение исходных значений рядом с исправленными и правила снятия подтверждения. + +## What Changes + +- Завершение остатка Среза 2c (§9.4–9.5 правил), с интеграцией с версиями Среза 4: ручной повтор одного файла + и подходящих файлов пакета версии. Новое recognition_job с новым id и attempt + 1, прежнее остаётся в истории. +- Повтор только tabular после timeout/TIMEOUT или dlq/NO_ACK, до max_attempts; OCR и другие причины исключены. +- PATCH /api/v1/documents/{id}/osv-header по §9.5 п.12: период и счёт меняются на месте, исходные четыре поля + сохраняются в document.original_values; копий document и правок file_ordinal нет. +- DBML v5.20: четыре колонки document, одна добавочная миграция; сброс подтверждения и снятие привязок, + ручное переподтверждение, аудит RECOGNIZED_DATA_EDIT. Строки osv_entry и первичка в S3 неизменны. +- DBML v5.21: отдельная миграция enum audit_action с FILE_REPROCESS по §9.5 п.13. +- Документировать новые API, ошибки и ограничения; временный CONSOLIDATION_PERIOD_FROM_VERSION сохраняется. + +## Capabilities + +### New Capabilities + +Нет: расширяются существующие возможности. + +### Modified Capabilities + +- `primary-files`: API ручной повторной обработки одного файла и пакета версии, права и ограничения. +- `recognition-job-exchange`: новая попытка через существующий outbox, сохранение истории и защита от старых ответов. +- `documents-api`: уточнение шапки ОСВ, сохранение исходных значений, сброс подтверждения и привязок. +- `data-model`: переход к DBML v5.21 через отдельные миграции v5.20 (document) и v5.21 (audit_action). +- `audit-trail`: RECOGNIZED_DATA_EDIT для шапки ОСВ и согласованный FILE_REPROCESS для каждого ручного повтора файла. + +## Impact + +Реализация — arm-core; документация — arm-ka-docs (`../docs`), отдельный согласованный docs change/PR для уточнения +§9.4–9.5 (п.12–13) и frontend-api-guide. Схема для ОСВ утверждена в v5.20, аудит повтора — в v5.21; +целевой источник истины — model/data-model-v5.21.dbml и entities-v5.21.md; +упоминания v5.17 в конфигурации OpenSpec устарели и не отменяют явно запрошенные пользователем v5.20 и v5.21. + +Таблицы: document (четыре новых поля), recognition_job, stored_file, consolidation_document, audit_event; +consolidation_version/member читаются для статусов и периметра. Новых таблиц не требуется. +Схема и аудит реализуются по опубликованным версиям: v5.20 — четыре поля document, +v5.21 — FILE_REPROCESS в audit_action. Дополнять старую v5.20 новым enum не требуется. +Отдельная миграция v5.21 добавляет только значение enum; откат запрещён при наличии событий FILE_REPROCESS. + +Согласованный пакет версии: файлы с stored_file.consolidation_id консолидации выбранной версии и ЮЛ из её +периметра, подходящие по текущему заданию и лимиту попыток. Версии одной консолидации могут видеть те же файлы. + +Код: api/src/{models,schemas,domain,services,repositories,api/endpoints/files,api/dependencies/files.py}, +api/alembic/versions, api/tests, Makefile, README. Контракты парсеров и фронтенд не меняются. +Основание: §9.4, §9.5 п.6–7,12–13; правила привязок §3; OQ-161 закрыт вариантом 2, OQ-55 относится к автоматическому обмену. + +## Non-goals + +- UI повторной обработки и правки ОСВ (отдельные задачи фронта, в том числе AURORA-1679). +- Повтор OCR, error результата сервиса, ошибок маппинга, автоматический retry с увеличением attempt. +- Копирование документов, перенос ручных правок на повторное распознавание, изменение строк ОСВ при смене счёта шапки. +- Отмена временного флага периода, изменение §7 ББ/ОФР, новые механизмы доставки NATS. diff --git a/openspec/changes/archive/2026-10-23-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 new file mode 100644 index 0000000..ea5f118 --- /dev/null +++ b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/specs/audit-trail/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### 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/changes/archive/2026-10-23-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 new file mode 100644 index 0000000..7f233fe --- /dev/null +++ b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/specs/data-model/spec.md @@ -0,0 +1,111 @@ +## RENAMED Requirements +- FROM: `### Requirement: Схема БД равна DBML v5.19` +- TO: `### Requirement: Схема БД равна DBML v5.21` + +## MODIFIED Requirements + +### 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, значения +по умолчанию и индексы SHALL соответствовать DBML, включая частичные уникальные индексы (`related_group_member` +`(related_group_id, legal_entity_id)` и `(legal_entity_id, kind)` WHERE `is_current`, `consolidation_lock` WHERE +`released_at IS NULL`) и `NULLS NOT DISTINCT` на `document (stored_file_id, document_type, osv_account_code)`. +`related_group_member.kind` SHALL быть NOT NULL и равняться `related_group.kind` группы (проверяет ядро при вставке). +`audit_action` SHALL содержать `LEGAL_ENTITY_CHANGE` и `USER_CHANGE`; `osv_data_type` SHALL содержать `NU` (после `BU`). +Enum `counterparty_match_kind` SHALL содержать `INN`, `NAME`, `ALIAS`, `MANUAL`, `PROPAGATED`; колонка +`osv_entry.counterparty_match_kind` (nullable) SHALL быть NULL тогда и только тогда, когда `counterparty_id` NULL +(проверяет ядро при записи). `app_user.email` SHALL храниться в нижнем регистре. Миграция `0004_dbml_v5_16` SHALL +создавать enum и колонку и заполнять её по уже смапленным строкам (ИНН строки без пробелов = `legal_entity.inn` → `INN`; +`trim(counterparty_name) = legal_entity.name` → `NAME`; остальные с `counterparty_id` → `ALIAS`); откат SHALL удалять +колонку и enum. Колонка `kpp` (`varchar`, nullable, без индексов и уникальности; уникален только `inn`) SHALL быть +последней в `legal_entity` и `legal_entity_history`; формат и запрет КПП у ИП проверяет ядро. Миграция `0005_dbml_v5_17` +SHALL добавлять обе колонки (существующие записи — `kpp IS NULL`), откат — удалять. +Колонка `stored_file.consolidation_id` (`uuid`, nullable, FK → `consolidation.id`; v5.18) SHALL быть последней в +`stored_file`; индекс `ix_stored_file_legal_entity_id_consolidation_id (legal_entity_id, consolidation_id)`. Миграция +`0006_dbml_v5_18` SHALL добавлять колонку, FK и индекс и заполнять колонку по `consolidation_document`: файлу с документом, +привязанным к версии, — консолидация этой версии; при привязках к версиям разных консолидаций — самый ранний период +(год, затем квартал); файлы без привязок остаются `NULL`; откат SHALL удалять индекс и колонку. Поля, отсутствующие в +DBML, MUST NOT существовать. +Схема v5.19 (RSHB/arm-ka-docs#116) SHALL добавлять: таблицу `document_issue` и enum'ы `statement_line_status`, `statement_form_variant`, `document_issue_kind` и прочие enum'ы DBML v5.19; колонки `document.file_ordinal` (уникальна в паре `(stored_file_id, file_ordinal)`), `recognition_job.statements_version`, `related_group.crm_id` (`varchar`, nullable, уникальна), в `financial_statement_meta` — `form_variant`, `needs_review_fields`, `header_source`, `organization_name_original_value`, `organization_name_modified`; в `financial_statement_column` — `period_kind`, `label_confidence`; в `financial_statement_line` — `row_index`, `source_ref`, `line_status`, `line_code_text`, `parent_row_index`, `needs_review`, `is_confirmed`; в `financial_statement_value` — `source_text`, `needs_review`, `is_confirmed` (с `confirmed_by/at`). Уникальность `(meta_id, line_code)` снимается, добавляется частичная уникальность `(meta_id, source_ref)` WHERE `source_ref IS NOT NULL`; уникальность документа `(stored_file_id, document_type, osv_account_code)` становится частичной (только для типов, кроме `BALANCE` / `INCOME`). Состав и порядок колонок — буквально по DBML v5.19. +Схема 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.21.dbml` (`make dbml-check`) +- **THEN** вывод пуст и код возврата 0 + +#### Scenario: Лишняя колонка в ORM +- **WHEN** в ORM добавлена колонка, которой нет в DBML (или удалена существующая) +- **THEN** скрипт печатает `таблица.колонка: отсутствует в DBML` (или `отсутствует в ORM`) и возвращает код 1 + +#### Scenario: Расхождение типа или nullability +- **WHEN** колонка `osv_entry.opening_debit` объявлена в ORM как `Numeric(18, 4)` или `NOT NULL` +- **THEN** скрипт сообщает `ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL` и возвращает код 1 + +#### Scenario: ЮЛ одновременно в ГСК и ГСЗ +- **WHEN** для одного `legal_entity_id` вставлены две текущие строки `related_group_member` с `kind = gsk` и `kind = gsz` +- **THEN** обе вставки проходят; третья текущая строка с `kind = gsk` нарушает уникальный индекс + +#### Scenario: Строка налогового учёта +- **WHEN** в `osv_entry` вставляется строка с `data_type = 'NU'` +- **THEN** вставка проходит + +#### Scenario: Backfill при миграции +- **WHEN** до миграции 0004 есть строки с `counterparty_id`, найденным по ИНН, по имени и по алиасу, и строка без `counterparty_id` +- **THEN** после `alembic upgrade head` у них `counterparty_match_kind` = `INN`, `NAME`, `ALIAS` и NULL соответственно; + `alembic downgrade 0003` удаляет колонку и enum + +#### Scenario: Backfill консолидации файла при миграции 0006 +- **WHEN** до миграции 0006 есть файл `A` с документом, привязанным к версии консолидации 2025 Q2 и к версии консолидации + 2025 Q1, и файл `B` без привязок +- **THEN** после `alembic upgrade head` у `A` `consolidation_id` = консолидация 2025 Q1, у `B` — NULL; колонка последняя в + `stored_file`; `alembic downgrade 0005_dbml_v5_17` удаляет индекс и колонку, повторный `upgrade` их возвращает + +#### Scenario: Сверка с DBML v5.19 +- **WHEN** выполняется `make dbml-check` по `../docs/model/data-model-v5.21.dbml` +- **THEN** таблицы, колонки, enum'ы, ключи и индексы ORM совпадают с DBML без расхождений + +### Requirement: Версия DBML зафиксирована в коде +Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.21"`); скрипт сверки SHALL сравнивать её с версией в шапке файла +DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна. + +#### Scenario: Новая версия DBML без обновления ORM +- **WHEN** в `docs/model/` появляется `data-model-v5.22.dbml`, а константа в ORM — `5.21` +- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц + +## ADDED Requirements + +### 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/changes/archive/2026-10-23-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 new file mode 100644 index 0000000..c23625b --- /dev/null +++ b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/specs/documents-api/spec.md @@ -0,0 +1,110 @@ +## ADDED Requirements + +### 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/changes/archive/2026-10-23-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 new file mode 100644 index 0000000..6d5b908 --- /dev/null +++ b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/specs/primary-files/spec.md @@ -0,0 +1,77 @@ +## ADDED Requirements + +### 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 сохраняется; дальнейшие переходы происходят по существующим событиям + +## MODIFIED Requirements + +### Requirement: Жизненный цикл статуса файла +`stored_file.status` SHALL меняться только по заданию `current_recognition_job_id`: `UPLOADED` при загрузке и при принятом ручном повторе с новым текущим заданием; +`PROCESSING` при ack сервиса; `ERROR` при финальной ошибке задания (`error`, `timeout`, `dlq`) и при невозможности +маппинга; `PROCESSED` после успешного маппинга; `SUPERSEDED` при автозамене. Карточка файла SHALL показывать причину +`ERROR` (`error_code`/`error_message` задания или ошибку маппинга). + +#### Scenario: Парсер вернул ошибку +- **WHEN** конверт `error UNSUPPORTED_FORMAT` для задания файла +- **THEN** `stored_file.status = ERROR`, в карточке файла `error_code = UNSUPPORTED_FORMAT` + +#### Scenario: Сервис не ответил +- **WHEN** задание переведено вахтёром в `timeout` +- **THEN** файл — `ERROR` с `error_code = TIMEOUT` diff --git a/openspec/changes/archive/2026-10-23-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 new file mode 100644 index 0000000..8a4b754 --- /dev/null +++ b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/specs/recognition-job-exchange/spec.md @@ -0,0 +1,47 @@ +## MODIFIED Requirements + +### 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`. Ручной повтор после TIMEOUT/NO_ACK SHALL создавать новое задание по требованию «Новая попытка обработки файла». + +#### Scenario: Сервис взял, но не ответил +- **WHEN** задание в `processing` и `reply_deadline` прошёл без результата +- **THEN** не позднее чем через 30 с задание имеет `status = timeout`, файл — `ERROR` с `error_code = TIMEOUT` + +## ADDED Requirements + +### 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 не создаётся diff --git a/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/tasks.md b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/tasks.md new file mode 100644 index 0000000..4549712 --- /dev/null +++ b/openspec/changes/archive/2026-10-23-retry-recognition-and-edit-osv-header/tasks.md @@ -0,0 +1,89 @@ +# Tasks + +Задачи по отдельности рассчитаны не более чем на половину рабочего дня. Репозиторий указан в каждой задаче. +Не отмечать пропущенные из-за инфраструктуры проверки выполненными. + +## 1. Регрессионные сценарии и источник истины + +- [x] 1.1 arm-core: в api/tests/test_recognition_exchange.py и новом test_api_file_retry.py подготовить сценарии TIMEOUT/NO_ACK, лимита, OCR и поздних ответов; проверить, что регрессии воспроизводятся до реализации. Форматы файлов не выдумывать; для статусов использовать существующие фабрики БД. +- [x] 1.2 arm-core: в api/tests/test_api_osv_header.py подготовить реальные ОСВ РБ через доступный fixtures, сценарии первой/повторной правки, возврата к исходному, сброса подтверждения и неизменности строк; проверка — тесты описывают дельту documents-api. Если ../fixtures недоступен, восстановить доступ до golden/e2e и явно отметить ограничение. +- [x] 1.3 arm-core и arm-ka-docs: сверить план с опубликованными model/data-model-v5.20.dbml, data-model-v5.21.dbml, entities-v5.21.md и context/05-domain-rules.md §9.5 п.12–13; отдельно уточнить в docs согласованный отбор пакета consolidation_id + периметр. Проверка — FILE_REPROCESS и его payload соответствуют v5.21, изменения схемы docs не требуются. + +## 2. Модель и отдельные миграции v5.20 и v5.21 + +- [x] 2.1 arm-core: расширить api/tests/test_migrations.py и test_dbml_parity.py сценариями v5.20/v5.21, непустой базы и независимых запретов отката v5.20 при is_modified и v5.21 при FILE_REPROCESS; проверка — регрессии падают по отсутствующей схеме. +- [x] 2.2 arm-core: api/src/models/files.py, enums.py и константа DBML_VERSION, Makefile — четыре поля document и FILE_REPROCESS строго по опубликованному DBML v5.21, итоговый DBML_VERSION = 5.21; проверить make dbml-check и тесты моделей. +- [x] 2.3 arm-core: api/alembic/versions/0008_dbml_v5_20.py после 0007 — только четыре поля document и запрет downgrade при is_modified=true; проверить upgrade/downgrade на пустой и непустой БД, сохранение документов и привязок. +- [x] 2.4 arm-core: отдельная api/alembic/versions/0009_dbml_v5_21.py после 0008 — только FILE_REPROCESS в audit_action и запрет downgrade при наличии таких событий; проверить переходы 5.20 ↔ 5.21 без удаления полей document, полный цикл, нестандартный DB_SCHEMA и пустой autogenerate. +- [x] 2.5 arm-core: README — схема v5.21, две отдельные миграции и независимые условия отката; проверка — инструкции соответствуют выполненным миграционным сценариям. + +## 3. Одиночный повтор файла + +- [x] 3.1 arm-core: api/src/services/file_retry.py и repositories/files.py — допустимость текущего job, блокировки job → файл → версии, новая попытка и история; проверить TIMEOUT/NO_ACK, лимит, отсутствие документов, неизменность исходника и старого job. +- [x] 3.2 arm-core: services/file_retry.py, file_status.py и audit.py — атомарное переключение current job, UPLOADED и FILE_REPROCESS; проверить откат всей операции и отсутствие копирования ошибок, метрик, результата и времён. +- [x] 3.3 arm-core: schemas/files.py, api/dependencies/files.py и новый endpoints/files/retry.py, регистрация router — POST /files/{id}/retry, 202 FileOut и коды ошибок; проверить api/tests/test_api_file_retry.py, права, одинаковый 404, OpenAPI и единственную транзакцию запроса. +- [x] 3.4 arm-core: api/tests/test_recognition_exchange.py — новый job проходит существующий outbox/inbox, поздние ack/done прежнего job игнорируются; проверить свежие deadline/message-id и восстановление после недоступности NATS. +- [x] 3.5 arm-core и arm-ka-docs (docs — ревьюер, RSHB/arm-ka-docs#157): README и dev/frontend-api-guide.md — одиночный retry, разделение ручной попытки и republish; проверить примеры запросов, ответы 202/409 и описание FILE_REPROCESS по OpenAPI. + +## 4. Пакет версии + +- [x] 4.1 arm-core: api/tests/test_api_file_retry.py — сценарии смешанного/пустого пакета, общей консолидации нескольких версий, чужих ЮЛ и файла вне периметра; проверить отбор ровно по согласованному правилу. +- [x] 4.2 arm-core: repositories/files.py и services/file_retry.py — пакетный отбор, общие проверки версии, повторная проверка периметра под блокировкой, accepted/skipped; проверить отсутствие повторного запуска и аудита skipped, откат на неожиданной ошибке БД. +- [x] 4.3 arm-core: schemas/files.py и endpoints/files/retry.py — POST /consolidations/versions/{id}/files/retry, 202 RetryBatchOut без consolidation_lock; проверить OpenAPI, стабильный порядок file_id и VERSION_NOT_EDITABLE без мутаций. +- [x] 4.4 arm-core: api/tests/test_api_constraint_races.py — две сессии для одиночного/пакетного retry и смены статуса/периметра; проверить ровно одну новую попытку, отсутствие дедлока и запрещённых операций после смены статуса. +- [x] 4.5 arm-core и arm-ka-docs (docs — ревьюер, RSHB/arm-ka-docs#157): README и dev/frontend-api-guide.md — состав пакета, частичные пропуски и правила версий; проверить примеры смешанного и пустого ответа по интеграционным тестам. + +## 5. Уточнение шапки ОСВ + +- [x] 5.1 arm-core: domain/osv_header.py и api/tests/test_domain_osv_header.py — итоговая частичная шапка, нормализация, периоды, no-op и сравнение с оригиналом; проверить границы 1900/2099, Q1, null и отсутствие полей. +- [x] 5.2 arm-core: schemas/documents.py, services/documents.py — DTO PATCH и выдача is_modified/original_values/modified_by/modified_at в карточке; проверить старые документы с false/null и отсутствие регрессий чтения. +- [x] 5.3 arm-core: services/osv_header.py, repositories/files.py — изменение на месте, первичный снимок, метаданные правки и DUPLICATE_ACCOUNT; проверить api/tests/test_api_osv_header.py, неизменность document.id/file_ordinal и всех osv_entry. +- [x] 5.4 arm-core: services/osv_header.py и consolidation_documents.py — проверка привязанных версий, сброс подтверждения, выборочная отвязка и аудит; проверить CONFIRMED/автоподтверждённый/CANCELLED/VERIFIED, rollback, no-op и отсутствие автоподтверждения. +- [x] 5.5 arm-core: endpoints/files/documents.py и api/dependencies/files.py — PATCH /documents/{id}/osv-header, 200 DocumentOut, доменные 409/422 в требуемом порядке; проверить OpenAPI, права, policy и ограничения тела без изменения отсутствующих полей. +- [x] 5.6 arm-core: api/tests/test_api_osv_header.py, test_api_constraint_races.py и test_api_consolidation_checklist.py — конкурентные PATCH/confirm, коллизия счёта, новое подтверждение и производные данные; проверить колонку ОСВ/чек-лист с флагом периода false/true и отсутствие автозамены файлов. +- [x] 5.7 arm-core и arm-ka-docs (docs — ревьюер, RSHB/arm-ka-docs#157): README и dev/frontend-api-guide.md — PATCH, original_values, no-op, снятие подтверждения и флаг периода; проверить примеры по сценариям тестов и OpenAPI, UI оставить отдельной задачей. + +## 6. Общая проверка и документация поставки + +- [ ] 6.1 arm-core: выполнить make check, make dbml-check, make test-integration, затем make check-full по требованиям репозитория; сохранить результаты и явно указать skips/недоступность инфраструктуры, проверить git diff на отсутствие посторонних изменений. +- [x] 6.2 arm-core и arm-ka-docs (ревьюер: README/CLAUDE.md v5.21, 05-domain-rules §9.4–9.5 и гайд фронта — RSHB/arm-ka-docs#157): сверить README, context/03-architecture.md, context/07-open-questions.md и dev/frontend-api-guide.md с реализацией, закрытым OQ-161 и согласованными решениями FILE_REPROCESS/пакета; проверить ссылки, порядок выкладки и согласованность core/docs PR. Это финальная задача документации среза, основные API-документы обновлены в своих группах. + +## Workflow follow-up + +- После реализации и проверки оформить отдельные PR для arm-core и arm-ka-docs; архивация — отдельным запросом. +- Не удалять CONSOLIDATION_PERIOD_FROM_VERSION до отдельной поставки UI правки ОСВ. + +## Результаты проверки реализации + +- Линтеры ruff, форматирование и mypy: пройдены; ORM соответствует DBML v5.21. +- 127 проверок API/домена/OpenAPI/миграций пройдены, включая защитные откаты. +- Расширенный набор с гонками, rollback и флагом периода: 108 passed до переноса маршрута шапки в documents.py. +- После переноса: 70 тестов OpenAPI passed; DOCUMENT_EDIT сохранён. +- Полный интеграционный набор без старых fixtures: 956 passed, 1 failed — ожидание test_document_card + не включало новые поля DocumentOut; ожидание обновлено, отдельный повторный test_document_card прошёл. +- Docker-сборка и web-check пройдены. +- Общий unit-прогон с текущими fixtures: первоначально 1835 passed, 6 failed, 1 skipped. + Четыре отказа относились к обновлённой версии модели/JSON-колонкам, ожидания исправлены; + два старых OCR-теста несовместимы с текущими fixtures v2.0 (помощник ожидает v1.2), вне реализации retry/ОСВ. +- statements-check: отсутствует соседний репозиторий arm-statements; e2e ещё не выполнен. +- Лишняя пустая строка в конце docs/dev/frontend-api-guide.md удалена; git -C ../docs diff --check проходит. +- Задача 6.1 остаётся открытой: общий зелёный check-full пока не подтверждён. + +- Уточнение строгих типов OsvHeaderPatch: StrictInt/StrictStr/StrictBool без приведения; порядок доменных + ошибок действует после проверки типов. Схема/домен/OpenAPI — 105 passed; API шапки и карточка — 8 passed; + mypy — без ошибок. Глобальный format-check отдельно указывает на пользовательский комментарий + в services/file_retry.py, не относящийся к этой правке; комментарий оставлен без изменений. + +## Ревью (RSHB/arm-core#161) + +- [x] 7.1 Ревьюер: `api/.env.test` — порт PostgreSQL 5432 по compose (свой порт — переменной `OPENBAO__DATABASE__DATABASE_URL`). +- [x] 7.2 Ревьюер: дельта data-model переименована на v5.21, `openspec/config.yaml` — DBML v5.21, README/CLAUDE.md — миграции 0008/0009. +- [x] 7.3 Ревьюер: синтетические тесты PATCH шапки ОСВ без `fixtures`, смешанный пакет и NO_ACK на пределе. +- [x] 7.4 Автор: замечания комментария ревью (пакет — только файлы в `ERROR`, блокировки версий, `CONTRACT_VERSION`, репозиторий, OpenAPI). + +Раунд 1: все шесть замечаний к коду исправлены. Пакет отбирается репозиторием только по ERROR до блокировок, +статус перепроверяется под блокировкой; одиночный повтор берёт FOR SHARE только рабочих версий. +Новая попытка использует CONTRACT_VERSION и subject из SettingsDep. OpenAPI-конфликты разделены; +проверка пары status/error_code сохранена, вопросительный комментарий удалён. +Проверки: 101 passed (file_retry, синтетическая шапка ОСВ, OpenAPI), make lint, +openspec validate --strict, git diff --check — успешно. .env.test не менялся. diff --git a/openspec/config.yaml b/openspec/config.yaml index 6f05017..df3ff4e 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -3,7 +3,7 @@ schema: spec-driven context: | Репозиторий arm-core — часть системы АРМ КА (ДКБ, Россельхозбанк): ядро (FastAPI + React), единственный писатель в PostgreSQL: справочники, файлы/документы, маппинг результатов распознавания, вычисления консолидации, согласование, ЕХ, аудит, права. Workspace из нескольких репозиториев; общий контекст — ../CLAUDE.md и ../docs/ (репозиторий arm-ka-docs). - Источники истины по приоритету: ../docs/model/data-model-v5.17.dbml → ../docs/model/entities-v5.17.md → + Источники истины по приоритету: ../docs/model/data-model-v5.21.dbml → ../docs/model/entities-v5.21.md → ../docs/context/05-domain-rules.md → страницы ТЗ в ../docs/source/tz (черновик, старые имена v4). Открытые вопросы — ../docs/context/07-open-questions.md; план срезов — ../docs/context/09-delivery-plan.md. Фикстуры — ../fixtures (реальные данные, не спецификация формата). @@ -14,7 +14,7 @@ context: | rules: proposal: - Указывать номер среза из 09-delivery-plan.md и затронутые репозитории; сквозные изменения — отдельный change в ../docs - - Перечислять таблицы DBML v5.17, которые затрагиваются; поля вне DBML не вводить — сначала новая версия DBML + - Перечислять таблицы текущей DBML (сейчас v5.21), которые затрагиваются; поля вне DBML не вводить — сначала новая версия DBML - Ссылаться на параграфы 05-domain-rules.md для каждого алгоритма; правило, которого там нет, — в 07-open-questions.md - Раздел «Non-goals» обязателен - Любое допущение по открытому вопросу помечать ASSUMPTION(OQ-nn) 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/document-review-ui/spec.md b/openspec/specs/document-review-ui/spec.md index 43dde86..4349988 100644 --- a/openspec/specs/document-review-ui/spec.md +++ b/openspec/specs/document-review-ui/spec.md @@ -183,3 +183,20 @@ SHALL быть отключена с подсказкой «Разберите #### Scenario: Предупреждение листа ОСВ - **WHEN** у ОСВ-документа в `issues` есть запись с кодом `OSV_*` - **THEN** над таблицей показан блок предупреждений с текстом записи и уровнем + +### Requirement: Переключатель документа +В шапке экрана документа SHALL быть выпадающий список «Документ» с документами файлов того же ЮЛ в той же консолидации, +что и файл открытого документа (файл вне консолидации — все файлы ЮЛ): у пункта имя файла, вторая строка (тип · формат · +дата) и статус; пункты, которые нельзя открыть, неактивны. Выбор пункта SHALL открывать `/documents/:document_id` +выбранного документа заменой текущей записи истории браузера, без новой записи: кнопка «Назад» экрана и браузера +возвращает на экран, с которого открыт первый документ, а не к предыдущему выбранному. + +#### Scenario: Назад после перебора документов +- **WHEN** аналитик открыл документ из панели «Организации» консолидации, выбрал в списке «Документ» подряд ещё два + документа и нажал «Назад» +- **THEN** открыт экран консолидации, с которого был открыт первый документ + +#### Scenario: Документ открыт прямой ссылкой +- **WHEN** документ открыт прямой ссылкой в новой вкладке, аналитик выбрал в списке «Документ» другой документ и нажал + «Назад» экрана +- **THEN** открыт список групп компаний `/groups` 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 не создаётся diff --git a/web/src/layouts/DocumentPage.tsx b/web/src/layouts/DocumentPage.tsx index 0b2846d..dda82ca 100644 --- a/web/src/layouts/DocumentPage.tsx +++ b/web/src/layouts/DocumentPage.tsx @@ -94,7 +94,7 @@ export function DocumentPage() { style={{ width: '100%' }} value={doc.id} options={documentOptions} - onChange={(value) => navigate(`/documents/${value}`)} + onChange={(value) => navigate(`/documents/${value}`, { replace: true })} optionRender={(option) => (
{/* у ББ и ОФР из одного PDF имя файла одно — различаются второй строкой (тип · формат · дата) */}