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>
This commit is contained in:
Andrey Epifancev 2026-10-23 21:47:47 +04:00
parent fdb1f179cc
commit 8940f32ae3
10 changed files with 221 additions and 61 deletions

View File

@ -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=` — итог тройки **Пары ВГО §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 (Σ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`, сводная ОСВ строится один раз. упорядоченные пары периметра) со статусом `AUTO` / `DISCREPANCY` / `ONE_SIDED` / `MANUAL`, сводная ОСВ строится один раз.
Каскада по остатку нет: строка общего счёта входит в несколько правил до разнесения (AURORA-1751). Ошибки: 404 (версия, Каскада по остатку нет: строка общего счёта входит в несколько правил до разнесения (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. `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` по дате создания версии и Поставка DBML v5.23: миграция `0010_dbml_v5_23` проставляет пустой `elimination_rules_version` по дате создания версии и
делает колонку NOT NULL; нет правил вовсе — миграция падает до изменения схемы, поэтому seed правил должен быть применён делает колонку NOT NULL; нет правил вовсе — миграция падает до изменения схемы, поэтому seed правил должен быть применён

View File

@ -6,6 +6,7 @@
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
from fractions import Fraction
from typing import TypeAlias from typing import TypeAlias
RowKey: TypeAlias = tuple[uuid.UUID, ...] RowKey: TypeAlias = tuple[uuid.UUID, ...]
@ -22,14 +23,14 @@ class PackRow:
key: RowKey key: RowKey
owner_id: uuid.UUID owner_id: uuid.UUID
counterparty_id: uuid.UUID counterparty_id: uuid.UUID
amount: Decimal # модуль суммы соответствующей стороны amount: Decimal # сумма стороны со знаком; в упаковку идут только строки с суммой > 0
@dataclass(frozen=True) @dataclass(frozen=True)
class KnapsackChoice: class KnapsackChoice:
"""Лучшие подмножества заданного размера; при ничьей возвращаем ключи всех затронутых строк.""" """Лучшие подмножества заданного размера; при ничьей возвращаем ключи всех затронутых строк."""
deviation: Decimal ratio: Fraction # расхождение к меньшей сумме, точной дробью: ничьи по проценту не зависят от округления
unique_rows: tuple[PackRow, ...] | None unique_rows: tuple[PackRow, ...] | None
involved_keys: frozenset[RowKey] involved_keys: frozenset[RowKey]
@ -64,18 +65,23 @@ class RuleTotals:
def calculate_rule_totals(amount_a: Decimal, amount_b: Decimal, *, manual: bool) -> RuleTotals: def calculate_rule_totals(amount_a: Decimal, amount_b: Decimal, *, manual: bool) -> RuleTotals:
"""Правило 5 % для всей тройки, независимо от сопоставления отдельных строк.""" """Правило 5 % для всей тройки, независимо от сопоставления строк; меньшая сторона ≤ 0 — элиминации нет."""
deviation = abs(amount_a - amount_b) deviation = abs(amount_a - amount_b)
base = min(amount_a, amount_b) base = min(amount_a, amount_b)
percent = deviation / base * Decimal("100") if base > ZERO else None 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 exceeds_threshold = deviation > ZERO if percent is None else percent > MAX_DEVIATION_PERCENT
eliminated = base if not manual and not exceeds_threshold else ZERO 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) 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) @dataclass(frozen=True)
class _Candidate: class _Candidate:
deviation: Decimal ratio: Fraction
involved_keys: frozenset[RowKey] involved_keys: frozenset[RowKey]
group: PackedGroup | None # None — несколько одинаково лучших подмножеств у одного якоря group: PackedGroup | None # None — несколько одинаково лучших подмножеств у одного якоря
@ -120,9 +126,10 @@ def best_subsets(rows: list[PackRow], *, size: int, capacity: Decimal) -> Knapsa
search(0, 0, ZERO, 0) search(0, 0, ZERO, 0)
if not combinations: if not combinations:
return None return None
combinations.sort(key=lambda item: abs(capacity - item[1])) # точные первыми, затем меньшие расхождения # точные первыми, затем по возрастанию процента расхождения к меньшей сумме
best_deviation = abs(capacity - combinations[0][1]) ratios = [(mask, _ratio(capacity, total)) for mask, total in combinations]
best_masks = [mask for mask, total in combinations if abs(capacity - total) == best_deviation] best_ratio = min(ratio for _, ratio in ratios)
best_masks = [mask for mask, ratio in ratios if ratio == best_ratio]
involved_mask = 0 involved_mask = 0
for mask in best_masks: for mask in best_masks:
involved_mask |= mask involved_mask |= mask
@ -132,13 +139,13 @@ def best_subsets(rows: list[PackRow], *, size: int, capacity: Decimal) -> Knapsa
if len(best_masks) == 1 if len(best_masks) == 1
else None 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: def _candidate(anchor: PackRow, choice: KnapsackChoice, *, anchor_is_debit: bool) -> _Candidate:
keys = choice.involved_keys | {anchor.key} keys = choice.involved_keys | {anchor.key}
if choice.unique_rows is None: 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 debit = (anchor,) if anchor_is_debit else choice.unique_rows
credit = choice.unique_rows if anchor_is_debit else (anchor,) credit = choice.unique_rows if anchor_is_debit else (anchor,)
debit_amount = sum((row.amount for row in debit), ZERO) 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, credit_rows=credit,
debit_amount=debit_amount, debit_amount=debit_amount,
credit_amount=credit_amount, credit_amount=credit_amount,
deviation_amount=choice.deviation, deviation_amount=abs(debit_amount - credit_amount),
deviation_percent=choice.deviation / min(debit_amount, credit_amount) * 100, 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( def _candidates(
debit_rows: list[PackRow], credit_rows: list[PackRow], unavailable: set[RowKey], total_size: int debit_rows: list[PackRow], credit_rows: list[PackRow], unavailable: set[RowKey], total_size: int
) -> list[_Candidate]: ) -> list[_Candidate]:
"""Входы уже ограничены одной тройкой: правило, компания А, компания Б.""" """Входы уже ограничены одной тройкой: правило, компания А, компания Б; строки с суммой ≤ 0 не участвуют."""
available_debit = [row for row in debit_rows if row.key not in unavailable] 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] available_credit = [row for row in credit_rows if row.key not in unavailable and row.amount > ZERO]
candidates: list[_Candidate] = [] candidates: list[_Candidate] = []
for anchor in available_debit: for anchor in available_debit:
choice = best_subsets(available_credit, size=total_size - 1, capacity=anchor.amount) 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: 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() used: set[RowKey] = set()
ambiguous: set[RowKey] = set() ambiguous: set[RowKey] = set()
groups: list[PackedGroup] = [] groups: list[PackedGroup] = []
for total_size in range(MIN_GROUP_SIZE, MAX_SIDE_ROWS + MIN_GROUP_SIZE): 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): while candidates := _candidates(debit_rows, credit_rows, used | ambiguous, total_size):
best_deviation = min(candidate.deviation for candidate in candidates) best_ratio = min(candidate.ratio for candidate in candidates)
best = [candidate for candidate in candidates if candidate.deviation == best_deviation] best = [candidate for candidate in candidates if candidate.ratio == best_ratio]
conflicted = {index for index, candidate in enumerate(best) if candidate.group is None} conflicted = {index for index, candidate in enumerate(best) if candidate.group is None}
for left in range(len(best)): for left in range(len(best)):
for right in range(left + 1, len(best)): for right in range(left + 1, len(best)):

View File

@ -347,6 +347,12 @@ class EliminationPairRowOut(BaseModel):
closing_debit: Decimal | None closing_debit: Decimal | None
closing_credit: 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): class EliminationMatchedPairOut(BaseModel):
"""Сопоставленная группа строк одного правила и пары компаний.""" """Сопоставленная группа строк одного правила и пары компаний."""
@ -356,30 +362,39 @@ class EliminationMatchedPairOut(BaseModel):
company_b_id: uuid.UUID company_b_id: uuid.UUID
amount_a: Decimal amount_a: Decimal
amount_b: Decimal amount_b: Decimal
elimination_amount: Decimal deviation_amount: Decimal
deviation_percent: Decimal deviation_percent: Decimal
osv_entry_ids_a: list[uuid.UUID] osv_entry_ids_a: list[uuid.UUID]
osv_entry_ids_b: 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): class EliminationUnpairedRowOut(BaseModel):
"""Строка, не вошедшая в автоматическую группу правила.""" """Строка, не вошедшая в автоматическую группу правила."""
row: EliminationPairRowOut row: EliminationPairRowOut
reason: Literal["ZERO_AMOUNT", "NO_MATCH", "AMBIGUOUS"] reason: UnpairedReason
class EliminationPairsOut(BaseModel): class EliminationPairsOut(BaseModel):
"""Точные пары, пары с расхождением и строки без пары для выбранной тройки.""" """Точные пары, пары с расхождением и строки без пары для выбранной тройки."""
version_no: int | None version_no: int
rule_id: uuid.UUID rule_id: uuid.UUID
rule_no: int rule_no: int
company_a_id: uuid.UUID company_a_id: uuid.UUID
company_b_id: uuid.UUID company_b_id: uuid.UUID
amount_a: Decimal amount_a: Decimal
amount_b: Decimal amount_b: Decimal
elimination_amount: Decimal deviation_amount: Decimal
deviation_percent: Decimal | None deviation_percent: Decimal | None
eliminated_amount: Decimal eliminated_amount: Decimal
exceeds_threshold: bool exceeds_threshold: bool
@ -387,6 +402,12 @@ class EliminationPairsOut(BaseModel):
with_discrepancy: list[EliminationMatchedPairOut] with_discrepancy: list[EliminationMatchedPairOut]
unpaired: list[EliminationUnpairedRowOut] 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"] EliminationTotalStatus = Literal["AUTO", "DISCREPANCY", "ONE_SIDED", "MANUAL"]

View File

@ -41,6 +41,7 @@ from src.schemas.consolidations import (
IntercompanyResultAmountOut, IntercompanyResultAmountOut,
IntercompanyResultArticleOut, IntercompanyResultArticleOut,
IntercompanyResultTableOut, IntercompanyResultTableOut,
UnpairedReason,
) )
from src.services.consolidated_osv import ConsolidatedOsvService from src.services.consolidated_osv import ConsolidatedOsvService
@ -398,7 +399,7 @@ def _matched_pair_out(
company_b_id=context.company_b_id, company_b_id=context.company_b_id,
amount_a=amount_a, amount_a=amount_a,
amount_b=amount_b, amount_b=amount_b,
elimination_amount=group.deviation_amount, deviation_amount=group.deviation_amount,
deviation_percent=group.deviation_percent, 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_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], 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) @dataclass(frozen=True)
class RuleSides: class RuleSides:
"""Встречные строки тройки: исходные строки свода и строки сторон для сумм и упаковки.""" """Встречные строки тройки: исходные строки свода и строки сторон с ненулевой суммой со знаком."""
sources: dict[RowKey, ConsolidatedRow] sources: dict[RowKey, ConsolidatedRow]
debit_rows: list[PackRow] debit_rows: list[PackRow]
@ -421,7 +422,7 @@ class RuleSides:
def _collect_rule_sides(rows: Sequence[ConsolidatedRow], selection: MatrixSelection, context: PairContext) -> RuleSides: def _collect_rule_sides(rows: Sequence[ConsolidatedRow], selection: MatrixSelection, context: PairContext) -> RuleSides:
"""Отобрать встречные строки одной направленной пары компаний и одного правила (§5).""" """Встречные строки направленной пары компаний по одному правилу; суммы со знаком, как в шахматке (§5)."""
rule = context.rule rule = context.rule
company_a_id = context.company_a_id company_a_id = context.company_a_id
company_b_id = context.company_b_id company_b_id = context.company_b_id
@ -447,12 +448,10 @@ def _collect_rule_sides(rows: Sequence[ConsolidatedRow], selection: MatrixSelect
continue continue
sources[row.entry_ids] = row sources[row.entry_ids] = row
assert row.counterparty_id is not None assert row.counterparty_id is not None
debit_amount = abs(row.closing_debit or ZERO) amount = _get_amount(row, "closing", "D" if in_debit else "K")
credit_amount = abs(row.closing_credit or ZERO) if amount != ZERO:
if in_debit and debit_amount > ZERO: pack_row = PackRow(row.entry_ids, row.legal_entity_id, row.counterparty_id, amount)
debit_rows.append(PackRow(row.entry_ids, row.legal_entity_id, row.counterparty_id, debit_amount)) (debit_rows if in_debit else credit_rows).append(pack_row)
if in_credit and credit_amount > ZERO:
credit_rows.append(PackRow(row.entry_ids, row.legal_entity_id, row.counterparty_id, credit_amount))
return RuleSides(sources, debit_rows, credit_rows) return RuleSides(sources, debit_rows, credit_rows)
@ -470,7 +469,8 @@ def _total_status(totals: RuleTotals, rule: EliminationRule) -> EliminationTotal
return "MANUAL" return "MANUAL"
if ZERO in (totals.amount_a, totals.amount_b): if ZERO in (totals.amount_a, totals.amount_b):
return "ONE_SIDED" 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( def calculate_rule_total_items(
@ -527,14 +527,18 @@ def _pack_preset_rows(
pair = _matched_pair_out(group, context) pair = _matched_pair_out(group, context)
(matched if group.deviation_amount == ZERO else with_discrepancy).append(pair) (matched if group.deviation_amount == ZERO else with_discrepancy).append(pair)
unpaired: list[EliminationUnpairedRowOut] = [] 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(): for key, row in sources.items():
if key in packed.used_keys: if key in packed.used_keys:
continue continue
amount = amounts.get(key, ZERO)
reason: UnpairedReason
if key in packed.ambiguous_keys: if key in packed.ambiguous_keys:
reason = "AMBIGUOUS" reason = "AMBIGUOUS"
elif key in positive_keys: elif amount > ZERO:
reason = "NO_MATCH" reason = "NO_MATCH"
elif amount < ZERO:
reason = "NEGATIVE_AMOUNT"
else: else:
reason = "ZERO_AMOUNT" reason = "ZERO_AMOUNT"
unpaired.append(EliminationUnpairedRowOut(row=_pair_row_out(row), reason=reason)) unpaired.append(EliminationUnpairedRowOut(row=_pair_row_out(row), reason=reason))
@ -561,6 +565,9 @@ class IntercompanyMatrixService:
accounts = await directory_repo.list_accounts(self._session) accounts = await directory_repo.list_accounts(self._session)
by_id, children = _make_account_tree(accounts) by_id, children = _make_account_tree(accounts)
selection, rule = await self._resolve_preset_selection(rule_id, version, by_id, children) 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 — одна строка может попасть # TODO(упаковка ОСВ): занятость строки отслеживать отдельно для каждого rule_id — одна строка может попасть
# в результаты разных правил. Глобальное исключение повторного использования потребует изменения модели # в результаты разных правил. Глобальное исключение повторного использования потребует изменения модели
# данных: нужно хранить/передавать принадлежность исходной строки конкретной паре между правилами. # данных: нужно хранить/передавать принадлежность исходной строки конкретной паре между правилами.
@ -577,7 +584,7 @@ class IntercompanyMatrixService:
company_b_id=company_b_id, company_b_id=company_b_id,
amount_a=packing.totals.amount_a, amount_a=packing.totals.amount_a,
amount_b=packing.totals.amount_b, amount_b=packing.totals.amount_b,
elimination_amount=packing.totals.deviation_amount, deviation_amount=packing.totals.deviation_amount,
deviation_percent=packing.totals.deviation_percent, deviation_percent=packing.totals.deviation_percent,
eliminated_amount=packing.totals.eliminated_amount, eliminated_amount=packing.totals.eliminated_amount,
exceeds_threshold=packing.totals.exceeds_threshold, exceeds_threshold=packing.totals.exceeds_threshold,

View File

@ -202,6 +202,25 @@ async def test_preset_rejects_income_rule(auth_client: AuthClientFactory, seeded
assert response.json()["detail"]["code"] == "INVALID_RULE" 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: async def test_preset_uses_fixed_rules_version(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded) world = await _world(seeded)
rule = ( rule = (
@ -448,7 +467,8 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915
assert "account_pairs" not in payload assert "account_pairs" not in payload
assert payload["with_discrepancy"] == [] assert payload["with_discrepancy"] == []
assert (Decimal(payload["amount_a"]), Decimal(payload["amount_b"])) == (Decimal("300"), Decimal("100")) 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 payload["exceeds_threshold"] is True
assert Decimal(payload["eliminated_amount"]) == 0 assert Decimal(payload["eliminated_amount"]) == 0
assert len(payload["matched"]) == 1 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_a"]) == len((debit_document, second_debit_document))
assert len(pair["osv_entry_ids_b"]) == 1 assert len(pair["osv_entry_ids_b"]) == 1
assert pair["rule_id"] == str(rule_ids[6]) assert pair["rule_id"] == str(rule_ids[6])
assert tuple(Decimal(pair[field]) for field in ("amount_a", "amount_b", "elimination_amount")) == ( assert tuple(pair[field] for field in ("amount_a", "amount_b", "deviation_amount", "deviation_percent")) == (
Decimal("100"), "100.00",
Decimal("100"), "100.00",
Decimal("0"), "0.00",
"0.00",
) )
assert "article_ids_a" not in pair and "article_ids_b" not in pair assert "article_ids_a" not in pair and "article_ids_b" not in pair
assert "deviation_amount" not in pair assert "elimination_amount" not in pair and "elimination_amount" not in payload
assert Decimal(pair["deviation_percent"]) == 0
assert [item["reason"] for item in payload["unpaired"]] == ["ZERO_AMOUNT", "NO_MATCH"] 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])}) loans = await client.get(url, params={**params, "rule_id": str(rule_ids[3])})
assert loans.status_code == OK, loans.text 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 assert len(other_pair.json()["with_discrepancy"]) == 1
discrepant = other_pair.json()["with_discrepancy"][0] discrepant = other_pair.json()["with_discrepancy"][0]
assert (Decimal(discrepant["amount_a"]), Decimal(discrepant["amount_b"])) == (Decimal("103"), Decimal("100")) 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 "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"])) == ( assert (Decimal(other_pair.json()["amount_a"]), Decimal(other_pair.json()["amount_b"])) == (
Decimal("123"), Decimal("123"),
Decimal("100"), Decimal("100"),

View File

@ -2,6 +2,7 @@
import uuid import uuid
from decimal import Decimal 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 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")) choice = best_subsets([first, second, third], size=2, capacity=Decimal("100"))
assert choice is not None assert choice is not None
assert choice.deviation == 0 assert choice.ratio == 0
assert choice.unique_rows is None assert choice.unique_rows is None
assert choice.involved_keys == {first.key, second.key, third.key} 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")) below, above = (_row(amount, debit=False) for amount in ("99.00", "101.00"))
tie = best_subsets([below, above], size=1, capacity=Decimal("100.00")) # расхождение одно и то же, 1 руб., но к меньшей сумме: 1/99 против 1/100
assert tie is not None nearest = best_subsets([below, above], size=1, capacity=Decimal("100.00"))
assert tie.deviation == Decimal("1.00") assert nearest is not None
assert tie.unique_rows is None assert nearest.ratio == Fraction(1, 100)
assert tie.involved_keys == {below.key, above.key} assert nearest.unique_rows == (above,)
boundary = best_subsets([_row("100.00", debit=False)], size=1, capacity=Decimal("105.00")) # 105 против 100 и 110,25 — по 5 % к меньшей сумме, хотя в рублях 5 и 5,25
assert boundary is not None lower, upper = (_row(amount, debit=False) for amount in ("100.00", "110.25"))
assert boundary.deviation == Decimal("5.00") 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: 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.deviation_percent is None
assert one_sided.exceeds_threshold is True assert one_sided.exceeds_threshold is True
assert one_sided.eliminated_amount == 0 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

View File

@ -385,3 +385,53 @@ def test_result_table_uses_fixed_articles_and_group_totals() -> None:
assert isinstance(result, IntercompanyResultTableOut) assert isinstance(result, IntercompanyResultTableOut)
response = result response = result
assert response.articles[0].amounts[0].company_id == company_a 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"

View File

@ -31,3 +31,5 @@
| R-4 (design #4) | 23.10.2026 | Миграция: дата создания по Москве; пустой `created_at` — текущая дата; нет действующих на дату — последняя открытая; нет правил вовсе — отказ миграции | Совпадает с правилом создания; запасной вариант нужен только для данных, созданных до появления правил | Неверный номер у старых версий — исправляется UPDATE до верификации | | 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-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-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` с двумя знаками |

View File

@ -136,6 +136,14 @@ osv_entry_ids_b[]}`; строка без пары — `{row, reason}`, `reason`:
- **WHEN** по одному правилу есть встречные строки для A→Б и A→В, а запрошены `company_a_id=A`, `company_b_id=Б` - **WHEN** по одному правилу есть встречные строки для A→Б и A→В, а запрошены `company_a_id=A`, `company_b_id=Б`
- **THEN** ответ содержит только строки A→Б и Б→A - **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: Пары ВГО версии ### Requirement: Пары ВГО версии
`GET /api/v1/consolidations/versions/{version_id}/elimination-totals` (`CONSOLIDATION_VIEW`, policy по ГК) SHALL `GET /api/v1/consolidations/versions/{version_id}/elimination-totals` (`CONSOLIDATION_VIEW`, policy по ГК) SHALL
возвращать `{version_no, items[]}` — итоги всех троек версии: правила ББ с `measure = closing` версии правил × возвращать `{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, один раз на запрос. Элемент — `{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[]}` deviation_amount, deviation_percent, eliminated_amount, exceeds_threshold, status, osv_entry_ids_a[], osv_entry_ids_b[]}`
с теми же суммами, что у тройки («Пары ВГО тройки»); `status`: `MANUAL` — `elimination_mode = manual`; `ONE_SIDED` — одна из с теми же суммами, что у тройки («Пары ВГО тройки»); `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`. Суммы и проценты — строки с двумя знаками. Невидимая и несуществующая версия — 404 `NOT_FOUND`.
#### Scenario: Список совпадает с тройкой #### Scenario: Список совпадает с тройкой
@ -160,6 +168,10 @@ deviation_amount, deviation_percent, eliminated_amount, exceeds_threshold, statu
- **WHEN** по счетам правила № 3 строк ни у A, ни у B нет - **WHEN** по счетам правила № 3 строк ни у A, ни у B нет
- **THEN** тройки (3, A, B) в `items` нет - **THEN** тройки (3, A, B) в `items` нет
#### Scenario: Обе стороны отрицательные
- **WHEN** у A по B −50, у B по A −50
- **THEN** тройка `DISCREPANCY`, `deviation_percent = null`, `eliminated_amount = 0.00`
## REMOVED Requirements ## REMOVED Requirements
### Requirement: Правила элиминации версии ### Requirement: Правила элиминации версии

View File

@ -7,6 +7,6 @@
`ELIMINATION_RULES_MISSING`. `ELIMINATION_RULES_MISSING`.
- [x] Список троек версии `GET …/elimination-totals` и тест на тех же данных, что и тройка. - [x] Список троек версии `GET …/elimination-totals` и тест на тех же данных, что и тройка.
- [x] OpenSpec-дельта, README, `05-domain-rules.md` §5 и гайд фронта (arm-ka-docs #164). - [x] OpenSpec-дельта, README, `05-domain-rules.md` §5 и гайд фронта (arm-ka-docs #164).
- [ ] Суммы сторон со знаком, `NEGATIVE_AMOUNT`, сортировка групп по проценту, `deviation_amount` у группы и тройки, суммы - [x] Суммы сторон со знаком, `NEGATIVE_AMOUNT`, сортировка групп по проценту, `deviation_amount` у группы и тройки, суммы
с двумя знаками, правило с `measure ≠ closing` — 422 `INVALID_RULE` (раунд ревью 1, автор). с двумя знаками, правило с `measure ≠ closing` — 422 `INVALID_RULE` (раунд ревью 1; доделано в ветке PR сами).
- [ ] `make check-full` и архив change. - [ ] `make check-full` и архив change.