From 010c15386bb51c124770e48e7384dbf508cab537 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Fri, 23 Oct 2026 15:55:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=20=D0=BE=D1=81?= =?UTF-8?q?=D0=B2=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D0=B0=20=D0=BF=D1=80=D0=B5=D1=81=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consolidations/elimination_rule.py | 26 ++- api/src/domain/elimination_packing.py | 175 ++++++++++++++++++ api/src/schemas/consolidations.py | 46 +++++ api/src/services/intercompany_matrix.py | 138 ++++++++++++++ api/tests/test_api_intercompany_matrix.py | 152 +++++++++++++++ api/tests/test_elimination_packing.py | 79 ++++++++ openspec/specs/consolidations-api/spec.md | 45 +++++ 7 files changed, 659 insertions(+), 2 deletions(-) create mode 100644 api/src/domain/elimination_packing.py create mode 100644 api/tests/test_elimination_packing.py diff --git a/api/src/api/endpoints/consolidations/elimination_rule.py b/api/src/api/endpoints/consolidations/elimination_rule.py index 60f93b1..b1d3954 100644 --- a/api/src/api/endpoints/consolidations/elimination_rule.py +++ b/api/src/api/endpoints/consolidations/elimination_rule.py @@ -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) diff --git a/api/src/domain/elimination_packing.py b/api/src/domain/elimination_packing.py new file mode 100644 index 0000000..376a153 --- /dev/null +++ b/api/src/domain/elimination_packing.py @@ -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)) diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index d3863d7..0e87942 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -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): """Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки.""" diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index 39a9009..d1721b8 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -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, diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index 8a9dd42..6b0cedc 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -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: diff --git a/api/tests/test_elimination_packing.py b/api/tests/test_elimination_packing.py new file mode 100644 index 0000000..4f102f9 --- /dev/null +++ b/api/tests/test_elimination_packing.py @@ -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 == () diff --git a/openspec/specs/consolidations-api/spec.md b/openspec/specs/consolidations-api/spec.md index 1ba4f24..e1a7f2c 100644 --- a/openspec/specs/consolidations-api/spec.md +++ b/openspec/specs/consolidations-api/spec.md @@ -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[]}`. Компании — все члены периметра