AURORA-1536 #175
@ -954,10 +954,11 @@ audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`), для PRESET — `r
|
||||
|
||||
**Пары ВГО §5 (AURORA-1536, PR #175).** `GET …/elimination-pairs?rule_id=&company_a_id=&company_b_id=` — итог тройки
|
||||
(ΣA, ΣB со знаком, расхождение, процент, `eliminated_amount` по правилу 5 %) и упаковка строк 1:1 / 1:N / N:1
|
||||
(`matched`, `with_discrepancy`, `unpaired` с причиной); `GET …/elimination-totals` — итоги всех троек версии (правила ББ ×
|
||||
(`matched`, `with_discrepancy`, `unpaired` с причиной `NO_MATCH` / `AMBIGUOUS` / `ZERO_AMOUNT` / `NEGATIVE_AMOUNT`;
|
||||
группы сортируются по проценту расхождения, отрицательные строки в упаковку не идут); `GET …/elimination-totals` — итоги всех троек версии (правила ББ ×
|
||||
упорядоченные пары периметра) со статусом `AUTO` / `DISCREPANCY` / `ONE_SIDED` / `MANUAL`, сводная ОСВ строится один раз.
|
||||
Каскада по остатку нет: строка общего счёта входит в несколько правил до разнесения (AURORA-1751). Ошибки: 404 (версия,
|
||||
правило не из версии правил), 422 `INVALID_COMPANY_PAIR`, `INVALID_RULE`, `ACCOUNTS_REQUIRED`. Решения —
|
||||
правило не из версии правил), 422 `INVALID_COMPANY_PAIR`, `INVALID_RULE` (в т. ч. правило по оборотам), `ACCOUNTS_REQUIRED`. Решения —
|
||||
`openspec/changes/elimination-pairs-rule-totals/design.md`; контракт — `../docs/dev/frontend-api-guide.md` §5.7.2.
|
||||
Поставка DBML v5.23: миграция `0010_dbml_v5_23` проставляет пустой `elimination_rules_version` по дате создания версии и
|
||||
делает колонку NOT NULL; нет правил вовсе — миграция падает до изменения схемы, поэтому seed правил должен быть применён
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from fractions import Fraction
|
||||
from typing import TypeAlias
|
||||
|
||||
RowKey: TypeAlias = tuple[uuid.UUID, ...]
|
||||
@ -22,14 +23,14 @@ class PackRow:
|
||||
key: RowKey
|
||||
owner_id: uuid.UUID
|
||||
counterparty_id: uuid.UUID
|
||||
amount: Decimal # модуль суммы соответствующей стороны
|
||||
amount: Decimal # сумма стороны со знаком; в упаковку идут только строки с суммой > 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnapsackChoice:
|
||||
"""Лучшие подмножества заданного размера; при ничьей возвращаем ключи всех затронутых строк."""
|
||||
|
||||
deviation: Decimal
|
||||
ratio: Fraction # расхождение к меньшей сумме, точной дробью: ничьи по проценту не зависят от округления
|
||||
unique_rows: tuple[PackRow, ...] | None
|
||||
involved_keys: frozenset[RowKey]
|
||||
|
||||
@ -64,18 +65,23 @@ class RuleTotals:
|
||||
|
||||
|
||||
def calculate_rule_totals(amount_a: Decimal, amount_b: Decimal, *, manual: bool) -> RuleTotals:
|
||||
"""Правило 5 % для всей тройки, независимо от сопоставления отдельных строк."""
|
||||
"""Правило 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 base == ZERO else percent is not None and percent > MAX_DEVIATION_PERCENT
|
||||
eliminated = base if not manual and not exceeds_threshold else ZERO
|
||||
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:
|
||||
deviation: Decimal
|
||||
ratio: Fraction
|
||||
involved_keys: frozenset[RowKey]
|
||||
group: PackedGroup | None # None — несколько одинаково лучших подмножеств у одного якоря
|
||||
|
||||
@ -120,9 +126,10 @@ def best_subsets(rows: list[PackRow], *, size: int, capacity: Decimal) -> Knapsa
|
||||
search(0, 0, ZERO, 0)
|
||||
if not combinations:
|
||||
return None
|
||||
combinations.sort(key=lambda item: abs(capacity - item[1])) # точные первыми, затем меньшие расхождения
|
||||
best_deviation = abs(capacity - combinations[0][1])
|
||||
best_masks = [mask for mask, total in combinations if abs(capacity - total) == best_deviation]
|
||||
# точные первыми, затем по возрастанию процента расхождения к меньшей сумме
|
||||
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
|
||||
@ -132,13 +139,13 @@ def best_subsets(rows: list[PackRow], *, size: int, capacity: Decimal) -> Knapsa
|
||||
if len(best_masks) == 1
|
||||
else None
|
||||
)
|
||||
return KnapsackChoice(best_deviation, unique_rows, involved_keys)
|
||||
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.deviation, keys, 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)
|
||||
@ -148,18 +155,18 @@ def _candidate(anchor: PackRow, choice: KnapsackChoice, *, anchor_is_debit: bool
|
||||
credit_rows=credit,
|
||||
debit_amount=debit_amount,
|
||||
credit_amount=credit_amount,
|
||||
deviation_amount=choice.deviation,
|
||||
deviation_percent=choice.deviation / min(debit_amount, credit_amount) * 100,
|
||||
deviation_amount=abs(debit_amount - credit_amount),
|
||||
deviation_percent=abs(debit_amount - credit_amount) / min(debit_amount, credit_amount) * 100,
|
||||
)
|
||||
return _Candidate(choice.deviation, keys, group)
|
||||
return _Candidate(choice.ratio, keys, group)
|
||||
|
||||
|
||||
def _candidates(
|
||||
debit_rows: list[PackRow], credit_rows: list[PackRow], unavailable: set[RowKey], total_size: int
|
||||
) -> list[_Candidate]:
|
||||
"""Входы уже ограничены одной тройкой: правило, компания А, компания Б."""
|
||||
available_debit = [row for row in debit_rows if row.key not in unavailable]
|
||||
available_credit = [row for row in credit_rows if row.key not in unavailable]
|
||||
"""Входы уже ограничены одной тройкой: правило, компания А, компания Б; строки с суммой ≤ 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)
|
||||
@ -174,14 +181,14 @@ def _candidates(
|
||||
|
||||
|
||||
def pack_rule_rows(debit_rows: list[PackRow], credit_rows: list[PackRow]) -> PackingResult:
|
||||
"""Жадно упаковать 1:1, 1:N, N:1; ничьи по размеру и расхождению отправить на ручной разбор."""
|
||||
"""Жадно упаковать 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_deviation = min(candidate.deviation for candidate in candidates)
|
||||
best = [candidate for candidate in candidates if candidate.deviation == best_deviation]
|
||||
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)):
|
||||
|
||||
@ -347,6 +347,12 @@ class EliminationPairRowOut(BaseModel):
|
||||
closing_debit: Decimal | None
|
||||
closing_credit: Decimal | None
|
||||
|
||||
@field_validator("closing_debit", "closing_credit")
|
||||
@classmethod
|
||||
def _two_decimals(cls, value: Decimal | None) -> Decimal | None:
|
||||
"""Суммы в JSON всегда с двумя знаками; пустая сторона — null."""
|
||||
return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
class EliminationMatchedPairOut(BaseModel):
|
||||
"""Сопоставленная группа строк одного правила и пары компаний."""
|
||||
@ -356,30 +362,39 @@ class EliminationMatchedPairOut(BaseModel):
|
||||
company_b_id: uuid.UUID
|
||||
amount_a: Decimal
|
||||
amount_b: Decimal
|
||||
elimination_amount: Decimal
|
||||
deviation_amount: Decimal
|
||||
deviation_percent: Decimal
|
||||
osv_entry_ids_a: list[uuid.UUID]
|
||||
osv_entry_ids_b: list[uuid.UUID]
|
||||
|
||||
@field_validator("amount_a", "amount_b", "deviation_amount", "deviation_percent")
|
||||
@classmethod
|
||||
def _two_decimals(cls, value: Decimal) -> Decimal:
|
||||
"""Суммы и процент в JSON всегда с двумя знаками."""
|
||||
return value.quantize(CENT, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
UnpairedReason = Literal["ZERO_AMOUNT", "NEGATIVE_AMOUNT", "NO_MATCH", "AMBIGUOUS"]
|
||||
|
||||
|
||||
class EliminationUnpairedRowOut(BaseModel):
|
||||
"""Строка, не вошедшая в автоматическую группу правила."""
|
||||
|
||||
row: EliminationPairRowOut
|
||||
reason: Literal["ZERO_AMOUNT", "NO_MATCH", "AMBIGUOUS"]
|
||||
reason: UnpairedReason
|
||||
|
||||
|
||||
class EliminationPairsOut(BaseModel):
|
||||
"""Точные пары, пары с расхождением и строки без пары для выбранной тройки."""
|
||||
|
||||
version_no: int | None
|
||||
version_no: int
|
||||
rule_id: uuid.UUID
|
||||
rule_no: int
|
||||
company_a_id: uuid.UUID
|
||||
company_b_id: uuid.UUID
|
||||
amount_a: Decimal
|
||||
amount_b: Decimal
|
||||
elimination_amount: Decimal
|
||||
deviation_amount: Decimal
|
||||
deviation_percent: Decimal | None
|
||||
eliminated_amount: Decimal
|
||||
exceeds_threshold: bool
|
||||
@ -387,6 +402,12 @@ class EliminationPairsOut(BaseModel):
|
||||
with_discrepancy: list[EliminationMatchedPairOut]
|
||||
unpaired: list[EliminationUnpairedRowOut]
|
||||
|
||||
@field_validator("amount_a", "amount_b", "deviation_amount", "deviation_percent", "eliminated_amount")
|
||||
@classmethod
|
||||
def _two_decimals(cls, value: Decimal | None) -> Decimal | None:
|
||||
"""Суммы и процент в JSON всегда с двумя знаками; неопределённый процент — null."""
|
||||
return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
EliminationTotalStatus = Literal["AUTO", "DISCREPANCY", "ONE_SIDED", "MANUAL"]
|
||||
|
||||
|
||||
@ -41,6 +41,7 @@ from src.schemas.consolidations import (
|
||||
IntercompanyResultAmountOut,
|
||||
IntercompanyResultArticleOut,
|
||||
IntercompanyResultTableOut,
|
||||
UnpairedReason,
|
||||
)
|
||||
from src.services.consolidated_osv import ConsolidatedOsvService
|
||||
|
||||
@ -398,7 +399,7 @@ def _matched_pair_out(
|
||||
company_b_id=context.company_b_id,
|
||||
amount_a=amount_a,
|
||||
amount_b=amount_b,
|
||||
elimination_amount=group.deviation_amount,
|
||||
deviation_amount=group.deviation_amount,
|
||||
deviation_percent=group.deviation_percent,
|
||||
osv_entry_ids_a=[entry_id for row in rows_a for entry_id in row.key],
|
||||
osv_entry_ids_b=[entry_id for row in rows_b for entry_id in row.key],
|
||||
@ -407,7 +408,7 @@ def _matched_pair_out(
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuleSides:
|
||||
"""Встречные строки тройки: исходные строки свода и строки сторон для сумм и упаковки."""
|
||||
"""Встречные строки тройки: исходные строки свода и строки сторон с ненулевой суммой со знаком."""
|
||||
|
||||
sources: dict[RowKey, ConsolidatedRow]
|
||||
debit_rows: list[PackRow]
|
||||
@ -421,7 +422,7 @@ class RuleSides:
|
||||
|
||||
|
||||
def _collect_rule_sides(rows: Sequence[ConsolidatedRow], selection: MatrixSelection, context: PairContext) -> RuleSides:
|
||||
"""Отобрать встречные строки одной направленной пары компаний и одного правила (§5)."""
|
||||
"""Встречные строки направленной пары компаний по одному правилу; суммы со знаком, как в шахматке (§5)."""
|
||||
rule = context.rule
|
||||
company_a_id = context.company_a_id
|
||||
company_b_id = context.company_b_id
|
||||
@ -447,12 +448,10 @@ def _collect_rule_sides(rows: Sequence[ConsolidatedRow], selection: MatrixSelect
|
||||
continue
|
||||
sources[row.entry_ids] = row
|
||||
assert row.counterparty_id is not None
|
||||
debit_amount = abs(row.closing_debit or ZERO)
|
||||
credit_amount = abs(row.closing_credit or ZERO)
|
||||
if in_debit and debit_amount > ZERO:
|
||||
debit_rows.append(PackRow(row.entry_ids, row.legal_entity_id, row.counterparty_id, debit_amount))
|
||||
if in_credit and credit_amount > ZERO:
|
||||
credit_rows.append(PackRow(row.entry_ids, row.legal_entity_id, row.counterparty_id, credit_amount))
|
||||
amount = _get_amount(row, "closing", "D" if in_debit else "K")
|
||||
if amount != ZERO:
|
||||
pack_row = PackRow(row.entry_ids, row.legal_entity_id, row.counterparty_id, amount)
|
||||
(debit_rows if in_debit else credit_rows).append(pack_row)
|
||||
return RuleSides(sources, debit_rows, credit_rows)
|
||||
|
||||
|
||||
@ -470,7 +469,8 @@ def _total_status(totals: RuleTotals, rule: EliminationRule) -> EliminationTotal
|
||||
return "MANUAL"
|
||||
if ZERO in (totals.amount_a, totals.amount_b):
|
||||
return "ONE_SIDED"
|
||||
return "DISCREPANCY" if totals.exceeds_threshold else "AUTO"
|
||||
# без автоматической элиминации — расхождение: превышен порог либо меньшая сторона отрицательна
|
||||
return "AUTO" if totals.eliminated_amount > ZERO else "DISCREPANCY"
|
||||
|
||||
|
||||
def calculate_rule_total_items(
|
||||
@ -527,14 +527,18 @@ def _pack_preset_rows(
|
||||
pair = _matched_pair_out(group, context)
|
||||
(matched if group.deviation_amount == ZERO else with_discrepancy).append(pair)
|
||||
unpaired: list[EliminationUnpairedRowOut] = []
|
||||
positive_keys = {item.key for item in [*debit_rows, *credit_rows]}
|
||||
amounts = {item.key: item.amount for item in [*debit_rows, *credit_rows]}
|
||||
for key, row in sources.items():
|
||||
if key in packed.used_keys:
|
||||
continue
|
||||
amount = amounts.get(key, ZERO)
|
||||
reason: UnpairedReason
|
||||
if key in packed.ambiguous_keys:
|
||||
reason = "AMBIGUOUS"
|
||||
elif key in positive_keys:
|
||||
elif amount > ZERO:
|
||||
reason = "NO_MATCH"
|
||||
elif amount < ZERO:
|
||||
reason = "NEGATIVE_AMOUNT"
|
||||
else:
|
||||
reason = "ZERO_AMOUNT"
|
||||
unpaired.append(EliminationUnpairedRowOut(row=_pair_row_out(row), reason=reason))
|
||||
@ -561,6 +565,9 @@ class IntercompanyMatrixService:
|
||||
accounts = await directory_repo.list_accounts(self._session)
|
||||
by_id, children = _make_account_tree(accounts)
|
||||
selection, rule = await self._resolve_preset_selection(rule_id, version, by_id, children)
|
||||
if rule.measure != "closing":
|
||||
# шахматка по такому правилу считает обороты; пары §5 — только по сальдо
|
||||
raise DomainValidationError("INVALID_RULE", "Пары ВГО строятся только по правилам сальдо")
|
||||
# TODO(упаковка ОСВ): занятость строки отслеживать отдельно для каждого rule_id — одна строка может попасть
|
||||
# в результаты разных правил. Глобальное исключение повторного использования потребует изменения модели
|
||||
# данных: нужно хранить/передавать принадлежность исходной строки конкретной паре между правилами.
|
||||
@ -577,7 +584,7 @@ class IntercompanyMatrixService:
|
||||
company_b_id=company_b_id,
|
||||
amount_a=packing.totals.amount_a,
|
||||
amount_b=packing.totals.amount_b,
|
||||
elimination_amount=packing.totals.deviation_amount,
|
||||
deviation_amount=packing.totals.deviation_amount,
|
||||
deviation_percent=packing.totals.deviation_percent,
|
||||
eliminated_amount=packing.totals.eliminated_amount,
|
||||
exceeds_threshold=packing.totals.exceeds_threshold,
|
||||
|
||||
@ -202,6 +202,25 @@ async def test_preset_rejects_income_rule(auth_client: AuthClientFactory, seeded
|
||||
assert response.json()["detail"]["code"] == "INVALID_RULE"
|
||||
|
||||
|
||||
async def test_elimination_pairs_reject_turnover_rule(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
"""Пары §5 — только по сальдо: шахматка по правилу оборотов считает другие суммы."""
|
||||
world = await _world(seeded)
|
||||
rule = (
|
||||
await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO))
|
||||
).one()
|
||||
rule.measure = "turnover"
|
||||
await seeded.flush()
|
||||
|
||||
client = await auth_client(KA_RF)
|
||||
company_a, company_b = world.members[:2]
|
||||
response = await client.get(
|
||||
f"/consolidations/versions/{world.version.id}/elimination-pairs",
|
||||
params={"rule_id": str(rule.id), "company_a_id": str(company_a.id), "company_b_id": str(company_b.id)},
|
||||
)
|
||||
assert response.status_code == UNPROCESSABLE, response.text
|
||||
assert response.json()["detail"]["code"] == "INVALID_RULE"
|
||||
|
||||
|
||||
async def test_preset_uses_fixed_rules_version(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
world = await _world(seeded)
|
||||
rule = (
|
||||
@ -448,7 +467,8 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915
|
||||
assert "account_pairs" not in payload
|
||||
assert payload["with_discrepancy"] == []
|
||||
assert (Decimal(payload["amount_a"]), Decimal(payload["amount_b"])) == (Decimal("300"), Decimal("100"))
|
||||
assert Decimal(payload["elimination_amount"]) == Decimal("200")
|
||||
assert (payload["amount_a"], payload["amount_b"], payload["deviation_amount"]) == ("300.00", "100.00", "200.00")
|
||||
assert payload["deviation_percent"] == "200.00"
|
||||
assert payload["exceeds_threshold"] is True
|
||||
assert Decimal(payload["eliminated_amount"]) == 0
|
||||
assert len(payload["matched"]) == 1
|
||||
@ -456,15 +476,16 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915
|
||||
assert len(pair["osv_entry_ids_a"]) == len((debit_document, second_debit_document))
|
||||
assert len(pair["osv_entry_ids_b"]) == 1
|
||||
assert pair["rule_id"] == str(rule_ids[6])
|
||||
assert tuple(Decimal(pair[field]) for field in ("amount_a", "amount_b", "elimination_amount")) == (
|
||||
Decimal("100"),
|
||||
Decimal("100"),
|
||||
Decimal("0"),
|
||||
assert tuple(pair[field] for field in ("amount_a", "amount_b", "deviation_amount", "deviation_percent")) == (
|
||||
"100.00",
|
||||
"100.00",
|
||||
"0.00",
|
||||
"0.00",
|
||||
)
|
||||
assert "article_ids_a" not in pair and "article_ids_b" not in pair
|
||||
assert "deviation_amount" not in pair
|
||||
assert Decimal(pair["deviation_percent"]) == 0
|
||||
assert "elimination_amount" not in pair and "elimination_amount" not in payload
|
||||
assert [item["reason"] for item in payload["unpaired"]] == ["ZERO_AMOUNT", "NO_MATCH"]
|
||||
assert [item["row"]["closing_debit"] for item in payload["unpaired"]] == ["0.00", "200.00"]
|
||||
|
||||
loans = await client.get(url, params={**params, "rule_id": str(rule_ids[3])})
|
||||
assert loans.status_code == OK, loans.text
|
||||
@ -480,9 +501,8 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915
|
||||
assert len(other_pair.json()["with_discrepancy"]) == 1
|
||||
discrepant = other_pair.json()["with_discrepancy"][0]
|
||||
assert (Decimal(discrepant["amount_a"]), Decimal(discrepant["amount_b"])) == (Decimal("103"), Decimal("100"))
|
||||
assert Decimal(discrepant["elimination_amount"]) == Decimal("3")
|
||||
assert (discrepant["deviation_amount"], discrepant["deviation_percent"]) == ("3.00", "3.00")
|
||||
assert "article_ids_a" not in discrepant and "article_ids_b" not in discrepant
|
||||
assert "deviation_amount" not in discrepant
|
||||
assert (Decimal(other_pair.json()["amount_a"]), Decimal(other_pair.json()["amount_b"])) == (
|
||||
Decimal("123"),
|
||||
Decimal("100"),
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
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
|
||||
|
||||
@ -19,23 +20,50 @@ def test_knapsack_finds_exact_subset_with_fixed_size_and_keeps_ties() -> None:
|
||||
choice = best_subsets([first, second, third], size=2, capacity=Decimal("100"))
|
||||
|
||||
assert choice is not None
|
||||
assert choice.deviation == 0
|
||||
assert choice.ratio == 0
|
||||
assert choice.unique_rows is None
|
||||
assert choice.involved_keys == {first.key, second.key, third.key}
|
||||
|
||||
|
||||
def test_knapsack_keeps_both_nearest_sums_and_the_five_percent_lower_boundary() -> None:
|
||||
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"))
|
||||
|
||||
tie = best_subsets([below, above], size=1, capacity=Decimal("100.00"))
|
||||
assert tie is not None
|
||||
assert tie.deviation == Decimal("1.00")
|
||||
assert tie.unique_rows is None
|
||||
assert tie.involved_keys == {below.key, above.key}
|
||||
# расхождение одно и то же, 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,)
|
||||
|
||||
boundary = best_subsets([_row("100.00", debit=False)], size=1, capacity=Decimal("105.00"))
|
||||
assert boundary is not None
|
||||
assert boundary.deviation == Decimal("5.00")
|
||||
# 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:
|
||||
@ -110,3 +138,15 @@ def test_rule_totals_over_five_percent_and_manual_eliminate_zero() -> None:
|
||||
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
|
||||
|
||||
@ -385,3 +385,53 @@ def test_result_table_uses_fixed_articles_and_group_totals() -> None:
|
||||
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"
|
||||
|
||||
@ -31,3 +31,5 @@
|
||||
| R-4 (design #4) | 23.10.2026 | Миграция: дата создания по Москве; пустой `created_at` — текущая дата; нет действующих на дату — последняя открытая; нет правил вовсе — отказ миграции | Совпадает с правилом создания; запасной вариант нужен только для данных, созданных до появления правил | Неверный номер у старых версий — исправляется UPDATE до верификации |
|
||||
| R-5 (design #5) | 23.10.2026 | `is_version_fixed` в ответе `elimination-rules` оставлен и всегда `true` | Фронт шахматки уже читает поле (PR #141, #158) | Удалить поле позже вместе с фронтом |
|
||||
| R-6 (design #6) | 23.10.2026 | Группа упаковки — `deviation_amount` вместо `elimination_amount`; отдельной суммы элиминации у группы нет | В КФО идёт итог тройки; упаковка его не меняет (§5), а сумма у группы читалась бы как вклад в элиминацию | Добавить поле у группы, если корректировкам понадобится |
|
||||
| R-7 (design #7) | 23.10.2026 | Статус тройки `AUTO` только при `eliminated_amount > 0`; обе стороны отрицательные и равные — `DISCREPANCY` | Иначе тройка без элиминации выглядела бы сопоставленной автоматически; совпадает с гайдом фронта §5.7.2 | Одна строка в `_total_status` |
|
||||
| R-8 (design #8) | 23.10.2026 | Процент расхождения при упаковке сравнивается точной дробью (`Fraction`), не округлённым `Decimal` | Ничья по проценту (100 и 110,25 против 105 — по 5 %) не должна зависеть от точности деления | Нет: на выход процент идёт `Decimal` с двумя знаками |
|
||||
|
||||
@ -136,6 +136,14 @@ osv_entry_ids_b[]}`; строка без пары — `{row, reason}`, `reason`:
|
||||
- **WHEN** по одному правилу есть встречные строки для A→Б и A→В, а запрошены `company_a_id=A`, `company_b_id=Б`
|
||||
- **THEN** ответ содержит только строки A→Б и Б→A
|
||||
|
||||
#### Scenario: Расхождение сравнивается в процентах
|
||||
- **WHEN** со строкой 100 стороны A могут сойтись строки B 95,50 (4,50 руб., 4,71 %) и 104,60 (4,60 руб., 4,60 %)
|
||||
- **THEN** группа — 100 ↔ 104,60; строка 95,50 — в `unpaired` с причиной `NO_MATCH`
|
||||
|
||||
#### Scenario: Правило по оборотам
|
||||
- **WHEN** запрошена тройка по правилу ББ с `measure = turnover`
|
||||
- **THEN** 422 `INVALID_RULE`
|
||||
|
||||
### Requirement: Пары ВГО версии
|
||||
`GET /api/v1/consolidations/versions/{version_id}/elimination-totals` (`CONSOLIDATION_VIEW`, policy по ГК) SHALL
|
||||
возвращать `{version_no, items[]}` — итоги всех троек версии: правила ББ с `measure = closing` версии правил ×
|
||||
@ -143,7 +151,7 @@ osv_entry_ids_b[]}`; строка без пары — `{row, reason}`, `reason`:
|
||||
один раз на запрос. Элемент — `{rule_id, rule_no, elimination_mode, company_a_id, company_b_id, amount_a, amount_b,
|
||||
deviation_amount, deviation_percent, eliminated_amount, exceeds_threshold, status, osv_entry_ids_a[], osv_entry_ids_b[]}`
|
||||
с теми же суммами, что у тройки («Пары ВГО тройки»); `status`: `MANUAL` — `elimination_mode = manual`; `ONE_SIDED` — одна из
|
||||
сумм нулевая; `DISCREPANCY` — `exceeds_threshold`; иначе `AUTO`. Порядок — `rule_no`, затем порядок компаний периметра.
|
||||
сумм нулевая; `AUTO` — `eliminated_amount > 0`; иначе `DISCREPANCY` (расхождение > 5 % или меньшая сторона < 0). Порядок — `rule_no`, затем порядок компаний периметра.
|
||||
Суммы и проценты — строки с двумя знаками. Невидимая и несуществующая версия — 404 `NOT_FOUND`.
|
||||
|
||||
#### Scenario: Список совпадает с тройкой
|
||||
@ -160,6 +168,10 @@ deviation_amount, deviation_percent, eliminated_amount, exceeds_threshold, statu
|
||||
- **WHEN** по счетам правила № 3 строк ни у A, ни у B нет
|
||||
- **THEN** тройки (3, A, B) в `items` нет
|
||||
|
||||
#### Scenario: Обе стороны отрицательные
|
||||
- **WHEN** у A по B −50, у B по A −50
|
||||
- **THEN** тройка `DISCREPANCY`, `deviation_percent = null`, `eliminated_amount = 0.00`
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Правила элиминации версии
|
||||
|
||||
@ -7,6 +7,6 @@
|
||||
`ELIMINATION_RULES_MISSING`.
|
||||
- [x] Список троек версии `GET …/elimination-totals` и тест на тех же данных, что и тройка.
|
||||
- [x] OpenSpec-дельта, README, `05-domain-rules.md` §5 и гайд фронта (arm-ka-docs #164).
|
||||
- [ ] Суммы сторон со знаком, `NEGATIVE_AMOUNT`, сортировка групп по проценту, `deviation_amount` у группы и тройки, суммы
|
||||
с двумя знаками, правило с `measure ≠ closing` — 422 `INVALID_RULE` (раунд ревью 1, автор).
|
||||
- [x] Суммы сторон со знаком, `NEGATIVE_AMOUNT`, сортировка групп по проценту, `deviation_amount` у группы и тройки, суммы
|
||||
с двумя знаками, правило с `measure ≠ closing` — 422 `INVALID_RULE` (раунд ревью 1; доделано в ветке PR сами).
|
||||
- [ ] `make check-full` и архив change.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user