fix(#237): «76-исключения», разнесение по стороне строки и по периметру (AURORA-2034) #238

Merged
aep merged 2 commits from feat/237-acc76-exclusions into main 2026-11-10 12:15:02 +03:00
8 changed files with 233 additions and 50 deletions
Showing only changes of commit d13a9554a1 - Show all commits

View File

@ -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"]

View File

@ -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

View File

@ -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")),

View File

@ -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(

View File

@ -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

View File

@ -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"}

View File

@ -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

View File

@ -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] == [