arm-core/api/src/services/correction_source.py

223 lines
10 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.

"""Граница с разнесением ОСВ: адаптация готового результа ``VgoService``."""
import uuid
from collections.abc import Sequence
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Literal, Protocol, cast
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.common.exceptions import ConflictError
from src.domain.consolidated_osv import ConsolidatedRow
from src.domain.corrections import calculate_totals
from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRuleAccount
from src.models.consolidation import ConsolidationVersion
from src.models.enums import StatementType
from src.schemas.consolidations import RefOut, VgoAssignmentGroupOut, VgoAssignmentRowOut
from src.schemas.corrections import (
AggregateSideOut,
CorrectionAggregateOut,
CorrectionEntryOut,
CorrectionPairOut,
CorrectionRuleOut,
)
from src.services.consolidated_osv import ConsolidatedOsvService
from src.services.vgo import VgoService, _manual_key_amount
ZERO = Decimal("0")
COMPANY_NUMBERS = (1, 2)
Side = Literal["D", "K"]
@dataclass(frozen=True)
class CorrectionSourceTable:
rules: list[CorrectionRuleOut] = field(default_factory=list)
aggregates: list[CorrectionAggregateOut] = field(default_factory=list)
class CorrectionAggregateSource(Protocol):
async def table(self, version_id: uuid.UUID, statement_type: StatementType) -> CorrectionSourceTable: ...
@dataclass
class _RuleSpec:
rule: EliminationRule
included_codes: dict[int, list[str]] = field(default_factory=lambda: {1: [], 2: []})
excluded_codes: dict[int, list[str]] = field(default_factory=lambda: {1: [], 2: []})
def _direction(rule: EliminationRule, company_no: int) -> Side:
if rule.side_1 not in ("D", "K"):
raise ConflictError("CORRECTION_SOURCE_INVALID", "В правиле задана неизвестная сторона")
first = cast(Side, rule.side_1)
if company_no == 1:
return first
return "K" if first == "D" else "D"
class VgoCorrectionSource:
"""Преобразует готовое автоматическое и ручное разнесение в таблицу корректировок."""
def __init__(self, session: AsyncSession, vgo_service: VgoService, osv_service: ConsolidatedOsvService) -> None:
self._session = session
self._vgo_service = vgo_service
self._osv_service = osv_service
async def _rules(self, statement_type: StatementType, version_no: int) -> dict[uuid.UUID, _RuleSpec]:
accounts = list((await self._session.scalars(select(ChartOfAccount))).all())
by_id = {account.id: account for account in accounts}
rows = (
await self._session.execute(
select(EliminationRule, EliminationRuleAccount)
.outerjoin(EliminationRuleAccount, EliminationRuleAccount.rule_id == EliminationRule.id)
.where(
EliminationRule.statement_type == statement_type,
EliminationRule.version_no == version_no,
)
.order_by(EliminationRule.rule_no, EliminationRuleAccount.company_no)
)
).all()
specs: dict[uuid.UUID, _RuleSpec] = {}
for rule, account in rows:
spec = specs.setdefault(rule.id, _RuleSpec(rule))
if account is None or account.company_no not in COMPANY_NUMBERS:
continue
root = by_id.get(account.account_id)
if root is not None:
codes = spec.excluded_codes if account.is_excluded else spec.included_codes
codes[account.company_no].append(root.code)
return specs
async def table(self, version_id: uuid.UUID, statement_type: StatementType) -> CorrectionSourceTable:
version = await self._session.get(ConsolidationVersion, version_id)
if version is None or statement_type != StatementType.balance:
return CorrectionSourceTable()
specs = await self._rules(statement_type, version.elimination_rules_version)
allocation = await self._vgo_service.assignment_candidates(version_id)
osv_result = await self._osv_service.build_with_members(version_id)
aggregates = [
self._aggregate(group, specs[group.rule_id], osv_result.consolidated.rows) for group in allocation.groups
]
used_rules = {group.rule_id for group in allocation.groups}
rules = [self._rule_out(spec) for rule_id, spec in specs.items() if rule_id in used_rules]
return CorrectionSourceTable(rules=rules, aggregates=aggregates)
@staticmethod
def _rule_out(spec: _RuleSpec) -> CorrectionRuleOut:
rule = spec.rule
return CorrectionRuleOut(
id=rule.id,
rule_no=rule.rule_no,
article_1_line=rule.article_1_line,
article_2_line=rule.article_2_line,
side_1=_direction(rule, 1),
measure=cast(Literal["closing", "turnover"], rule.measure),
company_1_accounts=sorted(set(spec.included_codes[1])),
company_2_accounts=sorted(set(spec.included_codes[2])),
company_1_excluded=sorted(set(spec.excluded_codes[1])),
company_2_excluded=sorted(set(spec.excluded_codes[2])),
)
@staticmethod
def _aggregate(
group: VgoAssignmentGroupOut, spec: _RuleSpec, rows: Sequence[ConsolidatedRow]
) -> CorrectionAggregateOut:
rule = spec.rule
side_1_code = _direction(rule, 1)
rows_by_entry_id = {entry_id: row for row in rows for entry_id in row.entry_ids}
def amount(row: ConsolidatedRow | VgoAssignmentRowOut, side: Side) -> Decimal:
if rule.measure == "turnover":
return (row.turnover_debit if side == "D" else row.turnover_credit) or ZERO
return (row.closing_debit if side == "D" else row.closing_credit) or ZERO
def entry(row: ConsolidatedRow | VgoAssignmentRowOut, side: Side, value: Decimal) -> CorrectionEntryOut | None:
if row.counterparty_id is None:
return None
return CorrectionEntryOut(
entry_ids=list(row.entry_ids),
legal_entity_id=row.legal_entity_id,
counterparty_id=row.counterparty_id,
account_code=row.account_code or "",
side=side,
amount=value,
)
entries_by_key: dict[tuple[tuple[uuid.UUID, ...], Side], CorrectionEntryOut] = {}
calculated = (*group.matched, *group.with_discrepancy)
for pair in calculated:
for entry_ids, side_code in (
(pair.osv_entry_ids_a, side_1_code),
(pair.osv_entry_ids_b, _direction(rule, 2)),
):
selected: dict[tuple[uuid.UUID, ...], ConsolidatedRow] = {}
for entry_id in entry_ids:
auto_row = rows_by_entry_id.get(entry_id)
if auto_row is not None:
selected.setdefault(tuple(auto_row.entry_ids), auto_row)
for auto_row in selected.values():
item = entry(auto_row, side_code, amount(auto_row, side_code))
if item is not None:
entries_by_key[(tuple(auto_row.entry_ids), side_code)] = item
for manual_row in group.manual:
if manual_row.is_exclude:
continue
_, amount_1, amount_2 = _manual_key_amount(manual_row, rule) # type: ignore[arg-type]
side_code = side_1_code if amount_1 != ZERO or amount_2 == ZERO else _direction(rule, 2)
item = entry(manual_row, side_code, amount_1 if side_code == side_1_code else amount_2)
if item is not None:
entries_by_key[(tuple(manual_row.entry_ids), side_code)] = item
entries = list(entries_by_key.values())
def side(first: bool) -> AggregateSideOut:
side_entries = [entry for entry in entries if (entry.side == side_1_code) == first]
return AggregateSideOut(
article_line=rule.article_1_line if first else rule.article_2_line,
accounts=sorted({entry.account_code for entry in side_entries}),
amount=sum((entry.amount for entry in side_entries), ZERO),
entries=side_entries,
)
def pair_side(entry_ids: list[uuid.UUID], *, first: bool, amount: Decimal) -> AggregateSideOut:
ids = set(entry_ids)
pair_entries = [entry for entry in entries if ids.intersection(entry.entry_ids)]
return AggregateSideOut(
article_line=rule.article_1_line if first else rule.article_2_line,
accounts=sorted({entry.account_code for entry in pair_entries}),
amount=amount,
entries=pair_entries,
)
first, second = side(True), side(False)
first.amount = group.amount_a
second.amount = group.amount_b
pairs = [
CorrectionPairOut(
side_1=pair_side(pair.osv_entry_ids_a, first=True, amount=pair.amount_a),
side_2=pair_side(pair.osv_entry_ids_b, first=False, amount=pair.amount_b),
deviation_amount=pair.deviation_amount,
deviation_percent=pair.deviation_percent,
)
for pair in (*group.matched, *group.with_discrepancy)
]
totals = calculate_totals(first.amount, second.amount, manual=rule.elimination_mode == "manual")
return CorrectionAggregateOut(
rule_id=group.rule_id,
rule_no=group.rule_no,
company_1=RefOut(id=group.company_a_id, name=group.company_a_name or str(group.company_a_id)),
company_2=RefOut(id=group.company_b_id, name=group.company_b_name or str(group.company_b_id)),
side_1=first,
side_2=second,
pairs=pairs,
deviation_amount=totals.deviation_amount,
deviation_percent=totals.deviation_percent,
exceeds_threshold=totals.exceeds_threshold,
status=totals.status,
eliminated_amount=totals.eliminated_amount,
correction=None,
)