Merge origin/main в AURORA-1536 (ревью #175)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
888ad4b401
@ -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`, как и
|
||||
|
||||
2
Makefile
2
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
|
||||
|
||||
|
||||
75
README.md
75
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) — план срезов;
|
||||
|
||||
28
api/alembic/versions/0008_dbml_v5_20.py
Normal file
28
api/alembic/versions/0008_dbml_v5_20.py
Normal file
@ -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}")
|
||||
28
api/alembic/versions/0009_dbml_v5_21.py
Normal file
28
api/alembic/versions/0009_dbml_v5_21.py
Normal file
@ -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 $$""")
|
||||
@ -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 = "нет"
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
40
api/src/api/endpoints/files/retry.py
Normal file
40
api/src/api/endpoints/files/retry.py
Normal file
@ -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)
|
||||
@ -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
|
||||
|
||||
34
api/src/domain/osv_header.py
Normal file
34
api/src/domain/osv_header.py
Normal file
@ -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
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 (дедуп)."""
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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),
|
||||
|
||||
187
api/src/services/file_retry.py
Normal file
187
api/src/services/file_retry.py
Normal file
@ -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
|
||||
104
api/src/services/osv_header.py
Normal file
104
api/src/services/osv_header.py
Normal file
@ -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)
|
||||
@ -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},
|
||||
}
|
||||
|
||||
421
api/tests/test_api_file_retry.py
Normal file
421
api/tests/test_api_file_retry.py
Normal file
@ -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
|
||||
228
api/tests/test_api_osv_header.py
Normal file
228
api/tests/test_api_osv_header.py
Normal file
@ -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
|
||||
280
api/tests/test_api_osv_header_synthetic.py
Normal file
280
api/tests/test_api_osv_header_synthetic.py
Normal file
@ -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
|
||||
@ -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"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
47
api/tests/test_domain_osv_header.py
Normal file
47
api/tests/test_domain_osv_header.py
Normal file
@ -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"
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
52
api/tests/test_osv_header_schema.py
Normal file
52
api/tests/test_osv_header_schema.py
Normal file
@ -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"}]
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-23
|
||||
@ -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` нет; поведение проверяется вручную по сценариям спеки.
|
||||
@ -0,0 +1,18 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Переключатель документа
|
||||
В шапке экрана документа SHALL быть выпадающий список «Документ» с документами файлов того же ЮЛ в той же консолидации,
|
||||
что и файл открытого документа (файл вне консолидации — все файлы ЮЛ): у пункта имя файла, вторая строка (тип · формат ·
|
||||
дата) и статус; пункты, которые нельзя открыть, неактивны. Выбор пункта SHALL открывать `/documents/:document_id`
|
||||
выбранного документа заменой текущей записи истории браузера, без новой записи: кнопка «Назад» экрана и браузера
|
||||
возвращает на экран, с которого открыт первый документ, а не к предыдущему выбранному.
|
||||
|
||||
#### Scenario: Назад после перебора документов
|
||||
- **WHEN** аналитик открыл документ из панели «Организации» консолидации, выбрал в списке «Документ» подряд ещё два
|
||||
документа и нажал «Назад»
|
||||
- **THEN** открыт экран консолидации, с которого был открыт первый документ
|
||||
|
||||
#### Scenario: Документ открыт прямой ссылкой
|
||||
- **WHEN** документ открыт прямой ссылкой в новой вкладке, аналитик выбрал в списке «Документ» другой документ и нажал
|
||||
«Назад» экрана
|
||||
- **THEN** открыт список групп компаний `/groups`
|
||||
@ -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`.
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-22
|
||||
@ -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 не выкладывается этой задачей.
|
||||
@ -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.
|
||||
@ -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 отражает эту правку; старые события не меняются
|
||||
@ -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, исходные значения и привязки сохранены
|
||||
@ -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
|
||||
@ -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`
|
||||
@ -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 не создаётся
|
||||
@ -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 не менялся.
|
||||
@ -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)
|
||||
|
||||
@ -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 отражает эту правку; старые события не меняются
|
||||
|
||||
@ -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, исходные значения и привязки сохранены
|
||||
|
||||
@ -183,3 +183,20 @@ SHALL быть отключена с подсказкой «Разберите
|
||||
#### Scenario: Предупреждение листа ОСВ
|
||||
- **WHEN** у ОСВ-документа в `issues` есть запись с кодом `OSV_*`
|
||||
- **THEN** над таблицей показан блок предупреждений с текстом записи и уровнем
|
||||
|
||||
### Requirement: Переключатель документа
|
||||
В шапке экрана документа SHALL быть выпадающий список «Документ» с документами файлов того же ЮЛ в той же консолидации,
|
||||
что и файл открытого документа (файл вне консолидации — все файлы ЮЛ): у пункта имя файла, вторая строка (тип · формат ·
|
||||
дата) и статус; пункты, которые нельзя открыть, неактивны. Выбор пункта SHALL открывать `/documents/:document_id`
|
||||
выбранного документа заменой текущей записи истории браузера, без новой записи: кнопка «Назад» экрана и браузера
|
||||
возвращает на экран, с которого открыт первый документ, а не к предыдущему выбранному.
|
||||
|
||||
#### Scenario: Назад после перебора документов
|
||||
- **WHEN** аналитик открыл документ из панели «Организации» консолидации, выбрал в списке «Документ» подряд ещё два
|
||||
документа и нажал «Назад»
|
||||
- **THEN** открыт экран консолидации, с которого был открыт первый документ
|
||||
|
||||
#### Scenario: Документ открыт прямой ссылкой
|
||||
- **WHEN** документ открыт прямой ссылкой в новой вкладке, аналитик выбрал в списке «Документ» другой документ и нажал
|
||||
«Назад» экрана
|
||||
- **THEN** открыт список групп компаний `/groups`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 сохраняется; дальнейшие переходы происходят по существующим событиям
|
||||
|
||||
@ -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 не создаётся
|
||||
|
||||
@ -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) => (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10 }}>
|
||||
{/* у ББ и ОФР из одного PDF имя файла одно — различаются второй строкой (тип · формат · дата) */}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user