"""Критерии шахматки через HTTP, реальные сервисы и PostgreSQL.""" import uuid from dataclasses import dataclass from decimal import Decimal from io import BytesIO from urllib.parse import unquote import pytest from httpx import AsyncClient, Response from openpyxl import load_workbook from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.models.access import AppUser from src.models.accounting import ChartOfAccount, EliminationRule from src.models.audit_ex import AuditEvent from src.models.consolidation import ConsolidationDocument, ConsolidationVersion from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice from src.models.enums import AuditAction, AuditSection, CounterpartyMatchKind, PeriodQuarter, StatementType from src.models.osv import OsvEntry from src.repositories import consolidations as consolidation_repo from src.repositories import directories as directory_repo from tests.conftest import AuthClientFactory from tests.factories import make_company_group, make_confirmed_osv, make_consolidation, make_legal_entity pytestmark = pytest.mark.integration OK, FORBIDDEN, NOT_FOUND, UNPROCESSABLE = 200, 403, 404, 422 KA_RF = "ka_rf@armka.local" RECEIVABLES_PAYABLES_RULE_NO = 6 ROUTES = ("all", "manual", "result-table", "all/export", "manual/export", "result-table/export") @dataclass class World: version: ConsolidationVersion members: list[LegalEntity] accounts: dict[str, ChartOfAccount] @property def selection(self) -> dict[str, list[str]]: return { "debit_account_ids": [str(self.accounts["62.01"].id)], "credit_account_ids": [str(self.accounts["60.01"].id)], } async def _world(session: AsyncSession, *, members: int = 2) -> World: office = ( await session.scalars(select(RegionalOffice).where(RegionalOffice.name == "Ростовский региональный филиал")) ).one() user = (await session.scalars(select(AppUser).where(AppUser.email == KA_RF))).one() group = await make_company_group(session, office=office) entities = [await make_legal_entity(session, name=f"Компания {n}", group=group) for n in range(members)] _, version = await make_consolidation( session, group=group, year=2024, quarter=PeriodQuarter.Q1, members=entities, initiator=user ) accounts = {account.code: account for account in (await session.scalars(select(ChartOfAccount))).all()} return World(version, entities, accounts) async def _request(client: AsyncClient, version_id: uuid.UUID, suffix: str, selection: dict) -> Response: url = f"/consolidations/versions/{version_id}/intercompany-matrix/{suffix}" return await client.post(url, json=selection) if suffix.startswith("manual") else await client.get(url) async def _source( session: AsyncSession, world: World, *, credit: bool, amount: str, account: str | None = None ) -> uuid.UUID: owner, counterparty = reversed(world.members) if credit else world.members code = account or ("60.01" if credit else "62.01") document = await make_confirmed_osv( session, entity=owner, account=code, year=2024, quarter=PeriodQuarter.Q1, is_cumulative=False, rows=[ { "name": counterparty.name, "counterparty_id": counterparty.id, "counterparty_match_kind": CounterpartyMatchKind.INN, "account_id": world.accounts[code].id, "closing_credit" if credit else "closing_debit": Decimal(amount), } ], ) session.add(ConsolidationDocument(consolidation_version_id=world.version.id, document_id=document.id)) await session.flush() return document.id @pytest.mark.parametrize("suffix", ["all", "manual"]) async def test_empty_perimeter(auth_client: AuthClientFactory, seeded: AsyncSession, suffix: str) -> None: world = await _world(seeded, members=0) response = await _request(await auth_client(KA_RF), world.version.id, suffix, world.selection) assert response.status_code == OK, response.text assert response.json() == {"companies": [], "cells": []} @pytest.mark.parametrize("suffix", ROUTES) async def test_matrix_reuses_members_and_reads_accounts_through_repository( auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch, suffix: str ) -> None: world = await _world(seeded) calls = {"members": 0, "accounts": 0} original_members = consolidation_repo.members original_accounts = directory_repo.list_accounts async def counted_members(*args: object, **kwargs: object): calls["members"] += 1 return await original_members(*args, **kwargs) async def counted_accounts(*args: object, **kwargs: object): calls["accounts"] += 1 return await original_accounts(*args, **kwargs) monkeypatch.setattr(consolidation_repo, "members", counted_members) monkeypatch.setattr(directory_repo, "list_accounts", counted_accounts) response = await _request(await auth_client(KA_RF), world.version.id, suffix, world.selection) assert response.status_code == OK, response.text assert calls == {"members": 1, "accounts": 1} async def test_no_osv_for_selected_accounts(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: world = await _world(seeded) await _source(seeded, world, credit=False, amount="999", account="58.03") response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection) assert response.status_code == OK, response.text assert len(response.json()["cells"]) == len(world.members) for cell in response.json()["cells"]: assert { "osv_entry_ids_debit", "osv_entry_ids_credit", "exceeds_threshold", } <= cell.keys() assert {"entry_osv_ids_debit", "entry_osv_ids_credit", "is_more_5_percent"}.isdisjoint(cell) assert cell["amount_a"] == cell["amount_b"] == cell["variance_amount"] == "0.00" assert cell["variance_percent"] is None assert cell["one_sided"] is False assert cell["osv_entry_ids_debit"] == cell["osv_entry_ids_credit"] == [] assert cell["skipped_osv_entry_count"] == 0 async def test_preset_uses_rule_sides_and_exclusions(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: world = await _world(seeded) rule = ( await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO)) ).one() world.version.elimination_rules_version = rule.version_no await seeded.flush() await _source(seeded, world, credit=False, amount="100", account="62.01") await _source(seeded, world, credit=True, amount="90", account="60.01") await _source(seeded, world, credit=False, amount="500", account="76.07.5") await _source(seeded, world, credit=True, amount="400", account="76.07.1") client = await auth_client(KA_RF) response = await client.get(f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}") assert response.status_code == OK, response.text cell = next(item for item in response.json()["cells"] if item["company_a_id"] == str(world.members[0].id)) assert cell["amount_a"] == "100.00" assert cell["amount_b"] == "90.00" exported = await client.get( f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}/export" ) assert exported.status_code == OK, exported.text assert f"_rule-balance-{rule.rule_no}.xlsx" in unquote(exported.headers["content-disposition"]) sheet = load_workbook(BytesIO(exported.content))["Шахматка"] assert sheet["B6"].value == "Пресет" assert "76.07.5" not in sheet["B7"].value assert "76.07.1" not in sheet["B8"].value assert sheet["B9"].value == "Сальдо" assert sheet["B10"].value == f"ББ № {rule.rule_no}" assert sheet["B11"].value == rule.article_1_line assert sheet["B12"].value == rule.article_2_line assert sheet["B13"].value == f"v{rule.version_no}" assert Decimal(str(sheet["D16"].value)) == Decimal("100") assert Decimal(str(sheet["D17"].value)) == Decimal("90") audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one() assert audit.after_value["mode"] == "PRESET" assert audit.after_value["rule_id"] == str(rule.id) assert audit.after_value["rule_no"] == rule.rule_no assert audit.after_value["rule_version_no"] == rule.version_no @pytest.mark.parametrize("suffix", ["", "/export"]) async def test_preset_rejects_income_rule(auth_client: AuthClientFactory, seeded: AsyncSession, suffix: str) -> None: world = await _world(seeded) rule = ( await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO)) ).one() rule.statement_type = StatementType.income await seeded.flush() client = await auth_client(KA_RF) response = await client.get( f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}{suffix}" ) assert response.status_code == UNPROCESSABLE, response.text assert response.json()["detail"]["code"] == "INVALID_RULE" async def test_preset_uses_fixed_rules_version(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: world = await _world(seeded) rule = ( await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO)) ).one() assert world.version.elimination_rules_version == rule.version_no client = await auth_client(KA_RF) url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}" listed = await client.get(f"/consolidations/versions/{world.version.id}/elimination-rules") assert listed.status_code == OK, listed.text assert listed.json()["is_version_fixed"] is True assert listed.json()["version_no"] == rule.version_no assert "as_of" not in listed.json() assert str(rule.id) in {item["id"] for item in listed.json()["rules"]} pairs = await client.get( f"/consolidations/versions/{world.version.id}/elimination-pairs", params={ "rule_id": str(rule.id), "company_a_id": str(world.members[0].id), "company_b_id": str(world.members[1].id), }, ) assert pairs.status_code == OK, pairs.text response = await client.get(url) assert response.status_code == OK, response.text assert (await client.get(f"{url}/export")).status_code == OK audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one() assert audit.after_value["rule_no"] == rule.rule_no assert audit.after_value["rule_version_no"] == rule.version_no assert world.version.elimination_rules_version == rule.version_no # GET номер правил не меняет world.version.elimination_rules_version = rule.version_no + 1 await seeded.flush() assert (await client.get(url)).status_code == NOT_FOUND assert (await client.get(f"{url}/export")).status_code == NOT_FOUND world.version.elimination_rules_version = rule.version_no await seeded.flush() missing_url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{uuid.uuid4()}" missing = await client.get(missing_url) assert missing.status_code == NOT_FOUND assert (await client.get(f"{missing_url}/export")).status_code == NOT_FOUND async def test_fixed_rules_version_ignores_newer_rules(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: """Новая версия правил после создания версии консолидации на неё не влияет (§4, DBML v5.23).""" world = await _world(seeded) old_rule, new_rule = ( await seeded.scalars(select(EliminationRule).order_by(EliminationRule.rule_no).limit(2)) ).all() new_rule.rule_no = old_rule.rule_no new_rule.version_no = old_rule.version_no + 1 await seeded.flush() client = await auth_client(KA_RF) base = f"/consolidations/versions/{world.version.id}" response = await client.get(f"{base}/elimination-rules") assert response.status_code == OK, response.text payload = response.json() assert payload["is_version_fixed"] is True assert payload["version_no"] == old_rule.version_no assert str(new_rule.id) not in {item["id"] for item in payload["rules"]} assert str(old_rule.id) in {item["id"] for item in payload["rules"]} new_preset = await client.get(f"{base}/intercompany-matrix/preset/{new_rule.id}") assert new_preset.status_code == NOT_FOUND async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915 auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: world = await _world(seeded, members=3) rule_ids = { rule.rule_no: rule.id for rule in (await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no.in_([1, 3, 6])))).all() } debit_document = await make_confirmed_osv( seeded, entity=world.members[0], account="62.01", year=2024, quarter=PeriodQuarter.Q1, is_cumulative=True, rows=[ { "name": member.name, "counterparty_id": member.id, "account_id": world.accounts["62.01"].id, "closing_debit": Decimal(amount), } for member, amount in [ *((world.members[1], amount) for amount in ("40", "0", "200")), (world.members[2], "103"), ] ], ) second_debit_document = await make_confirmed_osv( seeded, entity=world.members[0], account="60.02", year=2024, quarter=PeriodQuarter.Q1, is_cumulative=True, rows=[ { "name": world.members[1].name, "counterparty_id": world.members[1].id, "account_id": world.accounts["60.02"].id, "closing_debit": Decimal("60"), }, { "name": world.members[2].name, "counterparty_id": world.members[2].id, "account_id": world.accounts["60.02"].id, "closing_debit": Decimal("20"), }, ], ) reverse_debit_document = await make_confirmed_osv( seeded, entity=world.members[1], account="62.01", year=2024, quarter=PeriodQuarter.Q1, is_cumulative=True, rows=[ { "name": world.members[0].name, "counterparty_id": world.members[0].id, "account_id": world.accounts["62.01"].id, "closing_debit": Decimal("70"), } ], ) reverse_credit_document = await make_confirmed_osv( seeded, entity=world.members[0], account="60.01", year=2024, quarter=PeriodQuarter.Q1, is_cumulative=True, rows=[ { "name": world.members[1].name, "counterparty_id": world.members[1].id, "account_id": world.accounts["60.01"].id, "closing_credit": Decimal("70"), } ], ) manual_debit_document = await make_confirmed_osv( seeded, entity=world.members[0], account="03", year=2024, quarter=PeriodQuarter.Q1, is_cumulative=True, rows=[ { "name": world.members[1].name, "counterparty_id": world.members[1].id, "account_id": world.accounts["03"].id, "closing_debit": Decimal("100"), } ], ) manual_credit_document = await make_confirmed_osv( seeded, entity=world.members[1], account="76.07.1", year=2024, quarter=PeriodQuarter.Q1, is_cumulative=True, rows=[ { "name": world.members[0].name, "counterparty_id": world.members[0].id, "account_id": world.accounts["76.07.1"].id, "closing_credit": Decimal("100"), } ], ) credit_document = await make_confirmed_osv( seeded, entity=world.members[1], account="60.01", year=2024, quarter=PeriodQuarter.Q1, is_cumulative=True, rows=[ { "name": world.members[0].name, "counterparty_id": world.members[0].id, "account_id": world.accounts["60.01"].id, "closing_credit": Decimal("100"), } ], ) other_credit_document = await make_confirmed_osv( seeded, entity=world.members[2], account="60.01", year=2024, quarter=PeriodQuarter.Q1, is_cumulative=True, rows=[ { "name": world.members[0].name, "counterparty_id": world.members[0].id, "account_id": world.accounts["60.01"].id, "closing_credit": Decimal("100"), } ], ) seeded.add_all( [ ConsolidationDocument(consolidation_version_id=world.version.id, document_id=document.id) for document in ( debit_document, second_debit_document, credit_document, other_credit_document, reverse_debit_document, reverse_credit_document, manual_debit_document, manual_credit_document, ) ] ) await seeded.flush() client = await auth_client(KA_RF) url = f"/consolidations/versions/{world.version.id}/elimination-pairs" company_a_id, company_b_id, company_c_id = (member.id for member in world.members) params = {"rule_id": str(rule_ids[6]), "company_a_id": str(company_a_id), "company_b_id": str(company_b_id)} response = await client.get(url, params=params) assert response.status_code == OK, response.text payload = response.json() assert payload["version_no"] == 1 assert (payload["rule_id"], payload["rule_no"], payload["company_a_id"], payload["company_b_id"]) == ( str(rule_ids[6]), 6, str(company_a_id), str(company_b_id), ) assert "account_pairs" not in payload assert payload["with_discrepancy"] == [] assert (Decimal(payload["amount_a"]), Decimal(payload["amount_b"])) == (Decimal("300"), Decimal("100")) assert Decimal(payload["elimination_amount"]) == Decimal("200") assert payload["exceeds_threshold"] is True assert Decimal(payload["eliminated_amount"]) == 0 assert len(payload["matched"]) == 1 pair = payload["matched"][0] assert len(pair["osv_entry_ids_a"]) == len((debit_document, second_debit_document)) assert len(pair["osv_entry_ids_b"]) == 1 assert pair["rule_id"] == str(rule_ids[6]) assert tuple(Decimal(pair[field]) for field in ("amount_a", "amount_b", "elimination_amount")) == ( Decimal("100"), Decimal("100"), Decimal("0"), ) assert "article_ids_a" not in pair and "article_ids_b" not in pair assert "deviation_amount" not in pair assert Decimal(pair["deviation_percent"]) == 0 assert [item["reason"] for item in payload["unpaired"]] == ["ZERO_AMOUNT", "NO_MATCH"] loans = await client.get(url, params={**params, "rule_id": str(rule_ids[3])}) assert loans.status_code == OK, loans.text assert loans.json()["matched"] == [] manual = await client.get(url, params={**params, "rule_id": str(rule_ids[1])}) assert manual.status_code == OK, manual.text assert len(manual.json()["matched"]) == 1 assert Decimal(manual.json()["eliminated_amount"]) == 0 other_pair = await client.get(url, params={**params, "company_b_id": str(company_c_id)}) assert other_pair.status_code == OK, other_pair.text assert other_pair.json()["matched"] == [] assert len(other_pair.json()["with_discrepancy"]) == 1 discrepant = other_pair.json()["with_discrepancy"][0] assert (Decimal(discrepant["amount_a"]), Decimal(discrepant["amount_b"])) == (Decimal("103"), Decimal("100")) assert Decimal(discrepant["elimination_amount"]) == Decimal("3") assert "article_ids_a" not in discrepant and "article_ids_b" not in discrepant assert "deviation_amount" not in discrepant assert (Decimal(other_pair.json()["amount_a"]), Decimal(other_pair.json()["amount_b"])) == ( Decimal("123"), Decimal("100"), ) assert other_pair.json()["exceeds_threshold"] is True assert Decimal(other_pair.json()["eliminated_amount"]) == 0 reversed_pair = await client.get( url, params={**params, "company_a_id": str(company_b_id), "company_b_id": str(company_a_id)} ) assert reversed_pair.status_code == OK, reversed_pair.text assert len(reversed_pair.json()["matched"]) == 1 assert reversed_pair.json()["with_discrepancy"] == [] assert reversed_pair.json()["unpaired"] == [] assert reversed_pair.json()["matched"][0]["osv_entry_ids_a"] assert reversed_pair.json()["matched"][0]["osv_entry_ids_b"] assert (Decimal(reversed_pair.json()["amount_a"]), Decimal(reversed_pair.json()["amount_b"])) == ( Decimal("70"), Decimal("70"), ) assert Decimal(reversed_pair.json()["eliminated_amount"]) == Decimal("70") invalid_pair = await client.get(url, params={**params, "company_b_id": str(uuid.uuid4())}) assert invalid_pair.status_code == UNPROCESSABLE same_company = await client.get(url, params={**params, "company_b_id": str(company_a_id)}) assert same_company.status_code == UNPROCESSABLE missing_rule = await client.get(url, params={**params, "rule_id": str(uuid.uuid4())}) assert missing_rule.status_code == NOT_FOUND missing_parameters = await client.get(url) assert missing_parameters.status_code == UNPROCESSABLE missing = await client.get(f"/consolidations/versions/{uuid.uuid4()}/elimination-pairs", params=params) assert missing.status_code == NOT_FOUND # список версии — те же итоги, что у тройки, одним запросом (§5 п. 3) totals = await client.get(f"/consolidations/versions/{world.version.id}/elimination-totals") assert totals.status_code == OK, totals.text assert totals.json()["version_no"] == 1 by_triple = {(item["rule_no"], item["company_a_id"], item["company_b_id"]): item for item in totals.json()["items"]} a, b, c = str(company_a_id), str(company_b_id), str(company_c_id) main = by_triple[(6, a, b)] assert (main["amount_a"], main["amount_b"], main["deviation_amount"]) == ("300.00", "100.00", "200.00") assert (main["deviation_percent"], main["eliminated_amount"]) == ("200.00", "0.00") assert (main["status"], main["exceeds_threshold"], main["elimination_mode"]) == ("DISCREPANCY", True, "min") # у A по B три ненулевые строки: 62.01 40 и 200, 60.02 60; строка 0 в сумму не входит assert (len(main["osv_entry_ids_a"]), len(main["osv_entry_ids_b"])) == (len(("40", "200", "60")), 1) mirrored = by_triple[(6, b, a)] assert (mirrored["status"], mirrored["eliminated_amount"]) == ("AUTO", "70.00") assert by_triple[(6, a, c)]["status"] == "DISCREPANCY" assert (by_triple[(1, a, b)]["status"], by_triple[(1, a, b)]["eliminated_amount"]) == ("MANUAL", "0.00") # дебиторка № 6 входит и в № 7 (займы 67), встречной стороны нет — двойной учёт до AURORA-1751 one_sided = by_triple[(7, a, b)] assert (one_sided["status"], one_sided["amount_b"], one_sided["deviation_percent"]) == ("ONE_SIDED", "0.00", None) assert (3, a, b) not in by_triple # строк по счетам правила нет — тройки нет assert [item["rule_no"] for item in totals.json()["items"]] == sorted( item["rule_no"] for item in totals.json()["items"] ) missing_totals = await client.get(f"/consolidations/versions/{uuid.uuid4()}/elimination-totals") assert missing_totals.status_code == NOT_FOUND async def test_all_excludes_off_balance_accounts_but_manual_can_select_them( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: world = await _world(seeded) assert world.accounts["001"].is_off_balance is True await _source(seeded, world, credit=False, amount="100", account="001") client = await auth_client(KA_RF) all_response = await _request(client, world.version.id, "all", world.selection) assert all_response.status_code == OK, all_response.text assert all(Decimal(cell["amount_a"]) == 0 for cell in all_response.json()["cells"]) manual_selection = { "debit_account_ids": [str(world.accounts["001"].id)], "credit_account_ids": [str(world.accounts["60.01"].id)], } manual_response = await _request(client, world.version.id, "manual", manual_selection) assert manual_response.status_code == OK, manual_response.text cell = next(c for c in manual_response.json()["cells"] if c["company_a_id"] == str(world.members[0].id)) assert Decimal(cell["amount_a"]) == Decimal("100") async def test_unmapped_account_source_is_visible_in_cell_count( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: world = await _world(seeded) document_id = await _source(seeded, world, credit=False, amount="100") entry = (await seeded.scalars(select(OsvEntry).where(OsvEntry.document_id == document_id))).one() entry.account_id = None await seeded.flush() await _source(seeded, world, credit=True, amount="80") response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection) assert response.status_code == OK, response.text cells = response.json()["cells"] assert [cell["skipped_osv_entry_count"] for cell in cells] == [1, 1] cell = next(c for c in cells if c["company_a_id"] == str(world.members[0].id)) assert cell["amount_a"] == "0.00" assert cell["amount_b"] == "80.00" assert cell["osv_entry_ids_debit"] == [] assert len(cell["osv_entry_ids_credit"]) == 1 @pytest.mark.parametrize("credit", [False, True]) @pytest.mark.parametrize("amount", ["0", "100.25"]) async def test_pair_without_counterpart( auth_client: AuthClientFactory, seeded: AsyncSession, credit: bool, amount: str ) -> None: world = await _world(seeded) await _source(seeded, world, credit=credit, amount=amount) response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection) assert response.status_code == OK, response.text cell = next(c for c in response.json()["cells"] if c["company_a_id"] == str(world.members[0].id)) assert Decimal(cell["amount_a"]) == (Decimal(0) if credit else Decimal(amount)) assert Decimal(cell["amount_b"]) == (Decimal(amount) if credit else Decimal(0)) assert cell["one_sided"] is (Decimal(amount) != 0) assert cell["variance_percent"] is None assert cell["exceeds_threshold"] is False assert bool(cell["osv_entry_ids_credit"]) is credit assert bool(cell["osv_entry_ids_debit"]) is not credit @pytest.mark.parametrize("suffix", ROUTES) async def test_visibility_and_permission(auth_client: AuthClientFactory, seeded: AsyncSession, suffix: str) -> None: world = await _world(seeded) foreign = await auth_client("client_manager@armka.local") hidden = await _request(foreign, world.version.id, suffix, world.selection) unknown = await _request(foreign, uuid.uuid4(), suffix, world.selection) assert hidden.status_code == unknown.status_code == NOT_FOUND assert hidden.json() == unknown.json() forbidden = await _request(await auth_client("admin@armka.local"), world.version.id, suffix, world.selection) assert forbidden.status_code == FORBIDDEN assert "CONSOLIDATION_VIEW" in forbidden.json()["detail"] @pytest.mark.parametrize(("debit", "credit", "percent"), [("8890", "8000", "11.13"), ("105001", "100000", "5.00")]) async def test_rounded_percent_in_api( auth_client: AuthClientFactory, seeded: AsyncSession, debit: str, credit: str, percent: str ) -> None: world = await _world(seeded) await _source(seeded, world, credit=False, amount=debit) await _source(seeded, world, credit=True, amount=credit) response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection) assert response.status_code == OK, response.text cell = next(c for c in response.json()["cells"] if c["company_a_id"] == str(world.members[0].id)) assert cell["variance_percent"] == percent assert cell["exceeds_threshold"] is True assert cell["one_sided"] is False @pytest.mark.parametrize("suffix", ["manual", "manual/export"]) @pytest.mark.parametrize("side", ["debit_account_ids", "credit_account_ids"]) @pytest.mark.parametrize("code", ["ACCOUNT_NOT_FOUND", "ACCOUNT_KIND_MISMATCH"]) async def test_invalid_account_selection( auth_client: AuthClientFactory, seeded: AsyncSession, suffix: str, side: str, code: str ) -> None: world = await _world(seeded) selection = world.selection wrong_kind = world.accounts["80" if side == "debit_account_ids" else "01"] selection[side] = [str(uuid.uuid4() if code == "ACCOUNT_NOT_FOUND" else wrong_kind.id)] response = await _request(await auth_client(KA_RF), world.version.id, suffix, selection) assert response.status_code == UNPROCESSABLE, response.text assert response.json()["detail"]["code"] == code @pytest.mark.parametrize("suffix", ["result-table", "result-table/export"]) async def test_result_accounts_missing(auth_client: AuthClientFactory, seeded: AsyncSession, suffix: str) -> None: world = await _world(seeded) # Меняем код в тестовой транзакции, сохраняя FK существующих правил и потомков. world.accounts["58.03"].code = "58.03-missing" await seeded.flush() response = await _request(await auth_client(KA_RF), world.version.id, suffix, world.selection) assert response.status_code == UNPROCESSABLE, response.text assert response.json()["detail"]["code"] == "RESULT_ACCOUNTS_MISSING" assert "58.03" in response.json()["detail"]["message"] assert not (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).all() async def test_result_table_export_matches_json(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: world = await _world(seeded) await _source(seeded, world, credit=False, amount="100.25") client = await auth_client(KA_RF) result = await _request(client, world.version.id, "result-table", world.selection) export = await _request(client, world.version.id, "result-table/export", world.selection) assert result.status_code == export.status_code == OK data = result.json() sheet = load_workbook(BytesIO(export.content))["Результирующая таблица"] assert [sheet.cell(7, col).value for col in range(2, len(data["companies"]) + 2)] == [ company["name"] for company in data["companies"] ] for row, article in enumerate(data["articles"], start=8): assert sheet.cell(row, 1).value == article["name"] amounts = {item["company_id"]: Decimal(item["amount"]) for item in article["amounts"]} for col, company in enumerate(data["companies"], start=2): assert Decimal(str(sheet.cell(row, col).value)) == amounts[company["id"]] assert Decimal(str(sheet.cell(row, len(data["companies"]) + 2).value)) == Decimal(article["group_total"]) event = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one() assert event.after_value == {"report": "intercompany_result_table", "articles": len(data["articles"])} @pytest.mark.parametrize("mode", ["all", "manual"]) async def test_export_filename_header_and_percentage( auth_client: AuthClientFactory, seeded: AsyncSession, mode: str ) -> None: world = await _world(seeded) await _source(seeded, world, credit=False, amount="100") await _source(seeded, world, credit=True, amount="90") group_name = ( await seeded.scalars(select(CompanyGroup.name).where(CompanyGroup.id == world.members[0].company_group_id)) ).one() response = await _request(await auth_client(KA_RF), world.version.id, f"{mode}/export", world.selection) assert response.status_code == OK, response.text disposition = response.headers["content-disposition"] assert unquote(disposition.split("filename*=UTF-8''", 1)[1]) == ( f"intercompany-matrix_{group_name}_2024-Q1_v1.xlsx" ) workbook = load_workbook(BytesIO(response.content)) sheet = workbook["Шахматка"] assert [sheet[f"B{row}"].value for row in range(2, 9)] == [ group_name, "2024-Q1", "v1", "руб.; расхождение — %", "Выбранные счета" if mode == "manual" else "Все балансовые счета", "62.01" if mode == "manual" else "Все балансовые счета", "60.01" if mode == "manual" else "Все балансовые счета", ] assert sheet["B14"].value == "Расхождение, %" expected_percent = Decimal("11.11") assert Decimal(str(sheet["D14"].value)) == expected_percent assert sheet["D14"].number_format == '0.00"%"' @pytest.mark.parametrize("mode", ["all", "manual"]) async def test_export_appends_audit(auth_client: AuthClientFactory, seeded: AsyncSession, mode: str) -> None: world = await _world(seeded) client = await auth_client(KA_RF) query = select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT) for _ in range(2): response = await _request(client, world.version.id, f"{mode}/export", world.selection) assert response.status_code == OK, response.text events = (await seeded.scalars(query)).all() assert len(events) == 2 # noqa: PLR2004 - отдельное событие на каждую выгрузку assert events[0].id != events[1].id for event in events: assert event.section == AuditSection.EXPORT assert event.object_type == "consolidation_version" assert event.object_id == event.consolidation_version_id == world.version.id assert event.user_id is not None and event.before_value is None assert event.after_value == { "report": "intercompany_matrix", "mode": mode.upper(), "cells": len(world.members), **(world.selection if mode == "manual" else {"debit_account_ids": [], "credit_account_ids": []}), }