"""Фабрики справочников и фикстуры `app` / `auth_client` / `seeded` (задача 1.3): проверка до API-тестов.""" import datetime import json from decimal import Decimal from pathlib import Path import pytest from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.core.storage import ObjectNotFoundError from src.models.access import UserRole from src.models.consolidation import ( Consolidation, ConsolidationDocument, ConsolidationLock, ConsolidationMember, ConsolidationStatusHistory, ConsolidationVersion, ) from src.models.directories import LegalEntity from src.models.enums import ( ConsolidationStatus, DocumentStatus, DocumentType, EntityType, FileStatus, FileType, OsvDataType, OsvGroupType, PeriodQuarter, RecognitionPipeline, RecognitionStatus, RelatedGroupKind, S3BucketRole, ) from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry from tests.conftest import AuthClientFactory, FakeStorage from tests.factories import ( CommittedJobs, make_company_group, make_confirmed_osv, make_consolidation, make_document, make_entries_from_result, make_legal_entity, make_lock, make_membership, make_regional_office, make_related_group, make_result_object, make_stored_file, make_user, ) HTTP_OK = 200 INN_LENGTH = 10 @pytest.mark.integration async def test_directory_factories_build_consistent_chain(db_session: AsyncSession) -> None: office = await make_regional_office(db_session) head = await make_regional_office(db_session, name="ГО Тест", is_head_office=True) group = await make_company_group(db_session, office=office, crm_id="CRM-1") entity = await make_legal_entity(db_session, group=group) other = await make_legal_entity(db_session, name="ИП Тест", entity_type=EntityType.ИП, office=head) gsk = await make_related_group(db_session, group=group) gsz = await make_related_group(db_session, group=group, kind=RelatedGroupKind.gsz, name="ГСЗ Тест") member = await make_membership(db_session, related_group=gsk, entity=entity) # v5.14: одно ЮЛ одновременно в ГСК и ГСЗ member_gsz = await make_membership(db_session, related_group=gsz, entity=entity) assert head.is_head_office is True assert office.is_head_office is False assert group.code.startswith("GK-T") assert group.base_regional_office_id == office.id assert group.valid_from is not None assert entity.regional_office_id == office.id # по умолчанию — филиал группы assert entity.entity_type == EntityType.ЮЛ assert len(entity.inn) == INN_LENGTH assert entity.inn != other.inn assert other.regional_office_id == head.id assert other.company_group_id is None assert gsk.code.startswith("GSK-T") assert gsz.code.startswith("GSZ-T") assert member.kind == RelatedGroupKind.gsk assert member.is_current is True assert member.valid_to is None assert member_gsz.kind == RelatedGroupKind.gsz @pytest.mark.integration async def test_membership_not_current_is_closed(db_session: AsyncSession) -> None: group = await make_company_group(db_session) entity = await make_legal_entity(db_session, group=group) related = await make_related_group(db_session, group=group) closed = await make_membership(db_session, related_group=related, entity=entity, is_current=False) again = await make_membership(db_session, related_group=related, entity=entity) # закрытая версия не мешает assert closed.is_current is False assert closed.valid_to is not None assert again.is_current is True @pytest.mark.integration async def test_second_current_membership_in_same_kind_is_rejected(db_session: AsyncSession) -> None: """Фабрики не обходят ограничения БД: ЮЛ в двух ГСК одновременно — нарушение partial UNIQUE (v5.14).""" group = await make_company_group(db_session) entity = await make_legal_entity(db_session, group=group) first = await make_related_group(db_session, group=group) second = await make_related_group(db_session, group=group, name="ГСК-2") await make_membership(db_session, related_group=first, entity=entity) with pytest.raises(IntegrityError): async with db_session.begin_nested(): await make_membership(db_session, related_group=second, entity=entity) @pytest.mark.integration async def test_make_user_assigns_seeded_roles(seeded: AsyncSession) -> None: office = await make_regional_office(seeded) user = await make_user(seeded, email="factory@armka.local", office=office, roles=["KA_RF", "EXPERT"]) plain = await make_user(seeded, email="plain@armka.local", office=office, is_active=False) count = len((await seeded.execute(select(UserRole).where(UserRole.user_id == user.id))).scalars().all()) assert count == 2 # noqa: PLR2004 assert user.regional_office_id == office.id assert plain.is_active is False assert not (await seeded.execute(select(UserRole).where(UserRole.user_id == plain.id))).scalars().all() @pytest.mark.integration async def test_auth_client_gets_me_for_dev_user(auth_client: AuthClientFactory) -> None: client = await auth_client("ka_rf@armka.local") response = await client.get("/me") assert response.status_code == HTTP_OK, response.text assert response.json()["email"] == "ka_rf@armka.local" assert response.json()["roles"] == ["KA_RF"] @pytest.mark.integration async def test_auth_client_sees_data_created_by_factories( db_session: AsyncSession, auth_client: AuthClientFactory ) -> None: """Пользователь, созданный фабрикой в той же сессии, получает токен и проходит `/me` (одна `db_session`).""" office = await make_regional_office(db_session) await make_user(db_session, email="made@armka.local", office=office, roles=["KA_RF"]) client = await auth_client("made@armka.local") response = await client.get("/me") assert response.status_code == HTTP_OK, response.text assert response.json()["regional_office"]["id"] == str(office.id) @pytest.mark.integration async def test_make_stored_file_builds_file_job_chain(db_session: AsyncSession) -> None: entity = await make_legal_entity(db_session) file, job = await make_stored_file(db_session, entity=entity) assert file.legal_entity_id == entity.id assert file.status == FileStatus.UPLOADED assert file.file_type == FileType.XLS assert file.original_name == "osv.xls" assert file.content_hash is None assert file.current_recognition_job_id == job.id assert job.file_id == file.id assert job.status == RecognitionStatus.queued assert job.pipeline == RecognitionPipeline.tabular s3 = await db_session.get(S3Object, file.s3_object_id) assert s3 is not None assert s3.bucket_role == S3BucketRole.uploads assert s3.key.endswith("/osv.xls") @pytest.mark.integration async def test_make_stored_file_overrides_and_image_pipeline(db_session: AsyncSession) -> None: entity = await make_legal_entity(db_session) office = await make_regional_office(db_session) user = await make_user(db_session, email="uploader@armka.local", office=office) file, job = await make_stored_file( db_session, entity=entity, status=FileStatus.PROCESSING, file_type=FileType.PDF, original_name="bb.pdf", content_hash="a" * 64, job_status=RecognitionStatus.done, s3_key="uploads/custom/bb.pdf", uploaded_by=user.id, ) assert (file.status, file.file_type, file.content_hash, file.uploaded_by) == ( FileStatus.PROCESSING, FileType.PDF, "a" * 64, user.id, ) assert job.pipeline == RecognitionPipeline.image assert job.status == RecognitionStatus.done s3 = await db_session.get(S3Object, file.s3_object_id) assert s3 is not None assert s3.key == "uploads/custom/bb.pdf" @pytest.mark.integration async def test_make_document_defaults_and_uniqueness(db_session: AsyncSession) -> None: entity = await make_legal_entity(db_session) file, job = await make_stored_file(db_session, entity=entity) document = await make_document(db_session, file=file, job=job) other = await make_document(db_session, file=file, job=job, account="62", status=DocumentStatus.NEEDS_CHECK) assert document.stored_file_id == file.id assert document.legal_entity_id == entity.id assert document.document_type == DocumentType.OSV assert (document.osv_account_code, document.period_year, document.period_quarter) == ("60", 2025, PeriodQuarter.Q1) assert document.status == DocumentStatus.RECOGNIZED assert document.is_cumulative is True assert other.status == DocumentStatus.NEEDS_CHECK assert other.id != document.id # уникальность документа в файле (`uq_document_file_type_account`): второй ОСВ того же счёта не создаётся with pytest.raises(IntegrityError): async with db_session.begin_nested(): await make_document(db_session, file=file, job=job) MINIMAL_RESULT = Path(__file__).resolve().parents[1] / "contracts" / "examples" / "v1" / "result-osv-minimal.json" @pytest.mark.integration async def test_make_entries_from_result_builds_tree(seeded: AsyncSession) -> None: """R-2: документ на лист ОСВ, строки без TOTAL с родителями, `group_schema` задания по `locator`.""" entity = await make_legal_entity(seeded) file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED) result = json.loads(MINIMAL_RESULT.read_text("utf-8")) documents = await make_entries_from_result(seeded, file, job, result) assert len(documents) == 1 document = documents[0] assert (document.osv_account_code, document.locator, document.status) == ( "60", "sheet:Лист_1", DocumentStatus.NEEDS_CHECK, ) rows = ( ( await seeded.execute( select(OsvEntry).where(OsvEntry.document_id == document.id).order_by(OsvEntry.row_number) ) ) .scalars() .all() ) assert [row.row_number for row in rows] == [1, 2] assert rows[0].parent_entry_id is None assert rows[1].parent_entry_id == rows[0].id assert rows[0].account_id is not None # счёт 60 есть в плане счетов seed assert job.group_schema == [ {"locator": "sheet:Лист_1", "groups": [{"type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": True}]} ] assert job.metrics["mapping_warnings"] == [] @pytest.mark.integration async def test_make_result_object_stores_json_and_links_job(db_session: AsyncSession) -> None: entity = await make_legal_entity(db_session) file, job = await make_stored_file(db_session, entity=entity) storage = FakeStorage() result = {"schema_version": "1.0", "значение": "1.50"} s3 = await make_result_object(db_session, job=job, storage=storage, result=result) key = f"results/{file.id}/{job.id}/result.json" assert s3.key == key assert s3.bucket == storage.bucket assert s3.bucket_role == S3BucketRole.results assert job.result_s3_object_id == s3.id assert json.loads(await storage.get_bytes(key)) == result async def test_fake_storage_get_bytes_missing_object() -> None: storage = FakeStorage() await storage.put_object("a", b"x") assert await storage.get_bytes("a") == b"x" with pytest.raises(ObjectNotFoundError): await storage.get_bytes("b") @pytest.mark.integration async def test_committed_jobs_cleanup_removes_documents_and_entries( committed_sessionmaker: async_sessionmaker[AsyncSession], ) -> None: jobs = CommittedJobs(committed_sessionmaker) job_id = await jobs.make_queued_job() try: async with committed_sessionmaker() as session, session.begin(): job = await session.get(RecognitionJob, job_id) assert job is not None file = await session.get(StoredFile, job.file_id) assert file is not None file.current_recognition_job_id = job.id document = await make_document(session, file=file, job=job) session.add( OsvEntry( recognition_job_id=job.id, document_id=document.id, owner_legal_entity_id=file.legal_entity_id, row_number=1, closing_debit=Decimal("1.00"), ) ) file_id = file.id finally: await jobs.cleanup() async with committed_sessionmaker() as session: assert await session.get(StoredFile, file_id) is None assert not (await session.execute(select(Document).where(Document.stored_file_id == file_id))).all() assert not (await session.execute(select(OsvEntry).where(OsvEntry.recognition_job_id == job_id))).all() @pytest.mark.integration async def test_make_consolidation_builds_version_one_with_members_and_history(db_session: AsyncSession) -> None: office = await make_regional_office(db_session) group = await make_company_group(db_session, office=office) entities = [await make_legal_entity(db_session, group=group) for _ in range(2)] user = await make_user(db_session, email="cons@armka.local", office=office) consolidation, version = await make_consolidation( db_session, group=group, year=2024, quarter=PeriodQuarter.Q3, members=entities, initiator=user ) assert consolidation.company_group_id == group.id assert (consolidation.year, consolidation.quarter) == (2024, PeriodQuarter.Q3) assert consolidation.reporting_date == datetime.date(2024, 10, 1) assert (version.consolidation_id, version.version_no, version.status) == ( consolidation.id, 1, ConsolidationStatus.INITIATED, ) assert (version.initiator_id, version.initiator_office_id, version.is_in_work) == (user.id, office.id, True) members = ( ( await db_session.execute( select(ConsolidationMember).where(ConsolidationMember.consolidation_version_id == version.id) ) ) .scalars() .all() ) assert {member.legal_entity_id for member in members} == {entity.id for entity in entities} history = ( ( await db_session.execute( select(ConsolidationStatusHistory).where(ConsolidationStatusHistory.version_id == version.id) ) ) .scalars() .all() ) assert [(row.from_status, row.to_status, row.changed_by) for row in history] == [ (None, ConsolidationStatus.INITIATED, user.id) ] @pytest.mark.integration async def test_make_consolidation_other_status_and_lock(db_session: AsyncSession) -> None: office = await make_regional_office(db_session) group = await make_company_group(db_session, office=office) entity = await make_legal_entity(db_session, group=group) user = await make_user(db_session, email="cons2@armka.local", office=office) _, version = await make_consolidation( db_session, group=group, year=2024, quarter=PeriodQuarter.Q4, members=[entity], initiator=user, status=ConsolidationStatus.CANCELLED, ) active = await make_lock(db_session, version=version, user=user) released = await make_lock(db_session, version=version, user=user, released=True) assert version.status == ConsolidationStatus.CANCELLED assert version.is_in_work is False assert (await db_session.get(ConsolidationLock, active.id)) is active assert (active.locked_by, active.released_at) == (user.id, None) assert released.released_at is not None @pytest.mark.integration async def test_make_confirmed_osv_builds_file_job_document_and_rows(db_session: AsyncSession) -> None: entity = await make_legal_entity(db_session) user = await make_user(db_session, email="conf@armka.local", office=await make_regional_office(db_session)) document = await make_confirmed_osv( db_session, entity=entity, account="60", year=2024, quarter=PeriodQuarter.Q2, is_cumulative=False, rows=[ {"name": "Контрагент А", "inn": "7701000001", "opening_debit": Decimal("10.50")}, {"name": "Контрагент Б", "closing_credit": Decimal("3.00"), "data_type": OsvDataType.SUM}, ], confirmed_by=user.id, ) assert (document.legal_entity_id, document.document_type, document.osv_account_code) == ( entity.id, DocumentType.OSV, "60", ) assert (document.period_year, document.period_quarter, document.is_cumulative) == (2024, PeriodQuarter.Q2, False) assert (document.status, document.confirmed_by) == (DocumentStatus.CONFIRMED, user.id) assert document.confirmed_at is not None file = await db_session.get(StoredFile, document.stored_file_id) assert file is not None assert file.status == FileStatus.PROCESSED job = await db_session.get(RecognitionJob, file.current_recognition_job_id) assert job is not None assert job.status == RecognitionStatus.done rows = ( ( await db_session.execute( select(OsvEntry).where(OsvEntry.document_id == document.id).order_by(OsvEntry.row_number) ) ) .scalars() .all() ) assert [(row.row_number, row.counterparty_name, row.counterparty_inn) for row in rows] == [ (1, "Контрагент А", "7701000001"), (2, "Контрагент Б", None), ] assert [(row.group_type, row.data_type) for row in rows] == [ (OsvGroupType.COUNTERPARTY, OsvDataType.BU), (OsvGroupType.COUNTERPARTY, OsvDataType.SUM), ] assert (rows[0].opening_debit, rows[1].closing_credit) == (Decimal("10.50"), Decimal("3.00")) assert all(row.owner_legal_entity_id == entity.id and row.recognition_job_id == job.id for row in rows) assert (rows[0].account_code, rows[0].account_root) == ("60", "60") @pytest.mark.integration async def test_committed_jobs_cleanup_removes_consolidation_rows( committed_sessionmaker: async_sessionmaker[AsyncSession], ) -> None: """`cleanup()` снимает привязки, блокировки, историю, периметр, версии и консолидацию до файлов и ЮЛ.""" jobs = CommittedJobs(committed_sessionmaker) file_id, job_id = await jobs.make_file_job() try: async with committed_sessionmaker() as session, session.begin(): file = await session.get(StoredFile, file_id) job = await session.get(RecognitionJob, job_id) assert file is not None assert job is not None entity = await session.get(LegalEntity, file.legal_entity_id) assert entity is not None office = await make_regional_office(session) group = await make_company_group(session, office=office) user = await make_user(session, email="cleanup@armka.local", office=office) document = await make_document(session, file=file, job=job) _, version = await make_consolidation( session, group=group, year=2024, quarter=PeriodQuarter.Q3, members=[entity], initiator=user ) await make_lock(session, version=version, user=user) session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id)) finally: await jobs.cleanup() async with committed_sessionmaker() as session: assert await session.get(StoredFile, file_id) is None for model in ( ConsolidationDocument, ConsolidationLock, ConsolidationStatusHistory, ConsolidationMember, ConsolidationVersion, Consolidation, ): assert not (await session.execute(select(model))).all(), model.__name__