arm-core/api/tests/test_auto_confirm.py
2026-10-08 17:50:37 +04:00

226 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Автоподтверждение чистых документов (osv-result-mapping «Автоподтверждение чистого документа»;
OQ-157; design D-1–D-3)."""
import uuid
from pathlib import Path
import pytest
from loguru import logger
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.common.clock import utc_now
from src.models.access import AppUser
from src.models.audit_ex import AuditEvent
from src.models.consolidation import ConsolidationDocument, ConsolidationStatusHistory, ConsolidationVersion
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice
from src.models.enums import (
AuditAction,
ConsolidationStatus,
DocumentStatus,
DocumentType,
FileStatus,
PeriodQuarter,
RecognitionStatus,
)
from src.models.files import Document, StoredFile
from src.models.statements import FinancialStatementMeta
from src.services.auto_confirm import AUTO_CONFIRM_DESCRIPTION, auto_confirm, transition_comment
from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_consolidation, make_document, make_legal_entity, make_stored_file
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
pytestmark = pytest.mark.integration
ROSTOV = "Ростовский региональный филиал"
KA_RF = "ka_rf@armka.local"
YEAR = 2024
Q2, Q3 = PeriodQuarter.Q2, PeriodQuarter.Q3
HTTP_CONFLICT = 409
async def _user(session: AsyncSession) -> AppUser:
return (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()
async def _group(session: AsyncSession) -> tuple[CompanyGroup, list[LegalEntity]]:
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office)
return group, [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(2)]
async def _version(
session: AsyncSession, group: CompanyGroup, members: list[LegalEntity], status: ConsolidationStatus
) -> ConsolidationVersion:
_, version = await make_consolidation(
session, group=group, year=YEAR, quarter=Q3, members=members, initiator=await _user(session), status=status
)
return version
async def _file_with(
session: AsyncSession, entity: LegalEntity, statuses: list[DocumentStatus], uploaded_by: uuid.UUID | None
) -> tuple[StoredFile, list[Document]]:
"""Файл `PROCESSED` с квартальными ОСВ 60, 62, … за 2024 Q2 в статусах `statuses`."""
file, job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done, uploaded_by=uploaded_by
)
documents = [
await make_document(
session, file=file, job=job, account=account, year=YEAR, quarter=Q2, status=status, is_cumulative=False
)
for account, status in zip(["60", "62", "76"], statuses, strict=False)
]
return file, documents
async def _bound(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]:
stmt = select(ConsolidationDocument.document_id).where(ConsolidationDocument.consolidation_version_id == version_id)
return set((await session.execute(stmt)).scalars())
async def _confirm_events(session: AsyncSession) -> list[AuditEvent]:
stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.DOCUMENT_CONFIRM)
return list((await session.execute(stmt)).scalars())
def test_transition_comment() -> None:
osv = Document(document_type=DocumentType.OSV, osv_account_code="60")
income = Document(document_type=DocumentType.INCOME, osv_account_code=None)
assert transition_comment(osv) == "автоподтверждение документа OSV 60"
assert transition_comment(income) == "автоподтверждение документа INCOME"
async def test_clean_document_is_confirmed_by_system(seeded: AsyncSession) -> None:
group, entities = await _group(seeded)
user = await _user(seeded)
file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], user.id)
now = utc_now()
confirmed = await auto_confirm(seeded, file=file, documents=[document], now=now)
assert confirmed == [document]
assert (document.status, document.confirmed_by, document.confirmed_at) == (DocumentStatus.CONFIRMED, None, now)
[event] = await _confirm_events(seeded)
assert (event.object_id, event.user_id, event.description) == (document.id, None, AUTO_CONFIRM_DESCRIPTION)
assert (event.before_value, event.after_value) == ({"status": "RECOGNIZED"}, {"status": "CONFIRMED"})
async def test_needs_check_is_left_for_analyst(seeded: AsyncSession) -> None:
group, entities = await _group(seeded)
version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED)
file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.NEEDS_CHECK], (await _user(seeded)).id)
assert await auto_confirm(seeded, file=file, documents=[document], now=utc_now()) == []
assert document.status is DocumentStatus.NEEDS_CHECK and document.confirmed_at is None
assert await _confirm_events(seeded) == []
assert await _bound(seeded, version.id) == set()
async def test_binds_and_moves_version_with_uploader_and_comment(seeded: AsyncSession) -> None:
group, entities = await _group(seeded)
version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED)
user = await _user(seeded)
file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], user.id)
await auto_confirm(seeded, file=file, documents=[document], now=utc_now())
assert await _bound(seeded, version.id) == {document.id}
refreshed = await seeded.get(ConsolidationVersion, version.id, populate_existing=True)
assert refreshed is not None and refreshed.status is ConsolidationStatus.DATA_CORRECTION
last = (
await seeded.execute(
select(ConsolidationStatusHistory)
.where(ConsolidationStatusHistory.version_id == version.id)
.order_by(ConsolidationStatusHistory.changed_at.desc())
.limit(1)
)
).scalar_one()
assert (last.changed_by, last.comment) == (user.id, "автоподтверждение документа OSV 60")
async def test_file_without_uploader_binds_without_transition(seeded: AsyncSession) -> None:
group, entities = await _group(seeded)
version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED)
file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], None)
messages: list[str] = []
sink = logger.add(lambda message: messages.append(str(message)), level="INFO")
try:
await auto_confirm(seeded, file=file, documents=[document], now=utc_now())
finally:
logger.remove(sink)
assert document.status is DocumentStatus.CONFIRMED
assert await _bound(seeded, version.id) == {document.id}
assert any("Переход версии по подтверждению пропущен" in m and str(version.id) in m for m in messages)
refreshed = await seeded.get(ConsolidationVersion, version.id, populate_existing=True)
assert refreshed is not None and refreshed.status is ConsolidationStatus.INITIATED
async def test_mixed_documents_confirm_only_clean(seeded: AsyncSession) -> None:
group, entities = await _group(seeded)
file, documents = await _file_with(
seeded, entities[0], [DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK], (await _user(seeded)).id
)
confirmed = await auto_confirm(seeded, file=file, documents=documents, now=utc_now())
assert confirmed == [documents[0]]
assert [d.status for d in documents] == [DocumentStatus.CONFIRMED, DocumentStatus.NEEDS_CHECK]
@pytest.mark.fixtures
async def test_clean_form_sets_reviewed_at(seeded: AsyncSession, fixtures_dir: Path) -> None:
path = fixtures_dir / GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
import json # noqa: PLC0415 - нужен только здесь
data = json.loads(path.read_text("utf-8"))
inn = next(d["header"]["inn"] for d in data["documents"] if d.get("header", {}).get("inn"))
owner = await make_legal_entity(seeded, name="ООО Владелец", inn=inn)
(income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
assert income.status is DocumentStatus.RECOGNIZED
file = await seeded.get(StoredFile, income.stored_file_id)
assert file is not None
now = utc_now()
assert await auto_confirm(seeded, file=file, documents=[income], now=now) == [income]
meta = (
await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id))
).scalar_one()
assert (income.status, meta.reviewed_by, meta.reviewed_at) == (DocumentStatus.CONFIRMED, None, now)
@pytest.mark.fixtures
async def test_recognized_form_without_period_stays(seeded: AsyncSession, fixtures_dir: Path) -> None:
path = fixtures_dir / GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
import json # noqa: PLC0415 - нужен только здесь
data = json.loads(path.read_text("utf-8"))
inn = next(d["header"]["inn"] for d in data["documents"] if d.get("header", {}).get("inn"))
owner = await make_legal_entity(seeded, name="ООО Владелец", inn=inn)
(income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
income.period_quarter = None # рассинхрон правил: RECOGNIZED без периода не должен подтверждаться сам
await seeded.flush()
file = await seeded.get(StoredFile, income.stored_file_id)
assert file is not None
assert await auto_confirm(seeded, file=file, documents=[income], now=utc_now()) == []
assert income.status is DocumentStatus.RECOGNIZED
async def test_manual_confirm_of_auto_confirmed_is_409(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""Review Focus 4: фронт с устаревшим статусом жмёт «Подтвердить» — 409 `ALREADY_CONFIRMED`."""
office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
entity = await make_legal_entity(seeded, name="ООО Ростов", office=office)
file, [document] = await _file_with(seeded, entity, [DocumentStatus.RECOGNIZED], None)
await auto_confirm(seeded, file=file, documents=[document], now=utc_now())
client = await auth_client(KA_RF)
response = await client.post(f"/documents/{document.id}/confirm")
assert response.status_code == HTTP_CONFLICT, response.text
assert response.json()["detail"]["code"] == "ALREADY_CONFIRMED"
assert len(await _confirm_events(seeded)) == 1