fix(#237): «76-исключения», разнесение по стороне строки и по периметру (AURORA-2034) #238
@ -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"]
|
||||
|
||||
@ -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,17 +100,23 @@ 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()
|
||||
manual = row.entry_ids in data.manual_keys
|
||||
# §5 п. 2a: разносятся только строки между разными компаниями периметра; строка с контрагентом вне периметра
|
||||
# или «сам на себя» в таблицу не попадает, если по ней нет ручного решения аналитика (AURORA-2034)
|
||||
in_perimeter = (
|
||||
row.legal_entity_id in data.members
|
||||
and row.counterparty_id in data.members
|
||||
and row.legal_entity_id != row.counterparty_id
|
||||
)
|
||||
if not manual and not in_perimeter:
|
||||
continue
|
||||
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
|
||||
not manual
|
||||
and len(rule_ids) == 1
|
||||
and (
|
||||
row.legal_entity_id in data.members
|
||||
and row.counterparty_id in data.members
|
||||
and (row.closing_debit not in (None, ZERO) or row.closing_credit not in (None, ZERO))
|
||||
)
|
||||
and (row.closing_debit not in (None, ZERO) or row.closing_credit not in (None, ZERO))
|
||||
):
|
||||
auto_rules[row.entry_ids] = next(iter(rule_ids))
|
||||
elif rule_ids or row.entry_ids in data.manual_keys:
|
||||
elif rule_ids or manual:
|
||||
unassigned.add(row.entry_ids)
|
||||
return Distribution(selected, auto_rules, frozenset(unassigned), frozenset(used))
|
||||
|
||||
@ -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")),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,133 @@ 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}
|
||||
|
||||
|
||||
def test_rows_outside_perimeter_or_to_itself_are_not_unallocated() -> None:
|
||||
"""Разносятся только строки между компаниями периметра (§5 п. 2a): контрагент вне периметра и «сам на себя»
|
||||
в нераспределённые не попадают; ручное решение аналитика по такой строке сохраняется (AURORA-2034)."""
|
||||
rule_5, rule_6, account, company_a, company_b, outsider = (uuid.uuid4() for _ in range(6))
|
||||
inside = _row(uuid.uuid4(), company_a, company_b, 1)
|
||||
outside = _row(uuid.uuid4(), company_a, outsider, 2)
|
||||
to_itself = _row(uuid.uuid4(), company_a, company_a, 3)
|
||||
to_itself_single = _row(uuid.uuid4(), company_b, company_b, 4)
|
||||
outside_manual = _row(uuid.uuid4(), company_a, outsider, 5)
|
||||
for row in (inside, outside, to_itself, to_itself_single, outside_manual):
|
||||
row.account_id = account
|
||||
owners = account_owners([(rule_5, {account}, set()), (rule_6, {account}, {account})])
|
||||
single = account_owners([(rule_6, {account}, set())])
|
||||
distribution = distribute_vgo_rows(
|
||||
DistributionInput(
|
||||
rows=[inside, outside, to_itself, outside_manual],
|
||||
exact_ids=set(),
|
||||
diff_ids=[],
|
||||
manual_keys={outside_manual.entry_ids},
|
||||
owners=owners,
|
||||
members={company_a, company_b},
|
||||
)
|
||||
)
|
||||
assert distribution.unassigned == {inside.entry_ids, outside_manual.entry_ids}
|
||||
assert distribution.auto_rules == {}
|
||||
single_distribution = distribute_vgo_rows(
|
||||
DistributionInput(
|
||||
rows=[to_itself_single],
|
||||
exact_ids=set(),
|
||||
diff_ids=[],
|
||||
manual_keys=set(),
|
||||
owners=single,
|
||||
members={company_a, company_b},
|
||||
)
|
||||
)
|
||||
assert single_distribution.auto_rules == {}
|
||||
assert single_distribution.unassigned == frozenset()
|
||||
|
||||
|
||||
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 +344,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] == [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user