arm-core/api/tests/test_intercompany_matrix.py
Andrey Epifancev 8940f32ae3 fix: пары ВГО — суммы со знаком, процент в упаковке, deviation_amount (ревью #175, AURORA-1536)
- суммы сторон со знаком, как в шахматке; строки с суммой < 0 в упаковку не идут, причина NEGATIVE_AMOUNT;
  при min(ΣA, ΣB) ≤ 0 процент null и элиминация 0, статус списка AUTO — только при элиминации > 0
- группы одного размера выбираются по проценту расхождения к меньшей сумме (точной дробью), ничья — по равному проценту
- elimination_amount группы и тройки переименован в deviation_amount; version_no тройки обязателен
- суммы и проценты пар в JSON — с двумя знаками
- elimination-pairs по правилу с measure ≠ closing — 422 INVALID_RULE
- дельта спеки, design.md (R-7, R-8), tasks, README

make check-full: exit 0 (1896 passed; integration 990 passed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:47:47 +04:00

438 lines
20 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 datetime
import uuid
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import status as http_status
from src.api.endpoints.consolidations.intercompany_matrix import router
from src.models.enums import AccountKind, StatementType
from src.schemas.consolidations import (
IntercompanyMatrixCellOut,
IntercompanyMatrixCompanyOut,
IntercompanyMatrixOut,
IntercompanyResultTableOut,
)
from src.services import intercompany_matrix as matrix
def _account(kind: AccountKind, parent_id: uuid.UUID | None = None) -> SimpleNamespace:
return SimpleNamespace(id=uuid.uuid4(), kind=kind, parent_id=parent_id)
def _row(
owner: uuid.UUID,
counterparty: uuid.UUID,
account_id: uuid.UUID | None,
*,
debit: str = "0",
credit: str = "0",
) -> SimpleNamespace:
return SimpleNamespace(
legal_entity_id=owner,
counterparty_id=counterparty,
account_id=account_id,
closing_debit=Decimal(debit),
closing_credit=Decimal(credit),
turnover_debit=Decimal("0"),
turnover_credit=Decimal("0"),
entry_ids=(),
)
def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None:
paths = {(route.path, tuple(sorted(route.methods))) for route in router.routes}
assert ("/consolidations/versions/{version_id}/intercompany-matrix/all", ("GET",)) in paths
assert ("/consolidations/versions/{version_id}/intercompany-matrix/manual", ("POST",)) in paths
assert ("/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}", ("GET",)) in paths
assert not any(path.endswith("/unpaired-osv") for path, _ in paths)
assert ("/consolidations/versions/{version_id}/intercompany-matrix/result-table", ("GET",)) in paths
debit_parent = _account(AccountKind.A)
debit_child = _account(AccountKind.A, debit_parent.id)
credit_parent = _account(AccountKind.P)
credit_child = _account(AccountKind.P, credit_parent.id)
by_id, children = matrix._make_account_tree([debit_parent, debit_child, credit_parent, credit_child])
debit = matrix._select_accounts([debit_parent.id], [], "D", by_id, children)
credit = matrix._select_accounts([credit_parent.id], [], "K", by_id, children)
assert debit == {debit_parent.id, debit_child.id}
assert credit == {credit_parent.id, credit_child.id}
company_a, company_b = uuid.uuid4(), uuid.uuid4()
result = matrix.calculate_matrix(
[
_row(company_a, company_b, debit_child.id, debit="100"),
_row(company_b, company_a, credit_child.id, credit="90"),
],
[
IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"),
IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"),
],
matrix.MatrixSelection(debit, credit, "closing"),
)
assert [(company.id, company.name) for company in result.companies] == [
(company_a, "Компания А"),
(company_b, "Компания Б"),
]
assert result.cells[0].amount_a == Decimal("100")
assert result.cells[0].amount_b == Decimal("90")
assert result.cells[0].variance_percent == Decimal("11.11")
assert isinstance(result, IntercompanyMatrixOut)
response = result
assert response.model_dump(mode="json")["companies"] == [
{"id": str(company_a), "name": "Компания А"},
{"id": str(company_b), "name": "Компания Б"},
]
async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkeypatch: pytest.MonkeyPatch) -> None:
parent = _account(AccountKind.AP)
child = _account(AccountKind.AP, parent.id)
parent.code = "76"
child.code = "76.01"
version = SimpleNamespace(elimination_rules_version=1, version_no=2)
consolidation = SimpleNamespace(company_group_id=uuid.uuid4(), year=2024, quarter=matrix.PeriodQuarter.Q3)
osv_service = SimpleNamespace(
build_with_members=AsyncMock(
return_value=SimpleNamespace(
consolidated=SimpleNamespace(rows=[]), version=version, consolidation=consolidation, members={}
)
)
)
monkeypatch.setattr(matrix.directory_repo, "list_accounts", AsyncMock(return_value=[parent, child]))
rule = SimpleNamespace(
id=uuid.uuid4(),
rule_no=6,
statement_type=StatementType.balance,
article_1_line="1230",
article_2_line="1520",
version_no=1,
side_1="D",
measure="turnover",
valid_from=datetime.date(2024, 1, 1),
valid_to=None,
)
rule_accounts = [
SimpleNamespace(company_no=1, account_id=parent.id, is_excluded=False),
SimpleNamespace(company_no=1, account_id=child.id, is_excluded=True),
SimpleNamespace(company_no=2, account_id=parent.id, is_excluded=False),
]
monkeypatch.setattr(
matrix.elimination_rules_repo, "rule_with_account", AsyncMock(return_value=(rule, rule_accounts))
)
session = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(scalar_one=lambda: "ГК")))
service = matrix.IntercompanyMatrixService(session, None, osv_service)
captured = []
monkeypatch.setattr(service, "_calculate", lambda rows, members, selection: captured.append(selection))
await service.build_accounts(uuid.uuid4(), mode="PRESET", rule_id=uuid.uuid4())
assert len(captured) == 1
assert captured[0].company_1_account_ids == {parent.id}
assert captured[0].company_2_account_ids == {parent.id, child.id}
assert captured[0].measure == "turnover"
_, info = await service.build_export(uuid.uuid4(), mode="PRESET", rule_id=uuid.uuid4())
assert info.mode == "PRESET"
assert info.debit_account_codes == ("76",)
assert info.credit_account_codes == ("76", "76.01")
assert info.measure == "turnover"
assert info.rule_no == rule.rule_no
assert info.rule_version_no == rule.version_no
assert info.article_1_line == rule.article_1_line
assert info.article_2_line == rule.article_2_line
async def test_preset_credit_first_uses_second_side_for_debit(monkeypatch: pytest.MonkeyPatch) -> None:
credit_parent = _account(AccountKind.P)
credit_child = _account(AccountKind.P, credit_parent.id)
debit_parent = _account(AccountKind.A)
debit_child = _account(AccountKind.A, debit_parent.id)
accounts = [credit_parent, credit_child, debit_parent, debit_child]
by_id, children = matrix._make_account_tree(accounts)
rule = SimpleNamespace(
version_no=1,
valid_to=None,
statement_type=StatementType.balance,
side_1="K",
measure="turnover",
)
rule_accounts = [
SimpleNamespace(company_no=1, account_id=credit_parent.id, is_excluded=False),
SimpleNamespace(company_no=1, account_id=credit_child.id, is_excluded=True),
SimpleNamespace(company_no=2, account_id=debit_parent.id, is_excluded=False),
]
monkeypatch.setattr(
matrix.elimination_rules_repo, "rule_with_account", AsyncMock(return_value=(rule, rule_accounts))
)
service = matrix.IntercompanyMatrixService(object(), None, object())
selection, selected_rule = await service._resolve_preset_selection(
uuid.uuid4(), SimpleNamespace(elimination_rules_version=1), by_id, children
)
assert selected_rule is rule
assert selection.company_1_account_ids == {debit_parent.id, debit_child.id}
assert selection.company_2_account_ids == {credit_parent.id}
assert selection.measure == "turnover"
async def test_preset_rejects_income_rule_before_selecting_accounts(monkeypatch: pytest.MonkeyPatch) -> None:
rule = SimpleNamespace(
statement_type=StatementType.income,
version_no=1,
valid_to=None,
)
monkeypatch.setattr(matrix.elimination_rules_repo, "rule_with_account", AsyncMock(return_value=(rule, [])))
service = matrix.IntercompanyMatrixService(object(), None, object())
with pytest.raises(matrix.DomainValidationError) as error:
await service._resolve_preset_selection(uuid.uuid4(), SimpleNamespace(elimination_rules_version=1), {}, {})
assert error.value.status == http_status.HTTP_422_UNPROCESSABLE_CONTENT
assert error.value.code == "INVALID_RULE"
async def test_all_excludes_off_balance_accounts_only(monkeypatch: pytest.MonkeyPatch) -> None:
company_a, company_b = uuid.uuid4(), uuid.uuid4()
off_balance = SimpleNamespace(id=uuid.uuid4(), kind=AccountKind.A, parent_id=None, is_off_balance=True)
off_balance_credit = SimpleNamespace(id=uuid.uuid4(), kind=AccountKind.P, parent_id=None, is_off_balance=True)
debit = SimpleNamespace(id=uuid.uuid4(), kind=AccountKind.A, parent_id=None, is_off_balance=False)
credit = SimpleNamespace(id=uuid.uuid4(), kind=AccountKind.P, parent_id=None, is_off_balance=False)
accounts = [off_balance, off_balance_credit, debit, credit]
monkeypatch.setattr(matrix.directory_repo, "list_accounts", AsyncMock(return_value=accounts))
rows = [
_row(company_a, company_b, off_balance.id, debit="100"),
_row(company_a, company_b, debit.id, debit="30"),
_row(company_b, company_a, off_balance_credit.id, credit="40"),
_row(company_b, company_a, credit.id, credit="20"),
]
members = {
company_a: SimpleNamespace(id=company_a, name="Компания А"),
company_b: SimpleNamespace(id=company_b, name="Компания Б"),
}
osv_service = SimpleNamespace(
build_with_members=AsyncMock(
return_value=SimpleNamespace(
consolidated=SimpleNamespace(rows=rows), version=None, consolidation=None, members=members
)
)
)
service = matrix.IntercompanyMatrixService(SimpleNamespace(), None, osv_service)
all_matrix = await service.build_accounts(uuid.uuid4(), mode="ALL")
manual_matrix = await service.build_accounts(
uuid.uuid4(), mode="MANUAL", debit_account_ids=[off_balance.id], credit_account_ids=[off_balance_credit.id]
)
assert all_matrix.cells[0].amount_a == Decimal("30")
assert all_matrix.cells[0].amount_b == Decimal("20")
assert manual_matrix.cells[0].amount_a == Decimal("100")
assert manual_matrix.cells[0].amount_b == Decimal("40")
@pytest.mark.parametrize(
("debit_amount", "credit_amount", "one_sided", "exceeds"),
[
("100000000", "0", True, False),
("0", "100000000", True, False),
("0", "0", False, False),
("100", "100", False, False),
("100", "50", False, True),
("-50", "0", True, False),
("0", "-50", True, False),
],
)
def test_one_sided_uses_amounts_even_with_zero_balance_rows(
debit_amount: str, credit_amount: str, one_sided: bool, exceeds: bool
) -> None:
company_a, company_b = uuid.uuid4(), uuid.uuid4()
debit_account, credit_account = uuid.uuid4(), uuid.uuid4()
companies = [
IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"),
IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"),
]
selection = matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing")
debit = _row(company_a, company_b, debit_account, debit=debit_amount)
credit = _row(company_b, company_a, credit_account, credit=credit_amount)
debit.entry_ids = (uuid.uuid4(),)
credit.entry_ids = (uuid.uuid4(),)
cell = matrix.calculate_matrix([debit, credit], companies, selection).cells[0]
assert cell.one_sided is one_sided
assert cell.exceeds_threshold is exceeds
assert cell.model_dump(mode="json")["one_sided"] is one_sided
def test_unmapped_account_entries_are_counted_for_both_directions_of_pair() -> None:
company_a, company_b, outsider = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
account_id = uuid.uuid4()
missing_a = _row(company_a, company_b, None, debit="100")
missing_a.entry_ids = (uuid.uuid4(), uuid.uuid4())
missing_b = _row(company_b, company_a, None, credit="90")
missing_b.entry_ids = (uuid.uuid4(),)
outside = _row(company_a, outsider, None, debit="500")
outside.entry_ids = (uuid.uuid4(),)
matched = _row(company_a, company_b, account_id, debit="20")
matched.entry_ids = (uuid.uuid4(),)
companies = [
IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"),
IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"),
]
result = matrix.calculate_matrix(
[missing_a, missing_b, outside, matched],
companies,
matrix.MatrixSelection(frozenset({account_id}), frozenset({account_id}), "closing"),
)
expected_skipped = len(missing_a.entry_ids) + len(missing_b.entry_ids)
assert [cell.skipped_osv_entry_count for cell in result.cells] == [expected_skipped, expected_skipped]
assert result.cells[0].model_dump(mode="json")["skipped_osv_entry_count"] == expected_skipped
assert result.cells[0].amount_a == Decimal("20")
assert result.cells[0].amount_b == Decimal("0")
@pytest.mark.parametrize(
("debit", "expected", "exceeds"),
[
("111.125", "11.13", True),
("111.124", "11.12", True),
("105", "5.00", False),
("105.004", "5.00", True),
("104.999", "5.00", False),
("100", "0.00", False),
],
)
def test_percent_rounding_preserves_threshold(debit: str, expected: str, exceeds: bool) -> None:
company_a, company_b, account = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
result = matrix.calculate_matrix(
[_row(company_a, company_b, account, debit=debit), _row(company_b, company_a, account, credit="100")],
[
IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"),
IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"),
],
matrix.MatrixSelection(frozenset({account}), frozenset({account}), "closing"),
)
assert result.cells[0].variance_percent == Decimal(expected)
assert result.cells[0].model_dump(mode="json")["variance_percent"] == expected
assert result.cells[0].exceeds_threshold is exceeds
@pytest.mark.parametrize(
("value", "expected"),
[("0", "0.00"), ("1.005", "1.01"), ("-1.005", "-1.01"), ("3.873533480903112697872235289", "3.87")],
)
def test_cell_numbers_have_two_decimals(value: str, expected: str) -> None:
fields = ("amount_a", "amount_b", "variance_amount", "variance_percent")
cell = IntercompanyMatrixCellOut(
company_a_id=uuid.uuid4(),
company_b_id=uuid.uuid4(),
**dict.fromkeys(fields, value),
exceeds_threshold=False,
one_sided=False,
skipped_osv_entry_count=0,
osv_entry_ids_debit=[],
osv_entry_ids_credit=[],
)
assert {field: cell.model_dump(mode="json")[field] for field in fields} == dict.fromkeys(fields, expected)
def test_result_table_uses_fixed_articles_and_group_totals() -> None:
company_a, company_b, external = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
required_codes = {
code for article in matrix.RESULT_ARTICLES for code in (*article.account_codes, *article.excluded_codes)
}
kinds = {
code: AccountKind.A if article.side == "D" else AccountKind.P
for article in matrix.RESULT_ARTICLES
for code in article.account_codes
}
kinds.update({"76": AccountKind.AP, "76.07.1": AccountKind.P, "76.07.5": AccountKind.A, "76.07.9": AccountKind.A})
ids = {code: uuid.uuid4() for code in required_codes}
parents = {"67.03": "67", "66.03": "66", "66.04": "66", "76.07.1": "76", "76.07.5": "76", "76.07.9": "76"}
accounts = [
SimpleNamespace(id=ids[code], code=code, kind=kinds[code], parent_id=ids.get(parents.get(code)))
for code in required_codes
]
rows = [
_row(company_a, company_b, ids["60.02"], debit="100"),
_row(company_a, company_b, ids["76.07.5"], debit="50"),
_row(company_a, company_b, ids["76.07.1"], credit="20"),
_row(company_a, company_b, ids["58.01"], debit="30"),
_row(company_b, company_a, ids["60.01"], credit="70"),
_row(company_b, company_a, ids["67.03"], credit="40"),
_row(company_a, company_b, ids["66.04"], credit="10"),
_row(company_a, external, ids["60.02"], debit="999"),
]
result = matrix.calculate_result_table(
rows,
[
IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"),
IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"),
],
accounts,
)
articles = {article.code: article for article in result.articles}
assert list(articles) == [article.code for article in matrix.RESULT_ARTICLES]
assert [item.amount for item in articles["receivables"].amounts] == [Decimal("100"), Decimal("0")]
assert articles["receivables"].group_total == Decimal("100")
assert articles["payables"].group_total == Decimal("70")
assert articles["financial_investments"].group_total == Decimal("30")
assert articles["long_term_loans"].group_total == Decimal("40")
assert articles["short_term_loans"].group_total == Decimal("10")
assert isinstance(result, IntercompanyResultTableOut)
response = result
assert response.articles[0].amounts[0].company_id == company_a
def test_pairs_use_signed_sums_and_leave_negative_rows_unpaired() -> None:
"""§5: у A по B строки 150 и −50, у B по A 100 — суммы со знаком, как в шахматке; −50 в упаковку не идёт."""
company_a, company_b = uuid.uuid4(), uuid.uuid4()
debit_account, credit_account = uuid.uuid4(), uuid.uuid4()
selection = matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing")
positive = _row(company_a, company_b, debit_account, debit="150")
negative = _row(company_a, company_b, debit_account, debit="-50")
counter = _row(company_b, company_a, credit_account, credit="100")
for row in (positive, negative, counter):
row.entry_ids = (uuid.uuid4(),)
row.account_code = "62.01"
rows = [positive, negative, counter]
rule = SimpleNamespace(id=uuid.uuid4(), rule_no=6, side_1="D", elimination_mode="min")
packed = matrix._pack_preset_rows(rows, selection, matrix.PairContext(rule, company_a, company_b))
assert (packed.totals.amount_a, packed.totals.amount_b) == (Decimal("100"), Decimal("100"))
assert packed.totals.eliminated_amount == Decimal("100")
assert packed.matched == packed.with_discrepancy == [] # 150 против 100 — больше 5 %
reasons = {item.row.entry_ids[0]: item.reason for item in packed.unpaired}
assert reasons == {
positive.entry_ids[0]: "NO_MATCH",
negative.entry_ids[0]: "NEGATIVE_AMOUNT",
counter.entry_ids[0]: "NO_MATCH",
}
[item] = matrix.calculate_rule_total_items(rows, [(rule, selection)], [company_a, company_b])
assert (item.amount_a, item.amount_b, item.eliminated_amount, item.status) == (
Decimal("100.00"),
Decimal("100.00"),
Decimal("100.00"),
"AUTO",
)
assert set(item.osv_entry_ids_a) == {positive.entry_ids[0], negative.entry_ids[0]}
def test_negative_smaller_side_is_discrepancy_without_elimination() -> None:
company_a, company_b = uuid.uuid4(), uuid.uuid4()
debit_account, credit_account = uuid.uuid4(), uuid.uuid4()
selection = matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing")
debit = _row(company_a, company_b, debit_account, debit="-50")
credit = _row(company_b, company_a, credit_account, credit="-50")
debit.entry_ids, credit.entry_ids = (uuid.uuid4(),), (uuid.uuid4(),)
rule = SimpleNamespace(id=uuid.uuid4(), rule_no=6, side_1="D", elimination_mode="min")
[item] = matrix.calculate_rule_total_items([debit, credit], [(rule, selection)], [company_a, company_b])
assert (item.deviation_amount, item.deviation_percent, item.eliminated_amount) == (Decimal("0.00"), None, 0)
assert item.status == "DISCREPANCY"