- суммы сторон со знаком, как в шахматке; строки с суммой < 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>
153 lines
6.3 KiB
Python
153 lines
6.3 KiB
Python
"""Упаковка встречных строк по одному правилу без БД (§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
|