diff --git a/api/seed/core/elimination_rules_v1.yaml b/api/seed/core/elimination_rules_v1.yaml index 2d086dc..73444d8 100644 --- a/api/seed/core/elimination_rules_v1.yaml +++ b/api/seed/core/elimination_rules_v1.yaml @@ -17,6 +17,11 @@ # - «76.07.1 − 76.07.5 − 76.07.9» (правило 1, сторона Б) — вычитание субсчетов: 76.07.1 включён, 76.07.5 и 76.07.9 # — исключения (is_excluded) той же стороны; 76.07.9 при этом остаётся в счетах стороны А; # - счёт без указанного субсчёта («76», «66», «67», «80») — маска: сам счёт и все его субсчета. +# Исключения маски «76» в правилах 5–8 (обе стороны; список повторён явно — без YAML-якорей, чтобы правка одного +# правила скриптом не меняла остальные) — список Лысенко 10.11.2026 (AURORA-2034, +# замечание заказчика 09.11.2026): аренда 76.07/76.27/76.37 .1/.5/.9 и НДС/корректировки 76.АВ, 76.ВА, 76.К, 76.Н, +# 76.НА, 76.ОТ. Правка версии 1 на месте (решение владельца 10.11.2026: верифицированных версий консолидации нет) — +# на стендах нужен повторный seed. Тот же список — RESULT_ARTICLES в services/intercompany_matrix.py (§6). statement_type: balance version_no: 1 valid_from: 2024-01-01 @@ -78,7 +83,10 @@ rules: comment: Проценты. company_1: accounts: ["76"] - excluded: ["76.07.5", "76.07.9"] + excluded: [ + "76.07.1", "76.07.5", "76.07.9", "76.27.1", "76.27.5", "76.27.9", "76.37.1", "76.37.5", "76.37.9", + "76.АВ", "76.ВА", "76.К", "76.Н", "76.НА", "76.ОТ", + ] company_2: accounts: ["66.04", "67.04"] - rule_no: 6 @@ -90,10 +98,16 @@ rules: comment: Основная пара. company_1: accounts: ["60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"] - excluded: ["76.07.5", "76.07.9"] + excluded: [ + "76.07.1", "76.07.5", "76.07.9", "76.27.1", "76.27.5", "76.27.9", "76.37.1", "76.37.5", "76.37.9", + "76.АВ", "76.ВА", "76.К", "76.Н", "76.НА", "76.ОТ", + ] company_2: accounts: ["60.01", "60.03", "60.21", "60.31", "62.02", "62.22", "62.32", "76"] - excluded: ["76.07.1"] + excluded: [ + "76.07.1", "76.07.5", "76.07.9", "76.27.1", "76.27.5", "76.27.9", "76.37.1", "76.37.5", "76.37.9", + "76.АВ", "76.ВА", "76.К", "76.Н", "76.НА", "76.ОТ", + ] - rule_no: 7 article_1_line: "1230" article_2_line: "1410" @@ -103,7 +117,10 @@ rules: comment: Счета А — те же, что в правиле № 6. company_1: accounts: ["60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"] - excluded: ["76.07.5", "76.07.9"] + excluded: [ + "76.07.1", "76.07.5", "76.07.9", "76.27.1", "76.27.5", "76.27.9", "76.37.1", "76.37.5", "76.37.9", + "76.АВ", "76.ВА", "76.К", "76.Н", "76.НА", "76.ОТ", + ] company_2: accounts: ["67"] - rule_no: 8 @@ -115,6 +132,9 @@ rules: comment: Счета А — те же, что в правиле № 6. company_1: accounts: ["60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"] - excluded: ["76.07.5", "76.07.9"] + excluded: [ + "76.07.1", "76.07.5", "76.07.9", "76.27.1", "76.27.5", "76.27.9", "76.37.1", "76.37.5", "76.37.9", + "76.АВ", "76.ВА", "76.К", "76.Н", "76.НА", "76.ОТ", + ] company_2: accounts: ["66"] diff --git a/api/src/domain/vgo_distribution.py b/api/src/domain/vgo_distribution.py index a3516fa..a793947 100644 --- a/api/src/domain/vgo_distribution.py +++ b/api/src/domain/vgo_distribution.py @@ -5,11 +5,15 @@ from collections import Counter, defaultdict from collections.abc import Mapping, Sequence, Set from dataclasses import dataclass from decimal import Decimal +from typing import Literal from src.domain.consolidated_osv import ConsolidatedRow RowKey = tuple[uuid.UUID, ...] ZERO = Decimal("0") +Side = Literal["D", "K"] +# Сторона → счёт → правила, в которые счёт входит именно этой стороной (с исключениями стороны, §4). +RuleOwners = Mapping[Side, Mapping[uuid.UUID, Set[uuid.UUID]]] @dataclass(frozen=True) @@ -28,19 +32,41 @@ class DistributionInput: exact_ids: Set[uuid.UUID] diff_ids: Sequence[Set[uuid.UUID]] manual_keys: Set[RowKey] - owners: Mapping[uuid.UUID, Set[uuid.UUID]] + owners: RuleOwners members: Set[uuid.UUID] def account_owners( - selections: Sequence[tuple[uuid.UUID, Set[uuid.UUID]]], -) -> dict[uuid.UUID, frozenset[uuid.UUID]]: - """Для каждого раскрытого дочернего счёта найти все правила, в которые он входит.""" - owners: defaultdict[uuid.UUID, set[uuid.UUID]] = defaultdict(set) - for rule_id, account_ids in selections: - for account_id in account_ids: - owners[account_id].add(rule_id) - return {account_id: frozenset(rule_ids) for account_id, rule_ids in owners.items()} + selections: Sequence[tuple[uuid.UUID, Set[uuid.UUID], Set[uuid.UUID]]], +) -> dict[Side, dict[uuid.UUID, frozenset[uuid.UUID]]]: + """Для каждого раскрытого счёта — правила, в которые он входит, отдельно по дебету и кредиту (§5 п. 2a). + + Вход — (правило, счета дебетовой стороны, счета кредитовой стороны) уже с исключениями своей стороны. Стороны не + сливаются: исключение одной стороны (например, 76.07.1 из кредита правила 6) не должно возвращаться через маску + другой (дебетовую «76»), иначе кредитовая строка ложно попадает в несколько правил (AURORA-2034). + """ + owners: dict[Side, defaultdict[uuid.UUID, set[uuid.UUID]]] = {"D": defaultdict(set), "K": defaultdict(set)} + for rule_id, debit_ids, credit_ids in selections: + for account_id in debit_ids: + owners["D"][account_id].add(rule_id) + for account_id in credit_ids: + owners["K"][account_id].add(rule_id) + return { + side: {account_id: frozenset(rule_ids) for account_id, rule_ids in by_account.items()} + for side, by_account in owners.items() + } + + +def row_side(closing_debit: Decimal | None, closing_credit: Decimal | None) -> Side: + """Сторона строки свода: дебет, если есть дебетовое сальдо или нет кредитового; иначе кредит (§5 п. 2a).""" + return "D" if (closing_debit or ZERO) != ZERO or (closing_credit or ZERO) == ZERO else "K" + + +def rules_for(owners: RuleOwners, account_id: uuid.UUID | None, side: Side) -> frozenset[uuid.UUID]: + """Правила, в которые счёт входит заданной стороной; без счёта — ни одного.""" + if account_id is None: + return frozenset() + return frozenset(owners.get(side, {}).get(account_id, frozenset())) def select_exact_aggregates( @@ -74,7 +100,7 @@ def distribute_vgo_rows(data: DistributionInput) -> Distribution: for row in data.rows: if not used.isdisjoint(row.entry_ids): continue - rule_ids = data.owners.get(row.account_id, frozenset()) if row.account_id is not None else frozenset() + rule_ids = rules_for(data.owners, row.account_id, row_side(row.closing_debit, row.closing_credit)) if ( row.entry_ids not in data.manual_keys and len(rule_ids) == 1 diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index 1bbbadd..0f9f23d 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -62,20 +62,40 @@ class ResultArticleSpec: excluded_codes: tuple[str, ...] = () +# Субсчета 76, не участвующие в расчётах ВГО (§4, §6; Лысенко 10.11.2026, AURORA-2034): аренда и НДС/корректировки. +# Тот же список — исключения маски «76» правил 5–8 в seed/core/elimination_rules_v1.yaml (сверяет тест). +ACCOUNT_76_EXCLUDED = ( + "76.07.1", + "76.07.5", + "76.07.9", + "76.27.1", + "76.27.5", + "76.27.9", + "76.37.1", + "76.37.5", + "76.37.9", + "76.АВ", + "76.ВА", + "76.К", + "76.Н", + "76.НА", + "76.ОТ", +) + RESULT_ARTICLES = ( ResultArticleSpec( "receivables", "Дебиторская задолженность", "D", ("60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"), - ("76.07.5", "76.07.9"), + ACCOUNT_76_EXCLUDED, ), ResultArticleSpec( "payables", "Кредиторская задолженность", "K", ("60.01", "60.03", "60.21", "60.31", "62.02", "62.22", "62.32", "76"), - ("76.07.1",), + ACCOUNT_76_EXCLUDED, ), ResultArticleSpec("financial_investments", "Финансовые вложения", "D", ("58.01", "58.03")), ResultArticleSpec("long_term_loans", "Долгосрочные кредиты и займы", "K", ("67", "67.03")), diff --git a/api/src/services/vgo.py b/api/src/services/vgo.py index eaa5c2a..eed704d 100644 --- a/api/src/services/vgo.py +++ b/api/src/services/vgo.py @@ -2,7 +2,7 @@ import uuid from collections import defaultdict -from collections.abc import Mapping, Sequence, Set +from collections.abc import Mapping, Sequence from dataclasses import dataclass, field from decimal import Decimal from typing import Literal, cast @@ -18,8 +18,11 @@ from src.domain.policy import VisibilityScope from src.domain.vgo_distribution import ( Distribution, DistributionInput, + RuleOwners, account_owners, distribute_vgo_rows, + row_side, + rules_for, select_exact_aggregates, ) from src.models.accounting import EliminationRule @@ -242,7 +245,7 @@ def _build_groups( # noqa: PLR0913, PLR0917 - результат поиска manual: dict[GroupKey, list[ManualItem]], names: Mapping[uuid.UUID, str], rows: Sequence[ConsolidatedRow], - owners: Mapping[uuid.UUID, Set[uuid.UUID]], + owners: RuleOwners, ) -> list[VgoAssignmentGroupOut]: """Сгруппировать принятые агрегаты и посчитать суммы по правилу и компаниям.""" rule_by_id = {rule.id: rule for rule in rules} @@ -276,11 +279,7 @@ def _build_groups( # noqa: PLR0913, PLR0917 - результат поиска amount = ( source_position.closing_debit if side == "D" else source_position.closing_credit ) or ZERO - candidate_ids = ( - owners.get(source_position.account_id, frozenset()) - if source_position.account_id is not None - else frozenset() - ) + candidate_ids = rules_for(owners, source_position.account_id, side) position = _position( source_position, side, @@ -296,11 +295,7 @@ def _build_groups( # noqa: PLR0913, PLR0917 - результат поиска for manual_row, amount_a, amount_b in manual_rows: side = side_a if amount_a != ZERO or amount_b == ZERO else side_b source_row = rows_by_entry_id.get(manual_row.entry_ids[0]) if manual_row.entry_ids else None - candidate_ids = ( - owners.get(source_row.account_id, frozenset()) - if source_row is not None and source_row.account_id is not None - else frozenset() - ) + candidate_ids = rules_for(owners, source_row.account_id if source_row is not None else None, side) position = _position( manual_row, side, @@ -345,7 +340,7 @@ def _build_groups( # noqa: PLR0913, PLR0917 - результат поиска def _unallocated_positions( rows: Sequence[VgoAssignmentRowOut], source_rows: Sequence[ConsolidatedRow], - owners: Mapping[uuid.UUID, Set[uuid.UUID]], + owners: RuleOwners, rule_order: Mapping[uuid.UUID, int], ) -> list[VgoUnallocatedPositionOut]: """Дополнить результат разнесения представлением нераспределённых позиций.""" @@ -356,8 +351,8 @@ def _unallocated_positions( if source is None or row.counterparty_id is None: continue debit, credit = source.closing_debit or ZERO, source.closing_credit or ZERO - side: Literal["D", "K"] = "D" if debit != ZERO or credit == ZERO else "K" - candidate_ids = owners.get(source.account_id, frozenset()) if source.account_id is not None else frozenset() + side = row_side(debit, credit) + candidate_ids = rules_for(owners, source.account_id, side) reason: Literal["MULTIPLE_RULES", "UNASSIGNED"] = ( "UNASSIGNED" if row.action == "UNASSIGNED" or row.unassigned_reason is not None else "MULTIPLE_RULES" ) @@ -391,8 +386,8 @@ class VgoService: async def _rule_accounts( self, osv_result: ConsolidatedOsvBuildResult, rules: Sequence[EliminationRule] - ) -> dict[uuid.UUID, frozenset[uuid.UUID]]: - """Раскрыть дочерние счета и определить их принадлежность правилам.""" + ) -> RuleOwners: + """Раскрыть дочерние счета и определить их принадлежность правилам — отдельно по дебету и кредиту (§5 п. 2a).""" accounts = await directories_repo.list_accounts(self.session) by_id, children = _make_account_tree(accounts) selections = [] @@ -400,7 +395,8 @@ class VgoService: selection, _ = await self._matrix_service._resolve_preset_selection( rule.id, osv_result.version, by_id, children ) - selections.append((rule.id, selection.company_1_account_ids | selection.company_2_account_ids)) + # MatrixSelection: company_1_account_ids — дебетовая сторона правила, company_2_account_ids — кредитовая + selections.append((rule.id, selection.company_1_account_ids, selection.company_2_account_ids)) return account_owners(selections) async def _find_aggregates( diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index 0730453..2ec786c 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -3,10 +3,12 @@ import datetime import uuid from decimal import Decimal +from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, Mock import pytest +import yaml from fastapi import status as http_status from src.api.endpoints.consolidations.intercompany_matrix import build_intercompany_matrix, router @@ -466,8 +468,11 @@ def test_result_table_uses_fixed_articles_and_group_totals() -> None: for code in article.account_codes } kinds.update({"76": AccountKind.AP, "76.07.1": AccountKind.P, "76.07.5": AccountKind.A, "76.07.9": AccountKind.A}) + for code in required_codes: + kinds.setdefault(code, AccountKind.AP) ids = {code: uuid.uuid4() for code in required_codes} - parents = {"67.03": "67", "66.03": "66", "66.04": "66", "76.07.1": "76", "76.07.5": "76", "76.07.9": "76"} + parents = {"67.03": "67", "66.03": "66", "66.04": "66"} + parents.update({code: "76" for code in required_codes if code.startswith("76.")}) accounts = [ SimpleNamespace(id=ids[code], code=code, kind=kinds[code], parent_id=ids.get(parents.get(code))) for code in required_codes @@ -476,6 +481,10 @@ def test_result_table_uses_fixed_articles_and_group_totals() -> None: _row(company_a, company_b, ids["60.02"], debit="100"), _row(company_a, company_b, ids["76.07.5"], debit="50"), _row(company_a, company_b, ids["76.07.1"], credit="20"), + # субсчета 76 из списка AURORA-2034 не входят ни в дебиторку, ни в кредиторку + _row(company_a, company_b, ids["76.АВ"], debit="5"), + _row(company_b, company_a, ids["76.К"], credit="3"), + _row(company_b, company_a, ids["76.27.1"], credit="4"), _row(company_a, company_b, ids["58.01"], debit="30"), _row(company_b, company_a, ids["60.01"], credit="70"), _row(company_b, company_a, ids["67.03"], credit="40"), @@ -552,3 +561,15 @@ def test_negative_smaller_side_is_discrepancy_without_elimination() -> None: assert (item.deviation_amount, item.deviation_percent, item.eliminated_amount) == (Decimal("0.00"), None, 0) assert item.status == "DISCREPANCY" + + +def test_result_articles_76_exclusions_match_seed_rules() -> None: + """Исключения субсчетов 76 в результирующей таблице §6 те же, что у маски 76 в правилах 5–8 seed (AURORA-2034).""" + rules = yaml.safe_load((Path(__file__).resolve().parent.parent / "seed/core/elimination_rules_v1.yaml").read_text()) + by_no = {rule["rule_no"]: rule for rule in rules["rules"]} + seed_excluded = {frozenset(by_no[no]["company_1"]["excluded"]) for no in (5, 6, 7, 8)} + seed_excluded.add(frozenset(by_no[6]["company_2"]["excluded"])) + articles = {article.code: article for article in matrix.RESULT_ARTICLES} + assert seed_excluded == {frozenset(articles["receivables"].excluded_codes)} + assert frozenset(articles["payables"].excluded_codes) == frozenset(articles["receivables"].excluded_codes) + assert "76.АВ" in articles["receivables"].excluded_codes diff --git a/api/tests/test_intercompany_matrix_golden.py b/api/tests/test_intercompany_matrix_golden.py index e55bea1..413f9ab 100644 --- a/api/tests/test_intercompany_matrix_golden.py +++ b/api/tests/test_intercompany_matrix_golden.py @@ -158,7 +158,10 @@ def test_loan_66_03_against_58_03_when_counterpart_exists(fixtures_dir: Path) -> def test_rule_6_bakery_and_russian_biscuit_packs_only_exact_production_row(fixtures_dir: Path) -> None: - """Четыре реальные дебетовые строки дают итог тройки; упаковка берёт лишь точную 62.01 ↔ 60.01.""" + """Три реальные дебетовые строки дают итог тройки; упаковка берёт лишь точную 62.01 ↔ 60.01. + + 76.АВ (НДС по авансам) — «76-исключение» §4 (AURORA-2034, OQ-164): в правило 6 не входит, как в эталоне. + """ source_rows = [ _source(fixtures_dir, "schastl", root, number) for root, number in (("62", 21), ("62", 17), ("76", 20), ("76", 29)) @@ -201,7 +204,7 @@ def test_rule_6_bakery_and_russian_biscuit_packs_only_exact_production_row(fixtu ) ) selection = MatrixSelection( - frozenset(account_ids[code] for code in ("62.01", "76.06", "76.АВ")), + frozenset(account_ids[code] for code in ("62.01", "76.06")), frozenset({account_ids["60.01"]}), "closing", ) @@ -211,16 +214,16 @@ def test_rule_6_bakery_and_russian_biscuit_packs_only_exact_production_row(fixtu 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("108062446.46"), 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.totals.deviation_percent.quantize(Decimal("0.01")) == Decimal("1.65") 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.row.entry_ids[0] for item in packed.unpaired} == set(source_ids[1:3]) assert {item.reason for item in packed.unpaired} == {"NO_MATCH"} diff --git a/api/tests/test_seed_rules.py b/api/tests/test_seed_rules.py index e5057d0..3270a5e 100644 --- a/api/tests/test_seed_rules.py +++ b/api/tests/test_seed_rules.py @@ -29,6 +29,14 @@ SEED_DIR = API_DIR / "seed" # счета А, исключения А, счета Б, исключения Б). «Кроме» — исключения; «01 — резервный» включён в счета А правила 1; # «76.07.1 − 76.07.5 − 76.07.9» (Б, правило 1) — 76.07.1 с исключениями 76.07.5, 76.07.9. RECEIVABLES_A = ["60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"] +# Субсчета 76, исключённые из маски «76» на обеих сторонах правил 5–8 (Лысенко 10.11.2026, AURORA-2034): аренда +# 76.07/76.27/76.37 .1/.5/.9 и НДС/корректировки 76.АВ, 76.ВА, 76.К, 76.Н, 76.НА, 76.ОТ. +ACC76_EXCL = [ + "76.07.1", "76.07.5", "76.07.9", + "76.27.1", "76.27.5", "76.27.9", + "76.37.1", "76.37.5", "76.37.9", + "76.АВ", "76.ВА", "76.К", "76.Н", "76.НА", "76.ОТ", +] # fmt: skip RULES_S4: dict[int, dict[str, Any]] = { 1: { "lines": ("1150", "1450/1550"), @@ -52,7 +60,7 @@ RULES_S4: dict[int, dict[str, Any]] = { "lines": ("1230", "1510"), "mode": "min", "a": ["76"], - "a_excl": ["76.07.5", "76.07.9"], + "a_excl": ACC76_EXCL, "b": ["66.04", "67.04"], "b_excl": [], }, @@ -60,15 +68,15 @@ RULES_S4: dict[int, dict[str, Any]] = { "lines": ("1230", "1520"), "mode": "min", "a": RECEIVABLES_A, - "a_excl": ["76.07.5", "76.07.9"], + "a_excl": ACC76_EXCL, "b": ["60.01", "60.03", "60.21", "60.31", "62.02", "62.22", "62.32", "76"], - "b_excl": ["76.07.1"], + "b_excl": ACC76_EXCL, }, 7: { "lines": ("1230", "1410"), "mode": "min", "a": RECEIVABLES_A, - "a_excl": ["76.07.5", "76.07.9"], + "a_excl": ACC76_EXCL, "b": ["67"], "b_excl": [], }, @@ -76,7 +84,7 @@ RULES_S4: dict[int, dict[str, Any]] = { "lines": ("1230", "1510"), "mode": "min", "a": RECEIVABLES_A, - "a_excl": ["76.07.5", "76.07.9"], + "a_excl": ACC76_EXCL, "b": ["66"], "b_excl": [], }, @@ -365,9 +373,9 @@ async def test_rule_6_main_pair_scenario(db_session: AsyncSession) -> None: side_2 = {code for c, code, excluded in sides if c == COMPANY_2 and not excluded} side_2_excluded = {code for c, code, excluded in sides if c == COMPANY_2 and excluded} assert side_1 == {"60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"} - assert side_1_excluded == {"76.07.5", "76.07.9"} + assert side_1_excluded == set(ACC76_EXCL) assert side_2 == {"60.01", "60.03", "60.21", "60.31", "62.02", "62.22", "62.32", "76"} - assert side_2_excluded == {"76.07.1"} + assert side_2_excluded == set(ACC76_EXCL) @pytest.mark.integration diff --git a/api/tests/test_vgo.py b/api/tests/test_vgo.py index 8b5b467..25bd82c 100644 --- a/api/tests/test_vgo.py +++ b/api/tests/test_vgo.py @@ -9,7 +9,7 @@ import pytest from src.common.exceptions import DomainValidationError from src.domain.consolidated_osv import CounterpartyType -from src.domain.vgo_distribution import DistributionInput, distribute_vgo_rows +from src.domain.vgo_distribution import DistributionInput, account_owners, distribute_vgo_rows from src.models.enums import StatementType from src.schemas.consolidations import EliminationMatchedPairOut from src.services import vgo @@ -92,6 +92,94 @@ def test_partially_overlapping_diff_aggregates_remain_unassigned() -> None: assert distribution.used_ids == {extra_a, extra_b} +def test_account_owners_keep_debit_and_credit_sides_apart() -> None: + """Счёт в дебете правил 5 и 6 и в кредите только правила 6: по стороне строки — разные правила (AURORA-2034).""" + rule_5, rule_6, account = (uuid.uuid4() for _ in range(3)) + owners = account_owners([(rule_5, {account}, set()), (rule_6, {account}, {account})]) + assert owners["D"][account] == {rule_5, rule_6} + assert owners["K"][account] == {rule_6} + + +def test_credit_row_is_matched_by_credit_side_rules_only() -> None: + """Кт-строка 76.05 не попадает в правила 5–8 через дебетовую маску 76: у неё одно правило — 6 (§5 п. 2a).""" + rule_5, rule_6, rule_7, rule_8, account, company_a, company_b = (uuid.uuid4() for _ in range(7)) + debit_row = _row(uuid.uuid4(), company_a, company_b, 1) + debit_row.account_id = account + credit_row = _row(uuid.uuid4(), company_b, company_a, 2) + credit_row.account_id = account + credit_row.closing_debit = Decimal("0") + credit_row.closing_credit = Decimal("2") + owners = account_owners( + [ + (rule_5, {account}, set()), + (rule_6, {account}, {account}), + (rule_7, {account}, set()), + (rule_8, {account}, set()), + ] + ) + distribution = distribute_vgo_rows( + DistributionInput( + rows=[debit_row, credit_row], + exact_ids=set(), + diff_ids=[], + manual_keys=set(), + owners=owners, + members={company_a, company_b}, + ) + ) + assert distribution.auto_rules == {credit_row.entry_ids: rule_6} + assert distribution.unassigned == {debit_row.entry_ids} + + +async def test_unallocated_credit_row_offers_only_credit_side_rules(monkeypatch: pytest.MonkeyPatch) -> None: + """Меню «Отнести к правилу» Кт-строки — только правила, где счёт на стороне кредита (AURORA-2034).""" + version_id, company_a, company_b, rule_1, rule_2, rule_3, account = (uuid.uuid4() for _ in range(7)) + credit_row = _row(uuid.uuid4(), company_a, company_b, 1) + credit_row.account_id = account + credit_row.closing_debit = Decimal("0") + credit_row.closing_credit = Decimal("5") + rules = [ + SimpleNamespace( + id=rule_id, + rule_no=number, + statement_type=StatementType.balance, + measure="closing", + side_1="D", + article_1_line="A", + article_2_line="B", + ) + for number, rule_id in enumerate((rule_1, rule_2, rule_3), start=1) + ] + osv_result = SimpleNamespace( + version=SimpleNamespace(id=version_id, elimination_rules_version=1), + consolidated=SimpleNamespace(rows=[credit_row]), + members=_members(company_a, company_b), + ) + monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=rules)) + monkeypatch.setattr(vgo.vgo_assignments_repo, "list_assignments", AsyncMock(return_value=[])) + monkeypatch.setattr(vgo.directories_repo, "list_accounts", AsyncMock(return_value=[])) + + async def resolve(rule_id: uuid.UUID, *_args: object) -> tuple[SimpleNamespace, SimpleNamespace]: + credit = frozenset({account}) if rule_id in (rule_1, rule_2) else frozenset() + selection = SimpleNamespace(company_1_account_ids=frozenset({account}), company_2_account_ids=credit) + return selection, rules[0] + + matrix = SimpleNamespace( + _resolve_preset_selection=AsyncMock(side_effect=resolve), + build_pairs=AsyncMock(return_value=SimpleNamespace(matched=[], with_discrepancy=[])), + ) + service = vgo.VgoService( + AsyncMock(), SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)), matrix + ) + + response = await service.assignment_candidates(version_id) + + assert response.groups == [] + assert len(response.unallocated) == 1 + assert response.unallocated[0].side == "K" + assert response.unallocated[0].candidate_rule_ids == [rule_1, rule_2] + + async def test_unpaired_row_with_unique_rule_account_is_auto_assigned(monkeypatch: pytest.MonkeyPatch) -> None: version_id, company_a, company_b, rule_1, rule_2, unique_account, shared_account = (uuid.uuid4() for _ in range(7)) unique_row = _row(uuid.uuid4(), company_a, company_b, 1) @@ -217,7 +305,8 @@ async def test_pairs_are_grouped_and_entry_ids_are_used_once(monkeypatch: pytest account_id = uuid.uuid4() for row in rows: row.account_id = account_id - service._rule_accounts = AsyncMock(return_value={account_id: frozenset({rule_1, rule_2})}) + owners = {account_id: frozenset({rule_1, rule_2})} + service._rule_accounts = AsyncMock(return_value={"D": owners, "K": owners}) response = await service.assignment_candidates(version_id) calls = service._matrix_service.build_pairs.await_args_list assert [(call.args[1], call.args[2], call.args[3]) for call in calls] == [