"""Группировка найденных пар ВГО и единственность исходных строк во всей версии.""" 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, [])