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:
parent
fdb1f179cc
commit
8940f32ae3
@ -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 правил должен быть применён
|
||||||
|
|||||||
@ -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)):
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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"),
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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` с двумя знаками |
|
||||||
|
|||||||
@ -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: Правила элиминации версии
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user