Merge origin/main в AURORA-1536 (ревью #175)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-23 18:34:35 +04:00
commit 888ad4b401
54 changed files with 2774 additions and 41 deletions

View File

@ -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`, как и

View File

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

View File

@ -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) — план срезов;

View 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}")

View 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 $$""")

View File

@ -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 = "нет"

View File

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

View File

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

View File

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

View 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)

View File

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

View 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

View File

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

View File

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

View File

@ -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 (дедуп)."""

View File

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

View File

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

View File

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

View File

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

View 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

View 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)

View 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},
}

View 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

View 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

View 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

View File

@ -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"],
}

View File

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

View 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"

View File

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

View File

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

View File

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

View File

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

View 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"}]

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-23

View File

@ -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` нет; поведение проверяется вручную по сценариям спеки.

View File

@ -0,0 +1,18 @@
## ADDED Requirements
### Requirement: Переключатель документа
В шапке экрана документа SHALL быть выпадающий список «Документ» с документами файлов того же ЮЛ в той же консолидации,
что и файл открытого документа (файл вне консолидации — все файлы ЮЛ): у пункта имя файла, вторая строка (тип · формат ·
дата) и статус; пункты, которые нельзя открыть, неактивны. Выбор пункта SHALL открывать `/documents/:document_id`
выбранного документа заменой текущей записи истории браузера, без новой записи: кнопка «Назад» экрана и браузера
возвращает на экран, с которого открыт первый документ, а не к предыдущему выбранному.
#### Scenario: Назад после перебора документов
- **WHEN** аналитик открыл документ из панели «Организации» консолидации, выбрал в списке «Документ» подряд ещё два
документа и нажал «Назад»
- **THEN** открыт экран консолидации, с которого был открыт первый документ
#### Scenario: Документ открыт прямой ссылкой
- **WHEN** документ открыт прямой ссылкой в новой вкладке, аналитик выбрал в списке «Документ» другой документ и нажал
«Назад» экрана
- **THEN** открыт список групп компаний `/groups`

View File

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

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-22

View File

@ -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 не выкладывается этой задачей.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 не менялся.

View File

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

View File

@ -179,3 +179,37 @@ credit_account_ids, cells}`; для `PRESET` в `after_value` SHALL быть д
#### Scenario: Нет счёта статьи
- **WHEN** выгрузка отвечает 422 `RESULT_ACCOUNTS_MISSING`
- **THEN** событие `EXPORT` не записано
### Requirement: Аудит ручной повторной обработки файла
Каждая принятая ручная попытка SHALL писать одно audit_event с action = FILE_REPROCESS,
section = PRIMARY_DOCUMENTS, object_type = stored_file, object_id = file_id, user_id инициатора,
IP и User-Agent запроса. before_value SHALL содержать прежние job_id, attempt, status задания, error_code;
after_value — новые job_id, attempt, а при пакетном повторе также consolidation_id. Событие SHALL фиксироваться в транзакции создания попытки.
#### Scenario: Пакетный повтор
- **WHEN** пакет принял два файла ОСВ РБ и пропустил третий
- **THEN** два FILE_REPROCESS с разными object_id и прежними/новыми job_id, с consolidation_id в after_value;
before_value.status — статус прежнего задания (timeout или dlq), error_code — TIMEOUT или NO_ACK; для skipped события нет
#### Scenario: Отказ или откат
- **WHEN** повтор отклонён либо транзакция создания задания откатывается
- **THEN** FILE_REPROCESS для него отсутствует
#### Scenario: Автоматическая повторная публикация
- **WHEN** outbox повторно публикует прежнее задание без ack, не создавая новую попытку
- **THEN** FILE_REPROCESS не пишется
### Requirement: Аудит уточнения шапки ОСВ
Фактический PATCH шапки ОСВ SHALL писать одно RECOGNIZED_DATA_EDIT раздела RECOGNITION,
object_type = document, object_id документа, user_id инициатора. before/after SHALL содержать изменённые
поля и при сбросе подтверждения status, confirmed_by/at; after_value.unbound_version_ids SHALL содержать
версии снятых привязок. Запрос без изменений MUST NOT писать аудит.
#### Scenario: Подтверждённая ОСВ исправлена
- **WHEN** меняется счёт CONFIRMED ОСВ РБ, привязанной к версии DATA_CORRECTION
- **THEN** одно RECOGNIZED_DATA_EDIT с прежним/новым счётом и подтверждением, after_value содержит
unbound_version_ids, пользователь, IP и User-Agent сохранены
#### Scenario: Возврат к исходному
- **WHEN** аналитик возвращает исходные значения после предыдущей правки
- **THEN** новое RECOGNIZED_DATA_EDIT отражает эту правку; старые события не меняются

View File

@ -6,8 +6,8 @@
## Requirements
### Requirement: Схема БД равна DBML v5.19
ORM-метаданные SHALL содержать ровно 44 таблицы и 33 enum'а из `../docs/model/data-model-v5.19.dbml` в схеме PostgreSQL
### Requirement: Схема БД равна DBML v5.21
ORM-метаданные SHALL содержать ровно 44 таблицы и 33 enum'а из `../docs/model/data-model-v5.21.dbml` в схеме PostgreSQL
из настройки `DB_SCHEMA` (по умолчанию `dkb`; в контуре — `OPENBAO__APP__DB_SCHEMA`; простой идентификатор
`[a-z_][a-z0-9_]{0,62}`, не начинающийся с `pg_`, иначе приложение не стартует); сверка с DBML идёт в эталонной схеме `dkb`;
имена таблиц, колонок, enum'ов и их значений SHALL совпадать с DBML буквально; nullability, PK, UNIQUE, FK, значения
@ -31,9 +31,12 @@ SHALL добавлять обе колонки (существующие зап
(год, затем квартал); файлы без привязок остаются `NULL`; откат SHALL удалять индекс и колонку. Поля, отсутствующие в
DBML, MUST NOT существовать.
Схема v5.19 (RSHB/arm-ka-docs#116) SHALL добавлять: таблицу `document_issue` и enum'ы `statement_line_status`, `statement_form_variant`, `document_issue_kind` и прочие enum'ы DBML v5.19; колонки `document.file_ordinal` (уникальна в паре `(stored_file_id, file_ordinal)`), `recognition_job.statements_version`, `related_group.crm_id` (`varchar`, nullable, уникальна), в `financial_statement_meta` — `form_variant`, `needs_review_fields`, `header_source`, `organization_name_original_value`, `organization_name_modified`; в `financial_statement_column` — `period_kind`, `label_confidence`; в `financial_statement_line` — `row_index`, `source_ref`, `line_status`, `line_code_text`, `parent_row_index`, `needs_review`, `is_confirmed`; в `financial_statement_value` — `source_text`, `needs_review`, `is_confirmed` (с `confirmed_by/at`). Уникальность `(meta_id, line_code)` снимается, добавляется частичная уникальность `(meta_id, source_ref)` WHERE `source_ref IS NOT NULL`; уникальность документа `(stored_file_id, document_type, osv_account_code)` становится частичной (только для типов, кроме `BALANCE` / `INCOME`). Состав и порядок колонок — буквально по DBML v5.19.
Схема v5.20 SHALL добавлять в конец `document` колонки `is_modified` (`boolean NOT NULL DEFAULT false`), `original_values`
(`jsonb`, nullable), `modified_by` (`uuid`, nullable, FK → `app_user.id`) и `modified_at` (`timestamptz`, nullable); схема v5.21 SHALL
добавлять значение `FILE_REPROCESS` в enum `audit_action`. Состав и порядок колонок — буквально по DBML v5.21.
#### Scenario: Сверка ORM с DBML
- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.19.dbml` (`make dbml-check`)
- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.21.dbml` (`make dbml-check`)
- **THEN** вывод пуст и код возврата 0
#### Scenario: Лишняя колонка в ORM
@ -64,7 +67,7 @@ DBML, MUST NOT существовать.
`stored_file`; `alembic downgrade 0005_dbml_v5_17` удаляет индекс и колонку, повторный `upgrade` их возвращает
#### Scenario: Сверка с DBML v5.19
- **WHEN** выполняется `make dbml-check` по `../docs/model/data-model-v5.19.dbml`
- **WHEN** выполняется `make dbml-check` по `../docs/model/data-model-v5.21.dbml`
- **THEN** таблицы, колонки, enum'ы, ключи и индексы ORM совпадают с DBML без расхождений
### Requirement: Миграции применяются с нуля и откатываются
@ -99,11 +102,11 @@ enum'ы, таблицы, ограничения, индексы и послед
- **THEN** сгенерированная ревизия не содержит операций (пустые `upgrade`/`downgrade`)
### Requirement: Версия DBML зафиксирована в коде
Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.19"`); скрипт сверки SHALL сравнивать её с версией в шапке файла
Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.21"`); скрипт сверки SHALL сравнивать её с версией в шапке файла
DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна.
#### Scenario: Новая версия DBML без обновления ORM
- **WHEN** в `docs/model/` появляется `data-model-v5.20.dbml`, а константа в ORM — `5.19`
- **WHEN** в `docs/model/` появляется `data-model-v5.22.dbml`, а константа в ORM — `5.21`
- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц
### Requirement: Деньги и проценты — точные типы
@ -161,3 +164,35 @@ organization_name`, `document.file_ordinal` — по `(created_at, id)` внут
#### Scenario: Откат после подтверждений
- **WHEN** в БД нет несовместимых данных, но у ячеек есть `is_confirmed = true` и записи `document_issue`
- **THEN** `downgrade` проходит, удаляет колонки и таблицу и пишет в журнал число удалённых подтверждений и записей
### Requirement: Миграция DBML v5.20
Одна добавочная миграция после 0007_dbml_v5_19 SHALL добавить четыре колонки document по DBML v5.20; существующие записи получают is_modified = false и остальные поля NULL. Миграция MUST NOT менять документы, их строки, подтверждения и привязки. Downgrade SHALL до любых изменений проверить отсутствие document.is_modified = true и при наличии остановиться с ошибкой и числом записей.
#### Scenario: Обновление существующих ОСВ
- **WHEN** миграция применяется к базе с подтверждёнными ОСВ РБ
- **THEN** добавлены четыре поля, данные ОСВ, id, file_ordinal, подтверждения и привязки сохранены; сверка с DBML v5.20 проходит
#### Scenario: Защита от потери исправлений
- **WHEN** хотя бы один документ имеет is_modified = true и запрошен downgrade до v5.19
- **THEN** откат прекращается до DDL с причиной и числом записей, схема и данные не меняются
#### Scenario: Пустая база и нестандартная схема
- **WHEN** выполняется upgrade head, downgrade base, upgrade head на пустой базе с настроенным DB_SCHEMA
- **THEN** цикл успешен, ORM соответствует DBML v5.20, autogenerate пуст
### Requirement: Миграция DBML v5.21 и действие аудита повторной обработки
Отдельная миграция v5.21 после миграции v5.20 SHALL добавить только FILE_REPROCESS в enum audit_action
по DBML v5.21. Миграция SHALL сохранять все существующие значения и события. Downgrade MUST NOT удалять либо
переписывать audit_event; при наличии FILE_REPROCESS он SHALL завершаться ошибкой до изменения схемы.
#### Scenario: Схема после дополнения модели
- **WHEN** миграция v5.21 применена к БД v5.20
- **THEN** событие нового типа записывается, прежние события читаются без изменений, сверка DBML успешна
#### Scenario: Откат после ручного повтора
- **WHEN** существует audit_event с FILE_REPROCESS и выполняется downgrade с v5.21 к v5.20
- **THEN** ошибка с причиной и количеством несовместимых событий до DDL, события и схема сохранены
#### Scenario: Независимый откат enum
- **WHEN** в БД v5.21 нет FILE_REPROCESS и выполняется downgrade к v5.20
- **THEN** удалено только новое значение enum; четыре поля document, исходные значения и привязки сохранены

View File

@ -183,3 +183,20 @@ SHALL быть отключена с подсказкой «Разберите
#### Scenario: Предупреждение листа ОСВ
- **WHEN** у ОСВ-документа в `issues` есть запись с кодом `OSV_*`
- **THEN** над таблицей показан блок предупреждений с текстом записи и уровнем
### Requirement: Переключатель документа
В шапке экрана документа SHALL быть выпадающий список «Документ» с документами файлов того же ЮЛ в той же консолидации,
что и файл открытого документа (файл вне консолидации — все файлы ЮЛ): у пункта имя файла, вторая строка (тип · формат ·
дата) и статус; пункты, которые нельзя открыть, неактивны. Выбор пункта SHALL открывать `/documents/:document_id`
выбранного документа заменой текущей записи истории браузера, без новой записи: кнопка «Назад» экрана и браузера
возвращает на экран, с которого открыт первый документ, а не к предыдущему выбранному.
#### Scenario: Назад после перебора документов
- **WHEN** аналитик открыл документ из панели «Организации» консолидации, выбрал в списке «Документ» подряд ещё два
документа и нажал «Назад»
- **THEN** открыт экран консолидации, с которого был открыт первый документ
#### Scenario: Документ открыт прямой ссылкой
- **WHEN** документ открыт прямой ссылкой в новой вкладке, аналитик выбрал в списке «Документ» другой документ и нажал
«Назад» экрана
- **THEN** открыт список групп компаний `/groups`

View File

@ -568,3 +568,112 @@ locator?}` (`fileWarning` контракта): у `2.0` — как пришли,
#### Scenario: Нарушение с пустой целью в ответе
- **WHEN** у формы запись `relation` по `1700 = 1300 + 1400 + 1500` с `expected = NULL`
- **THEN** элемент `checks.failed` имеет `expected = null`, `actual` — сумма членов
### Requirement: Уточнение шапки ОСВ
`PATCH /api/v1/documents/{id}/osv-header` SHALL требовать `DOCUMENT_EDIT` и видимость ЮЛ, принимать частичный набор
`period_year`, `period_quarter`, `is_cumulative`, `osv_account_code` и возвращать карточку документа с
`is_modified`, `original_values`, `modified_by`, `modified_at`. Отсутствующие поля SHALL сохраняться.
Правила — §9.5 п.12. Чужой и несуществующий документ SHALL возвращать одинаковый 404.
#### Scenario: Изменение только счёта
- **WHEN** аналитик меняет счёт ОСВ РБ с 60 на 62, не передавая период
- **THEN** меняется только счёт; даже неопределённый квартал остаётся прежним
#### Scenario: Нет доступа
- **WHEN** пользователь без DOCUMENT_EDIT правит шапку либо документ находится вне видимости ЮЛ
- **THEN** без права возвращается 403, при наличии права и отсутствии видимости — 404; данные не меняются
### Requirement: Проверки правки шапки ОСВ
После проверки типов тела запроса Pydantic PATCH SHALL проверять последовательно: тип OSV (иначе 409 DOCUMENT_NOT_OSV), файл не SUPERSEDED (иначе 409
FILE_SUPERSEDED), корректность периода (422 INVALID_PERIOD), счёта (422 INVALID_ACCOUNT_CODE), отсутствие
другой ОСВ с тем же нормализованным счётом в файле (409 DUPLICATE_ACCOUNT с detail.document_id),
редактируемость привязанных версий (409 VERSION_NOT_EDITABLE с detail.version_ids). Все проверки SHALL
предшествовать записи.
#### Scenario: Частичная правка периода
- **WHEN** передано хотя бы одно поле периода
- **THEN** итоговые год, квартал и is_cumulative обязательны: год 1900–2099, квартал Q1–Q4, признак boolean;
для Q1 признак true; иначе 422 INVALID_PERIOD без изменений
#### Scenario: Нормализация и коллизия
- **WHEN** передано 60.1, а у другой ОСВ этого файла уже счёт 60.01
- **THEN** нормализация даёт 60.01, возвращается 409 DUPLICATE_ACCOUNT с id другого документа
#### Scenario: Пустой счёт
- **WHEN** передан пустой счёт
- **THEN** 422 INVALID_ACCOUNT_CODE, прежний счёт сохранён
#### Scenario: Порядок конфликтов
- **WHEN** у заменённого файла документ BALANCE и некорректный период в теле
- **THEN** возвращается DOCUMENT_NOT_OSV; у OSV того же файла — FILE_SUPERSEDED
### Requirement: Исходная шапка и признак исправления
При первой фактической правке ядро SHALL сохранить прежние значения всех четырёх полей в document.original_values;
при последующих правках снимок MUST NOT меняться. Новые значения SHALL записываться в ту же строку document,
is_modified SHALL отражать отличие от снимка, modified_by/at — последнюю правку. document.id, file_ordinal,
строки osv_entry, файл и JSON результата в S3 MUST NOT изменяться.
#### Scenario: Две правки
- **WHEN** счёт ОСВ РБ меняется 60 → 62 → 76
- **THEN** текущий счёт 76, original_values.osv_account_code = 60, в original_values также исходный период;
is_modified = true, id и количество документов и строк ОСВ прежние
#### Scenario: Возврат к исходному
- **WHEN** все четыре поля возвращены к значениям original_values
- **THEN** is_modified = false, original_values остаётся заполненным, последняя правка атрибутирована
#### Scenario: Запрос без изменений
- **WHEN** нормализованная итоговая шапка совпадает с текущей
- **THEN** 200 с карточкой без аудита, создания original_values, сброса подтверждения и изменения modified_by/at
### Requirement: Снятие подтверждения ОСВ после уточнения
Фактическая правка CONFIRMED ОСВ SHALL установить NEEDS_CHECK, очистить confirmed_by/at и снять привязки
consolidation_document из всех версий «в работе», сохранив привязки CANCELLED и статусы версий.
Привязка к иной версии SHALL запретить правку. Неподтверждённый документ SHALL сохранить статус.
consolidation_lock MUST NOT требоваться; автоподтверждение после PATCH MUST NOT запускаться.
#### Scenario: Исправление автоподтверждённой ОСВ
- **WHEN** ОСВ РБ привязана к версии DATA_CORRECTION, аналитик исправляет период
- **THEN** NEEDS_CHECK, confirmed_by/at = null, привязка снята, версия остаётся DATA_CORRECTION;
повторное ручное подтверждение привязывает документ по новому периоду и счёту
#### Scenario: Завершённая версия
- **WHEN** документ привязан к VERIFIED, даже если одновременно привязан к версии «в работе»
- **THEN** 409 VERSION_NOT_EDITABLE со списком запрещающих version_ids, ни одна привязка и шапка не меняются
#### Scenario: Отменённая версия
- **WHEN** подтверждённый документ связан только с CANCELLED и версиями «в работе»
- **THEN** правка разрешена без consolidation_lock, привязки CANCELLED остаются, остальные снимаются
### Requirement: Производные данные после уточнения ОСВ
Подходящесть версиям, чек-лист и колонка «ОСВ» SHALL отражать новую шапку при следующем чтении;
period_check, counterparty_check и строки osv_entry SHALL сохраняться. Правка MUST NOT запускать автозамену
другого файла; замещение привязки с тем же ключом происходит при повторном подтверждении по §3 п.1.
Перезагрузка файла MUST NOT переносить уточнения старых документов. Флаг CONSOLIDATION_PERIOD_FROM_VERSION сохраняется.
#### Scenario: Смена счёта шапки
- **WHEN** счёт ОСВ РБ исправлен с 60 на 62
- **THEN** колонка «ОСВ» и подходящие слоты чек-листа учитывают 62; account_code/account_id/account_root
строк ОСВ, period_check и counterparty_check не изменились, другой файл ОСВ 62 не заменён
#### Scenario: Перезагрузка
- **WHEN** новый загруженный файл распознан с исходным счётом 60
- **THEN** новые документы созданы из нового результата, исправление 62 предыдущего документа не переносится
### Requirement: Строгие типы шапки ОСВ
PATCH SHALL принимать period_year как integer, period_quarter и osv_account_code как string,
is_cumulative как boolean, без приведения типов. Отсутствующие поля не меняются; null SHALL передаваться
в доменную проверку явно. Неверные типы SHALL отклоняться стандартным 422 валидации тела до доменных
проверок. Границы года, допустимые кварталы и совместимость полей SHALL проверяться доменом.
#### Scenario: Неверный тип года
- **WHEN** передан period_year как строка, дробное число, boolean, массив или объект
- **THEN** стандартный 422 с detail-массивом ошибок валидации, доменная обработка не запускается
#### Scenario: Год вне диапазона
- **WHEN** у доступного незаменённого документа OSV передан period_year = 2100
- **THEN** тип integer принят, домен возвращает 422 INVALID_PERIOD
#### Scenario: Неизвестный квартал
- **WHEN** у доступного незаменённого документа OSV передан period_quarter = "Q0"
- **THEN** тип string принят, домен возвращает 422 INVALID_PERIOD

View File

@ -60,7 +60,7 @@ Query-параметр `consolidation_version_id` MUST NOT приниматьс
- **THEN** `rejected[0].code = DUPLICATE_FILE`, `rejected[0].file_id` — прежний файл; новых записей нет
### Requirement: Жизненный цикл статуса файла
`stored_file.status` SHALL меняться только по заданию `current_recognition_job_id`: `UPLOADED` при загрузке;
`stored_file.status` SHALL меняться только по заданию `current_recognition_job_id`: `UPLOADED` при загрузке и при принятом ручном повторе с новым текущим заданием;
`PROCESSING` при ack сервиса; `ERROR` при финальной ошибке задания (`error`, `timeout`, `dlq`) и при невозможности
маппинга; `PROCESSED` после успешного маппинга; `SUPERSEDED` при автозамене. Карточка файла SHALL показывать причину
`ERROR` (`error_code`/`error_message` задания или ошибку маппинга).
@ -238,3 +238,63 @@ advisory-блокировка привязок ГК → версии по `id`.
#### Scenario: Очистка и повторная загрузка
- **WHEN** очистка выполнена, ядро v5.19 выложено, аналитик загружает тот же PDF с ББ и ОФР
- **THEN** дедуп не мешает (файла больше нет), формы создаются по мапперу 1.x или 2.0 — смотря что шлёт сервис
### Requirement: Ручной повтор обработки файла
`POST /api/v1/files/{file_id}/retry` SHALL требовать DOCUMENT_EDIT и видимость ЮЛ. Повтор разрешён для
текущего задания tabular в timeout/TIMEOUT или dlq/NO_ACK при attempt < max_attempts, отсутствии документов
и файле ERROR. Успех SHALL вернуть 202 с карточкой файла и новым текущим заданием. Несуществующий или чужой файл — 404.
#### Scenario: После таймаута
- **WHEN** табличный файл ОСВ РБ завершился timeout/TIMEOUT, attempt = 1, max_attempts = 3
- **THEN** 202, создано новое задание attempt = 2, прежнее осталось, в карточке новое текущее задание
#### Scenario: Неподходящее состояние
- **WHEN** повтор запрошен для OCR, done, needs_check, error сервиса, ошибки маппинга либо файла с документами
- **THEN** 409 FILE_NOT_RETRYABLE без нового задания и аудита
#### Scenario: Лимит исчерпан
- **WHEN** подходящее задание имеет attempt = max_attempts
- **THEN** 409 MAX_ATTEMPTS_REACHED, состояние не меняется
### Requirement: Пакет повторной обработки версии
`POST /api/v1/consolidations/versions/{version_id}/files/retry` SHALL выбирать видимые ERROR-файлы
консолидации по периметру версии. Другие статусы не входят в пакет и не блокируются.
Ответ — 202 с accepted[{file_id, job_id, attempt}] и skipped[{file_id, code}]. В skipped SHALL входить
только ERROR-файлы, которые нельзя повторить (FILE_NOT_RETRYABLE, MAX_ATTEMPTS_REACHED).
Файлы без контекста консолидации MUST NOT включаться по совпадению ЮЛ.
#### Scenario: Смешанный пакет
- **WHEN** у версии есть файл timeout/TIMEOUT, OCR, успешный файл и файл с исчерпанным лимитом
- **THEN** новый job создаётся только для первого; OCR-файл в skipped с `FILE_NOT_RETRYABLE`, файл с исчерпанным
лимитом — с `MAX_ATTEMPTS_REACHED`; успешный (`PROCESSED`) файл не попадает ни в accepted, ни в skipped
#### Scenario: Периметр и контекст
- **WHEN** файл загружен в другую консолидацию либо его ЮЛ не входит в выбранную версию
- **THEN** файл не участвует ни в accepted, ни в skipped этой версии
#### Scenario: Общие файлы разных версий
- **WHEN** две версии одной консолидации содержат одно ЮЛ и вызывают пакетный повтор
- **THEN** они используют общий набор файлов консолидации; уже повторяемый файл второй раз не запускается
#### Scenario: Пустой пакет
- **WHEN** подходящих файлов нет
- **THEN** 202 с пустым accepted; в skipped — только файлы в `ERROR`, которые повторить нельзя (без них список пуст);
аудита повторов нет
### Requirement: Ограничения версии при повторе
Повтор в контексте версии SHALL разрешаться только для версий «в работе» (INITIATED, DOCUMENTS_PROCESSING,
DATA_CORRECTION). Иначе SHALL возвращаться 409 VERSION_NOT_EDITABLE. consolidation_lock MUST NOT требоваться.
Одиночный повтор файла с consolidation_id SHALL требовать наличия версии «в работе» с ЮЛ файла в периметре;
без consolidation_id SHALL применяться только policy ЮЛ. Повтор MUST NOT изменять статусы версий.
#### Scenario: Проверенная версия
- **WHEN** пакет запрошен для VERIFIED
- **THEN** 409 VERSION_NOT_EDITABLE, ни один файл не изменён
#### Scenario: Консолидация уже закрыта для обработки
- **WHEN** одиночный повтор файла консолидации запрошен без версии «в работе» с его ЮЛ
- **THEN** 409 VERSION_NOT_EDITABLE; другая версия консолидации не открывает недоступный пакет версии
#### Scenario: Сохранение статуса
- **WHEN** пакет допустимой версии INITIATED принят без пользовательской блокировки
- **THEN** попытки созданы, INITIATED сохраняется; дальнейшие переходы происходят по существующим событиям

View File

@ -82,8 +82,7 @@
### Requirement: Таймаут ожидания результата
Фоновый вахтёр SHALL с периодом 30 с переводить задания `processing` и `published` с `reply_deadline < now()` в
`status = timeout`, `error_code = TIMEOUT`, `finished_at = now()`, а их файлы (по `current_recognition_job_id`) — в
`stored_file.status = ERROR`. Публикатор при переводе задания в `dlq` SHALL так же переводить файл в `ERROR`. Replay
задания (новая попытка, `attempt + 1`) — вне этого среза.
`stored_file.status = ERROR`. Публикатор при переводе задания в `dlq` SHALL так же переводить файл в `ERROR`. Ручной повтор после TIMEOUT/NO_ACK SHALL создавать новое задание по требованию «Новая попытка обработки файла».
#### Scenario: Сервис взял, но не ответил
- **WHEN** задание в `processing` и `reply_deadline` прошёл без результата
@ -162,3 +161,38 @@ Inbox SHALL принимать конверты (включая ack `processing`
#### Scenario: Результат 2.0 разбирает маппер 2.0
- **WHEN** inbox принял файл результата со `schema_version = "2.0"`
- **THEN** документы создаёт маппер результата `2.0`; результат `1.x` по-прежнему разбирает маппер `1.x`
### Requirement: Новая попытка обработки файла
Ручной повтор SHALL создать recognition_job с новым id, attempt = прежний attempt + 1, тем же file_id,
pipeline и max_attempts, статусом queued. Предыдущая запись SHALL сохраняться без изменений.
Файл SHALL ссылаться на новое задание и получить UPLOADED. Ссылка S3 на исходник и consolidation_id сохраняются;
новых документов, файлов и копий исходника SHALL NOT создаваться.
#### Scenario: Повтор после NO_ACK
- **WHEN** tabular-задание ОСВ РБ завершилось dlq/NO_ACK с attempt = 1
- **THEN** новое задание queued имеет attempt = 2, файл UPLOADED с прежним id и объектом S3,
current_recognition_job_id указывает на новый id
### Requirement: Изоляция новой попытки
Новое задание SHALL начинаться без ack, результата, ошибок, метрик и времён старого задания, со свежим
requested_at. Публикатор SHALL вычислить новый reply_deadline и Nats-Msg-Id для нового job_id/attempt.
Запоздавшие ack/результаты прежней финальной попытки MUST NOT менять файл, создавать документы или
мешать обработке нового задания.
#### Scenario: Старый ответ
- **WHEN** после создания попытки 2 пришли ack и done для попытки 1 в timeout
- **THEN** текущее задание, статус файла и документы не меняются; ответ логируется и игнорируется
#### Scenario: Публикация после недоступности NATS
- **WHEN** ручной повтор принят, а NATS временно недоступен
- **THEN** задание остаётся queued; после восстановления публикуется со свежим сроком и новым Nats-Msg-Id
### Requirement: Конкурентные запросы повтора
Проверка текущего задания и лимита, создание новой попытки и переключение файла SHALL быть атомарны.
Два запроса к одной неуспешной попытке MUST NOT создавать два новых задания. При rollback новое задание,
переключение файла и аудит SHALL откатываться вместе.
#### Scenario: Двойное нажатие
- **WHEN** два параллельных запроса повторяют attempt = 1
- **THEN** создан ровно один job attempt = 2; второй одиночный запрос получает 409 FILE_NOT_RETRYABLE,
а пакетный отмечает файл skipped; attempt = 3 не создаётся

View File

@ -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 имя файла одно — различаются второй строкой (тип · формат · дата) */}