"""Автоподтверждение чистых документов (osv-result-mapping «Автоподтверждение чистого документа»; OQ-157; design D-1–D-3).""" import uuid from decimal import Decimal 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, StatementLineStatus, ) from src.models.files import Document, StoredFile from src.models.statements import ( FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue, ) 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 @pytest.mark.fixtures async def test_auto_confirm_skips_blocked_form(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) meta = ( await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id)) ).scalar_one() line = FinancialStatementLine(meta_id=meta.id, line_status=StatementLineStatus.missing, row_index=900) seeded.add(line) await seeded.flush() column = ( await seeded.execute( select(FinancialStatementColumn).where(FinancialStatementColumn.meta_id == meta.id).limit(1) ) ).scalar_one() seeded.add(FinancialStatementValue(line_id=line.id, column_id=column.id, amount=Decimal(5))) 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 async def _synthetic_disclosure(session: AsyncSession, fixtures_dir: Path, *, office: RegionalOffice) -> Document: """RECOGNIZED `DISCLOSURE` из синтетического снимка st06 (T11.1: форм-проверки к не-форме не применяются).""" import json # noqa: PLC0415 - нужен только здесь path = fixtures_dir / GOLDEN_STATEMENTS / "synthetic" / "st06_header_variants.json" data = json.loads(path.read_text("utf-8")) inn = next((d["header"].get("inn") for d in data["documents"] if d.get("header", {}).get("inn")), None) owner = await make_legal_entity(session, name="ООО Синтетика", inn=inn, office=office) documents = await map_golden_snapshot(session, entity=owner, path=path, uploaded_by=None) disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE) assert disclosure.status is DocumentStatus.RECOGNIZED return disclosure @pytest.mark.fixtures async def test_recognized_disclosure_is_auto_confirmed(seeded: AsyncSession, fixtures_dir: Path) -> None: office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() disclosure = await _synthetic_disclosure(seeded, fixtures_dir, office=office) file = await seeded.get(StoredFile, disclosure.stored_file_id) assert file is not None assert await auto_confirm(seeded, file=file, documents=[disclosure], now=utc_now()) == [disclosure] assert disclosure.status is DocumentStatus.CONFIRMED @pytest.mark.fixtures async def test_recognized_disclosure_is_manually_confirmable( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() disclosure = await _synthetic_disclosure(seeded, fixtures_dir, office=office) client = await auth_client(KA_RF) response = await client.post(f"/documents/{disclosure.id}/confirm") assert response.status_code == 200, response.text # noqa: PLR2004 assert response.json()["status"] == "CONFIRMED"