§5 п. 2a: в агрегаты и нераспределённые идут только строки между разными компаниями периметра версии; строка с ручным решением аналитика остаётся видна. На копии стенда нераспределённых РБ 2025 К1 — 37 → 3, РБ 2024 К3 — 31 → 3 (вместе с «76-исключениями»). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
123 lines
6.2 KiB
Python
123 lines
6.2 KiB
Python
"""Чистый выбор агрегатов и строк ВГО после поиска встречных сумм."""
|
||
|
||
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))
|