arm-core/api/tests/test_vgo.py

408 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Группировка найденных пар ВГО и единственность исходных строк во всей версии."""
import uuid
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from src.common.exceptions import DomainValidationError
from src.domain.consolidated_osv import CounterpartyType
from src.domain.vgo_distribution import DistributionInput, distribute_vgo_rows
from src.models.enums import StatementType
from src.schemas.consolidations import EliminationMatchedPairOut
from src.services import vgo
def _pair( # noqa: PLR0913 - фабрика тестовой пары с настраиваемой суммой
rule_id: uuid.UUID,
company_a: uuid.UUID,
company_b: uuid.UUID,
entry_a: uuid.UUID,
entry_b: uuid.UUID,
*,
amount_b: str = "10",
) -> EliminationMatchedPairOut:
deviation = Decimal("10") - Decimal(amount_b)
return EliminationMatchedPairOut(
rule_id=rule_id,
company_a_id=company_a,
company_b_id=company_b,
amount_a=Decimal("10"),
amount_b=Decimal(amount_b),
deviation_amount=deviation,
deviation_percent=deviation / Decimal(amount_b) * 100,
osv_entry_ids_a=[entry_a],
osv_entry_ids_b=[entry_b],
)
def _row(entry_id: uuid.UUID, company_a: uuid.UUID, company_b: uuid.UUID, index: int) -> SimpleNamespace:
return SimpleNamespace(
entry_ids=(entry_id,),
legal_entity_id=company_a,
legal_entity_name="A",
legal_entity_inn="1234567890",
account_id=None,
account_code="76",
account_root="76",
period="2026 Q1",
is_cumulative=True,
row_number=index,
counterparty_name="B",
counterparty_inn="0987654321",
counterparty_id=company_b,
counterparty_entity_name="B",
counterparty_entity_inn="0987654321",
counterparty_type=CounterpartyType.GSK,
match_kind=None,
match_stored=True,
opening_debit=Decimal("0"),
opening_credit=Decimal("0"),
turnover_debit=Decimal("0"),
turnover_credit=Decimal("0"),
closing_debit=Decimal("1"),
closing_credit=Decimal("0"),
warnings=(),
)
def _members(company_a: uuid.UUID, company_b: uuid.UUID) -> dict[uuid.UUID, SimpleNamespace]:
return {company_a: SimpleNamespace(name="A"), company_b: SimpleNamespace(name="B")}
def test_partially_overlapping_diff_aggregates_remain_unassigned() -> None:
rule_6, rule_7, company_a, company_b, a_76, b_60, b_76, extra_a, extra_b = (uuid.uuid4() for _ in range(9))
first = _pair(rule_6, company_a, company_b, a_76, b_60, amount_b="9")
second = _pair(rule_7, company_a, company_b, a_76, b_76, amount_b="9")
independent = _pair(rule_6, company_a, company_b, extra_a, extra_b, amount_b="9")
distribution = distribute_vgo_rows(
DistributionInput(
rows=[],
exact_ids=set(),
diff_ids=[vgo._ids_from_aggregate(pair) for pair in (first, second, independent)],
manual_keys=set(),
owners={},
members=set(),
)
)
assert distribution.selected_diff == (2,)
assert distribution.used_ids == {extra_a, extra_b}
async def test_unpaired_row_with_unique_rule_account_is_auto_assigned(monkeypatch: pytest.MonkeyPatch) -> None:
version_id, company_a, company_b, rule_1, rule_2, unique_account, shared_account = (uuid.uuid4() for _ in range(7))
unique_row = _row(uuid.uuid4(), company_a, company_b, 1)
unique_row.account_id = unique_account
unique_row.closing_debit = Decimal("7")
shared_row = _row(uuid.uuid4(), company_a, company_b, 2)
shared_row.account_id = shared_account
shared_row.closing_debit = Decimal("3")
outside_row = _row(uuid.uuid4(), company_a, company_b, 3)
outside_row.account_id = uuid.uuid4()
rules = [
SimpleNamespace(
id=rule_id,
rule_no=number,
statement_type=StatementType.balance,
measure="closing",
side_1="D",
article_1_line="A",
article_2_line="B",
)
for number, rule_id in enumerate((rule_1, rule_2), start=1)
]
osv_result = SimpleNamespace(
version=SimpleNamespace(id=version_id, elimination_rules_version=2),
consolidated=SimpleNamespace(rows=[unique_row, shared_row, outside_row]),
members=_members(company_a, company_b),
)
monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=rules))
monkeypatch.setattr(vgo.vgo_assignments_repo, "list_assignments", AsyncMock(return_value=[]))
monkeypatch.setattr(vgo.directories_repo, "list_accounts", AsyncMock(return_value=[]))
async def resolve(rule_id: uuid.UUID, *_args: object) -> tuple[SimpleNamespace, SimpleNamespace]:
account_ids = {shared_account, unique_account} if rule_id == rule_1 else {shared_account}
selection = SimpleNamespace(company_1_account_ids=frozenset(account_ids), company_2_account_ids=frozenset())
return selection, rules[0]
matrix = SimpleNamespace(
_resolve_preset_selection=AsyncMock(side_effect=resolve),
build_pairs=AsyncMock(return_value=SimpleNamespace(matched=[], with_discrepancy=[])),
)
osv_service = SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result))
service = vgo.VgoService(AsyncMock(), osv_service, matrix)
response = await service.assignment_candidates(version_id)
assert len(response.groups) == 1
assert response.groups[0].rule_id == rule_1
assert response.groups[0].amount_a == Decimal("7.00")
assert response.groups[0].manual[0].entry_ids == [unique_row.entry_ids[0]]
assert response.groups[0].manual[0].is_auto_assigned is True
assert response.groups[0].manual[0].is_exclude is False
assert len(response.groups[0].positions) == 1
assert response.groups[0].positions[0].side == "D"
assert response.groups[0].positions[0].amount == Decimal("7.00")
assert response.groups[0].positions[0].action is None
assert [row.entry_ids for row in response.unassigned] == [[shared_row.entry_ids[0]]]
assert len(response.unallocated) == 1
assert response.unallocated[0].side == "D"
assert response.unallocated[0].amount == Decimal("3.00")
assert response.unallocated[0].candidate_rule_ids == [rule_1, rule_2]
assert response.unallocated[0].reason == "MULTIPLE_RULES"
assert "match_stored" not in response.unassigned[0].model_dump()
async def test_pairs_are_grouped_and_entry_ids_are_used_once(monkeypatch: pytest.MonkeyPatch) -> None:
version_id, company_a, company_b, rule_1, rule_2 = (uuid.uuid4() for _ in range(5))
entry_ids = [uuid.uuid4() for _ in range(11)]
rows = [_row(entry_id, company_a, company_b, index) for index, entry_id in enumerate(entry_ids)]
osv_result = SimpleNamespace(
version=SimpleNamespace(elimination_rules_version=2),
consolidated=SimpleNamespace(rows=rows),
members=_members(company_a, company_b),
)
rules = [
SimpleNamespace(
id=rule_1,
rule_no=1,
statement_type=StatementType.balance,
measure="closing",
side_1="D",
article_1_line="Статья A1",
article_2_line="Статья B1",
),
SimpleNamespace(
id=rule_2,
rule_no=2,
statement_type=StatementType.balance,
measure="closing",
side_1="D",
article_1_line="Статья A2",
article_2_line="Статья B2",
),
]
monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=list(reversed(rules))))
monkeypatch.setattr(vgo.vgo_assignments_repo, "list_assignments", AsyncMock(return_value=[]))
exact_1 = _pair(rule_1, company_a, company_b, entry_ids[0], entry_ids[1])
exact_1_more = _pair(rule_1, company_a, company_b, entry_ids[9], entry_ids[10])
exact_conflict = _pair(rule_2, company_a, company_b, entry_ids[0], entry_ids[2])
exact_2 = _pair(rule_2, company_b, company_a, entry_ids[2], entry_ids[3])
discrepancy_1 = _pair(rule_1, company_a, company_b, entry_ids[4], entry_ids[5])
discrepancy_conflict = _pair(rule_2, company_b, company_a, entry_ids[5], entry_ids[4])
discrepancy_unique = _pair(rule_1, company_b, company_a, entry_ids[7], entry_ids[8], amount_b="9")
async def build_pairs(
_version: uuid.UUID, rule: uuid.UUID, a: uuid.UUID, b: uuid.UUID, available: list, **_kwargs: object
) -> SimpleNamespace:
if (rule, a, b) == (rule_1, company_a, company_b):
return SimpleNamespace(rule_no=1, matched=[exact_1, exact_1_more], with_discrepancy=[discrepancy_1])
if (rule, a, b) == (rule_2, company_a, company_b):
assert entry_ids[0] not in {row.entry_ids[0] for row in available}
return SimpleNamespace(rule_no=2, matched=[exact_conflict], with_discrepancy=[])
if (rule, a, b) == (rule_1, company_b, company_a):
return SimpleNamespace(rule_no=1, matched=[], with_discrepancy=[discrepancy_unique])
if (rule, a, b) == (rule_2, company_b, company_a):
return SimpleNamespace(rule_no=2, matched=[exact_2], with_discrepancy=[discrepancy_conflict])
return SimpleNamespace(rule_no=1, matched=[], with_discrepancy=[])
service = vgo.VgoService(
AsyncMock(),
SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)),
SimpleNamespace(build_pairs=AsyncMock(side_effect=build_pairs)),
)
account_id = uuid.uuid4()
for row in rows:
row.account_id = account_id
service._rule_accounts = AsyncMock(return_value={account_id: frozenset({rule_1, rule_2})})
response = await service.assignment_candidates(version_id)
calls = service._matrix_service.build_pairs.await_args_list
assert [(call.args[1], call.args[2], call.args[3]) for call in calls] == [
(rule_1, company_a, company_b),
(rule_1, company_b, company_a),
(rule_2, company_a, company_b),
(rule_2, company_b, company_a),
]
groups = response.groups
assert [(group.rule_id, group.company_a_id, group.company_b_id) for group in groups] == [
(rule_1, company_a, company_b),
(rule_2, company_b, company_a),
(rule_1, company_b, company_a),
]
assert groups[0].matched == [exact_1, exact_1_more]
assert (groups[0].article_1_line, groups[0].article_2_line) == ("Статья A1", "Статья B1")
assert (groups[0].company_a_name, groups[0].company_b_name) == ("A", "B")
assert (groups[0].amount_a, groups[0].amount_b, groups[0].deviation_amount) == (
Decimal("20.00"),
Decimal("20.00"),
Decimal("0.00"),
)
assert groups[0].with_discrepancy == []
assert "manual" not in response.model_dump()["groups"][0]
assert groups[1].matched == [exact_2]
assert groups[1].with_discrepancy == []
assert groups[2].matched == []
assert groups[2].with_discrepancy == [discrepancy_unique]
assert (groups[2].amount_a, groups[2].amount_b, groups[2].deviation_amount, groups[2].deviation_percent) == (
Decimal("10.00"),
Decimal("9.00"),
Decimal("1.00"),
Decimal("11.11"),
)
assert [row.entry_ids for row in response.unassigned] == [
[entry_ids[4]],
[entry_ids[5]],
[entry_ids[6]],
]
async def test_manual_assignments_override_auto_and_show_exclusion_flag(monkeypatch: pytest.MonkeyPatch) -> None:
version_id, company_a, company_b, rule_id = (uuid.uuid4() for _ in range(4))
entry_ids = [uuid.uuid4() for _ in range(4)]
rows = [_row(entry_id, company_a, company_b, index) for index, entry_id in enumerate(entry_ids)]
for row, amount in zip(rows, ("10", "20", "5", "1"), strict=True):
row.closing_debit = Decimal(amount)
osv_result = SimpleNamespace(
version=SimpleNamespace(elimination_rules_version=2),
consolidated=SimpleNamespace(rows=rows),
members=_members(company_a, company_b),
)
rule = SimpleNamespace(
id=rule_id,
rule_no=1,
statement_type=StatementType.balance,
measure="closing",
side_1="D",
article_1_line="Статья A",
article_2_line="Статья B",
)
assignments = [
SimpleNamespace(osv_entry_id=entry_ids[0], rule_id=rule_id, action="ASSIGN", is_exclude=False),
SimpleNamespace(osv_entry_id=entry_ids[1], rule_id=rule_id, action="ASSIGN", is_exclude=False),
SimpleNamespace(osv_entry_id=entry_ids[2], rule_id=None, action="UNASSIGNED", is_exclude=True),
]
monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=[rule]))
monkeypatch.setattr(vgo.vgo_assignments_repo, "list_assignments", AsyncMock(return_value=assignments))
async def build_pairs(
_version: uuid.UUID, _rule: uuid.UUID, _a: uuid.UUID, _b: uuid.UUID, available: list, **_kwargs: object
) -> SimpleNamespace:
assert [row.entry_ids[0] for row in available] == [entry_ids[3]]
return SimpleNamespace(matched=[], with_discrepancy=[])
service = vgo.VgoService(
AsyncMock(),
SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)),
SimpleNamespace(build_pairs=AsyncMock(side_effect=build_pairs)),
)
response = await service.assignment_candidates(version_id)
assert len(response.groups) == 1
assert (response.groups[0].rule_id, response.groups[0].company_a_id, response.groups[0].company_b_id) == (
rule_id,
company_a,
company_b,
)
assert (response.groups[0].amount_a, response.groups[0].amount_b) == (Decimal("30.00"), Decimal("0.00"))
assert [(row.entry_ids, row.is_exclude) for row in response.groups[0].manual] == [
([entry_ids[0]], False),
([entry_ids[1]], False),
]
assert "manual" in response.model_dump()["groups"][0]
assert [position.action for position in response.groups[0].positions] == ["ASSIGN", "ASSIGN"]
assert [(row.entry_ids, row.action, row.is_exclude) for row in response.unassigned] == [
([entry_ids[2]], "UNASSIGNED", True),
]
assert response.unallocated[0].reason == "UNASSIGNED"
assert response.unallocated[0].excluded is True
async def test_partial_manual_assignment_stays_unassigned_and_is_not_searched(monkeypatch: pytest.MonkeyPatch) -> None:
version_id, company_a, company_b, rule_id, entry_a, entry_b = (uuid.uuid4() for _ in range(6))
row = _row(entry_a, company_a, company_b, 1)
row.entry_ids = (entry_a, entry_b)
free_row = _row(uuid.uuid4(), company_a, company_b, 2)
osv_result = SimpleNamespace(
version=SimpleNamespace(elimination_rules_version=2),
consolidated=SimpleNamespace(rows=[row, free_row]),
members=_members(company_a, company_b),
)
assignment = SimpleNamespace(osv_entry_id=entry_a, rule_id=rule_id, action="ASSIGN", is_exclude=False)
rule = SimpleNamespace(id=rule_id, rule_no=1, statement_type=StatementType.balance, measure="closing")
monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=[rule]))
monkeypatch.setattr(vgo.vgo_assignments_repo, "list_assignments", AsyncMock(return_value=[assignment]))
matrix = SimpleNamespace(build_pairs=AsyncMock(return_value=SimpleNamespace(matched=[], with_discrepancy=[])))
service = vgo.VgoService(
AsyncMock(),
SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)),
matrix,
)
response = await service.assignment_candidates(version_id)
assert response.groups == []
assert [item.entry_ids for item in response.unassigned] == [[entry_a, entry_b]]
assert response.unassigned[0].unassigned_reason == "PARTIAL_MANUAL_ASSIGNMENT"
assert response.unassigned[0].action is None
assert response.unallocated[0].reason == "UNASSIGNED"
assert all(call.args[4] == [free_row] for call in matrix.build_pairs.await_args_list)
async def test_glued_row_with_same_assignments_is_counted_once(monkeypatch: pytest.MonkeyPatch) -> None:
version_id, company_a, company_b, rule_id, entry_a, entry_b = (uuid.uuid4() for _ in range(6))
row = _row(entry_a, company_a, company_b, 1)
row.entry_ids = (entry_a, entry_b)
row.closing_debit = Decimal("10")
osv_result = SimpleNamespace(
version=SimpleNamespace(elimination_rules_version=2),
consolidated=SimpleNamespace(rows=[row]),
members=_members(company_a, company_b),
)
rule = SimpleNamespace(
id=rule_id,
rule_no=1,
statement_type=StatementType.balance,
measure="closing",
side_1="D",
article_1_line="Статья A",
article_2_line="Статья B",
)
assignments = [
SimpleNamespace(osv_entry_id=entry_id, rule_id=rule_id, action="ASSIGN", is_exclude=False)
for entry_id in (entry_a, entry_b)
]
monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=[rule]))
monkeypatch.setattr(vgo.vgo_assignments_repo, "list_assignments", AsyncMock(return_value=assignments))
empty_result = SimpleNamespace(matched=[], with_discrepancy=[])
matrix_service = SimpleNamespace(build_pairs=AsyncMock(return_value=empty_result))
service = vgo.VgoService(
AsyncMock(), SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)), matrix_service
)
response = await service.assignment_candidates(version_id)
assert len(response.groups) == 1
assert response.groups[0].amount_a == Decimal("10.00")
assert len(response.groups[0].manual) == 1
assert response.groups[0].manual[0].entry_ids == [entry_a, entry_b]
assert "osv_entry_id" not in response.groups[0].manual[0].model_dump()
assert response.unassigned == []
assert all(call.args[4] == [] for call in matrix_service.build_pairs.await_args_list)
def test_glued_row_rejects_conflicting_manual_assignments() -> None:
company_a, company_b, rule_id, entry_a, entry_b = (uuid.uuid4() for _ in range(5))
row = _row(entry_a, company_a, company_b, 1)
row.entry_ids = (entry_a, entry_b)
assignments = [
SimpleNamespace(osv_entry_id=entry_a, rule_id=rule_id, action="ASSIGN", is_exclude=False),
SimpleNamespace(osv_entry_id=entry_b, rule_id=None, action="UNASSIGNED", is_exclude=False),
]
with pytest.raises(DomainValidationError, match="разные ручные назначения"):
vgo._manual_assignments([row], assignments, [])