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=` — итог тройки
(Σ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 правил должен быть применён

View File

@ -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)):

View File

@ -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"]

View File

@ -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,

View File

@ -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"),

View File

@ -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

View File

@ -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"

View File

@ -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` с двумя знаками |

View File

@ -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: Правила элиминации версии

View File

@ -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.