arm-core/api/tests/test_elimination_packing.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

153 lines
6.3 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.

"""Упаковка встречных строк по одному правилу без БД (§5)."""
import uuid
from decimal import Decimal
from fractions import Fraction
from src.domain.elimination_packing import MAX_SIDE_ROWS, PackRow, best_subsets, calculate_rule_totals, pack_rule_rows
A = uuid.uuid4()
B = uuid.uuid4()
def _row(amount: str, *, debit: bool) -> PackRow:
return PackRow((uuid.uuid4(),), A if debit else B, B if debit else A, Decimal(amount))
def test_knapsack_finds_exact_subset_with_fixed_size_and_keeps_ties() -> None:
first, second, third = (_row(amount, debit=False) for amount in ("40", "60", "60"))
choice = best_subsets([first, second, third], size=2, capacity=Decimal("100"))
assert choice is not None
assert choice.ratio == 0
assert choice.unique_rows is None
assert choice.involved_keys == {first.key, second.key, third.key}
def test_knapsack_ranks_by_percent_of_smaller_sum_and_keeps_equal_percent_ties() -> None:
below, above = (_row(amount, debit=False) for amount in ("99.00", "101.00"))
# расхождение одно и то же, 1 руб., но к меньшей сумме: 1/99 против 1/100
nearest = best_subsets([below, above], size=1, capacity=Decimal("100.00"))
assert nearest is not None
assert nearest.ratio == Fraction(1, 100)
assert nearest.unique_rows == (above,)
# 105 против 100 и 110,25 — по 5 % к меньшей сумме, хотя в рублях 5 и 5,25
lower, upper = (_row(amount, debit=False) for amount in ("100.00", "110.25"))
tie = best_subsets([lower, upper], size=1, capacity=Decimal("105.00"))
assert tie is not None
assert tie.ratio == Fraction(1, 20)
assert tie.unique_rows is None
assert tie.involved_keys == {lower.key, upper.key}
def test_groups_are_chosen_by_percent_not_by_rubles() -> None:
debit = [_row("100", debit=True)]
rubles, percent = _row("95.5", debit=False), _row("104.6", debit=False) # 4,5 руб. = 4,71 %; 4,6 руб. = 4,6 %
result = pack_rule_rows(debit, [rubles, percent])
assert [group.credit_rows for group in result.groups] == [(percent,)]
assert result.groups[0].deviation_amount == Decimal("4.6")
assert result.groups[0].deviation_percent == Decimal("4.6")
assert rubles.key not in result.used_keys
def test_non_positive_rows_are_not_packed() -> None:
debit = [_row("150", debit=True), _row("-50", debit=True)]
credit = [_row("150", debit=False), _row("-50", debit=False)]
result = pack_rule_rows(debit, credit)
assert len(result.groups) == 1
assert result.used_keys == {debit[0].key, credit[0].key}
assert result.ambiguous_keys == frozenset()
def test_exact_one_to_one_is_reserved_before_one_to_many() -> None:
debit = [_row("100", debit=True), _row("50", debit=True)]
credit = [_row("100", debit=False), _row("30", debit=False), _row("20", debit=False)]
result = pack_rule_rows(debit, credit)
assert [(len(group.debit_rows), len(group.credit_rows)) for group in result.groups] == [(1, 1), (1, 2)]
assert result.used_keys == {row.key for row in [*debit, *credit]}
assert result.ambiguous_keys == frozenset()
def test_equal_exact_groups_sharing_a_row_go_to_manual() -> None:
debit = [_row("100", debit=True)]
credit = [_row("100", debit=False), _row("100", debit=False)]
result = pack_rule_rows(debit, credit)
assert result.groups == ()
assert result.ambiguous_keys == {row.key for row in [*debit, *credit]}
def test_five_percent_boundary_and_no_many_to_many() -> None:
debit = [_row("100", debit=True)]
credit = [_row("21", debit=False) for _ in range(5)]
result = pack_rule_rows(debit, credit)
assert len(result.groups) == 1
assert len(result.groups[0].credit_rows) == MAX_SIDE_ROWS
assert result.groups[0].deviation_percent == Decimal("5")
many_to_many = pack_rule_rows(
[_row("40", debit=True), _row("60", debit=True)],
[_row("30", debit=False), _row("70", debit=False)],
)
assert many_to_many.groups == () # суммы сторон равны, но допустимой группы 1:N или N:1 нет
over_threshold = pack_rule_rows([_row("100", debit=True)], [_row("106", debit=False)])
assert over_threshold.groups == ()
def test_rule_totals_eliminate_minimum_within_five_percent() -> None:
totals = calculate_rule_totals(Decimal("110429113.15"), Decimal("106311116.46"), manual=False)
assert totals.amount_a == Decimal("110429113.15")
assert totals.amount_b == Decimal("106311116.46")
assert totals.deviation_amount == Decimal("4117996.69")
assert totals.deviation_percent is not None
assert totals.deviation_percent.quantize(Decimal("0.01")) == Decimal("3.87")
assert totals.eliminated_amount == Decimal("106311116.46")
assert totals.exceeds_threshold is False
def test_rule_totals_over_five_percent_and_manual_eliminate_zero() -> None:
over = calculate_rule_totals(Decimal("106"), Decimal("100"), manual=False)
assert over.deviation_amount == Decimal("6")
assert over.deviation_percent == Decimal("6")
assert over.exceeds_threshold is True
assert over.eliminated_amount == 0
manual = calculate_rule_totals(Decimal("100"), Decimal("100"), manual=True)
assert manual.exceeds_threshold is False
assert manual.eliminated_amount == 0
boundary = calculate_rule_totals(Decimal("105"), Decimal("100"), manual=False)
assert boundary.exceeds_threshold is False
assert boundary.eliminated_amount == Decimal("100")
one_sided = calculate_rule_totals(Decimal("100"), Decimal("0"), manual=False)
assert one_sided.deviation_percent is None
assert one_sided.exceeds_threshold is True
assert one_sided.eliminated_amount == 0
def test_rule_totals_with_negative_smaller_side_eliminate_zero() -> None:
negative = calculate_rule_totals(Decimal("100"), Decimal("-20"), manual=False)
assert (negative.deviation_amount, negative.deviation_percent) == (Decimal("120"), None)
assert negative.exceeds_threshold is True
assert negative.eliminated_amount == 0
equal_negative = calculate_rule_totals(Decimal("-50"), Decimal("-50"), manual=False)
assert equal_negative.deviation_percent is None
assert equal_negative.exceeds_threshold is False
assert equal_negative.eliminated_amount == 0