arm-core/api/tests/test_vgo.py
Andrey Epifancev d13a9554a1 fix(#237): «76-исключения» в правилах элиминации и разнесение по стороне строки (AURORA-2034)
Замечание заказчика 09.11.2026, п. 1: счета 76 (аренда, НДС) не должны участвовать в
расчётах и засорять таблицу корректировок. Список субсчетов — Лысенко 10.11.2026.

- seed правил v1 (правка на месте, решение владельца 10.11.2026): из маски 76 на обеих
  сторонах правил 5–8 исключены 76.07/76.27/76.37 .1/.5/.9, 76.АВ, 76.ВА, 76.К, 76.Н, 76.НА,
  76.ОТ; правило 1 не меняется; на стендах нужен повторный seed;
- результирующая таблица §6 (RESULT_ARTICLES) — тот же список ACCOUNT_76_EXCLUDED, совпадение
  с seed проверяет тест;
- дефект: VgoService._rule_accounts сливал счета дебета и кредита правила, исключения одной
  стороны возвращались через маску другой — Кт-строки 76.* попадали в правила 5–8 и висели
  в нераспределённых (MULTIPLE_RULES); то же в candidate_rule_ids. Теперь account_owners
  ведёт стороны раздельно, правило строки — по её стороне (row_side, rules_for);
- golden-тест правила 6 на фикстурах РБ: 76.АВ вне правила, ΣA 108 062 446,46, 1,65 %
  (совпадает с эталоном, OQ-164).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:49:52 +04:00

497 lines
22 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, account_owners, 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}
def test_account_owners_keep_debit_and_credit_sides_apart() -> None:
"""Счёт в дебете правил 5 и 6 и в кредите только правила 6: по стороне строки — разные правила (AURORA-2034)."""
rule_5, rule_6, account = (uuid.uuid4() for _ in range(3))
owners = account_owners([(rule_5, {account}, set()), (rule_6, {account}, {account})])
assert owners["D"][account] == {rule_5, rule_6}
assert owners["K"][account] == {rule_6}
def test_credit_row_is_matched_by_credit_side_rules_only() -> None:
"""Кт-строка 76.05 не попадает в правила 5–8 через дебетовую маску 76: у неё одно правило — 6 (§5 п. 2a)."""
rule_5, rule_6, rule_7, rule_8, account, company_a, company_b = (uuid.uuid4() for _ in range(7))
debit_row = _row(uuid.uuid4(), company_a, company_b, 1)
debit_row.account_id = account
credit_row = _row(uuid.uuid4(), company_b, company_a, 2)
credit_row.account_id = account
credit_row.closing_debit = Decimal("0")
credit_row.closing_credit = Decimal("2")
owners = account_owners(
[
(rule_5, {account}, set()),
(rule_6, {account}, {account}),
(rule_7, {account}, set()),
(rule_8, {account}, set()),
]
)
distribution = distribute_vgo_rows(
DistributionInput(
rows=[debit_row, credit_row],
exact_ids=set(),
diff_ids=[],
manual_keys=set(),
owners=owners,
members={company_a, company_b},
)
)
assert distribution.auto_rules == {credit_row.entry_ids: rule_6}
assert distribution.unassigned == {debit_row.entry_ids}
async def test_unallocated_credit_row_offers_only_credit_side_rules(monkeypatch: pytest.MonkeyPatch) -> None:
"""Меню «Отнести к правилу» Кт-строки — только правила, где счёт на стороне кредита (AURORA-2034)."""
version_id, company_a, company_b, rule_1, rule_2, rule_3, account = (uuid.uuid4() for _ in range(7))
credit_row = _row(uuid.uuid4(), company_a, company_b, 1)
credit_row.account_id = account
credit_row.closing_debit = Decimal("0")
credit_row.closing_credit = Decimal("5")
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, rule_3), start=1)
]
osv_result = SimpleNamespace(
version=SimpleNamespace(id=version_id, elimination_rules_version=1),
consolidated=SimpleNamespace(rows=[credit_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]:
credit = frozenset({account}) if rule_id in (rule_1, rule_2) else frozenset()
selection = SimpleNamespace(company_1_account_ids=frozenset({account}), company_2_account_ids=credit)
return selection, rules[0]
matrix = SimpleNamespace(
_resolve_preset_selection=AsyncMock(side_effect=resolve),
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 len(response.unallocated) == 1
assert response.unallocated[0].side == "K"
assert response.unallocated[0].candidate_rule_ids == [rule_1, rule_2]
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
owners = {account_id: frozenset({rule_1, rule_2})}
service._rule_accounts = AsyncMock(return_value={"D": owners, "K": owners})
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, [])