AURORA-1442: ручки возобновления обработки файла или версии консолидации, ручка обновления данных по осв #161
@ -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]
|
||||
|
||||
280
api/tests/test_api_osv_header_synthetic.py
Normal file
280
api/tests/test_api_osv_header_synthetic.py
Normal file
@ -0,0 +1,280 @@
|
||||
"""Правка шапки ОСВ на синтетических документах из фабрик (без golden-файлов), §9.5 п.12–13."""
|
||||
|
||||
import datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.audit_ex import AuditEvent
|
||||
from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationVersion
|
||||
from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus
|
||||
from src.models.files import Document, StoredFile
|
||||
from src.models.osv import OsvEntry
|
||||
from tests.conftest import AuthClientFactory
|
||||
from tests.factories import make_document, make_osv_entry, make_stored_file, make_version
|
||||
from tests.test_api_consolidation_members import _user, _version
|
||||
from tests.test_api_files_read import KA_RF, _rostov_entity
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
ORIGINAL = {"period_year": 2025, "period_quarter": "Q1", "is_cumulative": True, "osv_account_code": "60"}
|
||||
|
||||
|
||||
async def _events(session: AsyncSession, document: Document) -> list[AuditEvent]:
|
||||
rows = await session.scalars(
|
||||
select(AuditEvent).where(AuditEvent.object_id == document.id, AuditEvent.action == "RECOGNIZED_DATA_EDIT")
|
||||
)
|
||||
return list(rows.all())
|
||||
|
||||
|
||||
async def _bound(session: AsyncSession, document: Document) -> set[object]:
|
||||
rows = await session.scalars(
|
||||
select(ConsolidationDocument.consolidation_version_id).where(ConsolidationDocument.document_id == document.id)
|
||||
)
|
||||
return set(rows.all())
|
||||
|
||||
|
||||
async def _simple(session: AsyncSession, status: DocumentStatus = DocumentStatus.RECOGNIZED) -> Document:
|
||||
entity = await _rostov_entity(session)
|
||||
file, job = await make_stored_file(session, entity=entity, status=FileStatus.PROCESSED)
|
||||
return await make_document(session, file=file, job=job, status=status)
|
||||
|
||||
|
||||
async def test_conflict_order_type_then_superseded(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||
"""DOCUMENT_NOT_OSV раньше FILE_SUPERSEDED, а тот раньше проверок периода и счёта."""
|
||||
entity = await _rostov_entity(seeded)
|
||||
file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.SUPERSEDED)
|
||||
balance = await make_document(seeded, file=file, job=job)
|
||||
balance.document_type = DocumentType.BALANCE
|
||||
balance.osv_account_code = None
|
||||
osv = await make_document(seeded, file=file, job=job, account="62")
|
||||
await seeded.flush()
|
||||
client = await auth_client(KA_RF)
|
||||
bad_body = {"period_quarter": "Q9", "osv_account_code": " "}
|
||||
response = await client.patch(f"/documents/{balance.id}/osv-header", json=bad_body)
|
||||
assert response.status_code == HTTPStatus.CONFLICT
|
||||
assert response.json()["detail"]["code"] == "DOCUMENT_NOT_OSV"
|
||||
response = await client.patch(f"/documents/{osv.id}/osv-header", json=bad_body)
|
||||
assert response.status_code == HTTPStatus.CONFLICT
|
||||
assert response.json()["detail"]["code"] == "FILE_SUPERSEDED"
|
||||
await seeded.refresh(osv)
|
||||
assert osv.osv_account_code == "62" and osv.original_values is None
|
||||
|
||||
|
||||
async def test_conflict_order_period_account_duplicate_version(
|
||||
seeded: AsyncSession, auth_client: AuthClientFactory
|
||||
) -> None:
|
||||
"""Порядок: INVALID_PERIOD, INVALID_ACCOUNT_CODE, DUPLICATE_ACCOUNT, VERSION_NOT_EDITABLE; запись после всех."""
|
||||
version, members, _, _ = await _version(seeded, members=1, locked_by=None)
|
||||
file, job = await make_stored_file(seeded, entity=members[0], status=FileStatus.PROCESSED)
|
||||
doc = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED)
|
||||
twin = await make_document(seeded, file=file, job=job, account="62.01")
|
||||
consolidation = await seeded.get(Consolidation, version.consolidation_id)
|
||||
assert consolidation is not None
|
||||
verified = await make_version(
|
||||
seeded,
|
||||
consolidation=consolidation,
|
||||
version_no=2,
|
||||
members=members,
|
||||
initiator=await _user(seeded, KA_RF),
|
||||
status=ConsolidationStatus.VERIFIED,
|
||||
)
|
||||
seeded.add_all(
|
||||
[ConsolidationDocument(consolidation_version_id=v.id, document_id=doc.id) for v in (version, verified)]
|
||||
)
|
||||
await seeded.flush()
|
||||
client = await auth_client(KA_RF)
|
||||
url = f"/documents/{doc.id}/osv-header"
|
||||
|
||||
# период проверяется раньше счёта и коллизии
|
||||
response = await client.patch(
|
||||
url, json={"period_quarter": "Q1", "is_cumulative": False, "osv_account_code": "62.1"}
|
||||
)
|
||||
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
assert response.json()["detail"]["code"] == "INVALID_PERIOD"
|
||||
# пустой счёт — раньше коллизии и версий
|
||||
response = await client.patch(url, json={"osv_account_code": " "})
|
||||
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
assert response.json()["detail"]["code"] == "INVALID_ACCOUNT_CODE"
|
||||
# коллизия по нормализованному счёту раньше VERSION_NOT_EDITABLE
|
||||
response = await client.patch(url, json={"osv_account_code": "62.1"})
|
||||
assert response.status_code == HTTPStatus.CONFLICT
|
||||
assert response.json()["detail"]["code"] == "DUPLICATE_ACCOUNT"
|
||||
assert response.json()["detail"]["document_id"] == str(twin.id)
|
||||
# без коллизии — запрещающая версия
|
||||
response = await client.patch(url, json={"osv_account_code": "70"})
|
||||
assert response.status_code == HTTPStatus.CONFLICT
|
||||
assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE"
|
||||
assert response.json()["detail"]["version_ids"] == [str(verified.id)]
|
||||
|
||||
await seeded.refresh(doc)
|
||||
assert doc.osv_account_code == "60" and doc.status is DocumentStatus.CONFIRMED
|
||||
assert doc.original_values is None and doc.is_modified is False
|
||||
assert await _bound(seeded, doc) == {version.id, verified.id}
|
||||
assert await _events(seeded, doc) == []
|
||||
|
||||
|
||||
async def test_noop_patch_changes_nothing(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||
"""Совпадающая шапка: 200, без аудита, original_values, сброса подтверждения и modified_by/at."""
|
||||
version, members, _, _ = await _version(seeded, members=1, locked_by=None)
|
||||
file, job = await make_stored_file(seeded, entity=members[0], status=FileStatus.PROCESSED)
|
||||
doc = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED)
|
||||
seeded.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=doc.id))
|
||||
await seeded.flush()
|
||||
client = await auth_client(KA_RF)
|
||||
response = await client.patch(f"/documents/{doc.id}/osv-header", json=ORIGINAL)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
body = response.json()
|
||||
assert body["status"] == "CONFIRMED" and body["is_modified"] is False and body["original_values"] is None
|
||||
await seeded.refresh(doc)
|
||||
assert doc.modified_by is None and doc.modified_at is None
|
||||
assert await _bound(seeded, doc) == {version.id}
|
||||
assert await _events(seeded, doc) == []
|
||||
|
||||
|
||||
async def test_original_saved_once_and_is_modified_resets(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||
"""Снимок original_values — при первой правке и больше не меняется; is_modified зависит от отличия от снимка."""
|
||||
doc = await _simple(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
url = f"/documents/{doc.id}/osv-header"
|
||||
user = await _user(seeded, KA_RF)
|
||||
|
||||
body = (await client.patch(url, json={"osv_account_code": "62"})).json()
|
||||
assert body["osv_account_code"] == "62" and body["is_modified"] is True
|
||||
assert body["original_values"] == ORIGINAL
|
||||
body = (await client.patch(url, json={"osv_account_code": "76"})).json()
|
||||
assert body["osv_account_code"] == "76" and body["is_modified"] is True
|
||||
assert body["original_values"] == ORIGINAL
|
||||
|
||||
body = (await client.patch(url, json={"period_year": 2024, "period_quarter": "Q3", "is_cumulative": False})).json()
|
||||
assert (body["period_year"], body["period_quarter"], body["is_cumulative"]) == (2024, "Q3", False)
|
||||
assert body["original_values"] == ORIGINAL
|
||||
|
||||
# возврат всех четырёх полей к снимку
|
||||
response = await client.patch(url, json=ORIGINAL)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
body = response.json()
|
||||
assert body["is_modified"] is False and body["original_values"] == ORIGINAL
|
||||
await seeded.refresh(doc)
|
||||
assert doc.modified_by == user.id and doc.modified_at is not None
|
||||
# по одному событию на фактическую правку (4), старые не переписываются
|
||||
events = await _events(seeded, doc)
|
||||
assert len(events) == len(("62", "76", "период", "возврат"))
|
||||
afters = [e.after_value or {} for e in events]
|
||||
assert sorted(a["osv_account_code"] for a in afters if "osv_account_code" in a) == ["60", "62", "76"]
|
||||
|
||||
# частичный возврат: только счёт — снова изменён
|
||||
body = (await client.patch(url, json={"period_year": 2023})).json()
|
||||
assert body["is_modified"] is True and body["original_values"] == ORIGINAL
|
||||
|
||||
|
||||
async def test_confirmed_reset_unbinds_only_working_versions_with_single_audit(
|
||||
seeded: AsyncSession, auth_client: AuthClientFactory
|
||||
) -> None:
|
||||
"""CONFIRMED -> NEEDS_CHECK, снятие привязок «в работе», CANCELLED остаётся, одно событие аудита."""
|
||||
version, members, _, _ = await _version(seeded, members=1, locked_by=None)
|
||||
file, job = await make_stored_file(seeded, entity=members[0], status=FileStatus.PROCESSED)
|
||||
doc = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED)
|
||||
user = await _user(seeded, KA_RF)
|
||||
confirmed_at = datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.UTC)
|
||||
doc.confirmed_by, doc.confirmed_at = user.id, confirmed_at
|
||||
consolidation = await seeded.get(Consolidation, version.consolidation_id)
|
||||
assert consolidation is not None
|
||||
second = await make_version(
|
||||
seeded,
|
||||
consolidation=consolidation,
|
||||
version_no=2,
|
||||
members=members,
|
||||
initiator=user,
|
||||
status=ConsolidationStatus.INITIATED,
|
||||
)
|
||||
cancelled = await make_version(
|
||||
seeded,
|
||||
consolidation=consolidation,
|
||||
version_no=3,
|
||||
members=members,
|
||||
initiator=user,
|
||||
status=ConsolidationStatus.CANCELLED,
|
||||
)
|
||||
seeded.add_all(
|
||||
[ConsolidationDocument(consolidation_version_id=v.id, document_id=doc.id) for v in (version, second, cancelled)]
|
||||
)
|
||||
await seeded.flush()
|
||||
client = await auth_client(KA_RF)
|
||||
response = await client.patch(f"/documents/{doc.id}/osv-header", json={"osv_account_code": "62"})
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["status"] == "NEEDS_CHECK"
|
||||
|
||||
await seeded.refresh(doc)
|
||||
assert doc.status is DocumentStatus.NEEDS_CHECK and doc.confirmed_by is None and doc.confirmed_at is None
|
||||
assert await _bound(seeded, doc) == {cancelled.id}
|
||||
statuses = {
|
||||
v.id: v.status
|
||||
for v in (
|
||||
await seeded.scalars(
|
||||
select(ConsolidationVersion).where(ConsolidationVersion.id.in_([version.id, second.id, cancelled.id]))
|
||||
)
|
||||
).all()
|
||||
}
|
||||
assert statuses == {
|
||||
version.id: ConsolidationStatus.DATA_CORRECTION,
|
||||
second.id: ConsolidationStatus.INITIATED,
|
||||
cancelled.id: ConsolidationStatus.CANCELLED,
|
||||
}
|
||||
|
||||
(event,) = await _events(seeded, doc)
|
||||
assert event.user_id == user.id and event.object_type == "document"
|
||||
assert event.before_value["osv_account_code"] == "60"
|
||||
assert event.before_value["status"] == "CONFIRMED"
|
||||
assert event.before_value["confirmed_by"] == str(user.id)
|
||||
assert event.before_value["confirmed_at"] is not None
|
||||
assert event.after_value["osv_account_code"] == "62"
|
||||
assert event.after_value["status"] == "NEEDS_CHECK"
|
||||
assert event.after_value["confirmed_by"] is None and event.after_value["confirmed_at"] is None
|
||||
assert sorted(event.after_value["unbound_version_ids"]) == sorted([str(version.id), str(second.id)])
|
||||
|
||||
|
||||
async def test_unconfirmed_document_keeps_status_and_audit_has_no_reset_fields(
|
||||
seeded: AsyncSession, auth_client: AuthClientFactory
|
||||
) -> None:
|
||||
"""Неподтверждённый документ сохраняет статус; в аудите нет status и unbound_version_ids."""
|
||||
doc = await _simple(seeded, DocumentStatus.NEEDS_CHECK)
|
||||
client = await auth_client(KA_RF)
|
||||
response = await client.patch(f"/documents/{doc.id}/osv-header", json={"osv_account_code": "62"})
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["status"] == "NEEDS_CHECK"
|
||||
(event,) = await _events(seeded, doc)
|
||||
assert event.before_value == {"osv_account_code": "60"}
|
||||
assert event.after_value == {"osv_account_code": "62"}
|
||||
|
||||
|
||||
async def test_duplicate_account_after_normalization_and_entries_untouched(
|
||||
seeded: AsyncSession, auth_client: AuthClientFactory
|
||||
) -> None:
|
||||
"""DUPLICATE_ACCOUNT по нормализованному счёту; успешная правка не меняет osv_entry."""
|
||||
entity = await _rostov_entity(seeded)
|
||||
file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED)
|
||||
doc = await make_document(seeded, file=file, job=job)
|
||||
twin = await make_document(seeded, file=file, job=job, account="60.01")
|
||||
entry = await make_osv_entry(
|
||||
seeded, recognition_job_id=job.id, document_id=doc.id, owner_legal_entity_id=entity.id, row_number=1
|
||||
)
|
||||
before = {c.name: getattr(entry, c.name) for c in OsvEntry.__table__.columns}
|
||||
count = await seeded.scalar(select(func.count()).select_from(OsvEntry))
|
||||
client = await auth_client(KA_RF)
|
||||
url = f"/documents/{doc.id}/osv-header"
|
||||
response = await client.patch(url, json={"osv_account_code": "60.1"})
|
||||
assert response.status_code == HTTPStatus.CONFLICT
|
||||
assert response.json()["detail"]["code"] == "DUPLICATE_ACCOUNT"
|
||||
assert response.json()["detail"]["document_id"] == str(twin.id)
|
||||
await seeded.refresh(doc)
|
||||
assert doc.osv_account_code == "60" and doc.original_values is None
|
||||
|
||||
response = await client.patch(url, json={"osv_account_code": "61"})
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
await seeded.refresh(entry)
|
||||
assert {c.name: getattr(entry, c.name) for c in OsvEntry.__table__.columns} == before
|
||||
assert await seeded.scalar(select(func.count()).select_from(OsvEntry)) == count
|
||||
assert (await seeded.get(StoredFile, file.id)) is not None
|
||||
Loading…
x
Reference in New Issue
Block a user