Compare commits

...

3 Commits

Author SHA1 Message Date
Andrey Epifancev
98f00c0faa docs: change retry-recognition-and-edit-osv-header, README и CLAUDE.md — DBML v5.21 (ревью #161)
Дельта data-model переименована на v5.21, пакет — только файлы в ERROR,
tasks — ссылки на docs PR #157 и задачи ревью; config.yaml — DBML v5.21.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:13:41 +04:00
Andrey Epifancev
b32383eff7 test: синтетические тесты уточнения шапки ОСВ и пакетного повтора (ревью #161, AURORA-1442)
PATCH osv-header без fixtures: порядок ошибок, no-op, original_values, сброс
подтверждения и отвязка, аудит, DUPLICATE_ACCOUNT, неизменность osv_entry.
Пакет: смешанный (OCR и исчерпанный лимит в skipped), NO_ACK на пределе.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:13:41 +04:00
Andrey Epifancev
5c70fb5387 test: .env.test — порт PostgreSQL 5432 по compose (ревью #161, AURORA-1442)
Свой порт задаётся переменной OPENBAO__DATABASE__DATABASE_URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:13:41 +04:00
11 changed files with 374 additions and 26 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.21.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` + `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-таблиц.
- Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR.

View File

@ -1115,7 +1115,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.21.dbml` |
| `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate |
| `make check` | быстрая проверка без Docker: `lint` + `test` |
| `make docker-build` | сборка образа: `docker build -t arm-core .` |
@ -1167,7 +1167,7 @@ queue group `arm-core`, подписчики группы получают со
### Проверка ORM и DBML
Модель данных — `../docs/model/data-model-v5.19.dbml`. ORM обязана совпадать с ней один в один:
Модель данных — `../docs/model/data-model-v5.21.dbml`. ORM обязана совпадать с ней один в один:
```bash
make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно
@ -1190,7 +1190,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-данные: филиалы и пользователи по ролям
@ -1274,7 +1274,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.21.dbml`](../docs/model/data-model-v5.21.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

@ -1,5 +1,5 @@
# Окружение тестов (pytest.ini: ENV_FILE=.env.test). Только тестовые значения, секретов здесь нет.
# Порты и учётные данные — стандартные для docs/dev/docker-compose.yml (PostgreSQL 5431, NATS 4222, MinIO 9000):
# Порты и учётные данные — стандартные для docs/dev/docker-compose.yml (PostgreSQL 5432, NATS 4222, MinIO 9000):
# после `make up` тесты работают без правок. Рабочую БД тесты не трогают (создают armcore_pytest_<hex>).
DEBUG=true
@ -11,7 +11,7 @@ DB_SCHEMA=dkb
SERVER__HOST=127.0.0.1
SERVER__PORT=8000
OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:5431/armka
OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:5432/armka
OPENBAO__MINIO__MINIO_URL=localhost:9000
OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin

View File

@ -288,3 +288,58 @@ async def test_batch_rechecks_version_after_waiting_for_job(
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]

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

@ -26,7 +26,6 @@ def clean_env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch:
def test_env_test_is_read(clean_env: pytest.MonkeyPatch) -> None:
""".env.test читается: стандартные порты docker-compose из docs/dev."""
s = Settings(_env_file=API_DIR / ".env.test") # type: ignore[call-arg]
# А если у меня стоят другие порты в .env.test? Нам правда нужен этот тест?
assert s.OPENBAO.DATABASE.DATABASE_URL.endswith("localhost:5432/armka")
assert s.OPENBAO.NATS.NATS_URL == "nats://localhost:4222"
assert s.AUTH_DEV_ISSUER is True

View File

@ -2,7 +2,7 @@
## Why
AURORA-1442 (`some.xml`): после TIMEOUT/NO_ACK аналитик не может повторить обработку уже загруженного файла;
AURORA-1442: после TIMEOUT/NO_ACK аналитик не может повторить обработку уже загруженного файла;
ошибочный период или счёт ОСВ нельзя уточнить через API. DBML v5.20 и решение OQ-161 от 21.10.2026 уже задают
хранение исходных значений рядом с исправленными и правила снятия подтверждения.

View File

@ -1,6 +1,10 @@
## RENAMED Requirements
- FROM: `### Requirement: Схема БД равна DBML v5.19`
- TO: `### Requirement: Схема БД равна DBML v5.21`
## MODIFIED Requirements
### Requirement: Схема БД равна DBML v5.19
### 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`;
@ -25,6 +29,9 @@ 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.21.dbml` (`make dbml-check`)
@ -61,14 +68,12 @@ DBML, MUST NOT существовать.
- **WHEN** выполняется `make dbml-check` по `../docs/model/data-model-v5.21.dbml`
- **THEN** таблицы, колонки, enum'ы, ключи и индексы ORM совпадают с DBML без расхождений
Схема v5.20 SHALL дополнительно содержать document.is_modified (boolean NOT NULL DEFAULT false), original_values (jsonb NULL), modified_by (uuid NULL FK app_user.id), modified_at (timestamptz NULL); порядок колонок — по DBML v5.20.
### Requirement: Версия DBML зафиксирована в коде
Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.21"`); скрипт сверки SHALL сравнивать её с версией в шапке файла
DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна.
#### Scenario: Новая версия DBML без обновления ORM
- **WHEN** в `docs/model/` появляется `data-model-v5.21.dbml`, а константа в ORM — `5.19`
- **WHEN** в `docs/model/` появляется `data-model-v5.22.dbml`, а константа в ORM — `5.21`
- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц
## ADDED Requirements

View File

@ -18,15 +18,16 @@
- **THEN** 409 MAX_ATTEMPTS_REACHED, состояние не меняется
### Requirement: Пакет повторной обработки версии
`POST /api/v1/consolidations/versions/{version_id}/files/retry` SHALL выбирать видимые файлы с
consolidation_id консолидации версии и ЮЛ из периметра версии. Для каждого допустимого файла SHALL
создаваться новая попытка. Успех — 202 с accepted[{file_id, job_id, attempt}] и skipped[{file_id, code}].
Файлы без контекста консолидации MUST NOT включаться по одному лишь совпадению ЮЛ.
`POST /api/v1/consolidations/versions/{version_id}/files/retry` SHALL рассматривать видимые файлы в статусе `ERROR` с
consolidation_id консолидации версии и ЮЛ из периметра версии; файлы других статусов в пакет не входят и не блокируются.
Для каждого допустимого файла SHALL создаваться новая попытка. Успех — 202 с accepted[{file_id, job_id, attempt}] и
skipped[{file_id, code}], где skipped — рассмотренные файлы, которые повторить нельзя (`FILE_NOT_RETRYABLE`,
`MAX_ATTEMPTS_REACHED`). Файлы без контекста консолидации MUST NOT включаться по одному лишь совпадению ЮЛ.
#### Scenario: Смешанный пакет
- **WHEN** у версии есть файл timeout/TIMEOUT, OCR, успешный файл и файл с исчерпанным лимитом
- **THEN** новый job создаётся только для первого; остальные перечислены в skipped с причинами;
rejected-файлы не препятствуют повтору допустимых
- **THEN** новый job создаётся только для первого; OCR-файл в skipped с `FILE_NOT_RETRYABLE`, файл с исчерпанным
лимитом — с `MAX_ATTEMPTS_REACHED`; успешный (`PROCESSED`) файл не попадает ни в accepted, ни в skipped
#### Scenario: Периметр и контекст
- **WHEN** файл загружен в другую консолидацию либо его ЮЛ не входит в выбранную версию
@ -38,7 +39,8 @@ consolidation_id консолидации версии и ЮЛ из периме
#### Scenario: Пустой пакет
- **WHEN** подходящих файлов нет
- **THEN** 202 с пустым accepted и списком skipped для рассмотренных неподходящих файлов; аудита повторов нет
- **THEN** 202 с пустым accepted; в skipped — только файлы в `ERROR`, которые повторить нельзя (без них список пуст);
аудита повторов нет
### Requirement: Ограничения версии при повторе
Повтор в контексте версии SHALL разрешаться только для версий «в работе» (INITIATED, DOCUMENTS_PROCESSING,

View File

@ -23,7 +23,7 @@
- [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: README и dev/frontend-api-guide.md — одиночный retry, разделение ручной попытки и republish; проверить примеры запросов, ответы 202/409 и описание FILE_REPROCESS по OpenAPI.
- [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. Пакет версии
@ -31,7 +31,7 @@
- [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: README и dev/frontend-api-guide.md — состав пакета, частичные пропуски и правила версий; проверить примеры смешанного и пустого ответа по интеграционным тестам.
- [x] 4.5 arm-core и arm-ka-docs (docs — ревьюер, RSHB/arm-ka-docs#157): README и dev/frontend-api-guide.md — состав пакета, частичные пропуски и правила версий; проверить примеры смешанного и пустого ответа по интеграционным тестам.
## 5. Уточнение шапки ОСВ
@ -41,12 +41,12 @@
- [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: README и dev/frontend-api-guide.md — PATCH, original_values, no-op, снятие подтверждения и флаг периода; проверить примеры по сценариям тестов и OpenAPI, UI оставить отдельной задачей.
- [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, context/03-architecture.md, context/07-open-questions.md и dev/frontend-api-guide.md с реализацией, закрытым OQ-161 и согласованными решениями FILE_REPROCESS/пакета; проверить ссылки, порядок выкладки и согласованность core/docs PR. Это финальная задача документации среза, основные API-документы обновлены в своих группах.
- [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
@ -73,3 +73,10 @@
ошибок действует после проверки типов. Схема/домен/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 на пределе.
- [ ] 7.4 Автор: замечания комментария ревью (пакет — только файлы в `ERROR`, блокировки версий, `CONTRACT_VERSION`, репозиторий, OpenAPI).

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)