добавлен группировка строк осв для правила пресета
This commit is contained in:
parent
232fecec39
commit
010c15386b
@ -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)
|
||||
|
||||
175
api/src/domain/elimination_packing.py
Normal file
175
api/src/domain/elimination_packing.py
Normal file
@ -0,0 +1,175 @@
|
||||
"""Упаковка встречных строк сводной ОСВ внутри одного правила элиминации (§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 _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))
|
||||
@ -336,6 +336,52 @@ 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
|
||||
matched: list[EliminationMatchedPairOut]
|
||||
with_discrepancy: list[EliminationMatchedPairOut]
|
||||
unpaired: list[EliminationUnpairedRowOut]
|
||||
|
||||
|
||||
class IntercompanyMatrixManualIn(BaseModel):
|
||||
"""Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки."""
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ 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, 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 +21,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 +159,13 @@ class ResultTableBuild:
|
||||
consolidation: Consolidation
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PairContext:
|
||||
rule: EliminationRule
|
||||
company_a_id: uuid.UUID
|
||||
company_b_id: uuid.UUID
|
||||
|
||||
|
||||
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 +351,101 @@ 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,
|
||||
) -> tuple[list[EliminationMatchedPairOut], list[EliminationMatchedPairOut], list[EliminationUnpairedRowOut]]:
|
||||
"""Отобрать встречные строки одной направленной пары компаний и одного правила."""
|
||||
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))
|
||||
|
||||
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 matched, with_discrepancy, unpaired
|
||||
|
||||
|
||||
class IntercompanyMatrixService:
|
||||
"""Получает свод и периметр версии, затем вызывает чистый расчёт матрицы."""
|
||||
|
||||
@ -347,6 +454,37 @@ 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 — одна строка может попасть
|
||||
# в результаты разных правил. Глобальное исключение повторного использования потребует изменения модели
|
||||
# данных: нужно хранить/передавать принадлежность исходной строки конкретной паре между правилами.
|
||||
matched, with_discrepancy, unpaired = _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,
|
||||
matched=matched,
|
||||
with_discrepancy=with_discrepancy,
|
||||
unpaired=unpaired,
|
||||
)
|
||||
|
||||
async def build(
|
||||
self,
|
||||
version_id: uuid.UUID,
|
||||
|
||||
@ -264,6 +264,158 @@ 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"),
|
||||
}
|
||||
],
|
||||
)
|
||||
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)
|
||||
]
|
||||
)
|
||||
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 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 manual.json()["matched"] == []
|
||||
|
||||
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
|
||||
|
||||
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 reversed_pair.json()["matched"] == []
|
||||
assert reversed_pair.json()["with_discrepancy"] == []
|
||||
assert reversed_pair.json()["unpaired"] == []
|
||||
|
||||
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:
|
||||
|
||||
79
api/tests/test_elimination_packing.py
Normal file
79
api/tests/test_elimination_packing.py
Normal file
@ -0,0 +1,79 @@
|
||||
"""Упаковка встречных строк по одному правилу без БД (§5)."""
|
||||
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
|
||||
from src.domain.elimination_packing import MAX_SIDE_ROWS, PackRow, best_subsets, 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 == ()
|
||||
@ -261,6 +261,51 @@ 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 содержать `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