diff --git a/api/src/services/auto_confirm.py b/api/src/services/auto_confirm.py new file mode 100644 index 0000000..f5bb462 --- /dev/null +++ b/api/src/services/auto_confirm.py @@ -0,0 +1,75 @@ +"""Автоподтверждение чистых документов при маппинге результата (osv-result-mapping «Автоподтверждение чистого +документа»; `05-domain-rules.md` §9.3, §9.5; OQ-157; design D-1–D-3 change `auto-confirm-clean-documents`). + +Документ, получивший при маппинге `RECOGNIZED` (ТЗ 03_9 п. 4: «при отсутствии замечаний — „Загружен“»), становится +`CONFIRMED` от имени системы: `confirmed_by = NULL`, у формы `reviewed_by = NULL`, `audit_event(DOCUMENT_CONFIRM)` без +пользователя. Привязка к версиям и переход версии — тот же хук, что у ручного подтверждения, с автором +`stored_file.uploaded_by`. Вызывается в транзакции маппинга под заданием, файлом и документами (порядок блокировок D-5). +""" + +import datetime +from collections.abc import Sequence + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.enums import AuditAction, AuditSection, DocumentStatus +from src.models.files import Document, StoredFile +from src.repositories import statements as statements_repo +from src.services import consolidation_documents +from src.services.audit import AuditService +from src.services.statements import FORM_TYPES, form_confirmable + +AUTO_CONFIRM_DESCRIPTION = "автоподтверждение: без замечаний" +OBJECT_TYPE = "document" + + +def transition_comment(document: Document) -> str: + """Комментарий перехода версии: «автоподтверждение документа <тип> [<счёт>]» (design решение №6).""" + parts = ["автоподтверждение документа", document.document_type.value] + if document.osv_account_code: + parts.append(document.osv_account_code) + return " ".join(parts) + + +async def auto_confirm( + session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime +) -> list[Document]: + """Подтверждает документы `RECOGNIZED` файла (форма — только при `form_confirmable`, D-3); `NEEDS_CHECK` и прочие + не трогает. Возвращает подтверждённые документы по возрастанию `id`.""" + audit = AuditService(session) + confirmed: list[Document] = [] + for document in sorted(documents, key=lambda item: item.id): + if document.status is not DocumentStatus.RECOGNIZED: + continue + if not await form_confirmable(session, document): + continue + document.status = DocumentStatus.CONFIRMED + document.confirmed_by = None + document.confirmed_at = now + document.updated_at = now + if document.document_type in FORM_TYPES: + meta = await statements_repo.get_meta(session, document.id) + if meta is not None: + meta.reviewed_by = None + meta.reviewed_at = now + await session.flush() + await consolidation_documents.on_document_confirmed( + session, + audit, + document=document, + changed_by=file.uploaded_by, + comment=transition_comment(document), + now=now, + ) + await audit.record( + action=AuditAction.DOCUMENT_CONFIRM, + section=AuditSection.PRIMARY_DOCUMENTS, + object_type=OBJECT_TYPE, + object_id=document.id, + user_id=None, + description=AUTO_CONFIRM_DESCRIPTION, + before_value={"status": DocumentStatus.RECOGNIZED.value}, + after_value={"status": DocumentStatus.CONFIRMED.value}, + ) + confirmed.append(document) + return confirmed diff --git a/api/tests/test_auto_confirm.py b/api/tests/test_auto_confirm.py new file mode 100644 index 0000000..2fb4e37 --- /dev/null +++ b/api/tests/test_auto_confirm.py @@ -0,0 +1,218 @@ +"""Автоподтверждение чистых документов (osv-result-mapping «Автоподтверждение чистого документа»; +OQ-157; design D-1–D-3).""" + +import uuid +from pathlib import Path + +import pytest +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) + + await auto_confirm(seeded, file=file, documents=[document], now=utc_now()) + + assert document.status is DocumentStatus.CONFIRMED + 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.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