Compare commits
No commits in common. "98f00c0faac3698535d0febd7859c93937e76c2e" and "4f44d34d59f24bf08c151c842defd5f4bd39122b" have entirely different histories.
98f00c0faa
...
4f44d34d59
@ -175,7 +175,7 @@
|
||||
- NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`.
|
||||
|
||||
## Правила репозитория
|
||||
- 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`) — обязательная проверка.
|
||||
- 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`) — обязательная проверка.
|
||||
- Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring.
|
||||
- Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц.
|
||||
- Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR.
|
||||
|
||||
@ -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.21.dbml` |
|
||||
| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.19.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.21.dbml`. ORM обязана совпадать с ней один в один:
|
||||
Модель данных — `../docs/model/data-model-v5.19.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, 0008_dbml_v5_20.py — v5.20, 0009_dbml_v5_21.py — v5.21)
|
||||
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)
|
||||
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.21.dbml`](../docs/model/data-model-v5.21.dbml) — модель данных;
|
||||
[`../docs/model/data-model-v5.19.dbml`](../docs/model/data-model-v5.19.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) — план срезов;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
# Окружение тестов (pytest.ini: ENV_FILE=.env.test). Только тестовые значения, секретов здесь нет.
|
||||
# Порты и учётные данные — стандартные для docs/dev/docker-compose.yml (PostgreSQL 5432, NATS 4222, MinIO 9000):
|
||||
# Порты и учётные данные — стандартные для docs/dev/docker-compose.yml (PostgreSQL 5431, 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:5432/armka
|
||||
OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:5431/armka
|
||||
|
||||
OPENBAO__MINIO__MINIO_URL=localhost:9000
|
||||
OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin
|
||||
|
||||
@ -288,58 +288,3 @@ 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]
|
||||
|
||||
@ -1,280 +0,0 @@
|
||||
"""Правка шапки ОСВ на синтетических документах из фабрик (без 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
|
||||
@ -26,6 +26,7 @@ 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
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## Why
|
||||
|
||||
AURORA-1442: после TIMEOUT/NO_ACK аналитик не может повторить обработку уже загруженного файла;
|
||||
AURORA-1442 (`some.xml`): после TIMEOUT/NO_ACK аналитик не может повторить обработку уже загруженного файла;
|
||||
ошибочный период или счёт ОСВ нельзя уточнить через API. DBML v5.20 и решение OQ-161 от 21.10.2026 уже задают
|
||||
хранение исходных значений рядом с исправленными и правила снятия подтверждения.
|
||||
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
## RENAMED Requirements
|
||||
- FROM: `### Requirement: Схема БД равна DBML v5.19`
|
||||
- TO: `### Requirement: Схема БД равна DBML v5.21`
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Схема БД равна DBML v5.21
|
||||
### Requirement: Схема БД равна DBML v5.19
|
||||
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`;
|
||||
@ -29,9 +25,6 @@ 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`)
|
||||
@ -68,12 +61,14 @@ 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.22.dbml`, а константа в ORM — `5.21`
|
||||
- **WHEN** в `docs/model/` появляется `data-model-v5.21.dbml`, а константа в ORM — `5.19`
|
||||
- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
@ -18,16 +18,15 @@
|
||||
- **THEN** 409 MAX_ATTEMPTS_REACHED, состояние не меняется
|
||||
|
||||
### Requirement: Пакет повторной обработки версии
|
||||
`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 включаться по одному лишь совпадению ЮЛ.
|
||||
`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 включаться по одному лишь совпадению ЮЛ.
|
||||
|
||||
#### Scenario: Смешанный пакет
|
||||
- **WHEN** у версии есть файл timeout/TIMEOUT, OCR, успешный файл и файл с исчерпанным лимитом
|
||||
- **THEN** новый job создаётся только для первого; OCR-файл в skipped с `FILE_NOT_RETRYABLE`, файл с исчерпанным
|
||||
лимитом — с `MAX_ATTEMPTS_REACHED`; успешный (`PROCESSED`) файл не попадает ни в accepted, ни в skipped
|
||||
- **THEN** новый job создаётся только для первого; остальные перечислены в skipped с причинами;
|
||||
rejected-файлы не препятствуют повтору допустимых
|
||||
|
||||
#### Scenario: Периметр и контекст
|
||||
- **WHEN** файл загружен в другую консолидацию либо его ЮЛ не входит в выбранную версию
|
||||
@ -39,8 +38,7 @@ skipped[{file_id, code}], где skipped — рассмотренные файл
|
||||
|
||||
#### Scenario: Пустой пакет
|
||||
- **WHEN** подходящих файлов нет
|
||||
- **THEN** 202 с пустым accepted; в skipped — только файлы в `ERROR`, которые повторить нельзя (без них список пуст);
|
||||
аудита повторов нет
|
||||
- **THEN** 202 с пустым accepted и списком skipped для рассмотренных неподходящих файлов; аудита повторов нет
|
||||
|
||||
### Requirement: Ограничения версии при повторе
|
||||
Повтор в контексте версии SHALL разрешаться только для версий «в работе» (INITIATED, DOCUMENTS_PROCESSING,
|
||||
|
||||
@ -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 (docs — ревьюер, RSHB/arm-ka-docs#157): README и dev/frontend-api-guide.md — одиночный retry, разделение ручной попытки и republish; проверить примеры запросов, ответы 202/409 и описание FILE_REPROCESS по OpenAPI.
|
||||
- [x] 3.5 arm-core и arm-ka-docs: 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 (docs — ревьюер, RSHB/arm-ka-docs#157): README и dev/frontend-api-guide.md — состав пакета, частичные пропуски и правила версий; проверить примеры смешанного и пустого ответа по интеграционным тестам.
|
||||
- [x] 4.5 arm-core и arm-ka-docs: 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 (docs — ревьюер, RSHB/arm-ka-docs#157): README и dev/frontend-api-guide.md — PATCH, original_values, no-op, снятие подтверждения и флаг периода; проверить примеры по сценариям тестов и OpenAPI, UI оставить отдельной задачей.
|
||||
- [x] 5.7 arm-core и arm-ka-docs: 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-документы обновлены в своих группах.
|
||||
- [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-документы обновлены в своих группах.
|
||||
|
||||
## Workflow follow-up
|
||||
|
||||
@ -73,10 +73,3 @@
|
||||
ошибок действует после проверки типов. Схема/домен/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).
|
||||
|
||||
@ -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.21.dbml → ../docs/model/entities-v5.21.md →
|
||||
Источники истины по приоритету: ../docs/model/data-model-v5.17.dbml → ../docs/model/entities-v5.17.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.21), которые затрагиваются; поля вне DBML не вводить — сначала новая версия DBML
|
||||
- Перечислять таблицы DBML v5.17, которые затрагиваются; поля вне DBML не вводить — сначала новая версия DBML
|
||||
- Ссылаться на параграфы 05-domain-rules.md для каждого алгоритма; правило, которого там нет, — в 07-open-questions.md
|
||||
- Раздел «Non-goals» обязателен
|
||||
- Любое допущение по открытому вопросу помечать ASSUMPTION(OQ-nn)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user