Compare commits
3 Commits
22facb3ddf
...
827b490137
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
827b490137 | ||
|
|
010c15386b | ||
|
|
232fecec39 |
@ -6,9 +6,9 @@ from typing import Any
|
||||
from fastapi import APIRouter, Depends, status
|
||||
|
||||
from src.api.dependencies.auth import require
|
||||
from src.api.dependencies.consolidations import EliminationRuleServiceDep
|
||||
from src.api.dependencies.consolidations import EliminationRuleServiceDep, IntercompanyMatrixServiceDep
|
||||
from src.schemas.common import ErrorDetail
|
||||
from src.schemas.consolidations import EliminationRulesOut
|
||||
from src.schemas.consolidations import EliminationPairsOut, EliminationRulesOut
|
||||
|
||||
router = APIRouter(prefix="/consolidations/versions")
|
||||
|
||||
@ -16,6 +16,10 @@ VIEW = [Depends(require("CONSOLIDATION_VIEW"))]
|
||||
NOT_FOUND: dict[int | str, dict[str, Any]] = {
|
||||
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}
|
||||
}
|
||||
PAIRS_RESPONSES: dict[int | str, dict[str, Any]] = {
|
||||
**NOT_FOUND,
|
||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": ErrorDetail, "description": "Ошибка в составе правила или счетов"},
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
@ -31,3 +35,21 @@ async def list_rules(
|
||||
) -> EliminationRulesOut:
|
||||
"""Правила §4 зафиксированной либо последней открытой версии с основанием выбора."""
|
||||
return await service.list_rules(version_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{version_id}/elimination-pairs",
|
||||
response_model=EliminationPairsOut,
|
||||
dependencies=VIEW,
|
||||
responses=PAIRS_RESPONSES,
|
||||
summary="Сопоставление строк ОСВ по правилу и паре компаний",
|
||||
)
|
||||
async def build_elimination_pairs(
|
||||
version_id: uuid.UUID,
|
||||
rule_id: uuid.UUID,
|
||||
company_a_id: uuid.UUID,
|
||||
company_b_id: uuid.UUID,
|
||||
service: IntercompanyMatrixServiceDep,
|
||||
) -> EliminationPairsOut:
|
||||
"""Точные пары, пары с расхождением и строки без пары для выбранной тройки."""
|
||||
return await service.build_pairs(version_id, rule_id, company_a_id, company_b_id)
|
||||
|
||||
197
api/src/domain/elimination_packing.py
Normal file
197
api/src/domain/elimination_packing.py
Normal file
@ -0,0 +1,197 @@
|
||||
"""Упаковка встречных строк сводной ОСВ внутри одного правила элиминации (§5).
|
||||
|
||||
Счета уже отобраны пресетом. Функция не читает БД и не меняет сумму элиминации: она только группирует строки.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import TypeAlias
|
||||
|
||||
RowKey: TypeAlias = tuple[uuid.UUID, ...]
|
||||
MAX_SIDE_ROWS = 5
|
||||
MIN_GROUP_SIZE = 2
|
||||
MAX_DEVIATION_PERCENT = Decimal("5")
|
||||
ZERO = Decimal("0")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackRow:
|
||||
"""Одна неделимая строка свода на дебетовой или кредитовой стороне."""
|
||||
|
||||
key: RowKey
|
||||
owner_id: uuid.UUID
|
||||
counterparty_id: uuid.UUID
|
||||
amount: Decimal # модуль суммы соответствующей стороны
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnapsackChoice:
|
||||
"""Лучшие подмножества заданного размера; при ничьей возвращаем ключи всех затронутых строк."""
|
||||
|
||||
deviation: Decimal
|
||||
unique_rows: tuple[PackRow, ...] | None
|
||||
involved_keys: frozenset[RowKey]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackedGroup:
|
||||
debit_rows: tuple[PackRow, ...]
|
||||
credit_rows: tuple[PackRow, ...]
|
||||
debit_amount: Decimal
|
||||
credit_amount: Decimal
|
||||
deviation_amount: Decimal
|
||||
deviation_percent: Decimal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackingResult:
|
||||
groups: tuple[PackedGroup, ...]
|
||||
used_keys: frozenset[RowKey]
|
||||
ambiguous_keys: frozenset[RowKey]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuleTotals:
|
||||
"""Итог тройки до упаковки строк; упаковка его не меняет."""
|
||||
|
||||
amount_a: Decimal
|
||||
amount_b: Decimal
|
||||
deviation_amount: Decimal
|
||||
deviation_percent: Decimal | None
|
||||
eliminated_amount: Decimal
|
||||
exceeds_threshold: bool
|
||||
|
||||
|
||||
def calculate_rule_totals(amount_a: Decimal, amount_b: Decimal, *, manual: bool) -> RuleTotals:
|
||||
"""Правило 5 % для всей тройки, независимо от сопоставления отдельных строк."""
|
||||
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
|
||||
return RuleTotals(amount_a, amount_b, deviation, percent, eliminated, exceeds_threshold)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Candidate:
|
||||
deviation: Decimal
|
||||
involved_keys: frozenset[RowKey]
|
||||
group: PackedGroup | None # None — несколько одинаково лучших подмножеств у одного якоря
|
||||
|
||||
|
||||
def best_subsets(rows: list[PackRow], *, size: int, capacity: Decimal) -> KnapsackChoice | None:
|
||||
"""Перебрать группы заданного размера; битовая маска отмечает выбранные строки и ничьи."""
|
||||
if not 1 <= size <= MAX_SIDE_ROWS:
|
||||
raise ValueError("размер стороны должен быть от 1 до 5")
|
||||
candidates = sorted((row for row in rows if row.amount > ZERO), key=lambda row: row.amount)
|
||||
if capacity <= ZERO or len(candidates) < size:
|
||||
return None
|
||||
hundred_percent = Decimal("100")
|
||||
limit = hundred_percent + MAX_DEVIATION_PERCENT
|
||||
upper = capacity * limit / hundred_percent
|
||||
combinations: list[tuple[int, Decimal]] = [] # (битовая маска выбранных строк, точная сумма)
|
||||
|
||||
def search(start: int, selected: int, total: Decimal, mask: int) -> None:
|
||||
remaining = size - selected
|
||||
if remaining == 0:
|
||||
deviation = abs(capacity - total)
|
||||
if deviation * hundred_percent <= MAX_DEVIATION_PERCENT * min(capacity, total):
|
||||
combinations.append((mask, total))
|
||||
return
|
||||
|
||||
if len(candidates) - start < remaining:
|
||||
return
|
||||
|
||||
# Промежуточную сумму сравниваем с нижней границей лишь с учётом оставшихся строк.
|
||||
largest_total = total + sum((row.amount for row in candidates[-remaining:]), ZERO)
|
||||
if largest_total * limit < capacity * hundred_percent:
|
||||
return
|
||||
smallest_total = total + sum((row.amount for row in candidates[start : start + remaining]), ZERO)
|
||||
if smallest_total > upper:
|
||||
return
|
||||
|
||||
for index in range(start, len(candidates) - remaining + 1):
|
||||
next_total = total + candidates[index].amount
|
||||
if next_total > upper:
|
||||
break # суммы отсортированы: следующие строки только больше
|
||||
search(index + 1, selected + 1, next_total, mask | (1 << index))
|
||||
|
||||
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]
|
||||
involved_mask = 0
|
||||
for mask in best_masks:
|
||||
involved_mask |= mask
|
||||
involved_keys = frozenset(row.key for index, row in enumerate(candidates) if involved_mask & (1 << index))
|
||||
unique_rows = (
|
||||
tuple(row for index, row in enumerate(candidates) if best_masks[0] & (1 << index))
|
||||
if len(best_masks) == 1
|
||||
else None
|
||||
)
|
||||
return KnapsackChoice(best_deviation, 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)
|
||||
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)
|
||||
credit_amount = sum((row.amount for row in credit), ZERO)
|
||||
group = PackedGroup(
|
||||
debit_rows=debit,
|
||||
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,
|
||||
)
|
||||
return _Candidate(choice.deviation, 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]
|
||||
candidates: list[_Candidate] = []
|
||||
for anchor in available_debit:
|
||||
choice = best_subsets(available_credit, size=total_size - 1, capacity=anchor.amount)
|
||||
if choice is not None:
|
||||
candidates.append(_candidate(anchor, choice, anchor_is_debit=True))
|
||||
if total_size > MIN_GROUP_SIZE:
|
||||
for anchor in available_credit:
|
||||
choice = best_subsets(available_debit, size=total_size - 1, capacity=anchor.amount)
|
||||
if choice is not None:
|
||||
candidates.append(_candidate(anchor, choice, anchor_is_debit=False))
|
||||
return candidates
|
||||
|
||||
|
||||
def pack_rule_rows(debit_rows: list[PackRow], credit_rows: list[PackRow]) -> PackingResult:
|
||||
"""Жадно упаковать 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]
|
||||
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)):
|
||||
if best[left].involved_keys & best[right].involved_keys:
|
||||
conflicted.update((left, right))
|
||||
for index, candidate in enumerate(best):
|
||||
if index in conflicted:
|
||||
ambiguous.update(candidate.involved_keys)
|
||||
else:
|
||||
assert candidate.group is not None
|
||||
groups.append(candidate.group)
|
||||
used.update(candidate.involved_keys)
|
||||
return PackingResult(tuple(groups), frozenset(used), frozenset(ambiguous))
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import uuid
|
||||
from collections.abc import Collection
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@ -44,6 +45,16 @@ async def current_open_version_no(session: AsyncSession) -> int | None:
|
||||
return await session.scalar(_current_open_version_stmt())
|
||||
|
||||
|
||||
async def latest_effective_version_no(session: AsyncSession, as_of: date) -> int | None:
|
||||
"""Максимальный номер версии правил, действующей на дату создания"""
|
||||
return await session.scalar(
|
||||
select(func.max(EliminationRule.version_no)).where(
|
||||
EliminationRule.valid_from <= as_of,
|
||||
(EliminationRule.valid_to.is_(None) | (EliminationRule.valid_to >= as_of)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_valid_for_version(rule: EliminationRule | None, version_no: int | None) -> bool:
|
||||
"""Проверить версию правила §4 либо отсутствие `valid_to`."""
|
||||
return rule is not None and (rule.version_no == version_no if version_no is not None else rule.valid_to is None)
|
||||
|
||||
@ -336,6 +336,58 @@ class EliminationRulesOut(BaseModel):
|
||||
rules: list[EliminationRuleOut]
|
||||
|
||||
|
||||
class EliminationPairRowOut(BaseModel):
|
||||
"""Неделимая строка свода; `entry_ids` связывают её с исходными строками ОСВ."""
|
||||
|
||||
entry_ids: list[uuid.UUID]
|
||||
legal_entity_id: uuid.UUID
|
||||
counterparty_id: uuid.UUID
|
||||
account_id: uuid.UUID
|
||||
account_code: str
|
||||
closing_debit: Decimal | None
|
||||
closing_credit: Decimal | None
|
||||
|
||||
|
||||
class EliminationMatchedPairOut(BaseModel):
|
||||
"""Сопоставленная группа строк одного правила и пары компаний."""
|
||||
|
||||
rule_id: uuid.UUID
|
||||
company_a_id: uuid.UUID
|
||||
company_b_id: uuid.UUID
|
||||
amount_a: Decimal
|
||||
amount_b: Decimal
|
||||
elimination_amount: Decimal
|
||||
deviation_percent: Decimal
|
||||
osv_entry_ids_a: list[uuid.UUID]
|
||||
osv_entry_ids_b: list[uuid.UUID]
|
||||
|
||||
|
||||
class EliminationUnpairedRowOut(BaseModel):
|
||||
"""Строка, не вошедшая в автоматическую группу правила."""
|
||||
|
||||
row: EliminationPairRowOut
|
||||
reason: Literal["ZERO_AMOUNT", "NO_MATCH", "AMBIGUOUS"]
|
||||
|
||||
|
||||
class EliminationPairsOut(BaseModel):
|
||||
"""Точные пары, пары с расхождением и строки без пары для выбранной тройки."""
|
||||
|
||||
version_no: int | None
|
||||
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_percent: Decimal | None
|
||||
eliminated_amount: Decimal
|
||||
exceeds_threshold: bool
|
||||
matched: list[EliminationMatchedPairOut]
|
||||
with_discrepancy: list[EliminationMatchedPairOut]
|
||||
unpaired: list[EliminationUnpairedRowOut]
|
||||
|
||||
|
||||
class IntercompanyMatrixManualIn(BaseModel):
|
||||
"""Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки."""
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ from collections.abc import Sequence
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.dependencies.audit import RequestAudit
|
||||
from src.common.clock import utc_now
|
||||
from src.common.clock import moscow_date, utc_now
|
||||
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError
|
||||
from src.domain import checklist as checklist_domain
|
||||
from src.domain.consolidation_periods import quarters_through, reporting_date
|
||||
@ -40,6 +40,7 @@ from src.models.enums import (
|
||||
)
|
||||
from src.repositories import consolidations as repo
|
||||
from src.repositories import directories as directories_repo
|
||||
from src.repositories import elimination_rules as elimination_rules_repo
|
||||
from src.repositories.users import UserWithAccess
|
||||
from src.schemas.consolidations import (
|
||||
ChecklistCellOut,
|
||||
@ -347,6 +348,9 @@ class ConsolidationService:
|
||||
scenario=ConsolidationScenario.CREATE,
|
||||
approach=ConsolidationApproach.BASIC,
|
||||
goodwill_enabled=False,
|
||||
elimination_rules_version=await elimination_rules_repo.latest_effective_version_no(
|
||||
self._session, moscow_date(now)
|
||||
),
|
||||
initiator_id=user.id,
|
||||
initiator_office_id=user.regional_office_id,
|
||||
is_in_work=True,
|
||||
|
||||
@ -12,6 +12,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.common.exceptions import DomainValidationError, NotFoundError
|
||||
from src.domain.consolidated_osv import ConsolidatedRow, Entity
|
||||
from src.domain.elimination_packing import (
|
||||
PackedGroup,
|
||||
PackRow,
|
||||
RowKey,
|
||||
RuleTotals,
|
||||
calculate_rule_totals,
|
||||
pack_rule_rows,
|
||||
)
|
||||
from src.domain.policy import VisibilityScope
|
||||
from src.models.accounting import ChartOfAccount, EliminationRule
|
||||
from src.models.consolidation import Consolidation, ConsolidationVersion
|
||||
@ -20,6 +28,10 @@ from src.models.enums import AccountKind, PeriodQuarter, StatementType
|
||||
from src.repositories import directories as directory_repo
|
||||
from src.repositories import elimination_rules as elimination_rules_repo
|
||||
from src.schemas.consolidations import (
|
||||
EliminationMatchedPairOut,
|
||||
EliminationPairRowOut,
|
||||
EliminationPairsOut,
|
||||
EliminationUnpairedRowOut,
|
||||
IntercompanyMatrixCellOut,
|
||||
IntercompanyMatrixCompanyOut,
|
||||
IntercompanyMatrixOut,
|
||||
@ -154,6 +166,21 @@ class ResultTableBuild:
|
||||
consolidation: Consolidation
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PairContext:
|
||||
rule: EliminationRule
|
||||
company_a_id: uuid.UUID
|
||||
company_b_id: uuid.UUID
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RulePacking:
|
||||
matched: list[EliminationMatchedPairOut]
|
||||
with_discrepancy: list[EliminationMatchedPairOut]
|
||||
unpaired: list[EliminationUnpairedRowOut]
|
||||
totals: RuleTotals
|
||||
|
||||
|
||||
def _get_amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal:
|
||||
if measure == "closing":
|
||||
value = row.closing_debit if side == "D" else row.closing_credit
|
||||
@ -339,6 +366,106 @@ def calculate_result_table(
|
||||
return IntercompanyResultTableOut(companies=list(companies), articles=articles)
|
||||
|
||||
|
||||
def _pair_row_out(row: ConsolidatedRow) -> EliminationPairRowOut:
|
||||
"""Строка свода, уже отфильтрованная по счёту и встречному ЮЛ периметра."""
|
||||
assert row.counterparty_id is not None and row.account_id is not None and row.account_code is not None
|
||||
return EliminationPairRowOut(
|
||||
entry_ids=list(row.entry_ids),
|
||||
legal_entity_id=row.legal_entity_id,
|
||||
counterparty_id=row.counterparty_id,
|
||||
account_id=row.account_id,
|
||||
account_code=row.account_code,
|
||||
closing_debit=row.closing_debit,
|
||||
closing_credit=row.closing_credit,
|
||||
)
|
||||
|
||||
|
||||
def _matched_pair_out(
|
||||
group: PackedGroup,
|
||||
context: PairContext,
|
||||
) -> EliminationMatchedPairOut:
|
||||
rule = context.rule
|
||||
rows_a = group.debit_rows if rule.side_1 == "D" else group.credit_rows
|
||||
rows_b = group.credit_rows if rule.side_1 == "D" else group.debit_rows
|
||||
amount_a = group.debit_amount if rule.side_1 == "D" else group.credit_amount
|
||||
amount_b = group.credit_amount if rule.side_1 == "D" else group.debit_amount
|
||||
return EliminationMatchedPairOut(
|
||||
rule_id=rule.id,
|
||||
company_a_id=context.company_a_id,
|
||||
company_b_id=context.company_b_id,
|
||||
amount_a=amount_a,
|
||||
amount_b=amount_b,
|
||||
elimination_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],
|
||||
)
|
||||
|
||||
|
||||
def _pack_preset_rows(
|
||||
rows: Sequence[ConsolidatedRow],
|
||||
selection: MatrixSelection,
|
||||
context: PairContext,
|
||||
) -> RulePacking:
|
||||
"""Отобрать встречные строки одной направленной пары компаний и одного правила."""
|
||||
rule = context.rule
|
||||
company_a_id = context.company_a_id
|
||||
company_b_id = context.company_b_id
|
||||
debit_company_id = company_a_id if rule.side_1 == "D" else company_b_id
|
||||
credit_company_id = company_b_id if debit_company_id == company_a_id else company_a_id
|
||||
sources: dict[RowKey, ConsolidatedRow] = {}
|
||||
debit_rows: list[PackRow] = []
|
||||
credit_rows: list[PackRow] = []
|
||||
for row in rows:
|
||||
if row.account_id is None:
|
||||
continue
|
||||
in_debit = (
|
||||
row.legal_entity_id == debit_company_id
|
||||
and row.counterparty_id == credit_company_id
|
||||
and row.account_id in selection.company_1_account_ids
|
||||
)
|
||||
in_credit = (
|
||||
row.legal_entity_id == credit_company_id
|
||||
and row.counterparty_id == debit_company_id
|
||||
and row.account_id in selection.company_2_account_ids
|
||||
)
|
||||
if not in_debit and not in_credit:
|
||||
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))
|
||||
|
||||
rows_a = debit_rows if rule.side_1 == "D" else credit_rows
|
||||
rows_b = credit_rows if rule.side_1 == "D" else debit_rows
|
||||
amount_a = sum((row.amount for row in rows_a), ZERO)
|
||||
amount_b = sum((row.amount for row in rows_b), ZERO)
|
||||
totals = calculate_rule_totals(amount_a, amount_b, manual=rule.elimination_mode == "manual")
|
||||
packed = pack_rule_rows(debit_rows, credit_rows)
|
||||
matched: list[EliminationMatchedPairOut] = []
|
||||
with_discrepancy: list[EliminationMatchedPairOut] = []
|
||||
for group in packed.groups:
|
||||
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]}
|
||||
for key, row in sources.items():
|
||||
if key in packed.used_keys:
|
||||
continue
|
||||
if key in packed.ambiguous_keys:
|
||||
reason = "AMBIGUOUS"
|
||||
elif key in positive_keys:
|
||||
reason = "NO_MATCH"
|
||||
else:
|
||||
reason = "ZERO_AMOUNT"
|
||||
unpaired.append(EliminationUnpairedRowOut(row=_pair_row_out(row), reason=reason))
|
||||
return RulePacking(matched, with_discrepancy, unpaired, totals)
|
||||
|
||||
|
||||
class IntercompanyMatrixService:
|
||||
"""Получает свод и периметр версии, затем вызывает чистый расчёт матрицы."""
|
||||
|
||||
@ -347,6 +474,43 @@ class IntercompanyMatrixService:
|
||||
self._scope = scope
|
||||
self._osv_service = service_osv
|
||||
|
||||
async def build_pairs(
|
||||
self, version_id: uuid.UUID, rule_id: uuid.UUID, company_a_id: uuid.UUID, company_b_id: uuid.UUID
|
||||
) -> EliminationPairsOut:
|
||||
"""Счета правила ББ и строки одной пары компаний без сохранения в БД (§4–§5)."""
|
||||
osv_result = await self._osv_service.build_with_members(version_id)
|
||||
version = osv_result.version
|
||||
member_ids = set(osv_result.members)
|
||||
if company_a_id == company_b_id or company_a_id not in member_ids or company_b_id not in member_ids:
|
||||
raise DomainValidationError("INVALID_COMPANY_PAIR", "Выберите две разные компании периметра версии")
|
||||
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)
|
||||
# TODO(упаковка ОСВ): занятость строки отслеживать отдельно для каждого rule_id — одна строка может попасть
|
||||
# в результаты разных правил. Глобальное исключение повторного использования потребует изменения модели
|
||||
# данных: нужно хранить/передавать принадлежность исходной строки конкретной паре между правилами.
|
||||
packing = _pack_preset_rows(
|
||||
osv_result.consolidated.rows,
|
||||
selection,
|
||||
PairContext(rule, company_a_id, company_b_id),
|
||||
)
|
||||
return EliminationPairsOut(
|
||||
version_no=rule.version_no,
|
||||
rule_id=rule.id,
|
||||
rule_no=rule.rule_no,
|
||||
company_a_id=company_a_id,
|
||||
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_percent=packing.totals.deviation_percent,
|
||||
eliminated_amount=packing.totals.eliminated_amount,
|
||||
exceeds_threshold=packing.totals.exceeds_threshold,
|
||||
matched=packing.matched,
|
||||
with_discrepancy=packing.with_discrepancy,
|
||||
unpaired=packing.unpaired,
|
||||
)
|
||||
|
||||
async def build(
|
||||
self,
|
||||
version_id: uuid.UUID,
|
||||
|
||||
@ -4,13 +4,16 @@
|
||||
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.common.clock import moscow_date, utc_now
|
||||
from src.models.access import AppUser
|
||||
from src.models.accounting import EliminationRule
|
||||
from src.models.audit_ex import AuditEvent
|
||||
from src.models.consolidation import (
|
||||
Consolidation,
|
||||
@ -139,6 +142,7 @@ async def test_create_consolidation_rb_q3_2024(auth_client: AuthClientFactory, s
|
||||
assert str(consolidation.id) == card["consolidation_id"]
|
||||
version = (await seeded.execute(select(ConsolidationVersion))).scalar_one()
|
||||
assert (version.initiator_id, version.created_by, version.is_in_work) == (user.id, user.id, True)
|
||||
assert version.elimination_rules_version == 1
|
||||
assert await _count(seeded, ConsolidationLock) == 0
|
||||
assert await _count(seeded, ConsolidationMember) == len(entities)
|
||||
assert await _count(seeded, ConsolidationStatusHistory) == 1
|
||||
@ -162,6 +166,59 @@ async def test_create_consolidation_rb_q3_2024(auth_client: AuthClientFactory, s
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_create_fixes_rules_for_creation_date_not_reporting_period(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
group, gsk, entities = await _scenario(seeded)
|
||||
today = moscow_date(utc_now())
|
||||
source = (await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == 1))).one()
|
||||
next_rule = EliminationRule(
|
||||
statement_type=source.statement_type,
|
||||
rule_no=source.rule_no,
|
||||
article_1_line=source.article_1_line,
|
||||
article_2_line=source.article_2_line,
|
||||
side_1=source.side_1,
|
||||
measure=source.measure,
|
||||
elimination_mode=source.elimination_mode,
|
||||
excise_adjust=source.excise_adjust,
|
||||
version_no=2,
|
||||
valid_from=today + timedelta(days=1),
|
||||
)
|
||||
seeded.add(next_rule)
|
||||
await seeded.flush()
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
first = await client.post(CONSOLIDATIONS, json=_body(group, gsk, year=2024))
|
||||
assert first.status_code == HTTP_CREATED, first.text
|
||||
first_version = await seeded.get(ConsolidationVersion, uuid.UUID(first.json()["id"]))
|
||||
assert first_version is not None
|
||||
assert first_version.elimination_rules_version == 1
|
||||
|
||||
next_rule.valid_from = today
|
||||
second = await client.post(CONSOLIDATIONS, json=_body(group, gsk, year=2023))
|
||||
assert second.status_code == HTTP_CREATED, second.text
|
||||
second_version = await seeded.get(ConsolidationVersion, uuid.UUID(second.json()["id"]))
|
||||
assert second_version is not None
|
||||
assert second_version.elimination_rules_version == next_rule.version_no
|
||||
assert first_version.elimination_rules_version == 1
|
||||
|
||||
pairs_url = f"/consolidations/versions/{first_version.id}/elimination-pairs"
|
||||
params = {
|
||||
"rule_id": str(source.id),
|
||||
"company_a_id": str(entities[0].id),
|
||||
"company_b_id": str(entities[1].id),
|
||||
}
|
||||
for _ in range(2):
|
||||
pairs = await client.get(pairs_url, params=params)
|
||||
assert pairs.status_code == HTTP_OK, pairs.text
|
||||
assert pairs.json()["version_no"] == 1
|
||||
assert pairs.json()["matched"] == []
|
||||
assert (await client.get(pairs_url, params={**params, "rule_id": str(next_rule.id)})).status_code == HTTP_NOT_FOUND
|
||||
await seeded.refresh(first_version)
|
||||
assert first_version.elimination_rules_version == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_create_reporting_date_for_q4_is_next_year(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group, gsk, _ = await _scenario(seeded)
|
||||
|
||||
@ -217,6 +217,16 @@ async def test_preset_uses_current_rules_when_version_unfixed(
|
||||
assert listed.json()["version_no"] == rule.version_no
|
||||
assert "as_of" not in listed.json()
|
||||
assert str(rule.id) in {item["id"] for item in listed.json()["rules"]}
|
||||
pairs = await client.get(
|
||||
f"/consolidations/versions/{world.version.id}/elimination-pairs",
|
||||
params={
|
||||
"rule_id": str(rule.id),
|
||||
"company_a_id": str(world.members[0].id),
|
||||
"company_b_id": str(world.members[1].id),
|
||||
},
|
||||
)
|
||||
assert pairs.status_code == OK, pairs.text
|
||||
assert world.version.elimination_rules_version is None # GET не фиксирует номер правил
|
||||
response = await client.get(url)
|
||||
assert response.status_code == OK, response.text
|
||||
assert (await client.get(f"{url}/export")).status_code == OK
|
||||
@ -264,6 +274,255 @@ async def test_unfixed_rules_choose_latest_open_version(auth_client: AuthClientF
|
||||
assert old_preset.status_code == NOT_FOUND
|
||||
|
||||
|
||||
async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
world = await _world(seeded, members=3)
|
||||
rule_ids = {
|
||||
rule.rule_no: rule.id
|
||||
for rule in (await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no.in_([1, 3, 6])))).all()
|
||||
}
|
||||
debit_document = await make_confirmed_osv(
|
||||
seeded,
|
||||
entity=world.members[0],
|
||||
account="62.01",
|
||||
year=2024,
|
||||
quarter=PeriodQuarter.Q1,
|
||||
is_cumulative=True,
|
||||
rows=[
|
||||
{
|
||||
"name": member.name,
|
||||
"counterparty_id": member.id,
|
||||
"account_id": world.accounts["62.01"].id,
|
||||
"closing_debit": Decimal(amount),
|
||||
}
|
||||
for member, amount in [
|
||||
*((world.members[1], amount) for amount in ("40", "0", "200")),
|
||||
(world.members[2], "103"),
|
||||
]
|
||||
],
|
||||
)
|
||||
second_debit_document = await make_confirmed_osv(
|
||||
seeded,
|
||||
entity=world.members[0],
|
||||
account="60.02",
|
||||
year=2024,
|
||||
quarter=PeriodQuarter.Q1,
|
||||
is_cumulative=True,
|
||||
rows=[
|
||||
{
|
||||
"name": world.members[1].name,
|
||||
"counterparty_id": world.members[1].id,
|
||||
"account_id": world.accounts["60.02"].id,
|
||||
"closing_debit": Decimal("60"),
|
||||
},
|
||||
{
|
||||
"name": world.members[2].name,
|
||||
"counterparty_id": world.members[2].id,
|
||||
"account_id": world.accounts["60.02"].id,
|
||||
"closing_debit": Decimal("20"),
|
||||
},
|
||||
],
|
||||
)
|
||||
reverse_debit_document = await make_confirmed_osv(
|
||||
seeded,
|
||||
entity=world.members[1],
|
||||
account="62.01",
|
||||
year=2024,
|
||||
quarter=PeriodQuarter.Q1,
|
||||
is_cumulative=True,
|
||||
rows=[
|
||||
{
|
||||
"name": world.members[0].name,
|
||||
"counterparty_id": world.members[0].id,
|
||||
"account_id": world.accounts["62.01"].id,
|
||||
"closing_debit": Decimal("70"),
|
||||
}
|
||||
],
|
||||
)
|
||||
reverse_credit_document = await make_confirmed_osv(
|
||||
seeded,
|
||||
entity=world.members[0],
|
||||
account="60.01",
|
||||
year=2024,
|
||||
quarter=PeriodQuarter.Q1,
|
||||
is_cumulative=True,
|
||||
rows=[
|
||||
{
|
||||
"name": world.members[1].name,
|
||||
"counterparty_id": world.members[1].id,
|
||||
"account_id": world.accounts["60.01"].id,
|
||||
"closing_credit": Decimal("70"),
|
||||
}
|
||||
],
|
||||
)
|
||||
manual_debit_document = await make_confirmed_osv(
|
||||
seeded,
|
||||
entity=world.members[0],
|
||||
account="03",
|
||||
year=2024,
|
||||
quarter=PeriodQuarter.Q1,
|
||||
is_cumulative=True,
|
||||
rows=[
|
||||
{
|
||||
"name": world.members[1].name,
|
||||
"counterparty_id": world.members[1].id,
|
||||
"account_id": world.accounts["03"].id,
|
||||
"closing_debit": Decimal("100"),
|
||||
}
|
||||
],
|
||||
)
|
||||
manual_credit_document = await make_confirmed_osv(
|
||||
seeded,
|
||||
entity=world.members[1],
|
||||
account="76.07.1",
|
||||
year=2024,
|
||||
quarter=PeriodQuarter.Q1,
|
||||
is_cumulative=True,
|
||||
rows=[
|
||||
{
|
||||
"name": world.members[0].name,
|
||||
"counterparty_id": world.members[0].id,
|
||||
"account_id": world.accounts["76.07.1"].id,
|
||||
"closing_credit": Decimal("100"),
|
||||
}
|
||||
],
|
||||
)
|
||||
credit_document = await make_confirmed_osv(
|
||||
seeded,
|
||||
entity=world.members[1],
|
||||
account="60.01",
|
||||
year=2024,
|
||||
quarter=PeriodQuarter.Q1,
|
||||
is_cumulative=True,
|
||||
rows=[
|
||||
{
|
||||
"name": world.members[0].name,
|
||||
"counterparty_id": world.members[0].id,
|
||||
"account_id": world.accounts["60.01"].id,
|
||||
"closing_credit": Decimal("100"),
|
||||
}
|
||||
],
|
||||
)
|
||||
other_credit_document = await make_confirmed_osv(
|
||||
seeded,
|
||||
entity=world.members[2],
|
||||
account="60.01",
|
||||
year=2024,
|
||||
quarter=PeriodQuarter.Q1,
|
||||
is_cumulative=True,
|
||||
rows=[
|
||||
{
|
||||
"name": world.members[0].name,
|
||||
"counterparty_id": world.members[0].id,
|
||||
"account_id": world.accounts["60.01"].id,
|
||||
"closing_credit": Decimal("100"),
|
||||
}
|
||||
],
|
||||
)
|
||||
seeded.add_all(
|
||||
[
|
||||
ConsolidationDocument(consolidation_version_id=world.version.id, document_id=document.id)
|
||||
for document in (
|
||||
debit_document,
|
||||
second_debit_document,
|
||||
credit_document,
|
||||
other_credit_document,
|
||||
reverse_debit_document,
|
||||
reverse_credit_document,
|
||||
manual_debit_document,
|
||||
manual_credit_document,
|
||||
)
|
||||
]
|
||||
)
|
||||
await seeded.flush()
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
url = f"/consolidations/versions/{world.version.id}/elimination-pairs"
|
||||
company_a_id, company_b_id, company_c_id = (member.id for member in world.members)
|
||||
params = {"rule_id": str(rule_ids[6]), "company_a_id": str(company_a_id), "company_b_id": str(company_b_id)}
|
||||
response = await client.get(url, params=params)
|
||||
assert response.status_code == OK, response.text
|
||||
payload = response.json()
|
||||
assert payload["version_no"] == 1
|
||||
assert (payload["rule_id"], payload["rule_no"], payload["company_a_id"], payload["company_b_id"]) == (
|
||||
str(rule_ids[6]),
|
||||
6,
|
||||
str(company_a_id),
|
||||
str(company_b_id),
|
||||
)
|
||||
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["exceeds_threshold"] is True
|
||||
assert Decimal(payload["eliminated_amount"]) == 0
|
||||
assert len(payload["matched"]) == 1
|
||||
pair = payload["matched"][0]
|
||||
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 "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 [item["reason"] for item in payload["unpaired"]] == ["ZERO_AMOUNT", "NO_MATCH"]
|
||||
|
||||
loans = await client.get(url, params={**params, "rule_id": str(rule_ids[3])})
|
||||
assert loans.status_code == OK, loans.text
|
||||
assert loans.json()["matched"] == []
|
||||
manual = await client.get(url, params={**params, "rule_id": str(rule_ids[1])})
|
||||
assert manual.status_code == OK, manual.text
|
||||
assert len(manual.json()["matched"]) == 1
|
||||
assert Decimal(manual.json()["eliminated_amount"]) == 0
|
||||
|
||||
other_pair = await client.get(url, params={**params, "company_b_id": str(company_c_id)})
|
||||
assert other_pair.status_code == OK, other_pair.text
|
||||
assert other_pair.json()["matched"] == []
|
||||
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 "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"),
|
||||
)
|
||||
assert other_pair.json()["exceeds_threshold"] is True
|
||||
assert Decimal(other_pair.json()["eliminated_amount"]) == 0
|
||||
|
||||
reversed_pair = await client.get(
|
||||
url, params={**params, "company_a_id": str(company_b_id), "company_b_id": str(company_a_id)}
|
||||
)
|
||||
assert reversed_pair.status_code == OK, reversed_pair.text
|
||||
assert len(reversed_pair.json()["matched"]) == 1
|
||||
assert reversed_pair.json()["with_discrepancy"] == []
|
||||
assert reversed_pair.json()["unpaired"] == []
|
||||
assert reversed_pair.json()["matched"][0]["osv_entry_ids_a"]
|
||||
assert reversed_pair.json()["matched"][0]["osv_entry_ids_b"]
|
||||
assert (Decimal(reversed_pair.json()["amount_a"]), Decimal(reversed_pair.json()["amount_b"])) == (
|
||||
Decimal("70"),
|
||||
Decimal("70"),
|
||||
)
|
||||
assert Decimal(reversed_pair.json()["eliminated_amount"]) == Decimal("70")
|
||||
|
||||
invalid_pair = await client.get(url, params={**params, "company_b_id": str(uuid.uuid4())})
|
||||
assert invalid_pair.status_code == UNPROCESSABLE
|
||||
same_company = await client.get(url, params={**params, "company_b_id": str(company_a_id)})
|
||||
assert same_company.status_code == UNPROCESSABLE
|
||||
missing_rule = await client.get(url, params={**params, "rule_id": str(uuid.uuid4())})
|
||||
assert missing_rule.status_code == NOT_FOUND
|
||||
missing_parameters = await client.get(url)
|
||||
assert missing_parameters.status_code == UNPROCESSABLE
|
||||
missing = await client.get(f"/consolidations/versions/{uuid.uuid4()}/elimination-pairs", params=params)
|
||||
assert missing.status_code == NOT_FOUND
|
||||
|
||||
|
||||
async def test_all_excludes_off_balance_accounts_but_manual_can_select_them(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
|
||||
112
api/tests/test_elimination_packing.py
Normal file
112
api/tests/test_elimination_packing.py
Normal file
@ -0,0 +1,112 @@
|
||||
"""Упаковка встречных строк по одному правилу без БД (§5)."""
|
||||
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
|
||||
from src.domain.elimination_packing import MAX_SIDE_ROWS, PackRow, best_subsets, calculate_rule_totals, pack_rule_rows
|
||||
|
||||
A = uuid.uuid4()
|
||||
B = uuid.uuid4()
|
||||
|
||||
|
||||
def _row(amount: str, *, debit: bool) -> PackRow:
|
||||
return PackRow((uuid.uuid4(),), A if debit else B, B if debit else A, Decimal(amount))
|
||||
|
||||
|
||||
def test_knapsack_finds_exact_subset_with_fixed_size_and_keeps_ties() -> None:
|
||||
first, second, third = (_row(amount, debit=False) for amount in ("40", "60", "60"))
|
||||
|
||||
choice = best_subsets([first, second, third], size=2, capacity=Decimal("100"))
|
||||
|
||||
assert choice is not None
|
||||
assert choice.deviation == 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:
|
||||
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}
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def test_exact_one_to_one_is_reserved_before_one_to_many() -> None:
|
||||
debit = [_row("100", debit=True), _row("50", debit=True)]
|
||||
credit = [_row("100", debit=False), _row("30", debit=False), _row("20", debit=False)]
|
||||
|
||||
result = pack_rule_rows(debit, credit)
|
||||
|
||||
assert [(len(group.debit_rows), len(group.credit_rows)) for group in result.groups] == [(1, 1), (1, 2)]
|
||||
assert result.used_keys == {row.key for row in [*debit, *credit]}
|
||||
assert result.ambiguous_keys == frozenset()
|
||||
|
||||
|
||||
def test_equal_exact_groups_sharing_a_row_go_to_manual() -> None:
|
||||
debit = [_row("100", debit=True)]
|
||||
credit = [_row("100", debit=False), _row("100", debit=False)]
|
||||
|
||||
result = pack_rule_rows(debit, credit)
|
||||
|
||||
assert result.groups == ()
|
||||
assert result.ambiguous_keys == {row.key for row in [*debit, *credit]}
|
||||
|
||||
|
||||
def test_five_percent_boundary_and_no_many_to_many() -> None:
|
||||
debit = [_row("100", debit=True)]
|
||||
credit = [_row("21", debit=False) for _ in range(5)]
|
||||
|
||||
result = pack_rule_rows(debit, credit)
|
||||
|
||||
assert len(result.groups) == 1
|
||||
assert len(result.groups[0].credit_rows) == MAX_SIDE_ROWS
|
||||
assert result.groups[0].deviation_percent == Decimal("5")
|
||||
|
||||
many_to_many = pack_rule_rows(
|
||||
[_row("40", debit=True), _row("60", debit=True)],
|
||||
[_row("30", debit=False), _row("70", debit=False)],
|
||||
)
|
||||
assert many_to_many.groups == () # суммы сторон равны, но допустимой группы 1:N или N:1 нет
|
||||
|
||||
over_threshold = pack_rule_rows([_row("100", debit=True)], [_row("106", debit=False)])
|
||||
assert over_threshold.groups == ()
|
||||
|
||||
|
||||
def test_rule_totals_eliminate_minimum_within_five_percent() -> None:
|
||||
totals = calculate_rule_totals(Decimal("110429113.15"), Decimal("106311116.46"), manual=False)
|
||||
|
||||
assert totals.amount_a == Decimal("110429113.15")
|
||||
assert totals.amount_b == Decimal("106311116.46")
|
||||
assert totals.deviation_amount == Decimal("4117996.69")
|
||||
assert totals.deviation_percent is not None
|
||||
assert totals.deviation_percent.quantize(Decimal("0.01")) == Decimal("3.87")
|
||||
assert totals.eliminated_amount == Decimal("106311116.46")
|
||||
assert totals.exceeds_threshold is False
|
||||
|
||||
|
||||
def test_rule_totals_over_five_percent_and_manual_eliminate_zero() -> None:
|
||||
over = calculate_rule_totals(Decimal("106"), Decimal("100"), manual=False)
|
||||
assert over.deviation_amount == Decimal("6")
|
||||
assert over.deviation_percent == Decimal("6")
|
||||
assert over.exceeds_threshold is True
|
||||
assert over.eliminated_amount == 0
|
||||
|
||||
manual = calculate_rule_totals(Decimal("100"), Decimal("100"), manual=True)
|
||||
assert manual.exceeds_threshold is False
|
||||
assert manual.eliminated_amount == 0
|
||||
|
||||
boundary = calculate_rule_totals(Decimal("105"), Decimal("100"), manual=False)
|
||||
assert boundary.exceeds_threshold is False
|
||||
assert boundary.eliminated_amount == Decimal("100")
|
||||
|
||||
one_sided = calculate_rule_totals(Decimal("100"), Decimal("0"), manual=False)
|
||||
assert one_sided.deviation_percent is None
|
||||
assert one_sided.exceeds_threshold is True
|
||||
assert one_sided.eliminated_amount == 0
|
||||
@ -19,6 +19,7 @@ import pytest
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from src.schemas.consolidations import IntercompanyMatrixCompanyOut
|
||||
from src.services import intercompany_matrix as matrix
|
||||
from src.services.intercompany_matrix import MatrixSelection, calculate_matrix
|
||||
|
||||
pytestmark = pytest.mark.fixtures
|
||||
@ -116,6 +117,7 @@ def test_loan_66_03_against_58_03_when_counterpart_exists(fixtures_dir: Path) ->
|
||||
legal_entity_id=RB,
|
||||
counterparty_id=BAKERY,
|
||||
account_id=credit_account,
|
||||
account_code="66.03",
|
||||
closing_debit=Decimal(0),
|
||||
closing_credit=amount,
|
||||
entry_ids=(uuid.uuid4(),),
|
||||
@ -129,12 +131,18 @@ def test_loan_66_03_against_58_03_when_counterpart_exists(fixtures_dir: Path) ->
|
||||
assert one_sided.amount_a == 0
|
||||
assert one_sided.amount_b == amount
|
||||
assert one_sided.one_sided is True
|
||||
context = matrix.PairContext(SimpleNamespace(id=uuid.uuid4(), side_1="D", elimination_mode="min"), BAKERY, RB)
|
||||
unmatched = matrix._pack_preset_rows([credit_row], selection, context)
|
||||
assert unmatched.matched == []
|
||||
assert [item.reason for item in unmatched.unpaired] == ["NO_MATCH"]
|
||||
assert unmatched.totals.eliminated_amount == 0
|
||||
|
||||
# В наборе нет ОСВ 58.03 Счастливочной; модельная строка проверяет встречную сторону правила № 4.
|
||||
debit_row = SimpleNamespace(
|
||||
legal_entity_id=BAKERY,
|
||||
counterparty_id=RB,
|
||||
account_id=debit_account,
|
||||
account_code="58.03",
|
||||
closing_debit=amount,
|
||||
closing_credit=Decimal(0),
|
||||
entry_ids=(uuid.uuid4(),),
|
||||
@ -143,3 +151,76 @@ def test_loan_66_03_against_58_03_when_counterpart_exists(fixtures_dir: Path) ->
|
||||
assert paired.amount_a == paired.amount_b == amount
|
||||
assert paired.variance_amount == 0
|
||||
assert paired.one_sided is False
|
||||
packed = matrix._pack_preset_rows([debit_row, credit_row], selection, context)
|
||||
assert len(packed.matched) == 1
|
||||
assert packed.matched[0].amount_a == packed.matched[0].amount_b == amount
|
||||
assert packed.totals.eliminated_amount == amount
|
||||
|
||||
|
||||
def test_rule_6_bakery_and_russian_biscuit_packs_only_exact_production_row(fixtures_dir: Path) -> None:
|
||||
"""Четыре реальные дебетовые строки дают итог тройки; упаковка берёт лишь точную 62.01 ↔ 60.01."""
|
||||
source_rows = [
|
||||
_source(fixtures_dir, "schastl", root, number)
|
||||
for root, number in (("62", 21), ("62", 17), ("76", 20), ("76", 29))
|
||||
]
|
||||
credit_source = _source(fixtures_dir, "rb", "60", 409)
|
||||
assert [(row["account_code_raw"], Decimal(row["closing_debit"])) for row in source_rows] == [
|
||||
("62.01", Decimal("106311116.46")),
|
||||
("62.01", Decimal("1401330.00")),
|
||||
("76.06", Decimal("350000.00")),
|
||||
("76.АВ", Decimal("2366666.69")),
|
||||
]
|
||||
assert (credit_source["account_code_raw"], Decimal(credit_source["closing_credit"])) == (
|
||||
"60.01",
|
||||
Decimal("106311116.46"),
|
||||
)
|
||||
account_ids = {code: uuid.uuid4() for code in ("62.01", "76.06", "76.АВ", "60.01")}
|
||||
source_ids = [uuid.uuid4() for _ in source_rows]
|
||||
credit_id = uuid.uuid4()
|
||||
rows = [
|
||||
SimpleNamespace(
|
||||
legal_entity_id=BAKERY,
|
||||
counterparty_id=RB,
|
||||
account_id=account_ids[source["account_code_raw"]],
|
||||
account_code=source["account_code_raw"],
|
||||
closing_debit=Decimal(source["closing_debit"]),
|
||||
closing_credit=Decimal(source["closing_credit"] or "0"),
|
||||
entry_ids=(entry_id,),
|
||||
)
|
||||
for source, entry_id in zip(source_rows, source_ids, strict=True)
|
||||
]
|
||||
rows.append(
|
||||
SimpleNamespace(
|
||||
legal_entity_id=RB,
|
||||
counterparty_id=BAKERY,
|
||||
account_id=account_ids["60.01"],
|
||||
account_code="60.01",
|
||||
closing_debit=Decimal(credit_source["closing_debit"] or "0"),
|
||||
closing_credit=Decimal(credit_source["closing_credit"]),
|
||||
entry_ids=(credit_id,),
|
||||
)
|
||||
)
|
||||
selection = MatrixSelection(
|
||||
frozenset(account_ids[code] for code in ("62.01", "76.06", "76.АВ")),
|
||||
frozenset({account_ids["60.01"]}),
|
||||
"closing",
|
||||
)
|
||||
packed = matrix._pack_preset_rows(
|
||||
rows,
|
||||
selection,
|
||||
matrix.PairContext(SimpleNamespace(id=uuid.uuid4(), side_1="D", elimination_mode="min"), BAKERY, RB),
|
||||
)
|
||||
assert (packed.totals.amount_a, packed.totals.amount_b) == (
|
||||
Decimal("110429113.15"),
|
||||
Decimal("106311116.46"),
|
||||
)
|
||||
assert packed.totals.eliminated_amount == Decimal("106311116.46")
|
||||
assert packed.totals.deviation_percent is not None
|
||||
assert packed.totals.deviation_percent.quantize(Decimal("0.01")) == Decimal("3.87")
|
||||
assert packed.with_discrepancy == []
|
||||
assert len(packed.matched) == 1
|
||||
assert packed.matched[0].osv_entry_ids_a == [source_ids[0]]
|
||||
assert packed.matched[0].osv_entry_ids_b == [credit_id]
|
||||
assert packed.matched[0].amount_a == packed.matched[0].amount_b == Decimal("106311116.46")
|
||||
assert {item.row.entry_ids[0] for item in packed.unpaired} == set(source_ids[1:])
|
||||
assert {item.reason for item in packed.unpaired} == {"NO_MATCH"}
|
||||
|
||||
@ -75,6 +75,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
|
||||
"/api/v1/consolidations/versions/{version_id}/osv/entries/{entry_id}/counterparty": {"post"},
|
||||
# шахматка ВГО и правила элиминации
|
||||
"/api/v1/consolidations/versions/{version_id}/elimination-rules": {"get"},
|
||||
"/api/v1/consolidations/versions/{version_id}/elimination-pairs": {"get"},
|
||||
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all": {"get"},
|
||||
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all/export": {"get"},
|
||||
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual": {"post"},
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-23
|
||||
3
openspec/changes/elimination-pairs-rule-totals/README.md
Normal file
3
openspec/changes/elimination-pairs-rule-totals/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# elimination-pairs-rule-totals
|
||||
|
||||
Тесты упаковки и итог элиминации для тройки правила и компаний
|
||||
10
openspec/changes/elimination-pairs-rule-totals/design.md
Normal file
10
openspec/changes/elimination-pairs-rule-totals/design.md
Normal file
@ -0,0 +1,10 @@
|
||||
## Решение
|
||||
|
||||
Суммы выбранных строк считаются один раз до упаковки внутри тройки `(rule_id, A, B)` как чистая функция.
|
||||
`elimination_amount` сохраняет ранее согласованное значение абсолютного расхождения; новое `eliminated_amount`
|
||||
содержит `min(ΣA, ΣB)` при пороге не более 5 %, иначе 0, и всегда 0 для `manual`.
|
||||
Нулевая меньшая сторона даёт `deviation_percent = null` и нулевую элиминацию.
|
||||
|
||||
Упаковка не влияет на итог. Для обратной направленной пары клиент переставляет `company_a_id` и `company_b_id`.
|
||||
Эталонный Excel используется только для сверки точных встречных строк: он списывает полную сумму каждой стороны,
|
||||
поэтому его общий итог не равен системному правилу минимума.
|
||||
34
openspec/changes/elimination-pairs-rule-totals/proposal.md
Normal file
34
openspec/changes/elimination-pairs-rule-totals/proposal.md
Normal file
@ -0,0 +1,34 @@
|
||||
## Why
|
||||
|
||||
Срез 5: упаковка ОСВ показывает группы строк, но итог элиминации тройки должен учитывать все выбранные строки,
|
||||
включая оставшиеся без пары. Это нужно закрепить тестами на реальных ОСВ группы «Русский бисквит» и отдельным
|
||||
контрактом для фронта.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Для запроса `(правило, A, B)` добавить общий итог ΣA/ΣB, расхождение, процент, признак порога и сумму
|
||||
элиминации по правилу 5 %; при `manual` сумма элиминации равна нулю.
|
||||
- Проверить упаковку 1:1, 1:N, N:1, ничьи и оба направления пары на реальных и модельных строках.
|
||||
- Проверить неизменность зафиксированной версии правил при повторных GET и сопоставить точные строки с эталонным Excel.
|
||||
- Описать новый ответ в гайде фронта.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Нет.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `consolidations-api`: общий итог элиминации тройки независимо от упаковки строк.
|
||||
|
||||
## Impact
|
||||
|
||||
`arm-core`: чистый расчёт, сервис и схема ответа, тесты, OpenSpec. `arm-ka-docs`: гайд фронта.
|
||||
Читаются таблицы `consolidation_version`, `consolidation_member`, `consolidation_document`, `osv_entry`,
|
||||
`elimination_rule`, `elimination_rule_account`, `chart_of_account`; DBML и хранимые данные не меняются.
|
||||
Основание — `context/05-domain-rules.md` §4–§5, решение владельца 08.10.2026 о минимуме пары.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Пары всех правил версии в одном запросе, сохранение результатов в БД, корректировки, расчёт ОФР.
|
||||
@ -0,0 +1,21 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Итог элиминации тройки независимо от упаковки
|
||||
|
||||
Для `(rule_id, company_a_id, company_b_id)` API SHALL возвращать суммы всех выбранных строк ОСВ обеих сторон,
|
||||
абсолютное расхождение `elimination_amount`, процент расхождения к меньшей сумме, `exceeds_threshold` и отдельное
|
||||
`eliminated_amount`. При расхождении не более 5 % `eliminated_amount` SHALL равняться `min(amount_a, amount_b)`;
|
||||
при превышении порога, нулевой стороне или `elimination_mode = manual` SHALL равняться нулю. Упаковка строк
|
||||
не меняет этот итог.
|
||||
|
||||
#### Scenario: Точная строка среди несопоставленных остатков
|
||||
|
||||
- **WHEN** правило № 6 для Счастливочной и АО «Русский бисквит» даёт суммы 110 429 113,15 и 106 311 116,46 руб.,
|
||||
а точная группа 62.01 ↔ 60.01 составляет 106 311 116,46 руб.
|
||||
- **THEN** точная группа присутствует в `matched`, остальные три строки стороны A — в `unpaired`,
|
||||
`eliminated_amount = 106311116.46`, расхождение округляется до 3,87 %.
|
||||
|
||||
#### Scenario: Ручное правило и превышение порога
|
||||
|
||||
- **WHEN** правило ручное либо расхождение больше 5 %
|
||||
- **THEN** `eliminated_amount = 0`, независимо от результата упаковки.
|
||||
7
openspec/changes/elimination-pairs-rule-totals/tasks.md
Normal file
7
openspec/changes/elimination-pairs-rule-totals/tasks.md
Normal file
@ -0,0 +1,7 @@
|
||||
## Реализация
|
||||
|
||||
- [x] Добавить чистый расчёт общего итога и тесты порога, ручного режима и нулевой стороны.
|
||||
- [x] Проверить реальные строки правила № 6, точную пару и оставшиеся без пары по golden ОСВ.
|
||||
- [x] Проверить 42 млн по правилу № 4, ничью упаковки, обратную пару и фиксацию версии правил.
|
||||
- [x] Дополнить основной OpenSpec и гайд фронта.
|
||||
- [ ] Запустить `make check-full` и заархивировать change.
|
||||
@ -15,7 +15,13 @@ Q3 → 01.10.year, Q4 → 01.01.year+1; ASSUMPTION(OQ-40)), `consolidation_versi
|
||||
`is_in_work = true`), `consolidation_member` периметра (`is_head_entity = false`), первую запись
|
||||
`consolidation_status_history` (`from_status` NULL → `INITIATED`), подхват подтверждённых документов ЮЛ периметра (см.
|
||||
`documents-api` «Подхват документов») и одно событие `audit_event(CONSOLIDATION_CREATE)`, у которого
|
||||
`after_value.members` — отсортированный список ЮЛ периметра; ответ 201 с карточкой версии. Периметр SHALL быть: без
|
||||
`after_value.members` — отсортированный список ЮЛ периметра; ответ 201 с карточкой версии.
|
||||
При создании версии система SHALL автоматически записать в `consolidation_version.elimination_rules_version` номер
|
||||
самой актуальной версии правил: максимальный `version_no` среди правил, действующих на московскую календарную дату
|
||||
создания версии (`valid_from <= дата` и `valid_to IS NULL` либо `valid_to >= дата`; OQ-174, docs PR #146).
|
||||
Пользователь не выбирает версию правил. Отчётные год, квартал и `reporting_date` не влияют на определение номера.
|
||||
Если на дату создания нет действующих правил, поле остаётся NULL.
|
||||
Периметр SHALL быть: без
|
||||
`legal_entity_ids` (поле отсутствует или `null`) — текущие члены ГСК; с `legal_entity_ids` — ровно эти ЮЛ после
|
||||
схлопывания дублей (порядок первого вхождения), каждое SHALL принадлежать ГК консолидации
|
||||
(`legal_entity.company_group_id`), иначе 422 `ENTITY_NOT_IN_GROUP` с перечнем `legal_entity_ids` чужих в порядке
|
||||
@ -33,6 +39,10 @@ SHALL оставаться NULL (фиксируется при верифика
|
||||
- **THEN** 201; версия №1 `INITIATED`, `reporting_date = 2024-10-01`, два `consolidation_member`, история с одной записью,
|
||||
`audit_event(CONSOLIDATION_CREATE)` с `consolidation_version_id`; активной блокировки нет
|
||||
|
||||
#### Scenario: Правила действуют на дату создания версии
|
||||
- **WHEN** в день создания консолидации за 2024 год действует версия правил 2, а версия 1 действовала в отчётном периоде
|
||||
- **THEN** `consolidation_version.elimination_rules_version = 2`; номер не меняется при последующей публикации правил
|
||||
|
||||
#### Scenario: Выбранный периметр шире ГСК
|
||||
- **WHEN** в ГК три ЮЛ, в ГСК два из них, создаётся консолидация с `legal_entity_ids` из всех трёх ЮЛ ГК
|
||||
- **THEN** 201; в `members` три ЮЛ; `after_value.members` события `CONSOLIDATION_CREATE` — три ЮЛ; состав ГСК не изменился
|
||||
@ -251,6 +261,56 @@ SHALL возвращать объект `{version_no, is_version_fixed, rules}`.
|
||||
- **WHEN** открыты правила с `version_no = 1` и `version_no = 2`, в том числе с одинаковым `rule_no`
|
||||
- **THEN** ответ содержит только правила версии 2 и `version_no = 2`
|
||||
|
||||
### Requirement: Сопоставленные строки сводной ОСВ по правилу и паре компаний
|
||||
`GET /api/v1/consolidations/versions/{version_id}/elimination-pairs` SHALL требовать `CONSOLIDATION_VIEW` и проверять
|
||||
видимость версии по ГК. Запрос SHALL принимать обязательные query-параметры `rule_id`, `company_a_id`, `company_b_id`.
|
||||
Компании SHALL быть разными участниками периметра версии, а правило — пресетом ББ её версии правил. Для этой тройки
|
||||
SHALL возвращаться только её результат; строки других правил и направленных пар компаний в расчёт не входят.
|
||||
Отбор счетов, раскрытие счетов-предков и их исключений SHALL совпадать с расчётом шахматки PRESET. Ответ SHALL
|
||||
содержать `version_no`, `rule_id`, `rule_no`, `company_a_id`, `company_b_id`, `matched[]` (точные группы),
|
||||
`with_discrepancy[]` (группы с допустимым ненулевым расхождением) и `unpaired[]` (строки без группы), без полного
|
||||
перечня допустимых пар счетов. На уровне тройки ответ SHALL содержать `amount_a`, `amount_b` (суммы всех выбранных
|
||||
строк, включая оставшиеся без пары), `elimination_amount` (абсолютное расхождение), `deviation_percent` (от меньшей
|
||||
суммы, `null` при нуле), `eliminated_amount` (сумма фактической элиминации) и `exceeds_threshold`.
|
||||
При расхождении не более 5 % `eliminated_amount` SHALL равняться `min(amount_a, amount_b)`; при превышении порога,
|
||||
нулевой стороне или `elimination_mode = manual` SHALL равняться нулю. Упаковка строк SHALL не менять этот итог.
|
||||
Каждая сопоставленная группа SHALL содержать `rule_id`, `company_a_id`,
|
||||
`company_b_id`, `amount_a`, `amount_b`, `elimination_amount`, `deviation_percent` и исходные `osv_entry_ids_a/b`.
|
||||
Суммы `amount_a/b` SHALL рассчитываться по ОСВ; `elimination_amount` SHALL равняться
|
||||
`abs(amount_a - amount_b)`.
|
||||
Невидимая и несуществующая версия или отсутствующее правило SHALL отвечать 404 `NOT_FOUND`;
|
||||
недопустимая пара компаний — 422 `INVALID_COMPANY_PAIR`. Пары и группы не сохраняются.
|
||||
|
||||
Строка сводной ОСВ неделима, даже если содержит несколько `entry_ids`. Внутри выбранной тройки строки с распознанным
|
||||
встречным ЮЛ и допустимыми счетами группируются как 1:1, 1:N или N:1 при N ≤ 5; N:M не допускается. Комбинации
|
||||
строятся из всех строк выбранных счетов соответствующей стороны правила, без дополнительного требования совпадения
|
||||
конкретных счетов внутри группы.
|
||||
Сравниваются модули остатков по соответствующей стороне. Нулевые строки остаются в `unpaired` с причиной
|
||||
`ZERO_AMOUNT`. Сначала выбираются группы с наименьшим общим числом строк: 1:1, затем 1:2 и 2:1, и так до 1:5 и 5:1.
|
||||
Внутри размера сначала точные, затем с расхождением не более 5 % относительно меньшей из двух сумм, по возрастанию
|
||||
расхождения. Строка входит не более чем в одну группу данного правила. Если группы одного размера с одинаковым
|
||||
расхождением пересекаются, все строки конкурирующих групп исключаются из автоматического поиска и возвращаются в
|
||||
`unpaired` с причиной `AMBIGUOUS` для ручного разбора. Остальные строки без группы возвращаются в `unpaired` с
|
||||
причиной `NO_MATCH` независимо от того, отсутствуют ли встречные суммы, превышен ли порог или подходящие строки уже
|
||||
заняты другой группой.
|
||||
Упаковка не меняет сумму элиминации.
|
||||
|
||||
#### Scenario: Исключённый счёт не входит в пары
|
||||
- **WHEN** правило включает родительский счёт 76 и исключает 76.07.5
|
||||
- **THEN** строки счёта 76.07.5 и его потомков не входят в сопоставленные группы и `unpaired`
|
||||
|
||||
#### Scenario: Две строки свода упакованы с одной встречной
|
||||
- **WHEN** по одному правилу есть дебетовые строки 40 и 60 и встречная кредитовая строка 100
|
||||
- **THEN** `matched[]` содержит пару с суммами 100 и 100, нулевым расхождением и исходными `osv_entry_ids_a/b`
|
||||
|
||||
#### Scenario: Пара с допустимым расхождением
|
||||
- **WHEN** группа имеет суммы 103 и 100 при пороге 5 %
|
||||
- **THEN** она входит в `with_discrepancy[]`, а не в `matched[]`
|
||||
|
||||
#### Scenario: Другая пара компаний не смешивается с выбранной
|
||||
- **WHEN** по одному правилу есть встречные строки для A→Б и A→В, а запрошены `company_a_id=A`, `company_b_id=Б`
|
||||
- **THEN** ответ содержит только строки A→Б и Б→A
|
||||
|
||||
### Requirement: Результирующая таблица ВГО
|
||||
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table`
|
||||
(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[]}`. Компании — все члены периметра
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user