From 232fecec39beb67e730cc1e5c6a41607de936d9e Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Fri, 23 Oct 2026 12:10:35 +0300 Subject: [PATCH 1/7] =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=BE=D0=B5=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=81=D0=B8=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D0=B8?= =?UTF-8?q?=D0=B4=D0=B0=D1=86=D0=B8=D1=8F,=20=D0=B5=D1=81=D0=BB=D0=B8=20?= =?UTF-8?q?=D1=82=D0=B0=D0=BA=D0=B8=D1=85=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=D1=81=D1=8F=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D0=B8=D0=B4=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D1=81=20NULL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/repositories/elimination_rules.py | 11 ++++++ api/src/services/consolidations.py | 6 +++- api/tests/test_api_consolidations.py | 42 +++++++++++++++++++++++ openspec/specs/consolidations-api/spec.md | 12 ++++++- 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/api/src/repositories/elimination_rules.py b/api/src/repositories/elimination_rules.py index d059395..06fa06d 100644 --- a/api/src/repositories/elimination_rules.py +++ b/api/src/repositories/elimination_rules.py @@ -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) diff --git a/api/src/services/consolidations.py b/api/src/services/consolidations.py index 3d05f0c..19b0443 100644 --- a/api/src/services/consolidations.py +++ b/api/src/services/consolidations.py @@ -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, diff --git a/api/tests/test_api_consolidations.py b/api/tests/test_api_consolidations.py index 910ef67..0fa51a0 100644 --- a/api/tests/test_api_consolidations.py +++ b/api/tests/test_api_consolidations.py @@ -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,44 @@ 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, _ = 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 + + @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) diff --git a/openspec/specs/consolidations-api/spec.md b/openspec/specs/consolidations-api/spec.md index abd8832..1ba4f24 100644 --- a/openspec/specs/consolidations-api/spec.md +++ b/openspec/specs/consolidations-api/spec.md @@ -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` — три ЮЛ; состав ГСК не изменился -- 2.47.2 From 010c15386bb51c124770e48e7384dbf508cab537 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Fri, 23 Oct 2026 15:55:54 +0300 Subject: [PATCH 2/7] =?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[]}`. Компании — все члены периметра -- 2.47.2 From 827b490137f29b89c4d19765333f1b4e50bb37f7 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Fri, 23 Oct 2026 16:20:51 +0300 Subject: [PATCH 3/7] =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D1=8B=20+=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=BE=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=20=D0=B2=D1=8B=D1=85=D0=BE=D0=B4=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/domain/elimination_packing.py | 24 +++- api/src/schemas/consolidations.py | 6 + api/src/services/intercompany_matrix.py | 40 +++++-- api/tests/test_api_consolidations.py | 17 ++- api/tests/test_api_intercompany_matrix.py | 113 +++++++++++++++++- api/tests/test_elimination_packing.py | 35 +++++- api/tests/test_intercompany_matrix_golden.py | 81 +++++++++++++ api/tests/test_openapi_routes.py | 1 + .../.openspec.yaml | 2 + .../elimination-pairs-rule-totals/README.md | 3 + .../elimination-pairs-rule-totals/design.md | 10 ++ .../elimination-pairs-rule-totals/proposal.md | 34 ++++++ .../specs/consolidations-api/spec.md | 21 ++++ .../elimination-pairs-rule-totals/tasks.md | 7 ++ openspec/specs/consolidations-api/spec.md | 7 +- 15 files changed, 387 insertions(+), 14 deletions(-) create mode 100644 openspec/changes/elimination-pairs-rule-totals/.openspec.yaml create mode 100644 openspec/changes/elimination-pairs-rule-totals/README.md create mode 100644 openspec/changes/elimination-pairs-rule-totals/design.md create mode 100644 openspec/changes/elimination-pairs-rule-totals/proposal.md create mode 100644 openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md create mode 100644 openspec/changes/elimination-pairs-rule-totals/tasks.md diff --git a/api/src/domain/elimination_packing.py b/api/src/domain/elimination_packing.py index 376a153..3e7d30e 100644 --- a/api/src/domain/elimination_packing.py +++ b/api/src/domain/elimination_packing.py @@ -51,6 +51,28 @@ class PackingResult: 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 @@ -80,7 +102,7 @@ def best_subsets(rows: list[PackRow], *, size: int, capacity: Decimal) -> Knapsa 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: diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 0e87942..b1b1d51 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -377,6 +377,12 @@ class EliminationPairsOut(BaseModel): 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] diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index d1721b8..edeb7bf 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -12,7 +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, pack_rule_rows +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 @@ -166,6 +173,14 @@ class PairContext: 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 @@ -391,7 +406,7 @@ def _pack_preset_rows( rows: Sequence[ConsolidatedRow], selection: MatrixSelection, context: PairContext, -) -> tuple[list[EliminationMatchedPairOut], list[EliminationMatchedPairOut], list[EliminationUnpairedRowOut]]: +) -> RulePacking: """Отобрать встречные строки одной направленной пары компаний и одного правила.""" rule = context.rule company_a_id = context.company_a_id @@ -425,6 +440,11 @@ def _pack_preset_rows( 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] = [] @@ -443,7 +463,7 @@ def _pack_preset_rows( else: reason = "ZERO_AMOUNT" unpaired.append(EliminationUnpairedRowOut(row=_pair_row_out(row), reason=reason)) - return matched, with_discrepancy, unpaired + return RulePacking(matched, with_discrepancy, unpaired, totals) class IntercompanyMatrixService: @@ -469,7 +489,7 @@ class IntercompanyMatrixService: # TODO(упаковка ОСВ): занятость строки отслеживать отдельно для каждого rule_id — одна строка может попасть # в результаты разных правил. Глобальное исключение повторного использования потребует изменения модели # данных: нужно хранить/передавать принадлежность исходной строки конкретной паре между правилами. - matched, with_discrepancy, unpaired = _pack_preset_rows( + packing = _pack_preset_rows( osv_result.consolidated.rows, selection, PairContext(rule, company_a_id, company_b_id), @@ -480,9 +500,15 @@ class IntercompanyMatrixService: 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, + 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( diff --git a/api/tests/test_api_consolidations.py b/api/tests/test_api_consolidations.py index 0fa51a0..8387da6 100644 --- a/api/tests/test_api_consolidations.py +++ b/api/tests/test_api_consolidations.py @@ -170,7 +170,7 @@ async def test_create_consolidation_rb_q3_2024(auth_client: AuthClientFactory, s async def test_create_fixes_rules_for_creation_date_not_reporting_period( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: - group, gsk, _ = await _scenario(seeded) + 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( @@ -203,6 +203,21 @@ async def test_create_fixes_rules_for_creation_date_not_reporting_period( 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: diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index 6b0cedc..e551f7b 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -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 @@ -305,6 +315,76 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915 "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"), } ], ) @@ -343,7 +423,16 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915 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) + 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() @@ -364,6 +453,10 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915 ) 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)) @@ -384,7 +477,8 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915 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"] == [] + 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 @@ -395,14 +489,27 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915 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 reversed_pair.json()["matched"] == [] + 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 diff --git a/api/tests/test_elimination_packing.py b/api/tests/test_elimination_packing.py index 4f102f9..8216665 100644 --- a/api/tests/test_elimination_packing.py +++ b/api/tests/test_elimination_packing.py @@ -3,7 +3,7 @@ import uuid from decimal import Decimal -from src.domain.elimination_packing import MAX_SIDE_ROWS, PackRow, best_subsets, pack_rule_rows +from src.domain.elimination_packing import MAX_SIDE_ROWS, PackRow, best_subsets, calculate_rule_totals, pack_rule_rows A = uuid.uuid4() B = uuid.uuid4() @@ -77,3 +77,36 @@ def test_five_percent_boundary_and_no_many_to_many() -> None: 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 diff --git a/api/tests/test_intercompany_matrix_golden.py b/api/tests/test_intercompany_matrix_golden.py index 4669544..e55bea1 100644 --- a/api/tests/test_intercompany_matrix_golden.py +++ b/api/tests/test_intercompany_matrix_golden.py @@ -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"} diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 2346e9a..c78f8e5 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -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"}, diff --git a/openspec/changes/elimination-pairs-rule-totals/.openspec.yaml b/openspec/changes/elimination-pairs-rule-totals/.openspec.yaml new file mode 100644 index 0000000..f56fe13 --- /dev/null +++ b/openspec/changes/elimination-pairs-rule-totals/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-23 diff --git a/openspec/changes/elimination-pairs-rule-totals/README.md b/openspec/changes/elimination-pairs-rule-totals/README.md new file mode 100644 index 0000000..cfc19ce --- /dev/null +++ b/openspec/changes/elimination-pairs-rule-totals/README.md @@ -0,0 +1,3 @@ +# elimination-pairs-rule-totals + +Тесты упаковки и итог элиминации для тройки правила и компаний diff --git a/openspec/changes/elimination-pairs-rule-totals/design.md b/openspec/changes/elimination-pairs-rule-totals/design.md new file mode 100644 index 0000000..d9c793e --- /dev/null +++ b/openspec/changes/elimination-pairs-rule-totals/design.md @@ -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 используется только для сверки точных встречных строк: он списывает полную сумму каждой стороны, +поэтому его общий итог не равен системному правилу минимума. diff --git a/openspec/changes/elimination-pairs-rule-totals/proposal.md b/openspec/changes/elimination-pairs-rule-totals/proposal.md new file mode 100644 index 0000000..c2817c2 --- /dev/null +++ b/openspec/changes/elimination-pairs-rule-totals/proposal.md @@ -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 + +Пары всех правил версии в одном запросе, сохранение результатов в БД, корректировки, расчёт ОФР. diff --git a/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md b/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md new file mode 100644 index 0000000..4eef515 --- /dev/null +++ b/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md @@ -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`, независимо от результата упаковки. diff --git a/openspec/changes/elimination-pairs-rule-totals/tasks.md b/openspec/changes/elimination-pairs-rule-totals/tasks.md new file mode 100644 index 0000000..5c0205a --- /dev/null +++ b/openspec/changes/elimination-pairs-rule-totals/tasks.md @@ -0,0 +1,7 @@ +## Реализация + +- [x] Добавить чистый расчёт общего итога и тесты порога, ручного режима и нулевой стороны. +- [x] Проверить реальные строки правила № 6, точную пару и оставшиеся без пары по golden ОСВ. +- [x] Проверить 42 млн по правилу № 4, ничью упаковки, обратную пару и фиксацию версии правил. +- [x] Дополнить основной OpenSpec и гайд фронта. +- [ ] Запустить `make check-full` и заархивировать change. diff --git a/openspec/specs/consolidations-api/spec.md b/openspec/specs/consolidations-api/spec.md index e1a7f2c..660e7e9 100644 --- a/openspec/specs/consolidations-api/spec.md +++ b/openspec/specs/consolidations-api/spec.md @@ -269,7 +269,12 @@ 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`, +перечня допустимых пар счетов. На уровне тройки ответ 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)`. -- 2.47.2 From 812e530fe52a803f02ee115223f2def385cecc80 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 18:42:10 +0400 Subject: [PATCH 4/7] =?UTF-8?q?style:=20ruff=20format=20elimination=5Fpack?= =?UTF-8?q?ing=20(=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20#175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/domain/elimination_packing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/domain/elimination_packing.py b/api/src/domain/elimination_packing.py index 3e7d30e..cb0bbad 100644 --- a/api/src/domain/elimination_packing.py +++ b/api/src/domain/elimination_packing.py @@ -57,9 +57,9 @@ class RuleTotals: amount_a: Decimal amount_b: Decimal - deviation_amount: Decimal + deviation_amount: Decimal deviation_percent: Decimal | None - eliminated_amount: Decimal + eliminated_amount: Decimal exceeds_threshold: bool -- 2.47.2 From b4522addc09b4b0f12a54ce99377517d87958652 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 18:42:10 +0400 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20DBML=20v5.23=20=E2=80=94=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BE=D0=B1=D1=8F=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=B0;=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=20=D0=92=D0=93=D0=9E=20=D0=B2=D0=B5=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D0=B8=20(=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20#175,=20AURORA?= =?UTF-8?q?-1536)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - consolidation_version.elimination_rules_version NOT NULL: миграция 0010 проставляет существующим версиям номер правил, действовавших на дату создания (по Москве), иначе последних открытых; без правил — отказ; создание версии без действующих правил — 409 ELIMINATION_RULES_MISSING; ветки «правила не зафиксированы» убраны (rules, preset, is_version_fixed всегда true); - GET …/elimination-totals: все тройки «правило ББ × упорядоченная пара периметра» с ΣA, ΣB, расхождением, суммой элиминации, статусом AUTO/DISCREPANCY/ONE_SIDED/MANUAL и osv_entry_ids; сводная ОСВ строится один раз; сбор строк сторон вынесен в _collect_rule_sides — общий с упаковкой тройки. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- Makefile | 2 +- README.md | 8 +- api/alembic/versions/0010_dbml_v5_23.py | 28 ++++ api/scripts/dbml_parity.py | 2 +- .../consolidations/elimination_rule.py | 17 ++- api/src/models/__init__.py | 4 +- api/src/models/consolidation.py | 2 +- api/src/repositories/elimination_rules.py | 30 +---- api/src/schemas/consolidations.py | 35 +++++ api/src/services/consolidations.py | 8 +- api/src/services/elimination_rules.py | 6 +- api/src/services/intercompany_matrix.py | 113 ++++++++++++++-- api/tests/factories.py | 1 + api/tests/test_api_consolidations.py | 17 +++ api/tests/test_api_intercompany_matrix.py | 56 +++++--- api/tests/test_check_dbml_script.py | 14 +- api/tests/test_elimination_rules.py | 50 +------ api/tests/test_enums.py | 4 +- api/tests/test_intercompany_matrix.py | 4 - api/tests/test_migrations.py | 127 +++++++++++++++++- api/tests/test_models_part_07_09.py | 1 + api/tests/test_openapi_routes.py | 1 + api/tests/test_repositories_consolidations.py | 1 + 24 files changed, 397 insertions(+), 136 deletions(-) create mode 100644 api/alembic/versions/0010_dbml_v5_23.py diff --git a/CLAUDE.md b/CLAUDE.md index f4ebad5..bd14a19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,7 +175,7 @@ - NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`. ## Правила репозитория -- ORM 1:1 с `../docs/model/data-model-v5.22.dbml` (текущая версия; v5.22 структуру не меняла; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19` + `0008_dbml_v5_20` + `0009_dbml_v5_21`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. +- ORM 1:1 с `../docs/model/data-model-v5.23.dbml` (текущая версия; v5.22 структуру не меняла; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19` + `0008_dbml_v5_20` + `0009_dbml_v5_21` + `0010_dbml_v5_23`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. - Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring. - Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц. - Фронт (`web/`) разрабатывается здесь (с 21.10.2026): фронтовые задачи — worktree arm-core и PR в `main`, как и diff --git a/Makefile b/Makefile index 8026686..9c0598a 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ VENV := api/.venv BIN := $(CURDIR)/$(VENV)/bin COMPOSE_FILE ?= ../docs/dev/docker-compose.yml # DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/ -DBML ?= ../../docs/model/data-model-v5.22.dbml +DBML ?= ../../docs/model/data-model-v5.23.dbml .PHONY: venv lint test test-integration e2e dbml-check migration-cycle check check-infra docker-build web-check statements-check check-full up migrate seed seed-dev diff --git a/README.md b/README.md index 9118a25..f04b359 100644 --- a/README.md +++ b/README.md @@ -1127,7 +1127,7 @@ email без учёта регистра проверяет только сер | `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен | | `make test-integration` | интеграционные тесты: `pytest -m "integration and not e2e"`, нужны PostgreSQL (и NATS) | | `make e2e` | сквозной прогон загрузки ОСВ: `pytest -m e2e`, нужны compose и tabular-parser из профиля `services` (без него — skip) | -| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.22.dbml` | +| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.23.dbml` | | `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate | | `make check` | быстрая проверка без Docker: `lint` + `test` | | `make docker-build` | сборка образа: `docker build -t arm-core .` | @@ -1179,7 +1179,7 @@ queue group `arm-core`, подписчики группы получают со ### Проверка ORM и DBML -Модель данных — `../docs/model/data-model-v5.22.dbml`. ORM обязана совпадать с ней один в один: +Модель данных — `../docs/model/data-model-v5.23.dbml`. ORM обязана совпадать с ней один в один: ```bash make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно @@ -1202,7 +1202,7 @@ make migration-cycle # проверить цикл upgr ``` api/ run_server.py точка входа (uvicorn; хост и порт — из настроек) - alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16, 0005_dbml_v5_17.py — v5.17, 0006_dbml_v5_18.py — v5.18, 0007_dbml_v5_19.py — v5.19, 0008_dbml_v5_20.py — v5.20, 0009_dbml_v5_21.py — v5.21) + alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16, 0005_dbml_v5_17.py — v5.17, 0006_dbml_v5_18.py — v5.18, 0007_dbml_v5_19.py — v5.19, 0008_dbml_v5_20.py — v5.20, 0009_dbml_v5_21.py — v5.21, 0010_dbml_v5_23.py — v5.23; v5.22 структуру не меняла) rules/ statement_lines.yaml — справочник имён строк форм ББ/ОФР по редакциям (словарь проверок §7 — в пакете arm_statements) seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист) seed/dev/ dev-данные: филиалы и пользователи по ролям @@ -1286,7 +1286,7 @@ Dockerfile только для локального compose - [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы; - [`../docs/context/03-architecture.md`](../docs/context/03-architecture.md) — архитектура, соглашения шаблона, NATS и S3; - [`../docs/context/04-data-model-guide.md`](../docs/context/04-data-model-guide.md) и - [`../docs/model/data-model-v5.22.dbml`](../docs/model/data-model-v5.22.dbml) — модель данных; + [`../docs/model/data-model-v5.23.dbml`](../docs/model/data-model-v5.23.dbml) — модель данных; - [`../docs/context/05-domain-rules.md`](../docs/context/05-domain-rules.md) — алгоритмы предметной области; - [`../docs/context/07-open-questions.md`](../docs/context/07-open-questions.md) — открытые вопросы; - [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов; diff --git a/api/alembic/versions/0010_dbml_v5_23.py b/api/alembic/versions/0010_dbml_v5_23.py new file mode 100644 index 0000000..ddb6d74 --- /dev/null +++ b/api/alembic/versions/0010_dbml_v5_23.py @@ -0,0 +1,28 @@ +"""DBML v5.23: consolidation_version.elimination_rules_version NOT NULL; пустые — по дате создания (§4, OQ-174).""" + +from alembic import op +from src.models.base import SCHEMA + +revision = "0010_dbml_v5_23" +down_revision = "0009_dbml_v5_21" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # действующая на дату создания (по Москве), иначе последняя открытая; без правил версия не заполнится — отказ ниже + created = "(COALESCE(v.created_at, now()) AT TIME ZONE 'Europe/Moscow')::date" + op.execute(f"""UPDATE {SCHEMA}.consolidation_version v SET elimination_rules_version = COALESCE( + (SELECT max(r.version_no) FROM {SCHEMA}.elimination_rule r + WHERE r.valid_from <= {created} AND (r.valid_to IS NULL OR r.valid_to >= {created})), + (SELECT max(r.version_no) FROM {SCHEMA}.elimination_rule r WHERE r.valid_to IS NULL)) + WHERE v.elimination_rules_version IS NULL""") + op.execute(f"""DO $$ DECLARE n bigint; BEGIN + SELECT count(*) INTO n FROM {SCHEMA}.consolidation_version WHERE elimination_rules_version IS NULL; + IF n > 0 THEN RAISE EXCEPTION 'миграция v5.23 невозможна: нет правил элиминации для версий консолидации: %', n; + END IF; END $$""") + op.alter_column("consolidation_version", "elimination_rules_version", nullable=False, schema=SCHEMA) + + +def downgrade() -> None: + op.alter_column("consolidation_version", "elimination_rules_version", nullable=True, schema=SCHEMA) diff --git a/api/scripts/dbml_parity.py b/api/scripts/dbml_parity.py index a758501..121e6ef 100644 --- a/api/scripts/dbml_parity.py +++ b/api/scripts/dbml_parity.py @@ -57,7 +57,7 @@ from sqlalchemy.sql.functions import FunctionElement #: Эталонная схема модели. Имя схемы в БД конфигурируемо (`DB_SCHEMA`), CLI сверяет ORM, загруженную с этим значением. SCHEMA = "dkb" -DBML_FILE_NAME = "data-model-v5.22.dbml" +DBML_FILE_NAME = "data-model-v5.23.dbml" ORM = "ORM" DBML = "DBML" ABSENT = "нет" diff --git a/api/src/api/endpoints/consolidations/elimination_rule.py b/api/src/api/endpoints/consolidations/elimination_rule.py index b1d3954..b0ae17a 100644 --- a/api/src/api/endpoints/consolidations/elimination_rule.py +++ b/api/src/api/endpoints/consolidations/elimination_rule.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, status from src.api.dependencies.auth import require from src.api.dependencies.consolidations import EliminationRuleServiceDep, IntercompanyMatrixServiceDep from src.schemas.common import ErrorDetail -from src.schemas.consolidations import EliminationPairsOut, EliminationRulesOut +from src.schemas.consolidations import EliminationPairsOut, EliminationRulesOut, EliminationTotalsOut router = APIRouter(prefix="/consolidations/versions") @@ -37,6 +37,21 @@ async def list_rules( return await service.list_rules(version_id) +@router.get( + "/{version_id}/elimination-totals", + response_model=EliminationTotalsOut, + dependencies=VIEW, + responses=PAIRS_RESPONSES, + summary="Пары ВГО версии: итоги всех троек", +) +async def build_elimination_totals( + version_id: uuid.UUID, + service: IntercompanyMatrixServiceDep, +) -> EliminationTotalsOut: + """Правила ББ × упорядоченные пары компаний периметра: суммы сторон, расхождение, элиминация, статус (§5).""" + return await service.build_totals(version_id) + + @router.get( "/{version_id}/elimination-pairs", response_model=EliminationPairsOut, diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index a05af08..c3b0164 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -1,4 +1,4 @@ -"""ORM-модели. Источник истины — docs/model/data-model-v5.22.dbml; заполняется в Срезе 0. +"""ORM-модели. Источник истины — docs/model/data-model-v5.23.dbml; заполняется в Срезе 0. Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic). """ @@ -7,7 +7,7 @@ from . import access, accounting, audit_ex, consolidation, directories, files, k from .base import Base, UUIDPrimaryMixin #: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm). -DBML_VERSION = "5.22" +DBML_VERSION = "5.23" __all__ = [ "DBML_VERSION", diff --git a/api/src/models/consolidation.py b/api/src/models/consolidation.py index 79c99a6..e19497c 100644 --- a/api/src/models/consolidation.py +++ b/api/src/models/consolidation.py @@ -67,7 +67,7 @@ class ConsolidationVersion(UUIDPrimaryMixin, Base): uob_decision_no: Mapped[str | None] = mapped_column(String) uob_decision_date: Mapped[datetime.date | None] = mapped_column(Date) methodology_deviations: Mapped[str | None] = mapped_column(Text) - elimination_rules_version: Mapped[int | None] = mapped_column(Integer) + elimination_rules_version: Mapped[int] = mapped_column(Integer, nullable=False) is_in_work: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) previous_version_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), diff --git a/api/src/repositories/elimination_rules.py b/api/src/repositories/elimination_rules.py index 06fa06d..3d060ce 100644 --- a/api/src/repositories/elimination_rules.py +++ b/api/src/repositories/elimination_rules.py @@ -11,21 +11,13 @@ from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRu async def list_rules_with_accounts( - session: AsyncSession, version_no: int | None + session: AsyncSession, version_no: int ) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]: - """Правила §4 заданной либо последней открытой версии.""" - condition = ( - EliminationRule.version_no == version_no - if version_no is not None - else ( - EliminationRule.valid_to.is_(None) - & (EliminationRule.version_no == _current_open_version_stmt().scalar_subquery()) - ) - ) + """Правила §4 заданной версии.""" stmt = ( select(EliminationRule, EliminationRuleAccount) .outerjoin(EliminationRuleAccount, EliminationRuleAccount.rule_id == EliminationRule.id) - .where(condition) + .where(EliminationRule.version_no == version_no) .order_by( EliminationRule.statement_type, EliminationRule.rule_no, @@ -35,16 +27,6 @@ async def list_rules_with_accounts( return [(rule, account) for rule, account in (await session.execute(stmt)).all()] -def _current_open_version_stmt(): - """Запрос номера последней открытой версии правил §4.""" - return select(func.max(EliminationRule.version_no)).where(EliminationRule.valid_to.is_(None)) - - -async def current_open_version_no(session: AsyncSession) -> int | None: - """Номер последней версии с открытыми правилами §4.""" - 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( @@ -55,9 +37,9 @@ async def latest_effective_version_no(session: AsyncSession, as_of: date) -> int ) -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) +def is_valid_for_version(rule: EliminationRule | None, version_no: int) -> bool: + """Правило §4 из зафиксированной версии правил версии консолидации.""" + return rule is not None and rule.version_no == version_no async def list_rules(session: AsyncSession, version_no: int) -> list[EliminationRule]: diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index b1b1d51..0ca3331 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -388,6 +388,41 @@ class EliminationPairsOut(BaseModel): unpaired: list[EliminationUnpairedRowOut] +EliminationTotalStatus = Literal["AUTO", "DISCREPANCY", "ONE_SIDED", "MANUAL"] + + +class EliminationTotalItemOut(BaseModel): + """Итог тройки (правило, компания A, компания B) в списке версии (§5 п. 3).""" + + rule_id: uuid.UUID + rule_no: int + elimination_mode: str + company_a_id: uuid.UUID + company_b_id: uuid.UUID + amount_a: Decimal + amount_b: Decimal + deviation_amount: Decimal + deviation_percent: Decimal | None + eliminated_amount: Decimal + exceeds_threshold: bool + status: EliminationTotalStatus + osv_entry_ids_a: list[uuid.UUID] + osv_entry_ids_b: list[uuid.UUID] + + @field_validator("amount_a", "amount_b", "deviation_amount", "deviation_percent", "eliminated_amount") + @classmethod + def _two_decimals(cls, value: Decimal | None) -> Decimal | None: + """Суммы и процент в JSON всегда с двумя знаками; неопределённый процент — null.""" + return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP) + + +class EliminationTotalsOut(BaseModel): + """Все тройки версии консолидации по правилам ББ зафиксированной версии правил.""" + + version_no: int + items: list[EliminationTotalItemOut] + + class IntercompanyMatrixManualIn(BaseModel): """Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки.""" diff --git a/api/src/services/consolidations.py b/api/src/services/consolidations.py index 19b0443..8df89ef 100644 --- a/api/src/services/consolidations.py +++ b/api/src/services/consolidations.py @@ -332,6 +332,10 @@ class ConsolidationService: """Записывает консолидацию, версию №1, периметр и первую запись истории; подхватывает документы.""" now = utc_now() user = self._principal.user + # версия правил §4 — действующая на дату создания (OQ-174), с v5.23 обязательна + rules_version = await elimination_rules_repo.latest_effective_version_no(self._session, moscow_date(now)) + if rules_version is None: + raise ConflictError("ELIMINATION_RULES_MISSING", "Нет правил элиминации, действующих на дату создания") consolidation = Consolidation( company_group_id=group_id, year=data.year, @@ -348,9 +352,7 @@ 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) - ), + elimination_rules_version=rules_version, initiator_id=user.id, initiator_office_id=user.regional_office_id, is_in_work=True, diff --git a/api/src/services/elimination_rules.py b/api/src/services/elimination_rules.py index a67171e..b5a00c8 100644 --- a/api/src/services/elimination_rules.py +++ b/api/src/services/elimination_rules.py @@ -26,11 +26,9 @@ class EliminationRuleService: if found is None: raise NotFoundError(VERSION_NOT_FOUND) version, _ = found - is_version_fixed = version.elimination_rules_version is not None + # с v5.23 версия правил фиксируется при создании версии консолидации и не бывает пустой version_no = version.elimination_rules_version rows = await rules_repo.list_rules_with_accounts(self._session, version_no) - if not is_version_fixed and rows: - version_no = rows[0][0].version_no by_id: dict[uuid.UUID, EliminationRuleOut] = {} for rule, account in rows: result = by_id.get(rule.id) @@ -56,6 +54,6 @@ class EliminationRuleService: return EliminationRulesOut( version_no=version_no, - is_version_fixed=is_version_fixed, + is_version_fixed=True, rules=list(by_id.values()), ) diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index edeb7bf..6ae05fd 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -31,6 +31,9 @@ from src.schemas.consolidations import ( EliminationMatchedPairOut, EliminationPairRowOut, EliminationPairsOut, + EliminationTotalItemOut, + EliminationTotalsOut, + EliminationTotalStatus, EliminationUnpairedRowOut, IntercompanyMatrixCellOut, IntercompanyMatrixCompanyOut, @@ -402,12 +405,23 @@ def _matched_pair_out( ) -def _pack_preset_rows( - rows: Sequence[ConsolidatedRow], - selection: MatrixSelection, - context: PairContext, -) -> RulePacking: - """Отобрать встречные строки одной направленной пары компаний и одного правила.""" +@dataclass(frozen=True) +class RuleSides: + """Встречные строки тройки: исходные строки свода и строки сторон для сумм и упаковки.""" + + sources: dict[RowKey, ConsolidatedRow] + debit_rows: list[PackRow] + credit_rows: list[PackRow] + + def a_b(self, rule: EliminationRule) -> tuple[list[PackRow], list[PackRow]]: + """Строки стороны A (side_1 правила) и стороны B.""" + if rule.side_1 == "D": + return self.debit_rows, self.credit_rows + return self.credit_rows, self.debit_rows + + +def _collect_rule_sides(rows: Sequence[ConsolidatedRow], selection: MatrixSelection, context: PairContext) -> RuleSides: + """Отобрать встречные строки одной направленной пары компаний и одного правила (§5).""" rule = context.rule company_a_id = context.company_a_id company_b_id = context.company_b_id @@ -439,12 +453,73 @@ def _pack_preset_rows( 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)) + return RuleSides(sources, debit_rows, credit_rows) - rows_a = debit_rows if rule.side_1 == "D" else credit_rows - rows_b = credit_rows if rule.side_1 == "D" else debit_rows + +def _rule_totals(sides: RuleSides, rule: EliminationRule) -> RuleTotals: + """Итог тройки по строкам сторон: ΣA, ΣB, расхождение и сумма элиминации (§5 п. 1).""" + rows_a, rows_b = sides.a_b(rule) 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") + return calculate_rule_totals(amount_a, amount_b, manual=rule.elimination_mode == "manual") + + +def _total_status(totals: RuleTotals, rule: EliminationRule) -> EliminationTotalStatus: + """Статус тройки в списке версии (§5 п. 3).""" + if rule.elimination_mode == "manual": + return "MANUAL" + if ZERO in (totals.amount_a, totals.amount_b): + return "ONE_SIDED" + return "DISCREPANCY" if totals.exceeds_threshold else "AUTO" + + +def calculate_rule_total_items( + rows: Sequence[ConsolidatedRow], + rules: Sequence[tuple[EliminationRule, MatrixSelection]], + company_ids: Sequence[uuid.UUID], +) -> list[EliminationTotalItemOut]: + """Итоги всех троек версии: правило × упорядоченная пара компаний; тройки без строк пропускаются (§5 п. 3).""" + items: list[EliminationTotalItemOut] = [] + for rule, selection in rules: + for company_a_id in company_ids: + for company_b_id in company_ids: + if company_a_id == company_b_id: + continue + sides = _collect_rule_sides(rows, selection, PairContext(rule, company_a_id, company_b_id)) + if not sides.debit_rows and not sides.credit_rows: + continue + totals = _rule_totals(sides, rule) + rows_a, rows_b = sides.a_b(rule) + items.append( + EliminationTotalItemOut( + rule_id=rule.id, + rule_no=rule.rule_no, + elimination_mode=rule.elimination_mode, + company_a_id=company_a_id, + company_b_id=company_b_id, + amount_a=totals.amount_a, + amount_b=totals.amount_b, + deviation_amount=totals.deviation_amount, + deviation_percent=totals.deviation_percent, + eliminated_amount=totals.eliminated_amount, + exceeds_threshold=totals.exceeds_threshold, + status=_total_status(totals, rule), + 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], + ) + ) + return items + + +def _pack_preset_rows( + rows: Sequence[ConsolidatedRow], + selection: MatrixSelection, + context: PairContext, +) -> RulePacking: + """Итог тройки и упаковка её встречных строк (§5 п. 1–2).""" + sides = _collect_rule_sides(rows, selection, context) + sources, debit_rows, credit_rows = sides.sources, sides.debit_rows, sides.credit_rows + totals = _rule_totals(sides, context.rule) packed = pack_rule_rows(debit_rows, credit_rows) matched: list[EliminationMatchedPairOut] = [] with_discrepancy: list[EliminationMatchedPairOut] = [] @@ -511,6 +586,22 @@ class IntercompanyMatrixService: unpaired=packing.unpaired, ) + async def build_totals(self, version_id: uuid.UUID) -> EliminationTotalsOut: + """Итоги всех троек версии по правилам ББ её версии правил; сводная ОСВ строится один раз (§5 п. 3).""" + osv_result = await self._osv_service.build_with_members(version_id) + version = osv_result.version + accounts = await directory_repo.list_accounts(self._session) + by_id, children = _make_account_tree(accounts) + rules: list[tuple[EliminationRule, MatrixSelection]] = [] + for rule in await elimination_rules_repo.list_rules(self._session, version.elimination_rules_version): + # пары §5 — только правила ББ по сальдо; правила ОФР ещё не формализованы (§4) + if rule.statement_type != StatementType.balance or rule.measure != "closing": + continue + selection, _ = await self._resolve_preset_selection(rule.id, version, by_id, children) + rules.append((rule, selection)) + items = calculate_rule_total_items(osv_result.consolidated.rows, rules, list(osv_result.members)) + return EliminationTotalsOut(version_no=version.elimination_rules_version, items=items) + async def build( self, version_id: uuid.UUID, @@ -637,10 +728,6 @@ class IntercompanyMatrixService: rule, rule_accounts = await elimination_rules_repo.rule_with_account(self._session, rule_id) if rule is None or not elimination_rules_repo.is_valid_for_version(rule, version.elimination_rules_version): raise NotFoundError("правило-пресет не найдено для версии консолидации") - if version.elimination_rules_version is None: - open_version_no = await elimination_rules_repo.current_open_version_no(self._session) - if rule.version_no != open_version_no: - raise NotFoundError("правило-пресет не найдено для версии консолидации") if rule.statement_type != StatementType.balance: raise DomainValidationError("INVALID_RULE", "Шахматка PRESET поддерживает только правила ББ") if rule.side_1 not in ("D", "K") or rule.measure not in ("closing", "turnover"): diff --git a/api/tests/factories.py b/api/tests/factories.py index 35acd32..b157cd2 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -415,6 +415,7 @@ async def make_version( # noqa: PLR0913 - сигнатура фабрики ф scenario=ConsolidationScenario.CREATE, approach=ConsolidationApproach.BASIC, goodwill_enabled=False, + elimination_rules_version=1, # seed правил §4 — версия v1; с DBML v5.23 поле обязательно initiator_id=initiator.id, initiator_office_id=initiator.regional_office_id, is_in_work=status not in FINISHED_STATUSES, diff --git a/api/tests/test_api_consolidations.py b/api/tests/test_api_consolidations.py index 8387da6..8c20242 100644 --- a/api/tests/test_api_consolidations.py +++ b/api/tests/test_api_consolidations.py @@ -166,6 +166,23 @@ async def test_create_consolidation_rb_q3_2024(auth_client: AuthClientFactory, s } +@pytest.mark.integration +async def test_create_without_effective_rules_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """DBML v5.23: версия правил обязательна — без правил, действующих на дату создания, консолидация не создаётся.""" + group, gsk, _ = await _scenario(seeded) + tomorrow = moscow_date(utc_now()) + timedelta(days=1) + for rule in (await seeded.scalars(select(EliminationRule))).all(): + rule.valid_from = tomorrow + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.post(CONSOLIDATIONS, json=_body(group, gsk, year=2024)) + + assert response.status_code == HTTP_CONFLICT, response.text + assert response.json()["detail"]["code"] == "ELIMINATION_RULES_MISSING" + assert await _count(seeded, Consolidation) == 0 + + @pytest.mark.integration async def test_create_fixes_rules_for_creation_date_not_reporting_period( auth_client: AuthClientFactory, seeded: AsyncSession diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index e551f7b..58bdd4b 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -202,18 +202,17 @@ async def test_preset_rejects_income_rule(auth_client: AuthClientFactory, seeded assert response.json()["detail"]["code"] == "INVALID_RULE" -async def test_preset_uses_current_rules_when_version_unfixed( - auth_client: AuthClientFactory, seeded: AsyncSession -) -> None: +async def test_preset_uses_fixed_rules_version(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: world = await _world(seeded) rule = ( await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO)) ).one() + assert world.version.elimination_rules_version == rule.version_no client = await auth_client(KA_RF) url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}" listed = await client.get(f"/consolidations/versions/{world.version.id}/elimination-rules") assert listed.status_code == OK, listed.text - assert listed.json()["is_version_fixed"] is False + assert listed.json()["is_version_fixed"] is True 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"]} @@ -226,36 +225,31 @@ async def test_preset_uses_current_rules_when_version_unfixed( }, ) 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 audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one() assert audit.after_value["rule_no"] == rule.rule_no assert audit.after_value["rule_version_no"] == rule.version_no - assert world.version.elimination_rules_version is None + assert world.version.elimination_rules_version == rule.version_no # GET номер правил не меняет world.version.elimination_rules_version = rule.version_no + 1 await seeded.flush() assert (await client.get(url)).status_code == NOT_FOUND assert (await client.get(f"{url}/export")).status_code == NOT_FOUND world.version.elimination_rules_version = rule.version_no await seeded.flush() - fixed = await client.get(f"/consolidations/versions/{world.version.id}/elimination-rules") - assert fixed.status_code == OK, fixed.text - assert fixed.json()["is_version_fixed"] is True - assert fixed.json()["version_no"] == rule.version_no missing_url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{uuid.uuid4()}" missing = await client.get(missing_url) assert missing.status_code == NOT_FOUND assert (await client.get(f"{missing_url}/export")).status_code == NOT_FOUND -async def test_unfixed_rules_choose_latest_open_version(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: +async def test_fixed_rules_version_ignores_newer_rules(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Новая версия правил после создания версии консолидации на неё не влияет (§4, DBML v5.23).""" world = await _world(seeded) old_rule, new_rule = ( await seeded.scalars(select(EliminationRule).order_by(EliminationRule.rule_no).limit(2)) ).all() - assert old_rule.valid_to is None and new_rule.valid_to is None new_rule.rule_no = old_rule.rule_no new_rule.version_no = old_rule.version_no + 1 await seeded.flush() @@ -265,13 +259,13 @@ async def test_unfixed_rules_choose_latest_open_version(auth_client: AuthClientF response = await client.get(f"{base}/elimination-rules") assert response.status_code == OK, response.text payload = response.json() - assert payload["is_version_fixed"] is False - assert payload["version_no"] == new_rule.version_no - assert [item["id"] for item in payload["rules"]] == [str(new_rule.id)] - assert payload["rules"][0]["rule_no"] == old_rule.rule_no + assert payload["is_version_fixed"] is True + assert payload["version_no"] == old_rule.version_no + assert str(new_rule.id) not in {item["id"] for item in payload["rules"]} + assert str(old_rule.id) in {item["id"] for item in payload["rules"]} - old_preset = await client.get(f"{base}/intercompany-matrix/preset/{old_rule.id}") - assert old_preset.status_code == NOT_FOUND + new_preset = await client.get(f"{base}/intercompany-matrix/preset/{new_rule.id}") + assert new_preset.status_code == NOT_FOUND async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915 @@ -522,6 +516,32 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915 missing = await client.get(f"/consolidations/versions/{uuid.uuid4()}/elimination-pairs", params=params) assert missing.status_code == NOT_FOUND + # список версии — те же итоги, что у тройки, одним запросом (§5 п. 3) + totals = await client.get(f"/consolidations/versions/{world.version.id}/elimination-totals") + assert totals.status_code == OK, totals.text + assert totals.json()["version_no"] == 1 + by_triple = {(item["rule_no"], item["company_a_id"], item["company_b_id"]): item for item in totals.json()["items"]} + a, b, c = str(company_a_id), str(company_b_id), str(company_c_id) + main = by_triple[(6, a, b)] + assert (main["amount_a"], main["amount_b"], main["deviation_amount"]) == ("300.00", "100.00", "200.00") + assert (main["deviation_percent"], main["eliminated_amount"]) == ("200.00", "0.00") + assert (main["status"], main["exceeds_threshold"], main["elimination_mode"]) == ("DISCREPANCY", True, "min") + # у A по B три ненулевые строки: 62.01 40 и 200, 60.02 60; строка 0 в сумму не входит + assert (len(main["osv_entry_ids_a"]), len(main["osv_entry_ids_b"])) == (len(("40", "200", "60")), 1) + mirrored = by_triple[(6, b, a)] + assert (mirrored["status"], mirrored["eliminated_amount"]) == ("AUTO", "70.00") + assert by_triple[(6, a, c)]["status"] == "DISCREPANCY" + assert (by_triple[(1, a, b)]["status"], by_triple[(1, a, b)]["eliminated_amount"]) == ("MANUAL", "0.00") + # дебиторка № 6 входит и в № 7 (займы 67), встречной стороны нет — двойной учёт до AURORA-1751 + one_sided = by_triple[(7, a, b)] + assert (one_sided["status"], one_sided["amount_b"], one_sided["deviation_percent"]) == ("ONE_SIDED", "0.00", None) + assert (3, a, b) not in by_triple # строк по счетам правила нет — тройки нет + assert [item["rule_no"] for item in totals.json()["items"]] == sorted( + item["rule_no"] for item in totals.json()["items"] + ) + missing_totals = await client.get(f"/consolidations/versions/{uuid.uuid4()}/elimination-totals") + assert missing_totals.status_code == NOT_FOUND + async def test_all_excludes_off_balance_accounts_but_manual_can_select_them( auth_client: AuthClientFactory, seeded: AsyncSession diff --git a/api/tests/test_check_dbml_script.py b/api/tests/test_check_dbml_script.py index 126ece8..9f1cfa5 100644 --- a/api/tests/test_check_dbml_script.py +++ b/api/tests/test_check_dbml_script.py @@ -140,18 +140,18 @@ def test_enum_values_and_sets_are_compared(dbml_file: Path) -> None: def test_newer_dbml_version_fails_before_comparing_tables(tmp_path: Path, dbml_file: Path) -> None: lines = dbml_file.read_text(encoding="utf-8").splitlines() - newer = tmp_path / "data-model-v5.23.dbml" - # шапка 5.22 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора - newer.write_text(lines[0].replace("v5.22", "v5.23") + "\nTable broken {{{\n", encoding="utf-8") - assert run_check(newer) == (1, ["версия DBML 5.23 не совпадает с версией ORM 5.22: обновите модели"]) + newer = tmp_path / "data-model-v5.24.dbml" + # шапка 5.23 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора + newer.write_text(lines[0].replace("v5.23", "v5.24") + "\nTable broken {{{\n", encoding="utf-8") + assert run_check(newer) == (1, ["версия DBML 5.24 не совпадает с версией ORM 5.23: обновите модели"]) def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None: - newer = tmp_path / "data-model-v5.23.dbml" - newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.22", "v5.23", 1), encoding="utf-8") + newer = tmp_path / "data-model-v5.24.dbml" + newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.23", "v5.24", 1), encoding="utf-8") result = _run_cli(str(newer)) assert result.returncode == 1 - assert result.stdout == "версия DBML 5.23 не совпадает с версией ORM 5.22: обновите модели\n" + assert result.stdout == "версия DBML 5.24 не совпадает с версией ORM 5.23: обновите модели\n" def test_header_without_version_is_usage_error(tmp_path: Path) -> None: diff --git a/api/tests/test_elimination_rules.py b/api/tests/test_elimination_rules.py index fd5615e..b73722a 100644 --- a/api/tests/test_elimination_rules.py +++ b/api/tests/test_elimination_rules.py @@ -26,27 +26,19 @@ def _rule() -> SimpleNamespace: ("rule", "version_no", "expected"), [ (None, 3, False), - (SimpleNamespace(version_no=3, valid_to=None), None, True), - (SimpleNamespace(version_no=3, valid_to="2026-12-31"), None, False), (SimpleNamespace(version_no=3), 2, False), (SimpleNamespace(version_no=3), 3, True), (SimpleNamespace(version_no=3, valid_to="2026-12-31"), 3, True), ], ) -def test_rule_is_valid_for_version(rule: SimpleNamespace | None, version_no: int | None, expected: bool) -> None: +def test_rule_is_valid_for_version(rule: SimpleNamespace | None, version_no: int, expected: bool) -> None: assert rules_repo.is_valid_for_version(rule, version_no) is expected -async def test_rules_filter_by_version_or_open_valid_to() -> None: +async def test_rules_filter_by_fixed_version() -> None: version_no = 3 session = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(all=lambda: []))) - await rules_repo.list_rules_with_accounts(session, None) - query = str(session.execute.await_args.args[0]) - assert "valid_to IS NULL" in query - assert "max(" in query - assert "version_no = (SELECT" in query - await rules_repo.list_rules_with_accounts(session, version_no) assert "valid_to IS NULL" not in str(session.execute.await_args.args[0]) assert "version_no =" in str(session.execute.await_args.args[0]) @@ -73,41 +65,3 @@ async def test_fixed_rules_use_version_number(monkeypatch: pytest.MonkeyPatch) - assert [item.id for item in result.rules] == [rule.id] assert result.rules[0].company_1.accounts == [account_id] listed.assert_awaited_once_with(session, rule.version_no) - - -async def test_unfixed_rules_use_open_valid_to(monkeypatch: pytest.MonkeyPatch) -> None: - session, scope = object(), object() - version_id = uuid.uuid4() - rule = _rule() - monkeypatch.setattr( - service_module.consolidations_repo, - "get_version", - AsyncMock(return_value=(SimpleNamespace(elimination_rules_version=None), None)), - ) - listed = AsyncMock(return_value=[(rule, None)]) - monkeypatch.setattr(service_module.rules_repo, "list_rules_with_accounts", listed) - - result = await service_module.EliminationRuleService(session, scope).list_rules(version_id) - - assert result.is_version_fixed is False - assert result.version_no == rule.version_no - assert [item.id for item in result.rules] == [rule.id] - listed.assert_awaited_once_with(session, None) - - -async def test_no_current_rules_returns_empty_with_unfixed_flag(monkeypatch: pytest.MonkeyPatch) -> None: - session, scope = object(), object() - monkeypatch.setattr( - service_module.consolidations_repo, - "get_version", - AsyncMock(return_value=(SimpleNamespace(elimination_rules_version=None), None)), - ) - listed = AsyncMock(return_value=[]) - monkeypatch.setattr(service_module.rules_repo, "list_rules_with_accounts", listed) - - result = await service_module.EliminationRuleService(session, scope).list_rules(uuid.uuid4()) - - assert result.is_version_fixed is False - assert result.version_no is None - assert result.rules == [] - listed.assert_awaited_once_with(session, None) diff --git a/api/tests/test_enums.py b/api/tests/test_enums.py index cce314c..5fd2975 100644 --- a/api/tests/test_enums.py +++ b/api/tests/test_enums.py @@ -1,4 +1,4 @@ -"""Enum'ы ORM против блоков `Enum {}` DBML v5.22: имена, значения и их порядок (без БД).""" +"""Enum'ы ORM против блоков `Enum {}` DBML v5.23: имена, значения и их порядок (без БД).""" import enum import re @@ -15,7 +15,7 @@ EXPECTED_ENUM_COUNT = 33 def test_dbml_version_format() -> None: assert re.fullmatch(r"\d+\.\d+", DBML_VERSION) - assert DBML_VERSION == "5.22" + assert DBML_VERSION == "5.23" assert f"v{DBML_VERSION}" in dbml_path().name diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index cb196a9..b3666ff 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -136,10 +136,6 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey assert captured[0].company_2_account_ids == {parent.id, child.id} assert captured[0].measure == "turnover" - version.elimination_rules_version = None - monkeypatch.setattr( - matrix.elimination_rules_repo, "current_open_version_no", AsyncMock(return_value=rule.version_no) - ) _, info = await service.build_export(uuid.uuid4(), mode="PRESET", rule_id=uuid.uuid4()) assert info.mode == "PRESET" assert info.debit_account_codes == ("76",) diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 897bcf8..19a8c30 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -39,7 +39,8 @@ from tests.test_models_part_07_09 import ( V5_19 = "0007_dbml_v5_19" V5_20 = "0008_dbml_v5_20" -HEAD = "0009_dbml_v5_21" +V5_21 = "0009_dbml_v5_21" +HEAD = "0010_dbml_v5_23" V5_18 = "0006_dbml_v5_18" V5_17 = "0005_dbml_v5_17" V5_16 = "0004_dbml_v5_16" @@ -586,6 +587,9 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N ), ids, ) + await _insert_rule( + conn, version_no=1, valid_from=datetime.date(2020, 1, 1) + ) # v5.23: версиям нужна версия правил await alembic(scratch_db_url, "upgrade", "head") async with engine.begin() as conn: @@ -1299,7 +1303,8 @@ def test_offline_sql_creates_schema_before_version_table() -> None: (V5_18, V5_17), (V5_19, V5_18), (V5_20, V5_19), - (HEAD, V5_20), + (V5_21, V5_20), + (HEAD, V5_21), ): assert ( f"UPDATE dkb.alembic_version SET version_num='{new_version}' " @@ -1366,3 +1371,121 @@ async def test_v5_20_downgrade_refuses_modified_document(scratch_db_url: str) -> assert await conn.scalar(text("SELECT count(*) FROM dkb.document WHERE is_modified")) finally: await engine.dispose() + + +async def _insert_rule( + conn: AsyncConnection, *, version_no: int, valid_from: datetime.date, valid_to: datetime.date | None = None +) -> None: + await conn.execute( + text( + "INSERT INTO dkb.elimination_rule (id, statement_type, rule_no, article_1_line, article_2_line, side_1, " + "measure, elimination_mode, excise_adjust, version_no, valid_from, valid_to) " + "VALUES (gen_random_uuid(), 'balance', 6, '1230', '1520', 'D', 'closing', 'min', false, :no, " + ":start, :end)" + ), + {"no": version_no, "start": valid_from, "end": valid_to}, + ) + + +async def _insert_versions(conn: AsyncConnection, created: dict[str, datetime.datetime | None]) -> dict[str, uuid.UUID]: + """Версии консолидации v5.21 без номера правил; ключ — метка, значение — `created_at` (None — пусто).""" + ids = {k: uuid.uuid4() for k in ("office", "group", "user", "c")} + await conn.execute(text("INSERT INTO dkb.regional_office (id, name) VALUES (:office, 'ГО')"), ids) + await conn.execute( + text( + "INSERT INTO dkb.company_group (id, code, name, base_regional_office_id, valid_from) " + "VALUES (:group, 'G-1', 'ГК', :office, now())" + ), + ids, + ) + await conn.execute( + text( + "INSERT INTO dkb.app_user (id, email, full_name, regional_office_id) VALUES (:user, 'm@x.ru', 'Т', :office)" + ), + ids, + ) + await conn.execute( + text( + "INSERT INTO dkb.consolidation (id, company_group_id, year, quarter, reporting_date) " + "VALUES (:c, :group, 2026, 'Q1', '2026-04-01')" + ), + ids, + ) + versions: dict[str, uuid.UUID] = {} + for no, (label, created_at) in enumerate(created.items(), start=1): + versions[label] = uuid.uuid4() + await conn.execute( + text( + "INSERT INTO dkb.consolidation_version (id, consolidation_id, version_no, status, scenario, approach, " + "goodwill_enabled, initiator_id, initiator_office_id, is_in_work, created_at) " + "VALUES (:id, :c, :no, 'INITIATED', 'CREATE', 'BASIC', false, :user, :office, true, " + ":created)" + ), + ids | {"id": versions[label], "no": no, "created": created_at}, + ) + return versions + + +@pytest.mark.integration +async def test_upgrade_0010_fills_rules_version_by_creation_date(scratch_db_url: str) -> None: + """v5.23: номер правил — действующих на дату создания версии (по Москве), без даты — на сегодня; затем NOT NULL.""" + engine = create_async_engine(scratch_db_url) + try: + await alembic(scratch_db_url, "upgrade", V5_21) + async with engine.begin() as conn: + await _insert_rule( + conn, version_no=1, valid_from=datetime.date(2020, 1, 1), valid_to=datetime.date(2026, 6, 30) + ) + await _insert_rule(conn, version_no=2, valid_from=datetime.date(2026, 7, 1)) + versions = await _insert_versions( + conn, + { + "spring": datetime.datetime(2026, 3, 1, 9, tzinfo=datetime.UTC), + # 30.06 21:30 UTC — уже 01.07 по Москве + "edge": datetime.datetime(2026, 6, 30, 21, 30, tzinfo=datetime.UTC), + "undated": None, + }, + ) + await alembic(scratch_db_url, "upgrade", "head") + async with engine.connect() as conn: + rows = dict( + (await conn.execute(text("SELECT id, elimination_rules_version FROM dkb.consolidation_version"))).all() + ) + assert rows == {versions["spring"]: 1, versions["edge"]: 2, versions["undated"]: 2} + nullable = await _scalars( + conn, + "SELECT is_nullable FROM information_schema.columns WHERE table_schema='dkb' " + "AND table_name='consolidation_version' AND column_name='elimination_rules_version'", + ) + assert nullable == ["NO"] + await alembic(scratch_db_url, "downgrade", V5_21) + async with engine.connect() as conn: + nullable = await _scalars( + conn, + "SELECT is_nullable FROM information_schema.columns WHERE table_schema='dkb' " + "AND table_name='consolidation_version' AND column_name='elimination_rules_version'", + ) + assert nullable == ["YES"] + assert ( + await conn.scalar( + text("SELECT count(*) FROM dkb.consolidation_version WHERE elimination_rules_version IS NULL") + ) + == 0 + ) + finally: + await engine.dispose() + + +@pytest.mark.integration +async def test_upgrade_0010_refuses_versions_without_rules(scratch_db_url: str) -> None: + engine = create_async_engine(scratch_db_url) + try: + await alembic(scratch_db_url, "upgrade", V5_21) + async with engine.begin() as conn: + await _insert_versions(conn, {"lonely": datetime.datetime(2026, 3, 1, 9, tzinfo=datetime.UTC)}) + with pytest.raises(AssertionError, match="миграция v5.23 невозможна"): + await alembic(scratch_db_url, "upgrade", "head") + async with engine.connect() as conn: + assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [V5_21] + finally: + await engine.dispose() diff --git a/api/tests/test_models_part_07_09.py b/api/tests/test_models_part_07_09.py index 775d9dd..2666d52 100644 --- a/api/tests/test_models_part_07_09.py +++ b/api/tests/test_models_part_07_09.py @@ -210,6 +210,7 @@ async def _check_active_lock_is_unique(session: AsyncSession) -> None: version_no=1, status=ConsolidationStatus.INITIATED, scenario=ConsolidationScenario.CREATE, + elimination_rules_version=1, initiator_id=user.id, initiator_office_id=office.id, ) diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 7ec8a95..75c5255 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -79,6 +79,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = { # шахматка ВГО и правила элиминации "/api/v1/consolidations/versions/{version_id}/elimination-rules": {"get"}, "/api/v1/consolidations/versions/{version_id}/elimination-pairs": {"get"}, + "/api/v1/consolidations/versions/{version_id}/elimination-totals": {"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"}, diff --git a/api/tests/test_repositories_consolidations.py b/api/tests/test_repositories_consolidations.py index 600cb18..0f0b8b9 100644 --- a/api/tests/test_repositories_consolidations.py +++ b/api/tests/test_repositories_consolidations.py @@ -73,6 +73,7 @@ async def test_list_periods_sorts_periods_and_versions_descending(db_session: As version_no=2, status=ConsolidationStatus.INITIATED, scenario=first.scenario, + elimination_rules_version=first.elimination_rules_version, initiator_id=user.id, initiator_office_id=user.regional_office_id, ) -- 2.47.2 From fdb1f179cc42a609cb22690dd47d71e85f762f23 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 18:44:31 +0400 Subject: [PATCH 6/7] =?UTF-8?q?docs(openspec):=20elimination-pairs-rule-to?= =?UTF-8?q?tals=20=E2=80=94=20=D0=B4=D0=B5=D0=BB=D1=8C=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE=D0=B9?= =?UTF-8?q?=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B8,=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F,=20README=20(=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= =?UTF-8?q?=20#175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Основная спека consolidations-api возвращена к main: фиксация версии правил, пары тройки и список версии — в дельте change (MODIFIED «Создание консолидации», ADDED «Пары ВГО тройки», «Пары ВГО версии», «Правила элиминации зафиксированной версии», REMOVED старое требование правил); data-model — DBML 5.23 и миграция 0010. design.md — решения владельца 23.10 и «Decisions made without discussion» R-1…R-6. Co-Authored-By: Claude Opus 5.5 --- README.md | 18 +- .../elimination-pairs-rule-totals/README.md | 3 +- .../elimination-pairs-rule-totals/design.md | 35 +++- .../elimination-pairs-rule-totals/proposal.md | 35 ++-- .../specs/consolidations-api/spec.md | 169 ++++++++++++++++-- .../specs/data-model/spec.md | 26 +++ .../elimination-pairs-rule-totals/tasks.md | 15 +- openspec/specs/consolidations-api/spec.md | 62 +------ 8 files changed, 263 insertions(+), 100 deletions(-) create mode 100644 openspec/changes/elimination-pairs-rule-totals/specs/data-model/spec.md diff --git a/README.md b/README.md index f04b359..455084c 100644 --- a/README.md +++ b/README.md @@ -940,8 +940,9 @@ API: список счетов `GET /directories/accounts`, правила ве `GET …/intercompany-matrix/preset/{rule_id}` и его выгрузка `…/preset/{rule_id}/export`. Режим ALL — только балансовые счета (без 001–012), MANUAL принимает и забалансовые. `one_sided` — ровно одна сумма пары нулевая; пара подсвечивается (и в xlsx) при `exceeds_threshold` или `one_sided` (AURORA-1651). -`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): при незаданной -версии правил — открытые правила (`valid_to IS NULL`) с максимальным `version_no`; ответ сообщает этот номер. Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`, +`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): правила версии +`consolidation_version.elimination_rules_version` — она фиксируется при создании версии консолидации (действующая на дату +создания, OQ-174) и с DBML v5.23 есть всегда, `is_version_fixed = true`. Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`, `ACCOUNTS_REQUIRED`; правило недействительное для версии или несуществующее — 404. Имя XLSX пресета оканчивается на `_rule-balance-.xlsx`; в шапке — номер, статьи и версия правила, строка «Показатель», audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`), для PRESET — `rule_id`, `rule_no` и фактический @@ -951,6 +952,17 @@ audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`), для PRESET — `r `openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`. Расчёт читает сводную ОСВ версии, результат не хранится. +**Пары ВГО §5 (AURORA-1536, PR #175).** `GET …/elimination-pairs?rule_id=&company_a_id=&company_b_id=` — итог тройки +(ΣA, ΣB со знаком, расхождение, процент, `eliminated_amount` по правилу 5 %) и упаковка строк 1:1 / 1:N / N:1 +(`matched`, `with_discrepancy`, `unpaired` с причиной); `GET …/elimination-totals` — итоги всех троек версии (правила ББ × +упорядоченные пары периметра) со статусом `AUTO` / `DISCREPANCY` / `ONE_SIDED` / `MANUAL`, сводная ОСВ строится один раз. +Каскада по остатку нет: строка общего счёта входит в несколько правил до разнесения (AURORA-1751). Ошибки: 404 (версия, +правило не из версии правил), 422 `INVALID_COMPANY_PAIR`, `INVALID_RULE`, `ACCOUNTS_REQUIRED`. Решения — +`openspec/changes/elimination-pairs-rule-totals/design.md`; контракт — `../docs/dev/frontend-api-guide.md` §5.7.2. +Поставка DBML v5.23: миграция `0010_dbml_v5_23` проставляет пустой `elimination_rules_version` по дате создания версии и +делает колонку NOT NULL; нет правил вовсе — миграция падает до изменения схемы, поэтому seed правил должен быть применён +раньше. Откат снимает только NOT NULL. + ## Ошибки API Доменные ошибки (`src/common/exceptions.py`, обработчик — `src/core/exception_handlers.py`) отдаются объектом: @@ -1031,6 +1043,7 @@ email без учёта регистра проверяет только сер | `INVALID_IDS` | 422 | `GET /users/brief`: элемент `ids` — не UUID | | `FILE_SUPERSEDES_OTHERS` | 409 | удаление файла, на который ссылаются `superseded_by_file_id` других файлов | | `CONSOLIDATION_EXISTS` | 409 | консолидация этой ГК за год и квартал уже создана (в т. ч. гонка двух `POST /consolidations`) | +| `ELIMINATION_RULES_MISSING` | 409 | `POST /consolidations`: нет правил элиминации, действующих на дату создания (DBML v5.23: версия правил обязательна) | | `PERIMETER_TOO_SMALL` | 422 | в ГСК (при создании без списка), в выбранных ЮЛ (`legal_entity_ids` при создании) или в новом периметре (`PUT …/members`) меньше двух ЮЛ; ничего не создаётся и не меняется | | `RELATED_GROUP_MISMATCH` | 422 | ГСК принадлежит другой ГК или имеет вид `gsz` (невидимая/неизвестная ГСК — 404 `NOT_FOUND`) | | `ENTITY_NOT_IN_GROUP` | 422 | `POST /consolidations` с `legal_entity_ids` и `PUT …/members`: ЮЛ не из ГК консолидации, без ГК или несуществующее (`legal_entity_ids` — перечень); проверка раньше `PERIMETER_TOO_SMALL` | @@ -1041,6 +1054,7 @@ email без учёта регистра проверяет только сер | `ACCOUNT_KIND_MISMATCH` | 422 | MANUAL шахматки или её экспорта: вид счёта не подходит дебетовой/кредитовой стороне | | `ACCOUNTS_REQUIRED` | 422 | сервис шахматки: не выбран дебетовый или кредитовый набор; через HTTP пустые массивы отклоняются раньше валидацией тела | | `RESULT_ACCOUNTS_MISSING` | 422 | результирующая таблица ВГО: в плане счетов нет обязательных кодов статей | +| `INVALID_COMPANY_PAIR` | 422 | пары ВГО тройки: компании совпадают или не входят в периметр версии | | `ACCOUNTS_NOT_ALLOWED` / `INVALID_MODE` | 422 | защита внутреннего вызова сервиса шахматки; маршруты ALL/MANUAL не дают вызвать эти ветки | | `COMMENT_REQUIRED` | 422 | `POST …/status` в `CANCELLED` без комментария (из одних пробелов — тоже) | | `VERSION_NOT_EDITABLE` | 409 | версия не «в работе» (правки) либо `CANCELLED` при «взять в работу» | diff --git a/openspec/changes/elimination-pairs-rule-totals/README.md b/openspec/changes/elimination-pairs-rule-totals/README.md index cfc19ce..46de9a6 100644 --- a/openspec/changes/elimination-pairs-rule-totals/README.md +++ b/openspec/changes/elimination-pairs-rule-totals/README.md @@ -1,3 +1,4 @@ # elimination-pairs-rule-totals -Тесты упаковки и итог элиминации для тройки правила и компаний +Пары ВГО §5 (AURORA-1536, PR #175): фиксация версии правил при создании версии консолидации (DBML v5.23, NOT NULL), +итог и упаковка тройки «правило × A × B», список троек версии. diff --git a/openspec/changes/elimination-pairs-rule-totals/design.md b/openspec/changes/elimination-pairs-rule-totals/design.md index d9c793e..4fc01b3 100644 --- a/openspec/changes/elimination-pairs-rule-totals/design.md +++ b/openspec/changes/elimination-pairs-rule-totals/design.md @@ -1,10 +1,33 @@ ## Решение -Суммы выбранных строк считаются один раз до упаковки внутри тройки `(rule_id, A, B)` как чистая функция. -`elimination_amount` сохраняет ранее согласованное значение абсолютного расхождения; новое `eliminated_amount` -содержит `min(ΣA, ΣB)` при пороге не более 5 %, иначе 0, и всегда 0 для `manual`. -Нулевая меньшая сторона даёт `deviation_percent = null` и нулевую элиминацию. +Итог тройки `(rule_id, A, B)` считается чистой функцией по строкам обеих сторон до упаковки: суммы сторон со знаком, +`deviation_amount = |ΣA − ΣB|`, процент к меньшей стороне, `eliminated_amount = min(ΣA, ΣB)` при расхождении ≤ 5 %, +иначе и для `manual` — 0. Упаковка итог не меняет. Каждое правило считается отдельно (каскад по остатку отменён +владельцем 21.10.2026): строка общего счёта входит в несколько правил, двойной учёт снимает AURORA-1751. + +Сбор встречных строк тройки — одна функция `_collect_rule_sides` для итога, упаковки и списка версии: список версии +(`build_totals`) строит сводную ОСВ один раз и проходит правила ББ × упорядоченные пары периметра без упаковки. + +Версия правил фиксируется при создании версии консолидации (`latest_effective_version_no` на московскую дату) и дальше +не меняется; ветки «правила не зафиксированы» в чтении правил, пресете шахматки и парах убраны. -Упаковка не влияет на итог. Для обратной направленной пары клиент переставляет `company_a_id` и `company_b_id`. Эталонный Excel используется только для сверки точных встречных строк: он списывает полную сумму каждой стороны, -поэтому его общий итог не равен системному правилу минимума. +поэтому его общий итог не равен системному правилу минимума (OQ-155). + +## Решения владельца 23.10.2026 (ревью PR #175) + +1. Суммы сторон — со знаком, как в шахматке; строки с отрицательной суммой в упаковку не идут (`NEGATIVE_AMOUNT`). +2. Группы одного размера сортируются по проценту расхождения, а не по сумме. +3. Кроме тройки — список пар всей версии одним запросом (делаем сами в ветке PR). +4. `elimination_rules_version` — NOT NULL с миграцией существующих версий (DBML v5.23). + +## Decisions made without discussion + +| Маркер | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| R-1 (design #1) | 23.10.2026 | При `min(ΣA, ΣB) ≤ 0` процент `null`, элиминация 0, тройка подсвечивается при любом расхождении | Минимум со знаком дал бы элиминацию отрицательной суммы или отрицательный процент, который не пробивает порог; аналитик должен разобрать такой случай | Поменять одну функцию `calculate_rule_totals` и §5 | +| R-2 (design #2) | 23.10.2026 | Список версии — `GET …/elimination-totals`, статусы `AUTO` / `DISCREPANCY` / `ONE_SIDED` / `MANUAL`; тройки без строк не возвращаются; только правила ББ с `measure = closing` | Повторяет три категории задачи («сопоставленные, с расхождением, без пары») плюс ручные правила; ОФР в §4 не формализован | Переименование маршрута и статусов — правка фронта до его разработки | +| R-3 (design #3) | 23.10.2026 | Создание версии без правил, действующих на дату, — 409 `ELIMINATION_RULES_MISSING` | Поле NOT NULL; подставлять «последние открытые» значило бы взять правила, ещё не вступившие в силу | При пустом справочнике правил консолидацию не создать до seed — на практике seed всегда заводит v1 | +| R-4 (design #4) | 23.10.2026 | Миграция: дата создания по Москве; пустой `created_at` — текущая дата; нет действующих на дату — последняя открытая; нет правил вовсе — отказ миграции | Совпадает с правилом создания; запасной вариант нужен только для данных, созданных до появления правил | Неверный номер у старых версий — исправляется UPDATE до верификации | +| R-5 (design #5) | 23.10.2026 | `is_version_fixed` в ответе `elimination-rules` оставлен и всегда `true` | Фронт шахматки уже читает поле (PR #141, #158) | Удалить поле позже вместе с фронтом | +| R-6 (design #6) | 23.10.2026 | Группа упаковки — `deviation_amount` вместо `elimination_amount`; отдельной суммы элиминации у группы нет | В КФО идёт итог тройки; упаковка его не меняет (§5), а сумма у группы читалась бы как вклад в элиминацию | Добавить поле у группы, если корректировкам понадобится | diff --git a/openspec/changes/elimination-pairs-rule-totals/proposal.md b/openspec/changes/elimination-pairs-rule-totals/proposal.md index c2817c2..514fbf7 100644 --- a/openspec/changes/elimination-pairs-rule-totals/proposal.md +++ b/openspec/changes/elimination-pairs-rule-totals/proposal.md @@ -1,16 +1,21 @@ ## Why -Срез 5: упаковка ОСВ показывает группы строк, но итог элиминации тройки должен учитывать все выбранные строки, -включая оставшиеся без пары. Это нужно закрепить тестами на реальных ОСВ группы «Русский бисквит» и отдельным -контрактом для фронта. +Срез 5, AURORA-1536: автоматическое сопоставление ВГО по `05-domain-rules.md` §5. Нужны итог элиминации по тройке +«правило, компания A, компания B» (идёт в КФО), детализация строк ОСВ по группам и список всех троек версии для +таблицы корректировок (AURORA-1540, AURORA-1541) и разнесения строк (AURORA-1751). Версия правил, по которой считается +версия консолидации, должна быть зафиксирована всегда, иначе после публикации новых правил старые консолидации молча +пересчитаются по ним. ## What Changes -- Для запроса `(правило, A, B)` добавить общий итог ΣA/ΣB, расхождение, процент, признак порога и сумму - элиминации по правилу 5 %; при `manual` сумма элиминации равна нулю. -- Проверить упаковку 1:1, 1:N, N:1, ничьи и оба направления пары на реальных и модельных строках. -- Проверить неизменность зафиксированной версии правил при повторных GET и сопоставить точные строки с эталонным Excel. -- Описать новый ответ в гайде фронта. +- При создании версии консолидации фиксировать `elimination_rules_version` — версию правил, действующую на дату + создания (OQ-174); DBML v5.23 делает поле NOT NULL, миграция `0010_dbml_v5_23` заполняет существующие версии; + без действующих правил версия не создаётся (409 `ELIMINATION_RULES_MISSING`). +- `GET …/elimination-pairs` — итог тройки (суммы сторон со знаком, расхождение, процент, сумма элиминации по правилу 5 %) + и упаковка 1:1 / 1:N / N:1 с причинами строк без пары. +- `GET …/elimination-totals` — итоги всех троек версии одним запросом со статусом `AUTO` / `DISCREPANCY` / `ONE_SIDED` / + `MANUAL`. +- `GET …/elimination-rules` — всегда правила зафиксированной версии. ## Capabilities @@ -20,15 +25,17 @@ ### Modified Capabilities -- `consolidations-api`: общий итог элиминации тройки независимо от упаковки строк. +- `consolidations-api`: создание консолидации фиксирует версию правил; правила зафиксированной версии; пары ВГО тройки и + версии. +- `data-model`: DBML v5.23, миграция `0010_dbml_v5_23`. ## 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 о минимуме пары. +`arm-core`: миграция, ORM, чистый расчёт, сервис, схемы, маршрут, тесты, OpenSpec. `arm-ka-docs` (#164): DBML v5.23, +§4–§5 без каскада, OQ-173/174/175, гайд фронта §5.7.2. Основание — `context/05-domain-rules.md` §4–§5, решения +владельца 08.10, 19.10, 21.10 и 23.10.2026. ## Non-goals -Пары всех правил версии в одном запросе, сохранение результатов в БД, корректировки, расчёт ОФР. +Сохранение пар в БД (DECISION-5), разнесение строк по правилам и снятие двойного учёта (AURORA-1751), корректировки +(AURORA-1540), правила и пары ОФР. diff --git a/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md b/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md index 4eef515..10b65a3 100644 --- a/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md +++ b/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md @@ -1,21 +1,168 @@ +## MODIFIED Requirements + +### Requirement: Создание консолидации +`POST /consolidations` (`CONSOLIDATION_EDIT`, policy по ГК) с телом `{company_group_id, related_group_id, year, quarter, +legal_entity_ids?}` SHALL в одной транзакции создать `consolidation` (`reporting_date`: Q1 → 01.04.year, Q2 → 01.07.year, +Q3 → 01.10.year, Q4 → 01.01.year+1; ASSUMPTION(OQ-40)), `consolidation_version` №1 (`INITIATED`, `scenario = CREATE`, +`approach = BASIC`, `goodwill_enabled = false`, `initiator_id` и `initiator_office_id` из пользователя, +`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 быть: без +`legal_entity_ids` (поле отсутствует или `null`) — текущие члены ГСК; с `legal_entity_ids` — ровно эти ЮЛ после +схлопывания дублей (порядок первого вхождения), каждое SHALL принадлежать ГК консолидации +(`legal_entity.company_group_id`), иначе 422 `ENTITY_NOT_IN_GROUP` с перечнем `legal_entity_ids` чужих в порядке +запроса; состав ГСК при явном списке не проверяется и не меняется. ГСК SHALL быть обязательной, принадлежать ГК и иметь +`kind = gsk`, иначе 422 `RELATED_GROUP_MISMATCH`. Периметр меньше двух ЮЛ (членов ГСК без списка либо выбранных ЮЛ после +схлопывания) → 422 `PERIMETER_TOO_SMALL`; пара (ГК, год, квартал) уже есть → 409 `CONSOLIDATION_EXISTS` (в том числе при +гонке двух запросов — UNIQUE `(company_group_id, year, quarter)`); `year` вне 2000..2100 → 422. Порядок проверок: ГК +видима (404) → ГСК видима (404) и соответствует ГК (422) → периметр (422) → период свободен (409). Блокировка на +создателя MUST NOT ставиться; `MEMBERSHIP_CHANGE` при создании MUST NOT писаться. `related_group_id` в `consolidation` +SHALL оставаться NULL (фиксируется при верификации, Срез 7); `related_group_member` MUST NOT изменяться. +В `consolidation_version.elimination_rules_version` SHALL записываться номер версии правил элиминации, действующей на +московскую календарную дату создания: максимальный `version_no` среди правил с `valid_from <= дата` и `valid_to IS NULL` +либо `valid_to >= дата` (OQ-174; DBML v5.23, поле NOT NULL). Пользователь версию правил не выбирает; год, квартал и +`reporting_date` на номер не влияют; позже номер не меняется. Нет правил, действующих на дату, → 409 +`ELIMINATION_RULES_MISSING`, консолидация не создаётся. + +#### Scenario: Консолидация РБ за Q3 2024 +- **WHEN** `ka_rf` Санкт-Петербургского РФ создаёт консолидацию ГК «Русский бисквит», ГСК из двух ЮЛ, 2024, Q3, без + `legal_entity_ids` +- **THEN** 201; версия №1 `INITIATED`, `reporting_date = 2024-10-01`, два `consolidation_member`, история с одной записью, + `audit_event(CONSOLIDATION_CREATE)` с `consolidation_version_id`; активной блокировки нет + +#### Scenario: Выбранный периметр шире ГСК +- **WHEN** в ГК три ЮЛ, в ГСК два из них, создаётся консолидация с `legal_entity_ids` из всех трёх ЮЛ ГК +- **THEN** 201; в `members` три ЮЛ; `after_value.members` события `CONSOLIDATION_CREATE` — три ЮЛ; состав ГСК не изменился + +#### Scenario: ГСК из одного ЮЛ, второе отмечено вручную +- **WHEN** в ГСК один текущий член, в `legal_entity_ids` он и ещё одно ЮЛ той же ГК +- **THEN** 201; в `members` два ЮЛ + +#### Scenario: ГСК из одного ЮЛ +- **WHEN** в ГСК один текущий член, `legal_entity_ids` не передан +- **THEN** 422 `PERIMETER_TOO_SMALL`; `consolidation` и версия не созданы + +#### Scenario: ЮЛ другой ГК в списке +- **WHEN** в `legal_entity_ids` попало ЮЛ, `company_group_id` которого не равен ГК консолидации +- **THEN** 422 `ENTITY_NOT_IN_GROUP` с этим ЮЛ в `legal_entity_ids`; `consolidation` не создана + +#### Scenario: Дубли схлопываются до одного ЮЛ +- **WHEN** `legal_entity_ids` — один и тот же идентификатор дважды +- **THEN** 422 `PERIMETER_TOO_SMALL` + +#### Scenario: Повтор периода +- **WHEN** консолидация ГК за 2024 Q3 уже есть, создаётся ещё одна +- **THEN** 409 `CONSOLIDATION_EXISTS` + +#### Scenario: Правила действуют на дату создания версии +- **WHEN** в день создания консолидации за 2024 год действует версия правил 2, а версия 1 действовала в отчётном периоде +- **THEN** `consolidation_version.elimination_rules_version = 2`; номер не меняется при последующей публикации правил + +#### Scenario: Нет действующих правил +- **WHEN** у всех правил `valid_from` позже даты создания +- **THEN** 409 `ELIMINATION_RULES_MISSING`; `consolidation` и версия не созданы + ## ADDED Requirements -### Requirement: Итог элиминации тройки независимо от упаковки +### Requirement: Правила элиминации зафиксированной версии +`GET /api/v1/consolidations/versions/{version_id}/elimination-rules` (`CONSOLIDATION_VIEW`, policy по ГК) +SHALL возвращать объект `{version_no, is_version_fixed, rules}`: `version_no` — `consolidation_version.elimination_rules_version` +(с DBML v5.23 задан у каждой версии), `is_version_fixed = true` (поле оставлено для совместимости клиента), `rules` — +правила этой версии; правила других версий, в том числе более новых, MUST NOT попадать в ответ. Каждое правило SHALL +содержать `id`, `rule_no`, `version_no`, `statement_type`, `side_1` (`D`/`K`), `measure` (`closing`/`turnover`), +`company_1`, `company_2`; каждая сторона SHALL содержать `accounts: UUID[]` и `excluded: UUID[]` до раскрытия иерархии +счетов. Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`. -Для `(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** у правила первая сторона включает счёт 76 и исключает 76.07.5 +- **THEN** ID 76 находится в `company_1.accounts`, ID 76.07.5 — в `company_1.excluded` + +#### Scenario: Версия правил зафиксирована +- **WHEN** у версии консолидации `elimination_rules_version = 1` +- **THEN** ответ содержит `is_version_fixed = true`, `version_no = 1` и только правила с `version_no = 1` + +#### Scenario: Опубликована новая версия правил +- **WHEN** после создания версии консолидации с `elimination_rules_version = 1` открыта версия правил 2 +- **THEN** ответ по-прежнему содержит `version_no = 1` и правила версии 1; пресет правила версии 2 для этой версии — 404 + +### Requirement: Пары ВГО тройки +`GET /api/v1/consolidations/versions/{version_id}/elimination-pairs?rule_id=&company_a_id=&company_b_id=` SHALL требовать +`CONSOLIDATION_VIEW` и видимость версии по ГК; три параметра обязательны. Компании SHALL быть разными участниками +периметра версии (иначе 422 `INVALID_COMPANY_PAIR`), правило — правилом ББ версии правил версии консолидации с +`measure = closing` (иначе 404 `NOT_FOUND` или 422 `INVALID_RULE`). Отбор счетов, раскрытие счетов-предков и исключения +SHALL совпадать с шахматкой PRESET. Расчёт — по `05-domain-rules.md` §5, на лету, без сохранения. + +Итог тройки: `amount_a` — сумма строк A с контрагентом B по счетам стороны 1, `amount_b` — строк B с контрагентом A по +счетам стороны 2, **со знаком** (как в шахматке); `deviation_amount = |amount_a − amount_b|`; `deviation_percent` — к +`min(amount_a, amount_b)`, `null`, если он ≤ 0; `exceeds_threshold` — процент > 5 % или меньшая сторона ≤ 0 при ненулевом +расхождении; `eliminated_amount = min(amount_a, amount_b)` при расхождении ≤ 5 %, иначе, при `elimination_mode = manual` и +при меньшей стороне ≤ 0 — 0. Упаковка итог не меняет. Каждое правило считается отдельно: строка общего счёта может войти +в тройки нескольких правил (каскад отменён, AURORA-1751). + +Упаковка: строка свода неделима; в группы идут только строки с суммой > 0; группы 1:1, 1:N, N:1 при N ≤ 5, N:M нет; +сначала меньшее число строк, внутри размера — точные, затем с расхождением ≤ 5 % к меньшей сумме по возрастанию +**процента** расхождения; строка — не более чем в одной группе правила; группы одного размера с одинаковым процентом, +делящие строку, не сопоставляются. Ответ SHALL содержать `version_no`, `rule_id`, `rule_no`, `company_a_id`, +`company_b_id`, итог тройки, `matched[]` (точные группы), `with_discrepancy[]` (расхождение ≤ 5 %) и `unpaired[]`. Группа — +`{rule_id, company_a_id, company_b_id, amount_a, amount_b, deviation_amount, deviation_percent, osv_entry_ids_a[], +osv_entry_ids_b[]}`; строка без пары — `{row, reason}`, `reason`: `NO_MATCH`, `AMBIGUOUS` (ничья), `ZERO_AMOUNT`, +`NEGATIVE_AMOUNT` (сумма < 0, в упаковку не идёт). Суммы и проценты в JSON 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 %. +- **THEN** точная группа в `matched`, три остальные строки стороны A — в `unpaired`, `eliminated_amount = 106311116.46`, + `deviation_percent = 3.87` + +#### Scenario: Отрицательная строка +- **WHEN** у A по B строки 150 и −50, у B по A строка 100 +- **THEN** `amount_a = 100.00`, `amount_b = 100.00`, `eliminated_amount = 100.00`; строка −50 — в `unpaired` с причиной + `NEGATIVE_AMOUNT` + +#### Scenario: Две строки свода упакованы с одной встречной +- **WHEN** по одному правилу есть дебетовые строки 40 и 60 и встречная кредитовая строка 100 +- **THEN** `matched[]` содержит группу с суммами 100.00 и 100.00, `deviation_amount = 0.00` и исходными `osv_entry_ids_a/b` #### Scenario: Ручное правило и превышение порога - - **WHEN** правило ручное либо расхождение больше 5 % -- **THEN** `eliminated_amount = 0`, независимо от результата упаковки. +- **THEN** `eliminated_amount = 0.00` независимо от упаковки + +#### Scenario: Исключённый счёт не входит в пары +- **WHEN** правило включает родительский счёт 76 и исключает 76.07.5 +- **THEN** строки счёта 76.07.5 и его потомков не входят ни в группы, ни в `unpaired` + +#### Scenario: Другая пара компаний не смешивается с выбранной +- **WHEN** по одному правилу есть встречные строки для A→Б и A→В, а запрошены `company_a_id=A`, `company_b_id=Б` +- **THEN** ответ содержит только строки A→Б и Б→A + +### Requirement: Пары ВГО версии +`GET /api/v1/consolidations/versions/{version_id}/elimination-totals` (`CONSOLIDATION_VIEW`, policy по ГК) SHALL +возвращать `{version_no, items[]}` — итоги всех троек версии: правила ББ с `measure = closing` версии правил × +упорядоченные пары разных компаний периметра, у которых есть строки хотя бы на одной стороне. Сводная ОСВ SHALL строиться +один раз на запрос. Элемент — `{rule_id, rule_no, elimination_mode, company_a_id, company_b_id, amount_a, amount_b, +deviation_amount, deviation_percent, eliminated_amount, exceeds_threshold, status, osv_entry_ids_a[], osv_entry_ids_b[]}` +с теми же суммами, что у тройки («Пары ВГО тройки»); `status`: `MANUAL` — `elimination_mode = manual`; `ONE_SIDED` — одна из +сумм нулевая; `DISCREPANCY` — `exceeds_threshold`; иначе `AUTO`. Порядок — `rule_no`, затем порядок компаний периметра. +Суммы и проценты — строки с двумя знаками. Невидимая и несуществующая версия — 404 `NOT_FOUND`. + +#### Scenario: Список совпадает с тройкой +- **WHEN** по правилу № 6 у A на B 300, у B на A 100, у B на A дебиторка 70 и у A на B кредиторка 70 +- **THEN** в `items` тройка (6, A, B) — `DISCREPANCY`, `eliminated_amount = 0.00`; тройка (6, B, A) — `AUTO`, + `eliminated_amount = 70.00`; те же суммы возвращает `elimination-pairs` для каждой тройки + +#### Scenario: Пересечение правил +- **WHEN** дебиторка A на B по счетам № 6 есть, а займов B у A (67) нет +- **THEN** в `items` есть и тройка (6, A, B), и тройка (7, A, B) со статусом `ONE_SIDED` — двойной учёт до разнесения + строк (AURORA-1751) + +#### Scenario: Правило без строк +- **WHEN** по счетам правила № 3 строк ни у A, ни у B нет +- **THEN** тройки (3, A, B) в `items` нет + +## REMOVED Requirements + +### Requirement: Правила элиминации версии +**Reason**: с DBML v5.23 `elimination_rules_version` задан у каждой версии консолидации; ветка «версия правил не +зафиксирована — последние открытые правила» больше не существует. +**Migration**: требование «Правила элиминации зафиксированной версии»; клиенту `is_version_fixed` всегда `true`. diff --git a/openspec/changes/elimination-pairs-rule-totals/specs/data-model/spec.md b/openspec/changes/elimination-pairs-rule-totals/specs/data-model/spec.md new file mode 100644 index 0000000..4b5d9e7 --- /dev/null +++ b/openspec/changes/elimination-pairs-rule-totals/specs/data-model/spec.md @@ -0,0 +1,26 @@ +## MODIFIED Requirements + +### Requirement: Версия DBML зафиксирована в коде +Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.23"`); скрипт сверки SHALL сравнивать её с версией в шапке файла +DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна. + +#### Scenario: Новая версия DBML без обновления ORM +- **WHEN** в `docs/model/` появляется `data-model-v5.24.dbml`, а константа в ORM — `5.23` +- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц + +## ADDED Requirements + +### Requirement: Миграция DBML v5.23 — версия правил элиминации обязательна +Миграция `0010_dbml_v5_23` после `0009_dbml_v5_21` SHALL заполнить пустой `consolidation_version.elimination_rules_version` +номером версии правил, действовавшей на московскую дату `created_at` версии (пустой `created_at` — текущая дата), а если +такой нет — последней открытой версии (`valid_to IS NULL`), затем сделать колонку NOT NULL. Если после заполнения +остаются пустые значения (правил нет вовсе), миграция SHALL завершиться ошибкой с числом таких версий до изменения схемы. +Откат SHALL снимать только NOT NULL, значения сохраняются. v5.22 структуру БД не меняла, отдельной миграции у неё нет. + +#### Scenario: Заполнение по дате создания +- **WHEN** правила v1 действуют до 30.06.2026, v2 — с 01.07.2026, версии созданы 01.03.2026, 30.06.2026 21:30 UTC и без даты +- **THEN** после `upgrade head` у них 1, 2 и 2; колонка NOT NULL; `downgrade 0009_dbml_v5_21` снимает NOT NULL, значения остаются + +#### Scenario: Версии без правил +- **WHEN** в БД есть версия консолидации и нет ни одного правила элиминации +- **THEN** `upgrade head` падает с ошибкой «миграция v5.23 невозможна…», ревизия остаётся `0009_dbml_v5_21` diff --git a/openspec/changes/elimination-pairs-rule-totals/tasks.md b/openspec/changes/elimination-pairs-rule-totals/tasks.md index 5c0205a..32ba09b 100644 --- a/openspec/changes/elimination-pairs-rule-totals/tasks.md +++ b/openspec/changes/elimination-pairs-rule-totals/tasks.md @@ -1,7 +1,12 @@ ## Реализация -- [x] Добавить чистый расчёт общего итога и тесты порога, ручного режима и нулевой стороны. -- [x] Проверить реальные строки правила № 6, точную пару и оставшиеся без пары по golden ОСВ. -- [x] Проверить 42 млн по правилу № 4, ничью упаковки, обратную пару и фиксацию версии правил. -- [x] Дополнить основной OpenSpec и гайд фронта. -- [ ] Запустить `make check-full` и заархивировать change. +- [x] Чистый расчёт итога тройки и тесты порога, ручного режима и нулевой стороны. +- [x] Реальные строки правила № 6 (точная пара и строки без пары) по golden ОСВ; 42 млн по правилу № 4; ничья упаковки; + обратная пара. +- [x] Фиксация версии правил при создании версии; DBML v5.23, миграция `0010_dbml_v5_23` и её тесты; 409 + `ELIMINATION_RULES_MISSING`. +- [x] Список троек версии `GET …/elimination-totals` и тест на тех же данных, что и тройка. +- [x] OpenSpec-дельта, README, `05-domain-rules.md` §5 и гайд фронта (arm-ka-docs #164). +- [ ] Суммы сторон со знаком, `NEGATIVE_AMOUNT`, сортировка групп по проценту, `deviation_amount` у группы и тройки, суммы + с двумя знаками, правило с `measure ≠ closing` — 422 `INVALID_RULE` (раунд ревью 1, автор). +- [ ] `make check-full` и архив change. diff --git a/openspec/specs/consolidations-api/spec.md b/openspec/specs/consolidations-api/spec.md index 660e7e9..abd8832 100644 --- a/openspec/specs/consolidations-api/spec.md +++ b/openspec/specs/consolidations-api/spec.md @@ -15,13 +15,7 @@ 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 автоматически записать в `consolidation_version.elimination_rules_version` номер -самой актуальной версии правил: максимальный `version_no` среди правил, действующих на московскую календарную дату -создания версии (`valid_from <= дата` и `valid_to IS NULL` либо `valid_to >= дата`; OQ-174, docs PR #146). -Пользователь не выбирает версию правил. Отчётные год, квартал и `reporting_date` не влияют на определение номера. -Если на дату создания нет действующих правил, поле остаётся NULL. -Периметр SHALL быть: без +`after_value.members` — отсортированный список ЮЛ периметра; ответ 201 с карточкой версии. Периметр SHALL быть: без `legal_entity_ids` (поле отсутствует или `null`) — текущие члены ГСК; с `legal_entity_ids` — ровно эти ЮЛ после схлопывания дублей (порядок первого вхождения), каждое SHALL принадлежать ГК консолидации (`legal_entity.company_group_id`), иначе 422 `ENTITY_NOT_IN_GROUP` с перечнем `legal_entity_ids` чужих в порядке @@ -39,10 +33,6 @@ 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` — три ЮЛ; состав ГСК не изменился @@ -261,56 +251,6 @@ 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[]}`. Компании — все члены периметра -- 2.47.2 From 8940f32ae3e5c26daa373143c8c9ed6159b19ab8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 21:47:47 +0400 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=D0=BF=D0=B0=D1=80=D1=8B=20=D0=92?= =?UTF-8?q?=D0=93=D0=9E=20=E2=80=94=20=D1=81=D1=83=D0=BC=D0=BC=D1=8B=20?= =?UTF-8?q?=D1=81=D0=BE=20=D0=B7=D0=BD=D0=B0=D0=BA=D0=BE=D0=BC,=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=86=D0=B5=D0=BD=D1=82=20=D0=B2=20=D1=83=D0=BF?= =?UTF-8?q?=D0=B0=D0=BA=D0=BE=D0=B2=D0=BA=D0=B5,=20deviation=5Famount=20(?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20#175,=20AURORA-1536)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - суммы сторон со знаком, как в шахматке; строки с суммой < 0 в упаковку не идут, причина NEGATIVE_AMOUNT; при min(ΣA, ΣB) ≤ 0 процент null и элиминация 0, статус списка AUTO — только при элиминации > 0 - группы одного размера выбираются по проценту расхождения к меньшей сумме (точной дробью), ничья — по равному проценту - elimination_amount группы и тройки переименован в deviation_amount; version_no тройки обязателен - суммы и проценты пар в JSON — с двумя знаками - elimination-pairs по правилу с measure ≠ closing — 422 INVALID_RULE - дельта спеки, design.md (R-7, R-8), tasks, README make check-full: exit 0 (1896 passed; integration 990 passed). Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +- api/src/domain/elimination_packing.py | 47 ++++++++------- api/src/schemas/consolidations.py | 29 +++++++-- api/src/services/intercompany_matrix.py | 33 ++++++---- api/tests/test_api_intercompany_matrix.py | 38 +++++++++--- api/tests/test_elimination_packing.py | 60 +++++++++++++++---- api/tests/test_intercompany_matrix.py | 50 ++++++++++++++++ .../elimination-pairs-rule-totals/design.md | 2 + .../specs/consolidations-api/spec.md | 14 ++++- .../elimination-pairs-rule-totals/tasks.md | 4 +- 10 files changed, 221 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 455084c..4c13e12 100644 --- a/README.md +++ b/README.md @@ -954,10 +954,11 @@ audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`), для PRESET — `r **Пары ВГО §5 (AURORA-1536, PR #175).** `GET …/elimination-pairs?rule_id=&company_a_id=&company_b_id=` — итог тройки (ΣA, ΣB со знаком, расхождение, процент, `eliminated_amount` по правилу 5 %) и упаковка строк 1:1 / 1:N / N:1 -(`matched`, `with_discrepancy`, `unpaired` с причиной); `GET …/elimination-totals` — итоги всех троек версии (правила ББ × +(`matched`, `with_discrepancy`, `unpaired` с причиной `NO_MATCH` / `AMBIGUOUS` / `ZERO_AMOUNT` / `NEGATIVE_AMOUNT`; +группы сортируются по проценту расхождения, отрицательные строки в упаковку не идут); `GET …/elimination-totals` — итоги всех троек версии (правила ББ × упорядоченные пары периметра) со статусом `AUTO` / `DISCREPANCY` / `ONE_SIDED` / `MANUAL`, сводная ОСВ строится один раз. Каскада по остатку нет: строка общего счёта входит в несколько правил до разнесения (AURORA-1751). Ошибки: 404 (версия, -правило не из версии правил), 422 `INVALID_COMPANY_PAIR`, `INVALID_RULE`, `ACCOUNTS_REQUIRED`. Решения — +правило не из версии правил), 422 `INVALID_COMPANY_PAIR`, `INVALID_RULE` (в т. ч. правило по оборотам), `ACCOUNTS_REQUIRED`. Решения — `openspec/changes/elimination-pairs-rule-totals/design.md`; контракт — `../docs/dev/frontend-api-guide.md` §5.7.2. Поставка DBML v5.23: миграция `0010_dbml_v5_23` проставляет пустой `elimination_rules_version` по дате создания версии и делает колонку NOT NULL; нет правил вовсе — миграция падает до изменения схемы, поэтому seed правил должен быть применён diff --git a/api/src/domain/elimination_packing.py b/api/src/domain/elimination_packing.py index cb0bbad..fc10ed5 100644 --- a/api/src/domain/elimination_packing.py +++ b/api/src/domain/elimination_packing.py @@ -6,6 +6,7 @@ import uuid from dataclasses import dataclass from decimal import Decimal +from fractions import Fraction from typing import TypeAlias RowKey: TypeAlias = tuple[uuid.UUID, ...] @@ -22,14 +23,14 @@ class PackRow: key: RowKey owner_id: uuid.UUID counterparty_id: uuid.UUID - amount: Decimal # модуль суммы соответствующей стороны + amount: Decimal # сумма стороны со знаком; в упаковку идут только строки с суммой > 0 @dataclass(frozen=True) class KnapsackChoice: """Лучшие подмножества заданного размера; при ничьей возвращаем ключи всех затронутых строк.""" - deviation: Decimal + ratio: Fraction # расхождение к меньшей сумме, точной дробью: ничьи по проценту не зависят от округления unique_rows: tuple[PackRow, ...] | None involved_keys: frozenset[RowKey] @@ -64,18 +65,23 @@ class RuleTotals: def calculate_rule_totals(amount_a: Decimal, amount_b: Decimal, *, manual: bool) -> RuleTotals: - """Правило 5 % для всей тройки, независимо от сопоставления отдельных строк.""" + """Правило 5 % для всей тройки, независимо от сопоставления строк; меньшая сторона ≤ 0 — элиминации нет.""" 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 + exceeds_threshold = deviation > ZERO if percent is None else percent > MAX_DEVIATION_PERCENT + eliminated = base if base > ZERO and not manual and not exceeds_threshold else ZERO return RuleTotals(amount_a, amount_b, deviation, percent, eliminated, exceeds_threshold) +def _ratio(first: Decimal, second: Decimal) -> Fraction: + """Расхождение к меньшей из двух положительных сумм.""" + return Fraction(abs(first - second)) / Fraction(min(first, second)) + + @dataclass(frozen=True) class _Candidate: - deviation: Decimal + ratio: Fraction involved_keys: frozenset[RowKey] group: PackedGroup | None # None — несколько одинаково лучших подмножеств у одного якоря @@ -120,9 +126,10 @@ def best_subsets(rows: list[PackRow], *, size: int, capacity: Decimal) -> Knapsa 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] + # точные первыми, затем по возрастанию процента расхождения к меньшей сумме + ratios = [(mask, _ratio(capacity, total)) for mask, total in combinations] + best_ratio = min(ratio for _, ratio in ratios) + best_masks = [mask for mask, ratio in ratios if ratio == best_ratio] involved_mask = 0 for mask in best_masks: involved_mask |= mask @@ -132,13 +139,13 @@ def best_subsets(rows: list[PackRow], *, size: int, capacity: Decimal) -> Knapsa if len(best_masks) == 1 else None ) - return KnapsackChoice(best_deviation, unique_rows, involved_keys) + return KnapsackChoice(best_ratio, 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) + return _Candidate(choice.ratio, 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) @@ -148,18 +155,18 @@ def _candidate(anchor: PackRow, choice: KnapsackChoice, *, anchor_is_debit: bool 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, + deviation_amount=abs(debit_amount - credit_amount), + deviation_percent=abs(debit_amount - credit_amount) / min(debit_amount, credit_amount) * 100, ) - return _Candidate(choice.deviation, keys, group) + return _Candidate(choice.ratio, 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] + """Входы уже ограничены одной тройкой: правило, компания А, компания Б; строки с суммой ≤ 0 не участвуют.""" + available_debit = [row for row in debit_rows if row.key not in unavailable and row.amount > ZERO] + available_credit = [row for row in credit_rows if row.key not in unavailable and row.amount > ZERO] candidates: list[_Candidate] = [] for anchor in available_debit: choice = best_subsets(available_credit, size=total_size - 1, capacity=anchor.amount) @@ -174,14 +181,14 @@ def _candidates( def pack_rule_rows(debit_rows: list[PackRow], credit_rows: list[PackRow]) -> PackingResult: - """Жадно упаковать 1:1, 1:N, N:1; ничьи по размеру и расхождению отправить на ручной разбор.""" + """Жадно упаковать 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] + best_ratio = min(candidate.ratio for candidate in candidates) + best = [candidate for candidate in candidates if candidate.ratio == best_ratio] 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)): diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 0ca3331..0215801 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -347,6 +347,12 @@ class EliminationPairRowOut(BaseModel): closing_debit: Decimal | None closing_credit: Decimal | None + @field_validator("closing_debit", "closing_credit") + @classmethod + def _two_decimals(cls, value: Decimal | None) -> Decimal | None: + """Суммы в JSON всегда с двумя знаками; пустая сторона — null.""" + return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP) + class EliminationMatchedPairOut(BaseModel): """Сопоставленная группа строк одного правила и пары компаний.""" @@ -356,30 +362,39 @@ class EliminationMatchedPairOut(BaseModel): company_b_id: uuid.UUID amount_a: Decimal amount_b: Decimal - elimination_amount: Decimal + deviation_amount: Decimal deviation_percent: Decimal osv_entry_ids_a: list[uuid.UUID] osv_entry_ids_b: list[uuid.UUID] + @field_validator("amount_a", "amount_b", "deviation_amount", "deviation_percent") + @classmethod + def _two_decimals(cls, value: Decimal) -> Decimal: + """Суммы и процент в JSON всегда с двумя знаками.""" + return value.quantize(CENT, rounding=ROUND_HALF_UP) + + +UnpairedReason = Literal["ZERO_AMOUNT", "NEGATIVE_AMOUNT", "NO_MATCH", "AMBIGUOUS"] + class EliminationUnpairedRowOut(BaseModel): """Строка, не вошедшая в автоматическую группу правила.""" row: EliminationPairRowOut - reason: Literal["ZERO_AMOUNT", "NO_MATCH", "AMBIGUOUS"] + reason: UnpairedReason class EliminationPairsOut(BaseModel): """Точные пары, пары с расхождением и строки без пары для выбранной тройки.""" - version_no: int | None + version_no: int 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_amount: Decimal deviation_percent: Decimal | None eliminated_amount: Decimal exceeds_threshold: bool @@ -387,6 +402,12 @@ class EliminationPairsOut(BaseModel): with_discrepancy: list[EliminationMatchedPairOut] unpaired: list[EliminationUnpairedRowOut] + @field_validator("amount_a", "amount_b", "deviation_amount", "deviation_percent", "eliminated_amount") + @classmethod + def _two_decimals(cls, value: Decimal | None) -> Decimal | None: + """Суммы и процент в JSON всегда с двумя знаками; неопределённый процент — null.""" + return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP) + EliminationTotalStatus = Literal["AUTO", "DISCREPANCY", "ONE_SIDED", "MANUAL"] diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index 6ae05fd..a7d7aff 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -41,6 +41,7 @@ from src.schemas.consolidations import ( IntercompanyResultAmountOut, IntercompanyResultArticleOut, IntercompanyResultTableOut, + UnpairedReason, ) from src.services.consolidated_osv import ConsolidatedOsvService @@ -398,7 +399,7 @@ def _matched_pair_out( company_b_id=context.company_b_id, amount_a=amount_a, amount_b=amount_b, - elimination_amount=group.deviation_amount, + deviation_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], @@ -407,7 +408,7 @@ def _matched_pair_out( @dataclass(frozen=True) class RuleSides: - """Встречные строки тройки: исходные строки свода и строки сторон для сумм и упаковки.""" + """Встречные строки тройки: исходные строки свода и строки сторон с ненулевой суммой со знаком.""" sources: dict[RowKey, ConsolidatedRow] debit_rows: list[PackRow] @@ -421,7 +422,7 @@ class RuleSides: def _collect_rule_sides(rows: Sequence[ConsolidatedRow], selection: MatrixSelection, context: PairContext) -> RuleSides: - """Отобрать встречные строки одной направленной пары компаний и одного правила (§5).""" + """Встречные строки направленной пары компаний по одному правилу; суммы со знаком, как в шахматке (§5).""" rule = context.rule company_a_id = context.company_a_id company_b_id = context.company_b_id @@ -447,12 +448,10 @@ def _collect_rule_sides(rows: Sequence[ConsolidatedRow], selection: MatrixSelect 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)) + amount = _get_amount(row, "closing", "D" if in_debit else "K") + if amount != ZERO: + pack_row = PackRow(row.entry_ids, row.legal_entity_id, row.counterparty_id, amount) + (debit_rows if in_debit else credit_rows).append(pack_row) return RuleSides(sources, debit_rows, credit_rows) @@ -470,7 +469,8 @@ def _total_status(totals: RuleTotals, rule: EliminationRule) -> EliminationTotal return "MANUAL" if ZERO in (totals.amount_a, totals.amount_b): return "ONE_SIDED" - return "DISCREPANCY" if totals.exceeds_threshold else "AUTO" + # без автоматической элиминации — расхождение: превышен порог либо меньшая сторона отрицательна + return "AUTO" if totals.eliminated_amount > ZERO else "DISCREPANCY" def calculate_rule_total_items( @@ -527,14 +527,18 @@ def _pack_preset_rows( 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]} + amounts = {item.key: item.amount for item in [*debit_rows, *credit_rows]} for key, row in sources.items(): if key in packed.used_keys: continue + amount = amounts.get(key, ZERO) + reason: UnpairedReason if key in packed.ambiguous_keys: reason = "AMBIGUOUS" - elif key in positive_keys: + elif amount > ZERO: reason = "NO_MATCH" + elif amount < ZERO: + reason = "NEGATIVE_AMOUNT" else: reason = "ZERO_AMOUNT" unpaired.append(EliminationUnpairedRowOut(row=_pair_row_out(row), reason=reason)) @@ -561,6 +565,9 @@ class IntercompanyMatrixService: 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) + if rule.measure != "closing": + # шахматка по такому правилу считает обороты; пары §5 — только по сальдо + raise DomainValidationError("INVALID_RULE", "Пары ВГО строятся только по правилам сальдо") # TODO(упаковка ОСВ): занятость строки отслеживать отдельно для каждого rule_id — одна строка может попасть # в результаты разных правил. Глобальное исключение повторного использования потребует изменения модели # данных: нужно хранить/передавать принадлежность исходной строки конкретной паре между правилами. @@ -577,7 +584,7 @@ class IntercompanyMatrixService: 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_amount=packing.totals.deviation_amount, deviation_percent=packing.totals.deviation_percent, eliminated_amount=packing.totals.eliminated_amount, exceeds_threshold=packing.totals.exceeds_threshold, diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index 58bdd4b..a21275e 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -202,6 +202,25 @@ async def test_preset_rejects_income_rule(auth_client: AuthClientFactory, seeded assert response.json()["detail"]["code"] == "INVALID_RULE" +async def test_elimination_pairs_reject_turnover_rule(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Пары §5 — только по сальдо: шахматка по правилу оборотов считает другие суммы.""" + world = await _world(seeded) + rule = ( + await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO)) + ).one() + rule.measure = "turnover" + await seeded.flush() + + client = await auth_client(KA_RF) + company_a, company_b = world.members[:2] + response = await client.get( + f"/consolidations/versions/{world.version.id}/elimination-pairs", + params={"rule_id": str(rule.id), "company_a_id": str(company_a.id), "company_b_id": str(company_b.id)}, + ) + assert response.status_code == UNPROCESSABLE, response.text + assert response.json()["detail"]["code"] == "INVALID_RULE" + + async def test_preset_uses_fixed_rules_version(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: world = await _world(seeded) rule = ( @@ -448,7 +467,8 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915 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["amount_a"], payload["amount_b"], payload["deviation_amount"]) == ("300.00", "100.00", "200.00") + assert payload["deviation_percent"] == "200.00" assert payload["exceeds_threshold"] is True assert Decimal(payload["eliminated_amount"]) == 0 assert len(payload["matched"]) == 1 @@ -456,15 +476,16 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915 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 tuple(pair[field] for field in ("amount_a", "amount_b", "deviation_amount", "deviation_percent")) == ( + "100.00", + "100.00", + "0.00", + "0.00", ) 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 "elimination_amount" not in pair and "elimination_amount" not in payload assert [item["reason"] for item in payload["unpaired"]] == ["ZERO_AMOUNT", "NO_MATCH"] + assert [item["row"]["closing_debit"] for item in payload["unpaired"]] == ["0.00", "200.00"] loans = await client.get(url, params={**params, "rule_id": str(rule_ids[3])}) assert loans.status_code == OK, loans.text @@ -480,9 +501,8 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915 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 (discrepant["deviation_amount"], discrepant["deviation_percent"]) == ("3.00", "3.00") 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"), diff --git a/api/tests/test_elimination_packing.py b/api/tests/test_elimination_packing.py index 8216665..737c753 100644 --- a/api/tests/test_elimination_packing.py +++ b/api/tests/test_elimination_packing.py @@ -2,6 +2,7 @@ import uuid from decimal import Decimal +from fractions import Fraction from src.domain.elimination_packing import MAX_SIDE_ROWS, PackRow, best_subsets, calculate_rule_totals, pack_rule_rows @@ -19,23 +20,50 @@ def test_knapsack_finds_exact_subset_with_fixed_size_and_keeps_ties() -> None: choice = best_subsets([first, second, third], size=2, capacity=Decimal("100")) assert choice is not None - assert choice.deviation == 0 + assert choice.ratio == 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: +def test_knapsack_ranks_by_percent_of_smaller_sum_and_keeps_equal_percent_ties() -> 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} + # расхождение одно и то же, 1 руб., но к меньшей сумме: 1/99 против 1/100 + nearest = best_subsets([below, above], size=1, capacity=Decimal("100.00")) + assert nearest is not None + assert nearest.ratio == Fraction(1, 100) + assert nearest.unique_rows == (above,) - 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") + # 105 против 100 и 110,25 — по 5 % к меньшей сумме, хотя в рублях 5 и 5,25 + lower, upper = (_row(amount, debit=False) for amount in ("100.00", "110.25")) + tie = best_subsets([lower, upper], size=1, capacity=Decimal("105.00")) + assert tie is not None + assert tie.ratio == Fraction(1, 20) + assert tie.unique_rows is None + assert tie.involved_keys == {lower.key, upper.key} + + +def test_groups_are_chosen_by_percent_not_by_rubles() -> None: + debit = [_row("100", debit=True)] + rubles, percent = _row("95.5", debit=False), _row("104.6", debit=False) # 4,5 руб. = 4,71 %; 4,6 руб. = 4,6 % + + result = pack_rule_rows(debit, [rubles, percent]) + + assert [group.credit_rows for group in result.groups] == [(percent,)] + assert result.groups[0].deviation_amount == Decimal("4.6") + assert result.groups[0].deviation_percent == Decimal("4.6") + assert rubles.key not in result.used_keys + + +def test_non_positive_rows_are_not_packed() -> None: + debit = [_row("150", debit=True), _row("-50", debit=True)] + credit = [_row("150", debit=False), _row("-50", debit=False)] + + result = pack_rule_rows(debit, credit) + + assert len(result.groups) == 1 + assert result.used_keys == {debit[0].key, credit[0].key} + assert result.ambiguous_keys == frozenset() def test_exact_one_to_one_is_reserved_before_one_to_many() -> None: @@ -110,3 +138,15 @@ def test_rule_totals_over_five_percent_and_manual_eliminate_zero() -> None: assert one_sided.deviation_percent is None assert one_sided.exceeds_threshold is True assert one_sided.eliminated_amount == 0 + + +def test_rule_totals_with_negative_smaller_side_eliminate_zero() -> None: + negative = calculate_rule_totals(Decimal("100"), Decimal("-20"), manual=False) + assert (negative.deviation_amount, negative.deviation_percent) == (Decimal("120"), None) + assert negative.exceeds_threshold is True + assert negative.eliminated_amount == 0 + + equal_negative = calculate_rule_totals(Decimal("-50"), Decimal("-50"), manual=False) + assert equal_negative.deviation_percent is None + assert equal_negative.exceeds_threshold is False + assert equal_negative.eliminated_amount == 0 diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index b3666ff..11cb529 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -385,3 +385,53 @@ def test_result_table_uses_fixed_articles_and_group_totals() -> None: assert isinstance(result, IntercompanyResultTableOut) response = result assert response.articles[0].amounts[0].company_id == company_a + + +def test_pairs_use_signed_sums_and_leave_negative_rows_unpaired() -> None: + """§5: у A по B строки 150 и −50, у B по A 100 — суммы со знаком, как в шахматке; −50 в упаковку не идёт.""" + company_a, company_b = uuid.uuid4(), uuid.uuid4() + debit_account, credit_account = uuid.uuid4(), uuid.uuid4() + selection = matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing") + positive = _row(company_a, company_b, debit_account, debit="150") + negative = _row(company_a, company_b, debit_account, debit="-50") + counter = _row(company_b, company_a, credit_account, credit="100") + for row in (positive, negative, counter): + row.entry_ids = (uuid.uuid4(),) + row.account_code = "62.01" + rows = [positive, negative, counter] + rule = SimpleNamespace(id=uuid.uuid4(), rule_no=6, side_1="D", elimination_mode="min") + + packed = matrix._pack_preset_rows(rows, selection, matrix.PairContext(rule, company_a, company_b)) + + assert (packed.totals.amount_a, packed.totals.amount_b) == (Decimal("100"), Decimal("100")) + assert packed.totals.eliminated_amount == Decimal("100") + assert packed.matched == packed.with_discrepancy == [] # 150 против 100 — больше 5 % + reasons = {item.row.entry_ids[0]: item.reason for item in packed.unpaired} + assert reasons == { + positive.entry_ids[0]: "NO_MATCH", + negative.entry_ids[0]: "NEGATIVE_AMOUNT", + counter.entry_ids[0]: "NO_MATCH", + } + [item] = matrix.calculate_rule_total_items(rows, [(rule, selection)], [company_a, company_b]) + assert (item.amount_a, item.amount_b, item.eliminated_amount, item.status) == ( + Decimal("100.00"), + Decimal("100.00"), + Decimal("100.00"), + "AUTO", + ) + assert set(item.osv_entry_ids_a) == {positive.entry_ids[0], negative.entry_ids[0]} + + +def test_negative_smaller_side_is_discrepancy_without_elimination() -> None: + company_a, company_b = uuid.uuid4(), uuid.uuid4() + debit_account, credit_account = uuid.uuid4(), uuid.uuid4() + selection = matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing") + debit = _row(company_a, company_b, debit_account, debit="-50") + credit = _row(company_b, company_a, credit_account, credit="-50") + debit.entry_ids, credit.entry_ids = (uuid.uuid4(),), (uuid.uuid4(),) + rule = SimpleNamespace(id=uuid.uuid4(), rule_no=6, side_1="D", elimination_mode="min") + + [item] = matrix.calculate_rule_total_items([debit, credit], [(rule, selection)], [company_a, company_b]) + + assert (item.deviation_amount, item.deviation_percent, item.eliminated_amount) == (Decimal("0.00"), None, 0) + assert item.status == "DISCREPANCY" diff --git a/openspec/changes/elimination-pairs-rule-totals/design.md b/openspec/changes/elimination-pairs-rule-totals/design.md index 4fc01b3..b4e3120 100644 --- a/openspec/changes/elimination-pairs-rule-totals/design.md +++ b/openspec/changes/elimination-pairs-rule-totals/design.md @@ -31,3 +31,5 @@ | R-4 (design #4) | 23.10.2026 | Миграция: дата создания по Москве; пустой `created_at` — текущая дата; нет действующих на дату — последняя открытая; нет правил вовсе — отказ миграции | Совпадает с правилом создания; запасной вариант нужен только для данных, созданных до появления правил | Неверный номер у старых версий — исправляется UPDATE до верификации | | R-5 (design #5) | 23.10.2026 | `is_version_fixed` в ответе `elimination-rules` оставлен и всегда `true` | Фронт шахматки уже читает поле (PR #141, #158) | Удалить поле позже вместе с фронтом | | R-6 (design #6) | 23.10.2026 | Группа упаковки — `deviation_amount` вместо `elimination_amount`; отдельной суммы элиминации у группы нет | В КФО идёт итог тройки; упаковка его не меняет (§5), а сумма у группы читалась бы как вклад в элиминацию | Добавить поле у группы, если корректировкам понадобится | +| R-7 (design #7) | 23.10.2026 | Статус тройки `AUTO` только при `eliminated_amount > 0`; обе стороны отрицательные и равные — `DISCREPANCY` | Иначе тройка без элиминации выглядела бы сопоставленной автоматически; совпадает с гайдом фронта §5.7.2 | Одна строка в `_total_status` | +| R-8 (design #8) | 23.10.2026 | Процент расхождения при упаковке сравнивается точной дробью (`Fraction`), не округлённым `Decimal` | Ничья по проценту (100 и 110,25 против 105 — по 5 %) не должна зависеть от точности деления | Нет: на выход процент идёт `Decimal` с двумя знаками | diff --git a/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md b/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md index 10b65a3..c3f1bbb 100644 --- a/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md +++ b/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md @@ -136,6 +136,14 @@ osv_entry_ids_b[]}`; строка без пары — `{row, reason}`, `reason`: - **WHEN** по одному правилу есть встречные строки для A→Б и A→В, а запрошены `company_a_id=A`, `company_b_id=Б` - **THEN** ответ содержит только строки A→Б и Б→A +#### Scenario: Расхождение сравнивается в процентах +- **WHEN** со строкой 100 стороны A могут сойтись строки B 95,50 (4,50 руб., 4,71 %) и 104,60 (4,60 руб., 4,60 %) +- **THEN** группа — 100 ↔ 104,60; строка 95,50 — в `unpaired` с причиной `NO_MATCH` + +#### Scenario: Правило по оборотам +- **WHEN** запрошена тройка по правилу ББ с `measure = turnover` +- **THEN** 422 `INVALID_RULE` + ### Requirement: Пары ВГО версии `GET /api/v1/consolidations/versions/{version_id}/elimination-totals` (`CONSOLIDATION_VIEW`, policy по ГК) SHALL возвращать `{version_no, items[]}` — итоги всех троек версии: правила ББ с `measure = closing` версии правил × @@ -143,7 +151,7 @@ osv_entry_ids_b[]}`; строка без пары — `{row, reason}`, `reason`: один раз на запрос. Элемент — `{rule_id, rule_no, elimination_mode, company_a_id, company_b_id, amount_a, amount_b, deviation_amount, deviation_percent, eliminated_amount, exceeds_threshold, status, osv_entry_ids_a[], osv_entry_ids_b[]}` с теми же суммами, что у тройки («Пары ВГО тройки»); `status`: `MANUAL` — `elimination_mode = manual`; `ONE_SIDED` — одна из -сумм нулевая; `DISCREPANCY` — `exceeds_threshold`; иначе `AUTO`. Порядок — `rule_no`, затем порядок компаний периметра. +сумм нулевая; `AUTO` — `eliminated_amount > 0`; иначе `DISCREPANCY` (расхождение > 5 % или меньшая сторона < 0). Порядок — `rule_no`, затем порядок компаний периметра. Суммы и проценты — строки с двумя знаками. Невидимая и несуществующая версия — 404 `NOT_FOUND`. #### Scenario: Список совпадает с тройкой @@ -160,6 +168,10 @@ deviation_amount, deviation_percent, eliminated_amount, exceeds_threshold, statu - **WHEN** по счетам правила № 3 строк ни у A, ни у B нет - **THEN** тройки (3, A, B) в `items` нет +#### Scenario: Обе стороны отрицательные +- **WHEN** у A по B −50, у B по A −50 +- **THEN** тройка `DISCREPANCY`, `deviation_percent = null`, `eliminated_amount = 0.00` + ## REMOVED Requirements ### Requirement: Правила элиминации версии diff --git a/openspec/changes/elimination-pairs-rule-totals/tasks.md b/openspec/changes/elimination-pairs-rule-totals/tasks.md index 32ba09b..bc5edcb 100644 --- a/openspec/changes/elimination-pairs-rule-totals/tasks.md +++ b/openspec/changes/elimination-pairs-rule-totals/tasks.md @@ -7,6 +7,6 @@ `ELIMINATION_RULES_MISSING`. - [x] Список троек версии `GET …/elimination-totals` и тест на тех же данных, что и тройка. - [x] OpenSpec-дельта, README, `05-domain-rules.md` §5 и гайд фронта (arm-ka-docs #164). -- [ ] Суммы сторон со знаком, `NEGATIVE_AMOUNT`, сортировка групп по проценту, `deviation_amount` у группы и тройки, суммы - с двумя знаками, правило с `measure ≠ closing` — 422 `INVALID_RULE` (раунд ревью 1, автор). +- [x] Суммы сторон со знаком, `NEGATIVE_AMOUNT`, сортировка групп по проценту, `deviation_amount` у группы и тройки, суммы + с двумя знаками, правило с `measure ≠ closing` — 422 `INVALID_RULE` (раунд ревью 1; доделано в ветке PR сами). - [ ] `make check-full` и архив change. -- 2.47.2