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)`.