From 8940f32ae3e5c26daa373143c8c9ed6159b19ab8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 21:47:47 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BF=D0=B0=D1=80=D1=8B=20=D0=92=D0=93?= =?UTF-8?q?=D0=9E=20=E2=80=94=20=D1=81=D1=83=D0=BC=D0=BC=D1=8B=20=D1=81?= =?UTF-8?q?=D0=BE=20=D0=B7=D0=BD=D0=B0=D0=BA=D0=BE=D0=BC,=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=86=D0=B5=D0=BD=D1=82=20=D0=B2=20=D1=83=D0=BF=D0=B0?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=D0=BA=D0=B5,=20deviation=5Famount=20(=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E=20#175,=20AURORA-1536)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - суммы сторон со знаком, как в шахматке; строки с суммой < 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 --- README.md | 5 +- api/src/domain/elimination_packing.py | 47 ++++++++------- api/src/schemas/consolidations.py | 29 +++++++-- api/src/services/intercompany_matrix.py | 33 ++++++---- api/tests/test_api_intercompany_matrix.py | 38 +++++++++--- api/tests/test_elimination_packing.py | 60 +++++++++++++++---- api/tests/test_intercompany_matrix.py | 50 ++++++++++++++++ .../elimination-pairs-rule-totals/design.md | 2 + .../specs/consolidations-api/spec.md | 14 ++++- .../elimination-pairs-rule-totals/tasks.md | 4 +- 10 files changed, 221 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 455084c..4c13e12 100644 --- a/README.md +++ b/README.md @@ -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 правил должен быть применён diff --git a/api/src/domain/elimination_packing.py b/api/src/domain/elimination_packing.py index cb0bbad..fc10ed5 100644 --- a/api/src/domain/elimination_packing.py +++ b/api/src/domain/elimination_packing.py @@ -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)): diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 0ca3331..0215801 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -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"] diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index 6ae05fd..a7d7aff 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -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, diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index 58bdd4b..a21275e 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -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"), diff --git a/api/tests/test_elimination_packing.py b/api/tests/test_elimination_packing.py index 8216665..737c753 100644 --- a/api/tests/test_elimination_packing.py +++ b/api/tests/test_elimination_packing.py @@ -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 diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index b3666ff..11cb529 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -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" diff --git a/openspec/changes/elimination-pairs-rule-totals/design.md b/openspec/changes/elimination-pairs-rule-totals/design.md index 4fc01b3..b4e3120 100644 --- a/openspec/changes/elimination-pairs-rule-totals/design.md +++ b/openspec/changes/elimination-pairs-rule-totals/design.md @@ -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` с двумя знаками | diff --git a/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md b/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md index 10b65a3..c3f1bbb 100644 --- a/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md +++ b/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md @@ -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: Правила элиминации версии diff --git a/openspec/changes/elimination-pairs-rule-totals/tasks.md b/openspec/changes/elimination-pairs-rule-totals/tasks.md index 32ba09b..bc5edcb 100644 --- a/openspec/changes/elimination-pairs-rule-totals/tasks.md +++ b/openspec/changes/elimination-pairs-rule-totals/tasks.md @@ -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.