766 lines
36 KiB
Python
766 lines
36 KiB
Python
"""Критерии шахматки через 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_elimination_pairs_reject_turnover_rule(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||
"""Пары §5 — только по сальдо: шахматка по правилу оборотов считает другие суммы."""
|
||
world = await _world(seeded)
|
||
rule = (
|
||
await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO))
|
||
).one()
|
||
rule.measure = "turnover"
|
||
await seeded.flush()
|
||
|
||
client = await auth_client(KA_RF)
|
||
company_a, company_b = world.members[:2]
|
||
response = await client.get(
|
||
f"/consolidations/versions/{world.version.id}/elimination-pairs",
|
||
params={"rule_id": str(rule.id), "company_a_id": str(company_a.id), "company_b_id": str(company_b.id)},
|
||
)
|
||
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()
|
||
listed_rule = next(item for item in listed.json()["rules"] if item["id"] == str(rule.id))
|
||
assert listed_rule["article_1_line"] == rule.article_1_line
|
||
assert listed_rule["article_2_line"] == rule.article_2_line
|
||
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 (payload["amount_a"], payload["amount_b"], payload["deviation_amount"]) == ("300.00", "100.00", "200.00")
|
||
assert payload["deviation_percent"] == "200.00"
|
||
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(pair[field] for field in ("amount_a", "amount_b", "deviation_amount", "deviation_percent")) == (
|
||
"100.00",
|
||
"100.00",
|
||
"0.00",
|
||
"0.00",
|
||
)
|
||
assert "article_ids_a" not in pair and "article_ids_b" not in pair
|
||
assert "elimination_amount" not in pair and "elimination_amount" not in payload
|
||
assert [item["reason"] for item in payload["unpaired"]] == ["ZERO_AMOUNT", "NO_MATCH"]
|
||
assert [item["row"]["closing_debit"] for item in payload["unpaired"]] == ["0.00", "200.00"]
|
||
|
||
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 (discrepant["deviation_amount"], discrepant["deviation_percent"]) == ("3.00", "3.00")
|
||
assert "article_ids_a" not in discrepant and "article_ids_b" 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()
|
||
receivables = next(article for article in data["articles"] if article["code"] == "receivables")
|
||
assert sorted(item["amount"] for item in receivables["amounts"]) == ["0.00", "100.25"]
|
||
assert receivables["group_total"] == "100.25"
|
||
assert all(article["group_total"] == "0.00" for article in data["articles"] if article != receivables)
|
||
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": []}),
|
||
}
|