arm-core/api/src/domain/vgo_distribution.py
Andrey Epifancev 3296e4a849 fix(#237): разнесение — без строк вне периметра и «сам на себя» (AURORA-2034)
§5 п. 2a: в агрегаты и нераспределённые идут только строки между разными компаниями
периметра версии; строка с ручным решением аналитика остаётся видна. На копии стенда
нераспределённых РБ 2025 К1 — 37 → 3, РБ 2024 К3 — 31 → 3 (вместе с «76-исключениями»).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:04:10 +04:00

123 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Чистый выбор агрегатов и строк ВГО после поиска встречных сумм."""
import uuid
from collections import Counter, defaultdict
from collections.abc import Mapping, Sequence, Set
from dataclasses import dataclass
from decimal import Decimal
from typing import Literal
from src.domain.consolidated_osv import ConsolidatedRow
RowKey = tuple[uuid.UUID, ...]
ZERO = Decimal("0")
Side = Literal["D", "K"]
# Сторона → счёт → правила, в которые счёт входит именно этой стороной (с исключениями стороны, §4).
RuleOwners = Mapping[Side, Mapping[uuid.UUID, Set[uuid.UUID]]]
@dataclass(frozen=True)
class Distribution:
"""Результат выбора: расхождения, строки без пары и остаток для ручного разбора."""
selected_diff: tuple[int, ...]
auto_rules: Mapping[RowKey, uuid.UUID]
unassigned: frozenset[RowKey]
used_ids: frozenset[uuid.UUID]
@dataclass(frozen=True)
class DistributionInput:
rows: Sequence[ConsolidatedRow]
exact_ids: Set[uuid.UUID]
diff_ids: Sequence[Set[uuid.UUID]]
manual_keys: Set[RowKey]
owners: RuleOwners
members: Set[uuid.UUID]
def account_owners(
selections: Sequence[tuple[uuid.UUID, Set[uuid.UUID], Set[uuid.UUID]]],
) -> dict[Side, dict[uuid.UUID, frozenset[uuid.UUID]]]:
"""Для каждого раскрытого счёта — правила, в которые он входит, отдельно по дебету и кредиту (§5 п. 2a).
Вход — (правило, счета дебетовой стороны, счета кредитовой стороны) уже с исключениями своей стороны. Стороны не
сливаются: исключение одной стороны (например, 76.07.1 из кредита правила 6) не должно возвращаться через маску
другой (дебетовую «76»), иначе кредитовая строка ложно попадает в несколько правил (AURORA-2034).
"""
owners: dict[Side, defaultdict[uuid.UUID, set[uuid.UUID]]] = {"D": defaultdict(set), "K": defaultdict(set)}
for rule_id, debit_ids, credit_ids in selections:
for account_id in debit_ids:
owners["D"][account_id].add(rule_id)
for account_id in credit_ids:
owners["K"][account_id].add(rule_id)
return {
side: {account_id: frozenset(rule_ids) for account_id, rule_ids in by_account.items()}
for side, by_account in owners.items()
}
def row_side(closing_debit: Decimal | None, closing_credit: Decimal | None) -> Side:
"""Сторона строки свода: дебет, если есть дебетовое сальдо или нет кредитового; иначе кредит (§5 п. 2a)."""
return "D" if (closing_debit or ZERO) != ZERO or (closing_credit or ZERO) == ZERO else "K"
def rules_for(owners: RuleOwners, account_id: uuid.UUID | None, side: Side) -> frozenset[uuid.UUID]:
"""Правила, в которые счёт входит заданной стороной; без счёта — ни одного."""
if account_id is None:
return frozenset()
return frozenset(owners.get(side, {}).get(account_id, frozenset()))
def select_exact_aggregates(
used_ids: Set[uuid.UUID], candidate_ids: Sequence[Set[uuid.UUID]]
) -> tuple[tuple[int, ...], frozenset[uuid.UUID]]:
"""Закрепить точные агрегаты по порядку правил без повторного использования строк."""
used = set(used_ids)
selected = []
for index, ids in enumerate(candidate_ids):
if ids and used.isdisjoint(ids):
selected.append(index)
used.update(ids)
return tuple(selected), frozenset(used)
def distribute_vgo_rows(data: DistributionInput) -> Distribution:
"""Выбрать непересекающиеся расхождения, затем однозначные строки без пары и остаток.
Точные агрегаты уже закреплены по порядку правил. Пересекающиеся расхождения все остаются
нераспределёнными; ручные решения не заменяются автоматическими.
"""
candidates = [(index, ids) for index, ids in enumerate(data.diff_ids) if ids and data.exact_ids.isdisjoint(ids)]
counts = Counter(entry_id for _, ids in candidates for entry_id in ids)
selected = tuple(index for index, ids in candidates if all(counts[entry_id] == 1 for entry_id in ids))
used = set(data.exact_ids)
for index in selected:
used.update(data.diff_ids[index])
auto_rules: dict[RowKey, uuid.UUID] = {}
unassigned: set[RowKey] = set()
for row in data.rows:
if not used.isdisjoint(row.entry_ids):
continue
manual = row.entry_ids in data.manual_keys
# §5 п. 2a: разносятся только строки между разными компаниями периметра; строка с контрагентом вне периметра
# или «сам на себя» в таблицу не попадает, если по ней нет ручного решения аналитика (AURORA-2034)
in_perimeter = (
row.legal_entity_id in data.members
and row.counterparty_id in data.members
and row.legal_entity_id != row.counterparty_id
)
if not manual and not in_perimeter:
continue
rule_ids = rules_for(data.owners, row.account_id, row_side(row.closing_debit, row.closing_credit))
if (
not manual
and len(rule_ids) == 1
and (row.closing_debit not in (None, ZERO) or row.closing_credit not in (None, ZERO))
):
auto_rules[row.entry_ids] = next(iter(rule_ids))
elif rule_ids or manual:
unassigned.add(row.entry_ids)
return Distribution(selected, auto_rules, frozenset(unassigned), frozenset(used))