"""API таблицы и экспорта: реальные суммы ОСВ, права, видимость и отсутствие записи на GET.""" import datetime import uuid from decimal import Decimal from io import BytesIO from pathlib import Path import pytest from fastapi import FastAPI from openpyxl import load_workbook from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.consolidations import get_correction_source from src.api.endpoints.consolidations.corrections import _export_filename from src.models.accounting import ChartOfAccount, EliminationRule from src.models.audit_ex import AuditEvent from src.models.consolidation import ConsolidationDocument from src.models.enums import AuditAction, ConsolidationStatus, CorrectionStatus, PeriodQuarter, StatementType from src.models.kfo import Correction from src.schemas.corrections import CorrectionAggregateOut from src.services.correction_source import CorrectionSourceTable from tests.conftest import AuthClientFactory from tests.factories import make_confirmed_osv from tests.test_api_consolidated_osv import FOREIGN, KA_RF, _world from tests.test_intercompany_matrix_golden import _source pytestmark = [pytest.mark.integration, pytest.mark.fixtures] HTTP_OK, HTTP_NOT_FOUND, HTTP_FORBIDDEN = 200, 404, 403 TRADE_RULE_NO = 6 class MutableCorrectionSource: def __init__(self) -> None: self.tables: dict[StatementType, CorrectionSourceTable] = {} async def table(self, version_id, statement_type): del version_id table = self.tables.get(statement_type, CorrectionSourceTable()) return CorrectionSourceTable( rules=[rule.model_copy(deep=True) for rule in table.rules], aggregates=[aggregate.model_copy(deep=True) for aggregate in table.aggregates], ) @pytest.fixture def correction_source(app: FastAPI): source = MutableCorrectionSource() app.dependency_overrides[get_correction_source] = lambda: source yield source app.dependency_overrides.pop(get_correction_source, None) async def configure_trade_source( source: MutableCorrectionSource, session: AsyncSession, world, fixtures_dir: Path ) -> None: rule = ( await session.scalars( select(EliminationRule).where( EliminationRule.rule_no == TRADE_RULE_NO, EliminationRule.statement_type == StatementType.balance, EliminationRule.version_no == 1, ) ) ).one() amount = Decimal(_source(fixtures_dir, "schastl", "62", 21)["closing_debit"]) def entry(owner, other, account, side, entry_id): return dict( entry_ids=[uuid.UUID(int=entry_id)], legal_entity_id=owner.id, counterparty_id=other.id, account_code=account, side=side, amount=amount, ) aggregate = CorrectionAggregateOut.model_validate( dict( rule_id=rule.id, rule_no=rule.rule_no, company_1=dict(id=world.a.id, name=world.a.name), company_2=dict(id=world.b.id, name=world.b.name), side_1=dict( article_line="1230", accounts=["62.01"], amount=amount, entries=[entry(world.a, world.b, "62.01", "D", 1)], ), side_2=dict( article_line="1520", accounts=["60.01"], amount=amount, entries=[entry(world.b, world.a, "60.01", "K", 2)], ), deviation_amount=0, deviation_percent=0, exceeds_threshold=False, status="AUTO", eliminated_amount=amount, correction=None, ) ) source.tables[StatementType.balance] = CorrectionSourceTable(aggregates=[aggregate]) async def real_trade(session: AsyncSession, fixtures_dir: Path): world = await _world(session) await session.execute( delete(ConsolidationDocument).where(ConsolidationDocument.consolidation_version_id == world.version.id) ) for owner, other, folder, root, number, side in ( (world.a, world.b, "schastl", "62", 21, "closing_debit"), (world.b, world.a, "rb", "60", 409, "closing_credit"), ): row = _source(fixtures_dir, folder, root, number) account = ( await session.scalars(select(ChartOfAccount).where(ChartOfAccount.code == row["account_code_raw"])) ).one() document = await make_confirmed_osv( session, entity=owner, account=account.code, year=2024, quarter=PeriodQuarter.Q3, is_cumulative=True, rows=[ dict( name=other.name, inn=other.inn, account_id=account.id, counterparty_id=other.id, **{side: Decimal(row[side])}, ) ], ) session.add(ConsolidationDocument(consolidation_version_id=world.version.id, document_id=document.id)) await session.flush() return world async def test_read_and_export_real_trade( auth_client: AuthClientFactory, seeded: AsyncSession, fixtures_dir: Path, correction_source: MutableCorrectionSource, ) -> None: world = await real_trade(seeded, fixtures_dir) await configure_trade_source(correction_source, seeded, world, fixtures_dir) client = await auth_client(KA_RF) url = f"/consolidations/versions/{world.version.id}/corrections" response = await client.get(url) assert response.status_code == HTTP_OK, response.text body = response.json() assert len(body["aggregates"]) == 1 aggregate = body["aggregates"][0] assert aggregate["rule_no"] == 6 # noqa: PLR2004 - правило реальной торговой пары assert aggregate["side_1"]["amount"] == aggregate["side_2"]["amount"] == "106311116.46" assert aggregate["eliminated_amount"] == "106311116.46" assert aggregate["correction"] is None assert body["read_only_reason"] == "LOCK_REQUIRED" assert await seeded.scalar(select(func.count()).select_from(Correction)) == 0 assert ( await seeded.scalar(select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT)) == 0 ) exported = await client.get(url + "/export") assert exported.status_code == HTTP_OK, exported.text assert "attachment" in exported.headers["content-disposition"] assert "spreadsheetml" in exported.headers["content-type"] workbook = load_workbook(BytesIO(exported.content), data_only=True) try: assert Decimal(str(workbook["Корректировки"]["E2"].value)) == Decimal("106311116.46") finally: workbook.close() audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one() assert audit.consolidation_version_id == world.version.id assert audit.after_value["report"] == "corrections" income = await client.get(url, params={"statement_type": "income"}) assert income.status_code == HTTP_OK assert income.json()["aggregates"] == [] async def test_automatic_allocation_builds_correction_aggregate( auth_client: AuthClientFactory, seeded: AsyncSession, fixtures_dir: Path ) -> None: world = await real_trade(seeded, fixtures_dir) response = await (await auth_client(KA_RF)).get(f"/consolidations/versions/{world.version.id}/corrections") assert response.status_code == HTTP_OK, response.text body = response.json() assert len(body["aggregates"]) == 1 aggregate = body["aggregates"][0] assert aggregate["rule_no"] == TRADE_RULE_NO assert aggregate["side_1"]["amount"] == aggregate["side_2"]["amount"] == "106311116.46" assert [entry["account_code"] for entry in aggregate["side_1"]["entries"]] == ["62.01"] assert [entry["account_code"] for entry in aggregate["side_2"]["entries"]] == ["60.01"] async def test_table_read_only_visibility_and_unknown_version( auth_client: AuthClientFactory, seeded: AsyncSession, fixtures_dir: Path ) -> None: world = await real_trade(seeded, fixtures_dir) world.version.status = ConsolidationStatus.VERIFIED await seeded.flush() client = await auth_client(KA_RF) url = f"/consolidations/versions/{world.version.id}/corrections" response = await client.get(url) assert response.status_code == HTTP_OK assert response.json()["read_only_reason"] == "VERSION_NOT_EDITABLE" assert len(response.json()["aggregates"]) == 1 missing = await client.get(f"/consolidations/versions/{uuid.uuid4()}/corrections") foreign = await (await auth_client(FOREIGN)).get(url) assert missing.status_code == foreign.status_code == HTTP_NOT_FOUND assert missing.json() == foreign.json() async def test_view_permission_is_required_before_visibility(auth_client: AuthClientFactory) -> None: client = await auth_client("admin@armka.local") for suffix in ("corrections", "corrections/export"): response = await client.get(f"/consolidations/versions/{uuid.uuid4()}/{suffix}") assert response.status_code == HTTP_FORBIDDEN def test_corrections_export_has_human_readable_filename() -> None: assert _export_filename("ГК Русский бисквит", 2024, "Q3") == ("Корректировки_ГК Русский бисквит_2024_Q3.xlsx") async def test_existing_draft_keeps_original_amounts( auth_client: AuthClientFactory, seeded: AsyncSession, fixtures_dir: Path, correction_source: MutableCorrectionSource, ) -> None: world = await real_trade(seeded, fixtures_dir) await configure_trade_source(correction_source, seeded, world, fixtures_dir) rule = ( await seeded.scalars( select(EliminationRule).where( EliminationRule.rule_no == TRADE_RULE_NO, EliminationRule.statement_type == StatementType.balance, EliminationRule.version_no == 1, ) ) ).one() now = datetime.datetime.now(datetime.UTC) seeded.add( Correction( consolidation_version_id=world.version.id, statement_type=StatementType.balance, rule_id=rule.id, legal_entity_id=world.a.id, counterparty_id=world.b.id, debit_line_code="1230", debit_adjustment=Decimal("42"), comment="Черновик", status=CorrectionStatus.DRAFT, version_no=1, created_at=now, updated_at=now, ) ) await seeded.flush() response = await (await auth_client(KA_RF)).get(f"/consolidations/versions/{world.version.id}/corrections") assert response.status_code == HTTP_OK, response.text aggregate = response.json()["aggregates"][0] assert aggregate["side_1"]["amount"] == "106311116.46" assert aggregate["correction"]["debit"] == [{"line_code": "1230", "amount": "42.00"}] assert aggregate["correction"]["balance"]["remainder_1"] == "106311074.46" assert response.json()["totals"]["correction_debit"] == "0.00"