Compare commits

...

3 Commits

18 changed files with 1101 additions and 4 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,197 @@
"""Упаковка встречных строк сводной ОСВ внутри одного правила элиминации (§5).
Счета уже отобраны пресетом. Функция не читает БД и не меняет сумму элиминации: она только группирует строки.
"""
import uuid
from dataclasses import dataclass
from decimal import Decimal
from typing import TypeAlias
RowKey: TypeAlias = tuple[uuid.UUID, ...]
MAX_SIDE_ROWS = 5
MIN_GROUP_SIZE = 2
MAX_DEVIATION_PERCENT = Decimal("5")
ZERO = Decimal("0")
@dataclass(frozen=True)
class PackRow:
"""Одна неделимая строка свода на дебетовой или кредитовой стороне."""
key: RowKey
owner_id: uuid.UUID
counterparty_id: uuid.UUID
amount: Decimal # модуль суммы соответствующей стороны
@dataclass(frozen=True)
class KnapsackChoice:
"""Лучшие подмножества заданного размера; при ничьей возвращаем ключи всех затронутых строк."""
deviation: Decimal
unique_rows: tuple[PackRow, ...] | None
involved_keys: frozenset[RowKey]
@dataclass(frozen=True)
class PackedGroup:
debit_rows: tuple[PackRow, ...]
credit_rows: tuple[PackRow, ...]
debit_amount: Decimal
credit_amount: Decimal
deviation_amount: Decimal
deviation_percent: Decimal
@dataclass(frozen=True)
class PackingResult:
groups: tuple[PackedGroup, ...]
used_keys: frozenset[RowKey]
ambiguous_keys: frozenset[RowKey]
@dataclass(frozen=True)
class RuleTotals:
"""Итог тройки до упаковки строк; упаковка его не меняет."""
amount_a: Decimal
amount_b: Decimal
deviation_amount: Decimal
deviation_percent: Decimal | None
eliminated_amount: Decimal
exceeds_threshold: bool
def calculate_rule_totals(amount_a: Decimal, amount_b: Decimal, *, manual: bool) -> RuleTotals:
"""Правило 5 % для всей тройки, независимо от сопоставления отдельных строк."""
deviation = abs(amount_a - amount_b)
base = min(amount_a, amount_b)
percent = deviation / base * Decimal("100") if base > ZERO else None
exceeds_threshold = deviation > ZERO if base == ZERO else percent is not None and percent > MAX_DEVIATION_PERCENT
eliminated = base if not manual and not exceeds_threshold else ZERO
return RuleTotals(amount_a, amount_b, deviation, percent, eliminated, exceeds_threshold)
@dataclass(frozen=True)
class _Candidate:
deviation: Decimal
involved_keys: frozenset[RowKey]
group: PackedGroup | None # None — несколько одинаково лучших подмножеств у одного якоря
def best_subsets(rows: list[PackRow], *, size: int, capacity: Decimal) -> KnapsackChoice | None:
"""Перебрать группы заданного размера; битовая маска отмечает выбранные строки и ничьи."""
if not 1 <= size <= MAX_SIDE_ROWS:
raise ValueError("размер стороны должен быть от 1 до 5")
candidates = sorted((row for row in rows if row.amount > ZERO), key=lambda row: row.amount)
if capacity <= ZERO or len(candidates) < size:
return None
hundred_percent = Decimal("100")
limit = hundred_percent + MAX_DEVIATION_PERCENT
upper = capacity * limit / hundred_percent
combinations: list[tuple[int, Decimal]] = [] # (битовая маска выбранных строк, точная сумма)
def search(start: int, selected: int, total: Decimal, mask: int) -> None:
remaining = size - selected
if remaining == 0:
deviation = abs(capacity - total)
if deviation * hundred_percent <= MAX_DEVIATION_PERCENT * min(capacity, total):
combinations.append((mask, total))
return
if len(candidates) - start < remaining:
return
# Промежуточную сумму сравниваем с нижней границей лишь с учётом оставшихся строк.
largest_total = total + sum((row.amount for row in candidates[-remaining:]), ZERO)
if largest_total * limit < capacity * hundred_percent:
return
smallest_total = total + sum((row.amount for row in candidates[start : start + remaining]), ZERO)
if smallest_total > upper:
return
for index in range(start, len(candidates) - remaining + 1):
next_total = total + candidates[index].amount
if next_total > upper:
break # суммы отсортированы: следующие строки только больше
search(index + 1, selected + 1, next_total, mask | (1 << index))
search(0, 0, ZERO, 0)
if not combinations:
return None
combinations.sort(key=lambda item: abs(capacity - item[1])) # точные первыми, затем меньшие расхождения
best_deviation = abs(capacity - combinations[0][1])
best_masks = [mask for mask, total in combinations if abs(capacity - total) == best_deviation]
involved_mask = 0
for mask in best_masks:
involved_mask |= mask
involved_keys = frozenset(row.key for index, row in enumerate(candidates) if involved_mask & (1 << index))
unique_rows = (
tuple(row for index, row in enumerate(candidates) if best_masks[0] & (1 << index))
if len(best_masks) == 1
else None
)
return KnapsackChoice(best_deviation, unique_rows, involved_keys)
def _candidate(anchor: PackRow, choice: KnapsackChoice, *, anchor_is_debit: bool) -> _Candidate:
keys = choice.involved_keys | {anchor.key}
if choice.unique_rows is None:
return _Candidate(choice.deviation, keys, None)
debit = (anchor,) if anchor_is_debit else choice.unique_rows
credit = choice.unique_rows if anchor_is_debit else (anchor,)
debit_amount = sum((row.amount for row in debit), ZERO)
credit_amount = sum((row.amount for row in credit), ZERO)
group = PackedGroup(
debit_rows=debit,
credit_rows=credit,
debit_amount=debit_amount,
credit_amount=credit_amount,
deviation_amount=choice.deviation,
deviation_percent=choice.deviation / min(debit_amount, credit_amount) * 100,
)
return _Candidate(choice.deviation, keys, group)
def _candidates(
debit_rows: list[PackRow], credit_rows: list[PackRow], unavailable: set[RowKey], total_size: int
) -> list[_Candidate]:
"""Входы уже ограничены одной тройкой: правило, компания А, компания Б."""
available_debit = [row for row in debit_rows if row.key not in unavailable]
available_credit = [row for row in credit_rows if row.key not in unavailable]
candidates: list[_Candidate] = []
for anchor in available_debit:
choice = best_subsets(available_credit, size=total_size - 1, capacity=anchor.amount)
if choice is not None:
candidates.append(_candidate(anchor, choice, anchor_is_debit=True))
if total_size > MIN_GROUP_SIZE:
for anchor in available_credit:
choice = best_subsets(available_debit, size=total_size - 1, capacity=anchor.amount)
if choice is not None:
candidates.append(_candidate(anchor, choice, anchor_is_debit=False))
return candidates
def pack_rule_rows(debit_rows: list[PackRow], credit_rows: list[PackRow]) -> PackingResult:
"""Жадно упаковать 1:1, 1:N, N:1; ничьи по размеру и расхождению отправить на ручной разбор."""
used: set[RowKey] = set()
ambiguous: set[RowKey] = set()
groups: list[PackedGroup] = []
for total_size in range(MIN_GROUP_SIZE, MAX_SIDE_ROWS + MIN_GROUP_SIZE):
while candidates := _candidates(debit_rows, credit_rows, used | ambiguous, total_size):
best_deviation = min(candidate.deviation for candidate in candidates)
best = [candidate for candidate in candidates if candidate.deviation == best_deviation]
conflicted = {index for index, candidate in enumerate(best) if candidate.group is None}
for left in range(len(best)):
for right in range(left + 1, len(best)):
if best[left].involved_keys & best[right].involved_keys:
conflicted.update((left, right))
for index, candidate in enumerate(best):
if index in conflicted:
ambiguous.update(candidate.involved_keys)
else:
assert candidate.group is not None
groups.append(candidate.group)
used.update(candidate.involved_keys)
return PackingResult(tuple(groups), frozenset(used), frozenset(ambiguous))

View File

@ -2,6 +2,7 @@
import uuid
from collections.abc import Collection
from datetime import date
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@ -44,6 +45,16 @@ async def current_open_version_no(session: AsyncSession) -> int | None:
return await session.scalar(_current_open_version_stmt())
async def latest_effective_version_no(session: AsyncSession, as_of: date) -> int | None:
"""Максимальный номер версии правил, действующей на дату создания"""
return await session.scalar(
select(func.max(EliminationRule.version_no)).where(
EliminationRule.valid_from <= as_of,
(EliminationRule.valid_to.is_(None) | (EliminationRule.valid_to >= as_of)),
)
)
def is_valid_for_version(rule: EliminationRule | None, version_no: int | None) -> bool:
"""Проверить версию правила §4 либо отсутствие `valid_to`."""
return rule is not None and (rule.version_no == version_no if version_no is not None else rule.valid_to is None)

View File

@ -336,6 +336,58 @@ class EliminationRulesOut(BaseModel):
rules: list[EliminationRuleOut]
class EliminationPairRowOut(BaseModel):
"""Неделимая строка свода; `entry_ids` связывают её с исходными строками ОСВ."""
entry_ids: list[uuid.UUID]
legal_entity_id: uuid.UUID
counterparty_id: uuid.UUID
account_id: uuid.UUID
account_code: str
closing_debit: Decimal | None
closing_credit: Decimal | None
class EliminationMatchedPairOut(BaseModel):
"""Сопоставленная группа строк одного правила и пары компаний."""
rule_id: uuid.UUID
company_a_id: uuid.UUID
company_b_id: uuid.UUID
amount_a: Decimal
amount_b: Decimal
elimination_amount: Decimal
deviation_percent: Decimal
osv_entry_ids_a: list[uuid.UUID]
osv_entry_ids_b: list[uuid.UUID]
class EliminationUnpairedRowOut(BaseModel):
"""Строка, не вошедшая в автоматическую группу правила."""
row: EliminationPairRowOut
reason: Literal["ZERO_AMOUNT", "NO_MATCH", "AMBIGUOUS"]
class EliminationPairsOut(BaseModel):
"""Точные пары, пары с расхождением и строки без пары для выбранной тройки."""
version_no: int | None
rule_id: uuid.UUID
rule_no: int
company_a_id: uuid.UUID
company_b_id: uuid.UUID
amount_a: Decimal
amount_b: Decimal
elimination_amount: Decimal
deviation_percent: Decimal | None
eliminated_amount: Decimal
exceeds_threshold: bool
matched: list[EliminationMatchedPairOut]
with_discrepancy: list[EliminationMatchedPairOut]
unpaired: list[EliminationUnpairedRowOut]
class IntercompanyMatrixManualIn(BaseModel):
"""Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки."""

View File

@ -16,7 +16,7 @@ from collections.abc import Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.audit import RequestAudit
from src.common.clock import utc_now
from src.common.clock import moscow_date, utc_now
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError
from src.domain import checklist as checklist_domain
from src.domain.consolidation_periods import quarters_through, reporting_date
@ -40,6 +40,7 @@ from src.models.enums import (
)
from src.repositories import consolidations as repo
from src.repositories import directories as directories_repo
from src.repositories import elimination_rules as elimination_rules_repo
from src.repositories.users import UserWithAccess
from src.schemas.consolidations import (
ChecklistCellOut,
@ -347,6 +348,9 @@ class ConsolidationService:
scenario=ConsolidationScenario.CREATE,
approach=ConsolidationApproach.BASIC,
goodwill_enabled=False,
elimination_rules_version=await elimination_rules_repo.latest_effective_version_no(
self._session, moscow_date(now)
),
initiator_id=user.id,
initiator_office_id=user.regional_office_id,
is_in_work=True,

View File

@ -12,6 +12,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.common.exceptions import DomainValidationError, NotFoundError
from src.domain.consolidated_osv import ConsolidatedRow, Entity
from src.domain.elimination_packing import (
PackedGroup,
PackRow,
RowKey,
RuleTotals,
calculate_rule_totals,
pack_rule_rows,
)
from src.domain.policy import VisibilityScope
from src.models.accounting import ChartOfAccount, EliminationRule
from src.models.consolidation import Consolidation, ConsolidationVersion
@ -20,6 +28,10 @@ from src.models.enums import AccountKind, PeriodQuarter, StatementType
from src.repositories import directories as directory_repo
from src.repositories import elimination_rules as elimination_rules_repo
from src.schemas.consolidations import (
EliminationMatchedPairOut,
EliminationPairRowOut,
EliminationPairsOut,
EliminationUnpairedRowOut,
IntercompanyMatrixCellOut,
IntercompanyMatrixCompanyOut,
IntercompanyMatrixOut,
@ -154,6 +166,21 @@ class ResultTableBuild:
consolidation: Consolidation
@dataclass(frozen=True)
class PairContext:
rule: EliminationRule
company_a_id: uuid.UUID
company_b_id: uuid.UUID
@dataclass(frozen=True)
class RulePacking:
matched: list[EliminationMatchedPairOut]
with_discrepancy: list[EliminationMatchedPairOut]
unpaired: list[EliminationUnpairedRowOut]
totals: RuleTotals
def _get_amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal:
if measure == "closing":
value = row.closing_debit if side == "D" else row.closing_credit
@ -339,6 +366,106 @@ def calculate_result_table(
return IntercompanyResultTableOut(companies=list(companies), articles=articles)
def _pair_row_out(row: ConsolidatedRow) -> EliminationPairRowOut:
"""Строка свода, уже отфильтрованная по счёту и встречному ЮЛ периметра."""
assert row.counterparty_id is not None and row.account_id is not None and row.account_code is not None
return EliminationPairRowOut(
entry_ids=list(row.entry_ids),
legal_entity_id=row.legal_entity_id,
counterparty_id=row.counterparty_id,
account_id=row.account_id,
account_code=row.account_code,
closing_debit=row.closing_debit,
closing_credit=row.closing_credit,
)
def _matched_pair_out(
group: PackedGroup,
context: PairContext,
) -> EliminationMatchedPairOut:
rule = context.rule
rows_a = group.debit_rows if rule.side_1 == "D" else group.credit_rows
rows_b = group.credit_rows if rule.side_1 == "D" else group.debit_rows
amount_a = group.debit_amount if rule.side_1 == "D" else group.credit_amount
amount_b = group.credit_amount if rule.side_1 == "D" else group.debit_amount
return EliminationMatchedPairOut(
rule_id=rule.id,
company_a_id=context.company_a_id,
company_b_id=context.company_b_id,
amount_a=amount_a,
amount_b=amount_b,
elimination_amount=group.deviation_amount,
deviation_percent=group.deviation_percent,
osv_entry_ids_a=[entry_id for row in rows_a for entry_id in row.key],
osv_entry_ids_b=[entry_id for row in rows_b for entry_id in row.key],
)
def _pack_preset_rows(
rows: Sequence[ConsolidatedRow],
selection: MatrixSelection,
context: PairContext,
) -> RulePacking:
"""Отобрать встречные строки одной направленной пары компаний и одного правила."""
rule = context.rule
company_a_id = context.company_a_id
company_b_id = context.company_b_id
debit_company_id = company_a_id if rule.side_1 == "D" else company_b_id
credit_company_id = company_b_id if debit_company_id == company_a_id else company_a_id
sources: dict[RowKey, ConsolidatedRow] = {}
debit_rows: list[PackRow] = []
credit_rows: list[PackRow] = []
for row in rows:
if row.account_id is None:
continue
in_debit = (
row.legal_entity_id == debit_company_id
and row.counterparty_id == credit_company_id
and row.account_id in selection.company_1_account_ids
)
in_credit = (
row.legal_entity_id == credit_company_id
and row.counterparty_id == debit_company_id
and row.account_id in selection.company_2_account_ids
)
if not in_debit and not in_credit:
continue
sources[row.entry_ids] = row
assert row.counterparty_id is not None
debit_amount = abs(row.closing_debit or ZERO)
credit_amount = abs(row.closing_credit or ZERO)
if in_debit and debit_amount > ZERO:
debit_rows.append(PackRow(row.entry_ids, row.legal_entity_id, row.counterparty_id, debit_amount))
if in_credit and credit_amount > ZERO:
credit_rows.append(PackRow(row.entry_ids, row.legal_entity_id, row.counterparty_id, credit_amount))
rows_a = debit_rows if rule.side_1 == "D" else credit_rows
rows_b = credit_rows if rule.side_1 == "D" else debit_rows
amount_a = sum((row.amount for row in rows_a), ZERO)
amount_b = sum((row.amount for row in rows_b), ZERO)
totals = calculate_rule_totals(amount_a, amount_b, manual=rule.elimination_mode == "manual")
packed = pack_rule_rows(debit_rows, credit_rows)
matched: list[EliminationMatchedPairOut] = []
with_discrepancy: list[EliminationMatchedPairOut] = []
for group in packed.groups:
pair = _matched_pair_out(group, context)
(matched if group.deviation_amount == ZERO else with_discrepancy).append(pair)
unpaired: list[EliminationUnpairedRowOut] = []
positive_keys = {item.key for item in [*debit_rows, *credit_rows]}
for key, row in sources.items():
if key in packed.used_keys:
continue
if key in packed.ambiguous_keys:
reason = "AMBIGUOUS"
elif key in positive_keys:
reason = "NO_MATCH"
else:
reason = "ZERO_AMOUNT"
unpaired.append(EliminationUnpairedRowOut(row=_pair_row_out(row), reason=reason))
return RulePacking(matched, with_discrepancy, unpaired, totals)
class IntercompanyMatrixService:
"""Получает свод и периметр версии, затем вызывает чистый расчёт матрицы."""
@ -347,6 +474,43 @@ class IntercompanyMatrixService:
self._scope = scope
self._osv_service = service_osv
async def build_pairs(
self, version_id: uuid.UUID, rule_id: uuid.UUID, company_a_id: uuid.UUID, company_b_id: uuid.UUID
) -> EliminationPairsOut:
"""Счета правила ББ и строки одной пары компаний без сохранения в БД (§4–§5)."""
osv_result = await self._osv_service.build_with_members(version_id)
version = osv_result.version
member_ids = set(osv_result.members)
if company_a_id == company_b_id or company_a_id not in member_ids or company_b_id not in member_ids:
raise DomainValidationError("INVALID_COMPANY_PAIR", "Выберите две разные компании периметра версии")
accounts = await directory_repo.list_accounts(self._session)
by_id, children = _make_account_tree(accounts)
selection, rule = await self._resolve_preset_selection(rule_id, version, by_id, children)
# TODO(упаковка ОСВ): занятость строки отслеживать отдельно для каждого rule_id — одна строка может попасть
# в результаты разных правил. Глобальное исключение повторного использования потребует изменения модели
# данных: нужно хранить/передавать принадлежность исходной строки конкретной паре между правилами.
packing = _pack_preset_rows(
osv_result.consolidated.rows,
selection,
PairContext(rule, company_a_id, company_b_id),
)
return EliminationPairsOut(
version_no=rule.version_no,
rule_id=rule.id,
rule_no=rule.rule_no,
company_a_id=company_a_id,
company_b_id=company_b_id,
amount_a=packing.totals.amount_a,
amount_b=packing.totals.amount_b,
elimination_amount=packing.totals.deviation_amount,
deviation_percent=packing.totals.deviation_percent,
eliminated_amount=packing.totals.eliminated_amount,
exceeds_threshold=packing.totals.exceeds_threshold,
matched=packing.matched,
with_discrepancy=packing.with_discrepancy,
unpaired=packing.unpaired,
)
async def build(
self,
version_id: uuid.UUID,

View File

@ -4,13 +4,16 @@
import uuid
from collections.abc import Sequence
from datetime import timedelta
from typing import Any
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.common.clock import moscow_date, utc_now
from src.models.access import AppUser
from src.models.accounting import EliminationRule
from src.models.audit_ex import AuditEvent
from src.models.consolidation import (
Consolidation,
@ -139,6 +142,7 @@ async def test_create_consolidation_rb_q3_2024(auth_client: AuthClientFactory, s
assert str(consolidation.id) == card["consolidation_id"]
version = (await seeded.execute(select(ConsolidationVersion))).scalar_one()
assert (version.initiator_id, version.created_by, version.is_in_work) == (user.id, user.id, True)
assert version.elimination_rules_version == 1
assert await _count(seeded, ConsolidationLock) == 0
assert await _count(seeded, ConsolidationMember) == len(entities)
assert await _count(seeded, ConsolidationStatusHistory) == 1
@ -162,6 +166,59 @@ async def test_create_consolidation_rb_q3_2024(auth_client: AuthClientFactory, s
}
@pytest.mark.integration
async def test_create_fixes_rules_for_creation_date_not_reporting_period(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
group, gsk, entities = await _scenario(seeded)
today = moscow_date(utc_now())
source = (await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == 1))).one()
next_rule = EliminationRule(
statement_type=source.statement_type,
rule_no=source.rule_no,
article_1_line=source.article_1_line,
article_2_line=source.article_2_line,
side_1=source.side_1,
measure=source.measure,
elimination_mode=source.elimination_mode,
excise_adjust=source.excise_adjust,
version_no=2,
valid_from=today + timedelta(days=1),
)
seeded.add(next_rule)
await seeded.flush()
client = await auth_client(KA_RF)
first = await client.post(CONSOLIDATIONS, json=_body(group, gsk, year=2024))
assert first.status_code == HTTP_CREATED, first.text
first_version = await seeded.get(ConsolidationVersion, uuid.UUID(first.json()["id"]))
assert first_version is not None
assert first_version.elimination_rules_version == 1
next_rule.valid_from = today
second = await client.post(CONSOLIDATIONS, json=_body(group, gsk, year=2023))
assert second.status_code == HTTP_CREATED, second.text
second_version = await seeded.get(ConsolidationVersion, uuid.UUID(second.json()["id"]))
assert second_version is not None
assert second_version.elimination_rules_version == next_rule.version_no
assert first_version.elimination_rules_version == 1
pairs_url = f"/consolidations/versions/{first_version.id}/elimination-pairs"
params = {
"rule_id": str(source.id),
"company_a_id": str(entities[0].id),
"company_b_id": str(entities[1].id),
}
for _ in range(2):
pairs = await client.get(pairs_url, params=params)
assert pairs.status_code == HTTP_OK, pairs.text
assert pairs.json()["version_no"] == 1
assert pairs.json()["matched"] == []
assert (await client.get(pairs_url, params={**params, "rule_id": str(next_rule.id)})).status_code == HTTP_NOT_FOUND
await seeded.refresh(first_version)
assert first_version.elimination_rules_version == 1
@pytest.mark.integration
async def test_create_reporting_date_for_q4_is_next_year(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group, gsk, _ = await _scenario(seeded)

View File

@ -217,6 +217,16 @@ async def test_preset_uses_current_rules_when_version_unfixed(
assert listed.json()["version_no"] == rule.version_no
assert "as_of" not in listed.json()
assert str(rule.id) in {item["id"] for item in listed.json()["rules"]}
pairs = await client.get(
f"/consolidations/versions/{world.version.id}/elimination-pairs",
params={
"rule_id": str(rule.id),
"company_a_id": str(world.members[0].id),
"company_b_id": str(world.members[1].id),
},
)
assert pairs.status_code == OK, pairs.text
assert world.version.elimination_rules_version is None # GET не фиксирует номер правил
response = await client.get(url)
assert response.status_code == OK, response.text
assert (await client.get(f"{url}/export")).status_code == OK
@ -264,6 +274,255 @@ async def test_unfixed_rules_choose_latest_open_version(auth_client: AuthClientF
assert old_preset.status_code == NOT_FOUND
async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
world = await _world(seeded, members=3)
rule_ids = {
rule.rule_no: rule.id
for rule in (await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no.in_([1, 3, 6])))).all()
}
debit_document = await make_confirmed_osv(
seeded,
entity=world.members[0],
account="62.01",
year=2024,
quarter=PeriodQuarter.Q1,
is_cumulative=True,
rows=[
{
"name": member.name,
"counterparty_id": member.id,
"account_id": world.accounts["62.01"].id,
"closing_debit": Decimal(amount),
}
for member, amount in [
*((world.members[1], amount) for amount in ("40", "0", "200")),
(world.members[2], "103"),
]
],
)
second_debit_document = await make_confirmed_osv(
seeded,
entity=world.members[0],
account="60.02",
year=2024,
quarter=PeriodQuarter.Q1,
is_cumulative=True,
rows=[
{
"name": world.members[1].name,
"counterparty_id": world.members[1].id,
"account_id": world.accounts["60.02"].id,
"closing_debit": Decimal("60"),
},
{
"name": world.members[2].name,
"counterparty_id": world.members[2].id,
"account_id": world.accounts["60.02"].id,
"closing_debit": Decimal("20"),
},
],
)
reverse_debit_document = await make_confirmed_osv(
seeded,
entity=world.members[1],
account="62.01",
year=2024,
quarter=PeriodQuarter.Q1,
is_cumulative=True,
rows=[
{
"name": world.members[0].name,
"counterparty_id": world.members[0].id,
"account_id": world.accounts["62.01"].id,
"closing_debit": Decimal("70"),
}
],
)
reverse_credit_document = await make_confirmed_osv(
seeded,
entity=world.members[0],
account="60.01",
year=2024,
quarter=PeriodQuarter.Q1,
is_cumulative=True,
rows=[
{
"name": world.members[1].name,
"counterparty_id": world.members[1].id,
"account_id": world.accounts["60.01"].id,
"closing_credit": Decimal("70"),
}
],
)
manual_debit_document = await make_confirmed_osv(
seeded,
entity=world.members[0],
account="03",
year=2024,
quarter=PeriodQuarter.Q1,
is_cumulative=True,
rows=[
{
"name": world.members[1].name,
"counterparty_id": world.members[1].id,
"account_id": world.accounts["03"].id,
"closing_debit": Decimal("100"),
}
],
)
manual_credit_document = await make_confirmed_osv(
seeded,
entity=world.members[1],
account="76.07.1",
year=2024,
quarter=PeriodQuarter.Q1,
is_cumulative=True,
rows=[
{
"name": world.members[0].name,
"counterparty_id": world.members[0].id,
"account_id": world.accounts["76.07.1"].id,
"closing_credit": Decimal("100"),
}
],
)
credit_document = await make_confirmed_osv(
seeded,
entity=world.members[1],
account="60.01",
year=2024,
quarter=PeriodQuarter.Q1,
is_cumulative=True,
rows=[
{
"name": world.members[0].name,
"counterparty_id": world.members[0].id,
"account_id": world.accounts["60.01"].id,
"closing_credit": Decimal("100"),
}
],
)
other_credit_document = await make_confirmed_osv(
seeded,
entity=world.members[2],
account="60.01",
year=2024,
quarter=PeriodQuarter.Q1,
is_cumulative=True,
rows=[
{
"name": world.members[0].name,
"counterparty_id": world.members[0].id,
"account_id": world.accounts["60.01"].id,
"closing_credit": Decimal("100"),
}
],
)
seeded.add_all(
[
ConsolidationDocument(consolidation_version_id=world.version.id, document_id=document.id)
for document in (
debit_document,
second_debit_document,
credit_document,
other_credit_document,
reverse_debit_document,
reverse_credit_document,
manual_debit_document,
manual_credit_document,
)
]
)
await seeded.flush()
client = await auth_client(KA_RF)
url = f"/consolidations/versions/{world.version.id}/elimination-pairs"
company_a_id, company_b_id, company_c_id = (member.id for member in world.members)
params = {"rule_id": str(rule_ids[6]), "company_a_id": str(company_a_id), "company_b_id": str(company_b_id)}
response = await client.get(url, params=params)
assert response.status_code == OK, response.text
payload = response.json()
assert payload["version_no"] == 1
assert (payload["rule_id"], payload["rule_no"], payload["company_a_id"], payload["company_b_id"]) == (
str(rule_ids[6]),
6,
str(company_a_id),
str(company_b_id),
)
assert "account_pairs" not in payload
assert payload["with_discrepancy"] == []
assert (Decimal(payload["amount_a"]), Decimal(payload["amount_b"])) == (Decimal("300"), Decimal("100"))
assert Decimal(payload["elimination_amount"]) == Decimal("200")
assert payload["exceeds_threshold"] is True
assert Decimal(payload["eliminated_amount"]) == 0
assert len(payload["matched"]) == 1
pair = payload["matched"][0]
assert len(pair["osv_entry_ids_a"]) == len((debit_document, second_debit_document))
assert len(pair["osv_entry_ids_b"]) == 1
assert pair["rule_id"] == str(rule_ids[6])
assert tuple(Decimal(pair[field]) for field in ("amount_a", "amount_b", "elimination_amount")) == (
Decimal("100"),
Decimal("100"),
Decimal("0"),
)
assert "article_ids_a" not in pair and "article_ids_b" not in pair
assert "deviation_amount" not in pair
assert Decimal(pair["deviation_percent"]) == 0
assert [item["reason"] for item in payload["unpaired"]] == ["ZERO_AMOUNT", "NO_MATCH"]
loans = await client.get(url, params={**params, "rule_id": str(rule_ids[3])})
assert loans.status_code == OK, loans.text
assert loans.json()["matched"] == []
manual = await client.get(url, params={**params, "rule_id": str(rule_ids[1])})
assert manual.status_code == OK, manual.text
assert len(manual.json()["matched"]) == 1
assert Decimal(manual.json()["eliminated_amount"]) == 0
other_pair = await client.get(url, params={**params, "company_b_id": str(company_c_id)})
assert other_pair.status_code == OK, other_pair.text
assert other_pair.json()["matched"] == []
assert len(other_pair.json()["with_discrepancy"]) == 1
discrepant = other_pair.json()["with_discrepancy"][0]
assert (Decimal(discrepant["amount_a"]), Decimal(discrepant["amount_b"])) == (Decimal("103"), Decimal("100"))
assert Decimal(discrepant["elimination_amount"]) == Decimal("3")
assert "article_ids_a" not in discrepant and "article_ids_b" not in discrepant
assert "deviation_amount" not in discrepant
assert (Decimal(other_pair.json()["amount_a"]), Decimal(other_pair.json()["amount_b"])) == (
Decimal("123"),
Decimal("100"),
)
assert other_pair.json()["exceeds_threshold"] is True
assert Decimal(other_pair.json()["eliminated_amount"]) == 0
reversed_pair = await client.get(
url, params={**params, "company_a_id": str(company_b_id), "company_b_id": str(company_a_id)}
)
assert reversed_pair.status_code == OK, reversed_pair.text
assert len(reversed_pair.json()["matched"]) == 1
assert reversed_pair.json()["with_discrepancy"] == []
assert reversed_pair.json()["unpaired"] == []
assert reversed_pair.json()["matched"][0]["osv_entry_ids_a"]
assert reversed_pair.json()["matched"][0]["osv_entry_ids_b"]
assert (Decimal(reversed_pair.json()["amount_a"]), Decimal(reversed_pair.json()["amount_b"])) == (
Decimal("70"),
Decimal("70"),
)
assert Decimal(reversed_pair.json()["eliminated_amount"]) == Decimal("70")
invalid_pair = await client.get(url, params={**params, "company_b_id": str(uuid.uuid4())})
assert invalid_pair.status_code == UNPROCESSABLE
same_company = await client.get(url, params={**params, "company_b_id": str(company_a_id)})
assert same_company.status_code == UNPROCESSABLE
missing_rule = await client.get(url, params={**params, "rule_id": str(uuid.uuid4())})
assert missing_rule.status_code == NOT_FOUND
missing_parameters = await client.get(url)
assert missing_parameters.status_code == UNPROCESSABLE
missing = await client.get(f"/consolidations/versions/{uuid.uuid4()}/elimination-pairs", params=params)
assert missing.status_code == NOT_FOUND
async def test_all_excludes_off_balance_accounts_but_manual_can_select_them(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:

View File

@ -0,0 +1,112 @@
"""Упаковка встречных строк по одному правилу без БД (§5)."""
import uuid
from decimal import Decimal
from src.domain.elimination_packing import MAX_SIDE_ROWS, PackRow, best_subsets, calculate_rule_totals, pack_rule_rows
A = uuid.uuid4()
B = uuid.uuid4()
def _row(amount: str, *, debit: bool) -> PackRow:
return PackRow((uuid.uuid4(),), A if debit else B, B if debit else A, Decimal(amount))
def test_knapsack_finds_exact_subset_with_fixed_size_and_keeps_ties() -> None:
first, second, third = (_row(amount, debit=False) for amount in ("40", "60", "60"))
choice = best_subsets([first, second, third], size=2, capacity=Decimal("100"))
assert choice is not None
assert choice.deviation == 0
assert choice.unique_rows is None
assert choice.involved_keys == {first.key, second.key, third.key}
def test_knapsack_keeps_both_nearest_sums_and_the_five_percent_lower_boundary() -> None:
below, above = (_row(amount, debit=False) for amount in ("99.00", "101.00"))
tie = best_subsets([below, above], size=1, capacity=Decimal("100.00"))
assert tie is not None
assert tie.deviation == Decimal("1.00")
assert tie.unique_rows is None
assert tie.involved_keys == {below.key, above.key}
boundary = best_subsets([_row("100.00", debit=False)], size=1, capacity=Decimal("105.00"))
assert boundary is not None
assert boundary.deviation == Decimal("5.00")
def test_exact_one_to_one_is_reserved_before_one_to_many() -> None:
debit = [_row("100", debit=True), _row("50", debit=True)]
credit = [_row("100", debit=False), _row("30", debit=False), _row("20", debit=False)]
result = pack_rule_rows(debit, credit)
assert [(len(group.debit_rows), len(group.credit_rows)) for group in result.groups] == [(1, 1), (1, 2)]
assert result.used_keys == {row.key for row in [*debit, *credit]}
assert result.ambiguous_keys == frozenset()
def test_equal_exact_groups_sharing_a_row_go_to_manual() -> None:
debit = [_row("100", debit=True)]
credit = [_row("100", debit=False), _row("100", debit=False)]
result = pack_rule_rows(debit, credit)
assert result.groups == ()
assert result.ambiguous_keys == {row.key for row in [*debit, *credit]}
def test_five_percent_boundary_and_no_many_to_many() -> None:
debit = [_row("100", debit=True)]
credit = [_row("21", debit=False) for _ in range(5)]
result = pack_rule_rows(debit, credit)
assert len(result.groups) == 1
assert len(result.groups[0].credit_rows) == MAX_SIDE_ROWS
assert result.groups[0].deviation_percent == Decimal("5")
many_to_many = pack_rule_rows(
[_row("40", debit=True), _row("60", debit=True)],
[_row("30", debit=False), _row("70", debit=False)],
)
assert many_to_many.groups == () # суммы сторон равны, но допустимой группы 1:N или N:1 нет
over_threshold = pack_rule_rows([_row("100", debit=True)], [_row("106", debit=False)])
assert over_threshold.groups == ()
def test_rule_totals_eliminate_minimum_within_five_percent() -> None:
totals = calculate_rule_totals(Decimal("110429113.15"), Decimal("106311116.46"), manual=False)
assert totals.amount_a == Decimal("110429113.15")
assert totals.amount_b == Decimal("106311116.46")
assert totals.deviation_amount == Decimal("4117996.69")
assert totals.deviation_percent is not None
assert totals.deviation_percent.quantize(Decimal("0.01")) == Decimal("3.87")
assert totals.eliminated_amount == Decimal("106311116.46")
assert totals.exceeds_threshold is False
def test_rule_totals_over_five_percent_and_manual_eliminate_zero() -> None:
over = calculate_rule_totals(Decimal("106"), Decimal("100"), manual=False)
assert over.deviation_amount == Decimal("6")
assert over.deviation_percent == Decimal("6")
assert over.exceeds_threshold is True
assert over.eliminated_amount == 0
manual = calculate_rule_totals(Decimal("100"), Decimal("100"), manual=True)
assert manual.exceeds_threshold is False
assert manual.eliminated_amount == 0
boundary = calculate_rule_totals(Decimal("105"), Decimal("100"), manual=False)
assert boundary.exceeds_threshold is False
assert boundary.eliminated_amount == Decimal("100")
one_sided = calculate_rule_totals(Decimal("100"), Decimal("0"), manual=False)
assert one_sided.deviation_percent is None
assert one_sided.exceeds_threshold is True
assert one_sided.eliminated_amount == 0

View File

@ -19,6 +19,7 @@ import pytest
from openpyxl import load_workbook
from src.schemas.consolidations import IntercompanyMatrixCompanyOut
from src.services import intercompany_matrix as matrix
from src.services.intercompany_matrix import MatrixSelection, calculate_matrix
pytestmark = pytest.mark.fixtures
@ -116,6 +117,7 @@ def test_loan_66_03_against_58_03_when_counterpart_exists(fixtures_dir: Path) ->
legal_entity_id=RB,
counterparty_id=BAKERY,
account_id=credit_account,
account_code="66.03",
closing_debit=Decimal(0),
closing_credit=amount,
entry_ids=(uuid.uuid4(),),
@ -129,12 +131,18 @@ def test_loan_66_03_against_58_03_when_counterpart_exists(fixtures_dir: Path) ->
assert one_sided.amount_a == 0
assert one_sided.amount_b == amount
assert one_sided.one_sided is True
context = matrix.PairContext(SimpleNamespace(id=uuid.uuid4(), side_1="D", elimination_mode="min"), BAKERY, RB)
unmatched = matrix._pack_preset_rows([credit_row], selection, context)
assert unmatched.matched == []
assert [item.reason for item in unmatched.unpaired] == ["NO_MATCH"]
assert unmatched.totals.eliminated_amount == 0
# В наборе нет ОСВ 58.03 Счастливочной; модельная строка проверяет встречную сторону правила № 4.
debit_row = SimpleNamespace(
legal_entity_id=BAKERY,
counterparty_id=RB,
account_id=debit_account,
account_code="58.03",
closing_debit=amount,
closing_credit=Decimal(0),
entry_ids=(uuid.uuid4(),),
@ -143,3 +151,76 @@ def test_loan_66_03_against_58_03_when_counterpart_exists(fixtures_dir: Path) ->
assert paired.amount_a == paired.amount_b == amount
assert paired.variance_amount == 0
assert paired.one_sided is False
packed = matrix._pack_preset_rows([debit_row, credit_row], selection, context)
assert len(packed.matched) == 1
assert packed.matched[0].amount_a == packed.matched[0].amount_b == amount
assert packed.totals.eliminated_amount == amount
def test_rule_6_bakery_and_russian_biscuit_packs_only_exact_production_row(fixtures_dir: Path) -> None:
"""Четыре реальные дебетовые строки дают итог тройки; упаковка берёт лишь точную 62.01 ↔ 60.01."""
source_rows = [
_source(fixtures_dir, "schastl", root, number)
for root, number in (("62", 21), ("62", 17), ("76", 20), ("76", 29))
]
credit_source = _source(fixtures_dir, "rb", "60", 409)
assert [(row["account_code_raw"], Decimal(row["closing_debit"])) for row in source_rows] == [
("62.01", Decimal("106311116.46")),
("62.01", Decimal("1401330.00")),
("76.06", Decimal("350000.00")),
("76.АВ", Decimal("2366666.69")),
]
assert (credit_source["account_code_raw"], Decimal(credit_source["closing_credit"])) == (
"60.01",
Decimal("106311116.46"),
)
account_ids = {code: uuid.uuid4() for code in ("62.01", "76.06", "76.АВ", "60.01")}
source_ids = [uuid.uuid4() for _ in source_rows]
credit_id = uuid.uuid4()
rows = [
SimpleNamespace(
legal_entity_id=BAKERY,
counterparty_id=RB,
account_id=account_ids[source["account_code_raw"]],
account_code=source["account_code_raw"],
closing_debit=Decimal(source["closing_debit"]),
closing_credit=Decimal(source["closing_credit"] or "0"),
entry_ids=(entry_id,),
)
for source, entry_id in zip(source_rows, source_ids, strict=True)
]
rows.append(
SimpleNamespace(
legal_entity_id=RB,
counterparty_id=BAKERY,
account_id=account_ids["60.01"],
account_code="60.01",
closing_debit=Decimal(credit_source["closing_debit"] or "0"),
closing_credit=Decimal(credit_source["closing_credit"]),
entry_ids=(credit_id,),
)
)
selection = MatrixSelection(
frozenset(account_ids[code] for code in ("62.01", "76.06", "76.АВ")),
frozenset({account_ids["60.01"]}),
"closing",
)
packed = matrix._pack_preset_rows(
rows,
selection,
matrix.PairContext(SimpleNamespace(id=uuid.uuid4(), side_1="D", elimination_mode="min"), BAKERY, RB),
)
assert (packed.totals.amount_a, packed.totals.amount_b) == (
Decimal("110429113.15"),
Decimal("106311116.46"),
)
assert packed.totals.eliminated_amount == Decimal("106311116.46")
assert packed.totals.deviation_percent is not None
assert packed.totals.deviation_percent.quantize(Decimal("0.01")) == Decimal("3.87")
assert packed.with_discrepancy == []
assert len(packed.matched) == 1
assert packed.matched[0].osv_entry_ids_a == [source_ids[0]]
assert packed.matched[0].osv_entry_ids_b == [credit_id]
assert packed.matched[0].amount_a == packed.matched[0].amount_b == Decimal("106311116.46")
assert {item.row.entry_ids[0] for item in packed.unpaired} == set(source_ids[1:])
assert {item.reason for item in packed.unpaired} == {"NO_MATCH"}

View File

@ -75,6 +75,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
"/api/v1/consolidations/versions/{version_id}/osv/entries/{entry_id}/counterparty": {"post"},
# шахматка ВГО и правила элиминации
"/api/v1/consolidations/versions/{version_id}/elimination-rules": {"get"},
"/api/v1/consolidations/versions/{version_id}/elimination-pairs": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all/export": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual": {"post"},

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-23

View File

@ -0,0 +1,3 @@
# elimination-pairs-rule-totals
Тесты упаковки и итог элиминации для тройки правила и компаний

View File

@ -0,0 +1,10 @@
## Решение
Суммы выбранных строк считаются один раз до упаковки внутри тройки `(rule_id, A, B)` как чистая функция.
`elimination_amount` сохраняет ранее согласованное значение абсолютного расхождения; новое `eliminated_amount`
содержит `min(ΣA, ΣB)` при пороге не более 5 %, иначе 0, и всегда 0 для `manual`.
Нулевая меньшая сторона даёт `deviation_percent = null` и нулевую элиминацию.
Упаковка не влияет на итог. Для обратной направленной пары клиент переставляет `company_a_id` и `company_b_id`.
Эталонный Excel используется только для сверки точных встречных строк: он списывает полную сумму каждой стороны,
поэтому его общий итог не равен системному правилу минимума.

View File

@ -0,0 +1,34 @@
## Why
Срез 5: упаковка ОСВ показывает группы строк, но итог элиминации тройки должен учитывать все выбранные строки,
включая оставшиеся без пары. Это нужно закрепить тестами на реальных ОСВ группы «Русский бисквит» и отдельным
контрактом для фронта.
## What Changes
- Для запроса `(правило, A, B)` добавить общий итог ΣA/ΣB, расхождение, процент, признак порога и сумму
элиминации по правилу 5 %; при `manual` сумма элиминации равна нулю.
- Проверить упаковку 1:1, 1:N, N:1, ничьи и оба направления пары на реальных и модельных строках.
- Проверить неизменность зафиксированной версии правил при повторных GET и сопоставить точные строки с эталонным Excel.
- Описать новый ответ в гайде фронта.
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `consolidations-api`: общий итог элиминации тройки независимо от упаковки строк.
## Impact
`arm-core`: чистый расчёт, сервис и схема ответа, тесты, OpenSpec. `arm-ka-docs`: гайд фронта.
Читаются таблицы `consolidation_version`, `consolidation_member`, `consolidation_document`, `osv_entry`,
`elimination_rule`, `elimination_rule_account`, `chart_of_account`; DBML и хранимые данные не меняются.
Основание — `context/05-domain-rules.md` §4–§5, решение владельца 08.10.2026 о минимуме пары.
## Non-goals
Пары всех правил версии в одном запросе, сохранение результатов в БД, корректировки, расчёт ОФР.

View File

@ -0,0 +1,21 @@
## ADDED Requirements
### Requirement: Итог элиминации тройки независимо от упаковки
Для `(rule_id, company_a_id, company_b_id)` API SHALL возвращать суммы всех выбранных строк ОСВ обеих сторон,
абсолютное расхождение `elimination_amount`, процент расхождения к меньшей сумме, `exceeds_threshold` и отдельное
`eliminated_amount`. При расхождении не более 5 % `eliminated_amount` SHALL равняться `min(amount_a, amount_b)`;
при превышении порога, нулевой стороне или `elimination_mode = manual` SHALL равняться нулю. Упаковка строк
не меняет этот итог.
#### Scenario: Точная строка среди несопоставленных остатков
- **WHEN** правило № 6 для Счастливочной и АО «Русский бисквит» даёт суммы 110 429 113,15 и 106 311 116,46 руб.,
а точная группа 62.01 ↔ 60.01 составляет 106 311 116,46 руб.
- **THEN** точная группа присутствует в `matched`, остальные три строки стороны A — в `unpaired`,
`eliminated_amount = 106311116.46`, расхождение округляется до 3,87 %.
#### Scenario: Ручное правило и превышение порога
- **WHEN** правило ручное либо расхождение больше 5 %
- **THEN** `eliminated_amount = 0`, независимо от результата упаковки.

View File

@ -0,0 +1,7 @@
## Реализация
- [x] Добавить чистый расчёт общего итога и тесты порога, ручного режима и нулевой стороны.
- [x] Проверить реальные строки правила № 6, точную пару и оставшиеся без пары по golden ОСВ.
- [x] Проверить 42 млн по правилу № 4, ничью упаковки, обратную пару и фиксацию версии правил.
- [x] Дополнить основной OpenSpec и гайд фронта.
- [ ] Запустить `make check-full` и заархивировать change.

View File

@ -15,7 +15,13 @@ Q3 → 01.10.year, Q4 → 01.01.year+1; ASSUMPTION(OQ-40)), `consolidation_versi
`is_in_work = true`), `consolidation_member` периметра (`is_head_entity = false`), первую запись
`consolidation_status_history` (`from_status` NULL → `INITIATED`), подхват подтверждённых документов ЮЛ периметра (см.
`documents-api` «Подхват документов») и одно событие `audit_event(CONSOLIDATION_CREATE)`, у которого
`after_value.members` — отсортированный список ЮЛ периметра; ответ 201 с карточкой версии. Периметр SHALL быть: без
`after_value.members` — отсортированный список ЮЛ периметра; ответ 201 с карточкой версии.
При создании версии система SHALL автоматически записать в `consolidation_version.elimination_rules_version` номер
самой актуальной версии правил: максимальный `version_no` среди правил, действующих на московскую календарную дату
создания версии (`valid_from <= дата` и `valid_to IS NULL` либо `valid_to >= дата`; OQ-174, docs PR #146).
Пользователь не выбирает версию правил. Отчётные год, квартал и `reporting_date` не влияют на определение номера.
Если на дату создания нет действующих правил, поле остаётся NULL.
Периметр SHALL быть: без
`legal_entity_ids` (поле отсутствует или `null`) — текущие члены ГСК; с `legal_entity_ids` — ровно эти ЮЛ после
схлопывания дублей (порядок первого вхождения), каждое SHALL принадлежать ГК консолидации
(`legal_entity.company_group_id`), иначе 422 `ENTITY_NOT_IN_GROUP` с перечнем `legal_entity_ids` чужих в порядке
@ -33,6 +39,10 @@ SHALL оставаться NULL (фиксируется при верифика
- **THEN** 201; версия №1 `INITIATED`, `reporting_date = 2024-10-01`, два `consolidation_member`, история с одной записью,
`audit_event(CONSOLIDATION_CREATE)` с `consolidation_version_id`; активной блокировки нет
#### Scenario: Правила действуют на дату создания версии
- **WHEN** в день создания консолидации за 2024 год действует версия правил 2, а версия 1 действовала в отчётном периоде
- **THEN** `consolidation_version.elimination_rules_version = 2`; номер не меняется при последующей публикации правил
#### Scenario: Выбранный периметр шире ГСК
- **WHEN** в ГК три ЮЛ, в ГСК два из них, создаётся консолидация с `legal_entity_ids` из всех трёх ЮЛ ГК
- **THEN** 201; в `members` три ЮЛ; `after_value.members` события `CONSOLIDATION_CREATE` — три ЮЛ; состав ГСК не изменился
@ -251,6 +261,56 @@ SHALL возвращать объект `{version_no, is_version_fixed, rules}`.
- **WHEN** открыты правила с `version_no = 1` и `version_no = 2`, в том числе с одинаковым `rule_no`
- **THEN** ответ содержит только правила версии 2 и `version_no = 2`
### Requirement: Сопоставленные строки сводной ОСВ по правилу и паре компаний
`GET /api/v1/consolidations/versions/{version_id}/elimination-pairs` SHALL требовать `CONSOLIDATION_VIEW` и проверять
видимость версии по ГК. Запрос SHALL принимать обязательные query-параметры `rule_id`, `company_a_id`, `company_b_id`.
Компании SHALL быть разными участниками периметра версии, а правило — пресетом ББ её версии правил. Для этой тройки
SHALL возвращаться только её результат; строки других правил и направленных пар компаний в расчёт не входят.
Отбор счетов, раскрытие счетов-предков и их исключений SHALL совпадать с расчётом шахматки PRESET. Ответ SHALL
содержать `version_no`, `rule_id`, `rule_no`, `company_a_id`, `company_b_id`, `matched[]` (точные группы),
`with_discrepancy[]` (группы с допустимым ненулевым расхождением) и `unpaired[]` (строки без группы), без полного
перечня допустимых пар счетов. На уровне тройки ответ SHALL содержать `amount_a`, `amount_b` (суммы всех выбранных
строк, включая оставшиеся без пары), `elimination_amount` (абсолютное расхождение), `deviation_percent` (от меньшей
суммы, `null` при нуле), `eliminated_amount` (сумма фактической элиминации) и `exceeds_threshold`.
При расхождении не более 5 % `eliminated_amount` SHALL равняться `min(amount_a, amount_b)`; при превышении порога,
нулевой стороне или `elimination_mode = manual` SHALL равняться нулю. Упаковка строк SHALL не менять этот итог.
Каждая сопоставленная группа SHALL содержать `rule_id`, `company_a_id`,
`company_b_id`, `amount_a`, `amount_b`, `elimination_amount`, `deviation_percent` и исходные `osv_entry_ids_a/b`.
Суммы `amount_a/b` SHALL рассчитываться по ОСВ; `elimination_amount` SHALL равняться
`abs(amount_a - amount_b)`.
Невидимая и несуществующая версия или отсутствующее правило SHALL отвечать 404 `NOT_FOUND`;
недопустимая пара компаний — 422 `INVALID_COMPANY_PAIR`. Пары и группы не сохраняются.
Строка сводной ОСВ неделима, даже если содержит несколько `entry_ids`. Внутри выбранной тройки строки с распознанным
встречным ЮЛ и допустимыми счетами группируются как 1:1, 1:N или N:1 при N ≤ 5; N:M не допускается. Комбинации
строятся из всех строк выбранных счетов соответствующей стороны правила, без дополнительного требования совпадения
конкретных счетов внутри группы.
Сравниваются модули остатков по соответствующей стороне. Нулевые строки остаются в `unpaired` с причиной
`ZERO_AMOUNT`. Сначала выбираются группы с наименьшим общим числом строк: 1:1, затем 1:2 и 2:1, и так до 1:5 и 5:1.
Внутри размера сначала точные, затем с расхождением не более 5 % относительно меньшей из двух сумм, по возрастанию
расхождения. Строка входит не более чем в одну группу данного правила. Если группы одного размера с одинаковым
расхождением пересекаются, все строки конкурирующих групп исключаются из автоматического поиска и возвращаются в
`unpaired` с причиной `AMBIGUOUS` для ручного разбора. Остальные строки без группы возвращаются в `unpaired` с
причиной `NO_MATCH` независимо от того, отсутствуют ли встречные суммы, превышен ли порог или подходящие строки уже
заняты другой группой.
Упаковка не меняет сумму элиминации.
#### Scenario: Исключённый счёт не входит в пары
- **WHEN** правило включает родительский счёт 76 и исключает 76.07.5
- **THEN** строки счёта 76.07.5 и его потомков не входят в сопоставленные группы и `unpaired`
#### Scenario: Две строки свода упакованы с одной встречной
- **WHEN** по одному правилу есть дебетовые строки 40 и 60 и встречная кредитовая строка 100
- **THEN** `matched[]` содержит пару с суммами 100 и 100, нулевым расхождением и исходными `osv_entry_ids_a/b`
#### Scenario: Пара с допустимым расхождением
- **WHEN** группа имеет суммы 103 и 100 при пороге 5 %
- **THEN** она входит в `with_discrepancy[]`, а не в `matched[]`
#### Scenario: Другая пара компаний не смешивается с выбранной
- **WHEN** по одному правилу есть встречные строки для A→Б и A→В, а запрошены `company_a_id=A`, `company_b_id=Б`
- **THEN** ответ содержит только строки A→Б и Б→A
### Requirement: Результирующая таблица ВГО
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table`
(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[]}`. Компании — все члены периметра