- суммы сторон со знаком, как в шахматке; строки с суммой < 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>
205 lines
9.6 KiB
Python
205 lines
9.6 KiB
Python
"""Упаковка встречных строк сводной ОСВ внутри одного правила элиминации (§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))
|