добавлен группировка строк осв для правила пресета

This commit is contained in:
PotapovaA 2026-10-23 15:55:54 +03:00
parent 232fecec39
commit 010c15386b
7 changed files with 659 additions and 2 deletions

View File

@ -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)

View 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))

View File

@ -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):
"""Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки."""

View File

@ -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,

View File

@ -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:

View 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 == ()

View File

@ -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[]}`. Компании — все члены периметра