Merge pull request 'fix(#237): «76-исключения», разнесение по стороне строки и по периметру (AURORA-2034)' (#238) from feat/237-acc76-exclusions into main

This commit is contained in:
aep 2026-11-10 12:14:59 +03:00
commit d67a67ae58
8 changed files with 285 additions and 57 deletions

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 # - «76.07.1 − 76.07.5 − 76.07.9» (правило 1, сторона Б) — вычитание субсчетов: 76.07.1 включён, 76.07.5 и 76.07.9
# — исключения (is_excluded) той же стороны; 76.07.9 при этом остаётся в счетах стороны А; # — исключения (is_excluded) той же стороны; 76.07.9 при этом остаётся в счетах стороны А;
# - счёт без указанного субсчёта («76», «66», «67», «80») — маска: сам счёт и все его субсчета. # - счёт без указанного субсчёта («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 statement_type: balance
version_no: 1 version_no: 1
valid_from: 2024-01-01 valid_from: 2024-01-01
@ -78,7 +83,10 @@ rules:
comment: Проценты. comment: Проценты.
company_1: company_1:
accounts: ["76"] 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: company_2:
accounts: ["66.04", "67.04"] accounts: ["66.04", "67.04"]
- rule_no: 6 - rule_no: 6
@ -90,10 +98,16 @@ rules:
comment: Основная пара. comment: Основная пара.
company_1: company_1:
accounts: ["60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"] 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: company_2:
accounts: ["60.01", "60.03", "60.21", "60.31", "62.02", "62.22", "62.32", "76"] 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 - rule_no: 7
article_1_line: "1230" article_1_line: "1230"
article_2_line: "1410" article_2_line: "1410"
@ -103,7 +117,10 @@ rules:
comment: Счета А — те же, что в правиле № 6. comment: Счета А — те же, что в правиле № 6.
company_1: company_1:
accounts: ["60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"] 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: company_2:
accounts: ["67"] accounts: ["67"]
- rule_no: 8 - rule_no: 8
@ -115,6 +132,9 @@ rules:
comment: Счета А — те же, что в правиле № 6. comment: Счета А — те же, что в правиле № 6.
company_1: company_1:
accounts: ["60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"] 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: company_2:
accounts: ["66"] accounts: ["66"]

View File

@ -5,11 +5,15 @@ from collections import Counter, defaultdict
from collections.abc import Mapping, Sequence, Set from collections.abc import Mapping, Sequence, Set
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
from typing import Literal
from src.domain.consolidated_osv import ConsolidatedRow from src.domain.consolidated_osv import ConsolidatedRow
RowKey = tuple[uuid.UUID, ...] RowKey = tuple[uuid.UUID, ...]
ZERO = Decimal("0") ZERO = Decimal("0")
Side = Literal["D", "K"]
# Сторона → счёт → правила, в которые счёт входит именно этой стороной (с исключениями стороны, §4).
RuleOwners = Mapping[Side, Mapping[uuid.UUID, Set[uuid.UUID]]]
@dataclass(frozen=True) @dataclass(frozen=True)
@ -28,19 +32,41 @@ class DistributionInput:
exact_ids: Set[uuid.UUID] exact_ids: Set[uuid.UUID]
diff_ids: Sequence[Set[uuid.UUID]] diff_ids: Sequence[Set[uuid.UUID]]
manual_keys: Set[RowKey] manual_keys: Set[RowKey]
owners: Mapping[uuid.UUID, Set[uuid.UUID]] owners: RuleOwners
members: Set[uuid.UUID] members: Set[uuid.UUID]
def account_owners( def account_owners(
selections: Sequence[tuple[uuid.UUID, Set[uuid.UUID]]], selections: Sequence[tuple[uuid.UUID, Set[uuid.UUID], Set[uuid.UUID]]],
) -> dict[uuid.UUID, frozenset[uuid.UUID]]: ) -> dict[Side, dict[uuid.UUID, frozenset[uuid.UUID]]]:
"""Для каждого раскрытого дочернего счёта найти все правила, в которые он входит.""" """Для каждого раскрытого счёта — правила, в которые он входит, отдельно по дебету и кредиту (§5 п. 2a).
owners: defaultdict[uuid.UUID, set[uuid.UUID]] = defaultdict(set)
for rule_id, account_ids in selections: Вход — (правило, счета дебетовой стороны, счета кредитовой стороны) уже с исключениями своей стороны. Стороны не
for account_id in account_ids: сливаются: исключение одной стороны (например, 76.07.1 из кредита правила 6) не должно возвращаться через маску
owners[account_id].add(rule_id) другой (дебетовую «76»), иначе кредитовая строка ложно попадает в несколько правил (AURORA-2034).
return {account_id: frozenset(rule_ids) for account_id, rule_ids in owners.items()} """
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( def select_exact_aggregates(
@ -74,17 +100,23 @@ def distribute_vgo_rows(data: DistributionInput) -> Distribution:
for row in data.rows: for row in data.rows:
if not used.isdisjoint(row.entry_ids): if not used.isdisjoint(row.entry_ids):
continue 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 ( if (
row.entry_ids not in data.manual_keys not manual
and len(rule_ids) == 1 and len(rule_ids) == 1
and ( and (row.closing_debit not in (None, ZERO) or row.closing_credit not in (None, ZERO))
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))
)
): ):
auto_rules[row.entry_ids] = next(iter(rule_ids)) 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) unassigned.add(row.entry_ids)
return Distribution(selected, auto_rules, frozenset(unassigned), frozenset(used)) return Distribution(selected, auto_rules, frozenset(unassigned), frozenset(used))

View File

@ -62,20 +62,40 @@ class ResultArticleSpec:
excluded_codes: tuple[str, ...] = () 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 = ( RESULT_ARTICLES = (
ResultArticleSpec( ResultArticleSpec(
"receivables", "receivables",
"Дебиторская задолженность", "Дебиторская задолженность",
"D", "D",
("60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"), ("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( ResultArticleSpec(
"payables", "payables",
"Кредиторская задолженность", "Кредиторская задолженность",
"K", "K",
("60.01", "60.03", "60.21", "60.31", "62.02", "62.22", "62.32", "76"), ("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("financial_investments", "Финансовые вложения", "D", ("58.01", "58.03")),
ResultArticleSpec("long_term_loans", "Долгосрочные кредиты и займы", "K", ("67", "67.03")), ResultArticleSpec("long_term_loans", "Долгосрочные кредиты и займы", "K", ("67", "67.03")),

View File

@ -2,7 +2,7 @@
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from collections.abc import Mapping, Sequence, Set from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from decimal import Decimal from decimal import Decimal
from typing import Literal, cast from typing import Literal, cast
@ -18,8 +18,11 @@ from src.domain.policy import VisibilityScope
from src.domain.vgo_distribution import ( from src.domain.vgo_distribution import (
Distribution, Distribution,
DistributionInput, DistributionInput,
RuleOwners,
account_owners, account_owners,
distribute_vgo_rows, distribute_vgo_rows,
row_side,
rules_for,
select_exact_aggregates, select_exact_aggregates,
) )
from src.models.accounting import EliminationRule from src.models.accounting import EliminationRule
@ -242,7 +245,7 @@ def _build_groups( # noqa: PLR0913, PLR0917 - результат поиска
manual: dict[GroupKey, list[ManualItem]], manual: dict[GroupKey, list[ManualItem]],
names: Mapping[uuid.UUID, str], names: Mapping[uuid.UUID, str],
rows: Sequence[ConsolidatedRow], rows: Sequence[ConsolidatedRow],
owners: Mapping[uuid.UUID, Set[uuid.UUID]], owners: RuleOwners,
) -> list[VgoAssignmentGroupOut]: ) -> list[VgoAssignmentGroupOut]:
"""Сгруппировать принятые агрегаты и посчитать суммы по правилу и компаниям.""" """Сгруппировать принятые агрегаты и посчитать суммы по правилу и компаниям."""
rule_by_id = {rule.id: rule for rule in rules} rule_by_id = {rule.id: rule for rule in rules}
@ -276,11 +279,7 @@ def _build_groups( # noqa: PLR0913, PLR0917 - результат поиска
amount = ( amount = (
source_position.closing_debit if side == "D" else source_position.closing_credit source_position.closing_debit if side == "D" else source_position.closing_credit
) or ZERO ) or ZERO
candidate_ids = ( candidate_ids = rules_for(owners, source_position.account_id, side)
owners.get(source_position.account_id, frozenset())
if source_position.account_id is not None
else frozenset()
)
position = _position( position = _position(
source_position, source_position,
side, side,
@ -296,11 +295,7 @@ def _build_groups( # noqa: PLR0913, PLR0917 - результат поиска
for manual_row, amount_a, amount_b in manual_rows: for manual_row, amount_a, amount_b in manual_rows:
side = side_a if amount_a != ZERO or amount_b == ZERO else side_b 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 source_row = rows_by_entry_id.get(manual_row.entry_ids[0]) if manual_row.entry_ids else None
candidate_ids = ( candidate_ids = rules_for(owners, source_row.account_id if source_row is not None else None, side)
owners.get(source_row.account_id, frozenset())
if source_row is not None and source_row.account_id is not None
else frozenset()
)
position = _position( position = _position(
manual_row, manual_row,
side, side,
@ -345,7 +340,7 @@ def _build_groups( # noqa: PLR0913, PLR0917 - результат поиска
def _unallocated_positions( def _unallocated_positions(
rows: Sequence[VgoAssignmentRowOut], rows: Sequence[VgoAssignmentRowOut],
source_rows: Sequence[ConsolidatedRow], source_rows: Sequence[ConsolidatedRow],
owners: Mapping[uuid.UUID, Set[uuid.UUID]], owners: RuleOwners,
rule_order: Mapping[uuid.UUID, int], rule_order: Mapping[uuid.UUID, int],
) -> list[VgoUnallocatedPositionOut]: ) -> list[VgoUnallocatedPositionOut]:
"""Дополнить результат разнесения представлением нераспределённых позиций.""" """Дополнить результат разнесения представлением нераспределённых позиций."""
@ -356,8 +351,8 @@ def _unallocated_positions(
if source is None or row.counterparty_id is None: if source is None or row.counterparty_id is None:
continue continue
debit, credit = source.closing_debit or ZERO, source.closing_credit or ZERO 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" side = row_side(debit, credit)
candidate_ids = owners.get(source.account_id, frozenset()) if source.account_id is not None else frozenset() candidate_ids = rules_for(owners, source.account_id, side)
reason: Literal["MULTIPLE_RULES", "UNASSIGNED"] = ( reason: Literal["MULTIPLE_RULES", "UNASSIGNED"] = (
"UNASSIGNED" if row.action == "UNASSIGNED" or row.unassigned_reason is not None else "MULTIPLE_RULES" "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( async def _rule_accounts(
self, osv_result: ConsolidatedOsvBuildResult, rules: Sequence[EliminationRule] self, osv_result: ConsolidatedOsvBuildResult, rules: Sequence[EliminationRule]
) -> dict[uuid.UUID, frozenset[uuid.UUID]]: ) -> RuleOwners:
"""Раскрыть дочерние счета и определить их принадлежность правилам.""" """Раскрыть дочерние счета и определить их принадлежность правилам — отдельно по дебету и кредиту (§5 п. 2a)."""
accounts = await directories_repo.list_accounts(self.session) accounts = await directories_repo.list_accounts(self.session)
by_id, children = _make_account_tree(accounts) by_id, children = _make_account_tree(accounts)
selections = [] selections = []
@ -400,7 +395,8 @@ class VgoService:
selection, _ = await self._matrix_service._resolve_preset_selection( selection, _ = await self._matrix_service._resolve_preset_selection(
rule.id, osv_result.version, by_id, children 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) return account_owners(selections)
async def _find_aggregates( async def _find_aggregates(

View File

@ -3,10 +3,12 @@
import datetime import datetime
import uuid import uuid
from decimal import Decimal from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
import pytest import pytest
import yaml
from fastapi import status as http_status from fastapi import status as http_status
from src.api.endpoints.consolidations.intercompany_matrix import build_intercompany_matrix, router 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 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}) 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} 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 = [ accounts = [
SimpleNamespace(id=ids[code], code=code, kind=kinds[code], parent_id=ids.get(parents.get(code))) SimpleNamespace(id=ids[code], code=code, kind=kinds[code], parent_id=ids.get(parents.get(code)))
for code in required_codes 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["60.02"], debit="100"),
_row(company_a, company_b, ids["76.07.5"], debit="50"), _row(company_a, company_b, ids["76.07.5"], debit="50"),
_row(company_a, company_b, ids["76.07.1"], credit="20"), _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_a, company_b, ids["58.01"], debit="30"),
_row(company_b, company_a, ids["60.01"], credit="70"), _row(company_b, company_a, ids["60.01"], credit="70"),
_row(company_b, company_a, ids["67.03"], credit="40"), _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.deviation_amount, item.deviation_percent, item.eliminated_amount) == (Decimal("0.00"), None, 0)
assert item.status == "DISCREPANCY" 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: 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_rows = [
_source(fixtures_dir, "schastl", root, number) _source(fixtures_dir, "schastl", root, number)
for root, number in (("62", 21), ("62", 17), ("76", 20), ("76", 29)) 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( 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"]}), frozenset({account_ids["60.01"]}),
"closing", "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), matrix.PairContext(SimpleNamespace(id=uuid.uuid4(), side_1="D", elimination_mode="min"), BAKERY, RB),
) )
assert (packed.totals.amount_a, packed.totals.amount_b) == ( assert (packed.totals.amount_a, packed.totals.amount_b) == (
Decimal("110429113.15"), Decimal("108062446.46"),
Decimal("106311116.46"), Decimal("106311116.46"),
) )
assert packed.totals.eliminated_amount == Decimal("106311116.46") assert packed.totals.eliminated_amount == Decimal("106311116.46")
assert packed.totals.deviation_percent is not None 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 packed.with_discrepancy == []
assert len(packed.matched) == 1 assert len(packed.matched) == 1
assert packed.matched[0].osv_entry_ids_a == [source_ids[0]] 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].osv_entry_ids_b == [credit_id]
assert packed.matched[0].amount_a == packed.matched[0].amount_b == Decimal("106311116.46") 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"} assert {item.reason for item in packed.unpaired} == {"NO_MATCH"}

View File

@ -29,6 +29,14 @@ SEED_DIR = API_DIR / "seed"
# счета А, исключения А, счета Б, исключения Б). «Кроме» — исключения; «01 — резервный» включён в счета А правила 1; # счета А, исключения А, счета Б, исключения Б). «Кроме» — исключения; «01 — резервный» включён в счета А правила 1;
# «76.07.1 − 76.07.5 − 76.07.9» (Б, правило 1) — 76.07.1 с исключениями 76.07.5, 76.07.9. # «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"] 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]] = { RULES_S4: dict[int, dict[str, Any]] = {
1: { 1: {
"lines": ("1150", "1450/1550"), "lines": ("1150", "1450/1550"),
@ -52,7 +60,7 @@ RULES_S4: dict[int, dict[str, Any]] = {
"lines": ("1230", "1510"), "lines": ("1230", "1510"),
"mode": "min", "mode": "min",
"a": ["76"], "a": ["76"],
"a_excl": ["76.07.5", "76.07.9"], "a_excl": ACC76_EXCL,
"b": ["66.04", "67.04"], "b": ["66.04", "67.04"],
"b_excl": [], "b_excl": [],
}, },
@ -60,15 +68,15 @@ RULES_S4: dict[int, dict[str, Any]] = {
"lines": ("1230", "1520"), "lines": ("1230", "1520"),
"mode": "min", "mode": "min",
"a": RECEIVABLES_A, "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": ["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: { 7: {
"lines": ("1230", "1410"), "lines": ("1230", "1410"),
"mode": "min", "mode": "min",
"a": RECEIVABLES_A, "a": RECEIVABLES_A,
"a_excl": ["76.07.5", "76.07.9"], "a_excl": ACC76_EXCL,
"b": ["67"], "b": ["67"],
"b_excl": [], "b_excl": [],
}, },
@ -76,7 +84,7 @@ RULES_S4: dict[int, dict[str, Any]] = {
"lines": ("1230", "1510"), "lines": ("1230", "1510"),
"mode": "min", "mode": "min",
"a": RECEIVABLES_A, "a": RECEIVABLES_A,
"a_excl": ["76.07.5", "76.07.9"], "a_excl": ACC76_EXCL,
"b": ["66"], "b": ["66"],
"b_excl": [], "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 = {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} 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 == {"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 == {"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 @pytest.mark.integration

View File

@ -9,7 +9,7 @@ import pytest
from src.common.exceptions import DomainValidationError from src.common.exceptions import DomainValidationError
from src.domain.consolidated_osv import CounterpartyType 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.models.enums import StatementType
from src.schemas.consolidations import EliminationMatchedPairOut from src.schemas.consolidations import EliminationMatchedPairOut
from src.services import vgo 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} 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: 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)) 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) 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() account_id = uuid.uuid4()
for row in rows: for row in rows:
row.account_id = account_id 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) response = await service.assignment_candidates(version_id)
calls = service._matrix_service.build_pairs.await_args_list calls = service._matrix_service.build_pairs.await_args_list
assert [(call.args[1], call.args[2], call.args[3]) for call in calls] == [ assert [(call.args[1], call.args[2], call.args[3]) for call in calls] == [