arm-core/api/src/domain/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

205 lines
9.6 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 dataclasses import dataclass
from decimal import Decimal
from fractions import Fraction
from typing import TypeAlias
RowKey: TypeAlias = tuple[uuid.UUID, ...]
MAX_SIDE_ROWS = 5
MIN_GROUP_SIZE = 2
MAX_DEVIATION_PERCENT = Decimal("5")
ZERO = Decimal("0")
@dataclass(frozen=True)
class PackRow:
"""Одна неделимая строка свода на дебетовой или кредитовой стороне."""
key: RowKey
owner_id: uuid.UUID
counterparty_id: uuid.UUID
amount: Decimal # сумма стороны со знаком; в упаковку идут только строки с суммой > 0
@dataclass(frozen=True)
class KnapsackChoice:
"""Лучшие подмножества заданного размера; при ничьей возвращаем ключи всех затронутых строк."""
ratio: Fraction # расхождение к меньшей сумме, точной дробью: ничьи по проценту не зависят от округления
unique_rows: tuple[PackRow, ...] | None
involved_keys: frozenset[RowKey]
@dataclass(frozen=True)
class PackedGroup:
debit_rows: tuple[PackRow, ...]
credit_rows: tuple[PackRow, ...]
debit_amount: Decimal
credit_amount: Decimal
deviation_amount: Decimal
deviation_percent: Decimal
@dataclass(frozen=True)
class PackingResult:
groups: tuple[PackedGroup, ...]
used_keys: frozenset[RowKey]
ambiguous_keys: frozenset[RowKey]
@dataclass(frozen=True)
class RuleTotals:
"""Итог тройки до упаковки строк; упаковка его не меняет."""
amount_a: Decimal
amount_b: Decimal
deviation_amount: Decimal
deviation_percent: Decimal | None
eliminated_amount: Decimal
exceeds_threshold: bool
def calculate_rule_totals(amount_a: Decimal, amount_b: Decimal, *, manual: bool) -> RuleTotals:
"""Правило 5 % для всей тройки, независимо от сопоставления строк; меньшая сторона ≤ 0 — элиминации нет."""
deviation = abs(amount_a - amount_b)
base = min(amount_a, amount_b)
percent = deviation / base * Decimal("100") if base > ZERO else None
exceeds_threshold = deviation > ZERO if percent is None else percent > MAX_DEVIATION_PERCENT
eliminated = base if base > ZERO and not manual and not exceeds_threshold else ZERO
return RuleTotals(amount_a, amount_b, deviation, percent, eliminated, exceeds_threshold)
def _ratio(first: Decimal, second: Decimal) -> Fraction:
"""Расхождение к меньшей из двух положительных сумм."""
return Fraction(abs(first - second)) / Fraction(min(first, second))
@dataclass(frozen=True)
class _Candidate:
ratio: Fraction
involved_keys: frozenset[RowKey]
group: PackedGroup | None # None — несколько одинаково лучших подмножеств у одного якоря
def best_subsets(rows: list[PackRow], *, size: int, capacity: Decimal) -> KnapsackChoice | None:
"""Перебрать группы заданного размера; битовая маска отмечает выбранные строки и ничьи."""
if not 1 <= size <= MAX_SIDE_ROWS:
raise ValueError("размер стороны должен быть от 1 до 5")
candidates = sorted((row for row in rows if row.amount > ZERO), key=lambda row: row.amount)
if capacity <= ZERO or len(candidates) < size:
return None
hundred_percent = Decimal("100")
limit = hundred_percent + MAX_DEVIATION_PERCENT
upper = capacity * limit / hundred_percent
combinations: list[tuple[int, Decimal]] = [] # (битовая маска выбранных строк, точная сумма)
def search(start: int, selected: int, total: Decimal, mask: int) -> None:
remaining = size - selected
if remaining == 0:
deviation = abs(capacity - total)
if deviation * hundred_percent <= MAX_DEVIATION_PERCENT * min(capacity, total):
combinations.append((mask, total))
return
if len(candidates) - start < remaining:
return
# Промежуточную сумму сравниваем с нижней границей лишь с учётом оставшихся строк.
largest_total = total + sum((row.amount for row in candidates[-remaining:]), ZERO)
if largest_total * limit < capacity * hundred_percent:
return
smallest_total = total + sum((row.amount for row in candidates[start : start + remaining]), ZERO)
if smallest_total > upper:
return
for index in range(start, len(candidates) - remaining + 1):
next_total = total + candidates[index].amount
if next_total > upper:
break # суммы отсортированы: следующие строки только больше
search(index + 1, selected + 1, next_total, mask | (1 << index))
search(0, 0, ZERO, 0)
if not combinations:
return None
# точные первыми, затем по возрастанию процента расхождения к меньшей сумме
ratios = [(mask, _ratio(capacity, total)) for mask, total in combinations]
best_ratio = min(ratio for _, ratio in ratios)
best_masks = [mask for mask, ratio in ratios if ratio == best_ratio]
involved_mask = 0
for mask in best_masks:
involved_mask |= mask
involved_keys = frozenset(row.key for index, row in enumerate(candidates) if involved_mask & (1 << index))
unique_rows = (
tuple(row for index, row in enumerate(candidates) if best_masks[0] & (1 << index))
if len(best_masks) == 1
else None
)
return KnapsackChoice(best_ratio, unique_rows, involved_keys)
def _candidate(anchor: PackRow, choice: KnapsackChoice, *, anchor_is_debit: bool) -> _Candidate:
keys = choice.involved_keys | {anchor.key}
if choice.unique_rows is None:
return _Candidate(choice.ratio, keys, None)
debit = (anchor,) if anchor_is_debit else choice.unique_rows
credit = choice.unique_rows if anchor_is_debit else (anchor,)
debit_amount = sum((row.amount for row in debit), ZERO)
credit_amount = sum((row.amount for row in credit), ZERO)
group = PackedGroup(
debit_rows=debit,
credit_rows=credit,
debit_amount=debit_amount,
credit_amount=credit_amount,
deviation_amount=abs(debit_amount - credit_amount),
deviation_percent=abs(debit_amount - credit_amount) / min(debit_amount, credit_amount) * 100,
)
return _Candidate(choice.ratio, keys, group)
def _candidates(
debit_rows: list[PackRow], credit_rows: list[PackRow], unavailable: set[RowKey], total_size: int
) -> list[_Candidate]:
"""Входы уже ограничены одной тройкой: правило, компания А, компания Б; строки с суммой ≤ 0 не участвуют."""
available_debit = [row for row in debit_rows if row.key not in unavailable and row.amount > ZERO]
available_credit = [row for row in credit_rows if row.key not in unavailable and row.amount > ZERO]
candidates: list[_Candidate] = []
for anchor in available_debit:
choice = best_subsets(available_credit, size=total_size - 1, capacity=anchor.amount)
if choice is not None:
candidates.append(_candidate(anchor, choice, anchor_is_debit=True))
if total_size > MIN_GROUP_SIZE:
for anchor in available_credit:
choice = best_subsets(available_debit, size=total_size - 1, capacity=anchor.amount)
if choice is not None:
candidates.append(_candidate(anchor, choice, anchor_is_debit=False))
return candidates
def pack_rule_rows(debit_rows: list[PackRow], credit_rows: list[PackRow]) -> PackingResult:
"""Жадно упаковать 1:1, 1:N, N:1; ничьи по размеру и проценту расхождения отправить на ручной разбор."""
used: set[RowKey] = set()
ambiguous: set[RowKey] = set()
groups: list[PackedGroup] = []
for total_size in range(MIN_GROUP_SIZE, MAX_SIDE_ROWS + MIN_GROUP_SIZE):
while candidates := _candidates(debit_rows, credit_rows, used | ambiguous, total_size):
best_ratio = min(candidate.ratio for candidate in candidates)
best = [candidate for candidate in candidates if candidate.ratio == best_ratio]
conflicted = {index for index, candidate in enumerate(best) if candidate.group is None}
for left in range(len(best)):
for right in range(left + 1, len(best)):
if best[left].involved_keys & best[right].involved_keys:
conflicted.update((left, right))
for index, candidate in enumerate(best):
if index in conflicted:
ambiguous.update(candidate.involved_keys)
else:
assert candidate.group is not None
groups.append(candidate.group)
used.update(candidate.involved_keys)
return PackingResult(tuple(groups), frozenset(used), frozenset(ambiguous))