fix(#237): «76-исключения» в правилах элиминации и разнесение по стороне строки (AURORA-2034)
Замечание заказчика 09.11.2026, п. 1: счета 76 (аренда, НДС) не должны участвовать в расчётах и засорять таблицу корректировок. Список субсчетов — Лысенко 10.11.2026. - seed правил v1 (правка на месте, решение владельца 10.11.2026): из маски 76 на обеих сторонах правил 5–8 исключены 76.07/76.27/76.37 .1/.5/.9, 76.АВ, 76.ВА, 76.К, 76.Н, 76.НА, 76.ОТ; правило 1 не меняется; на стендах нужен повторный seed; - результирующая таблица §6 (RESULT_ARTICLES) — тот же список ACCOUNT_76_EXCLUDED, совпадение с seed проверяет тест; - дефект: VgoService._rule_accounts сливал счета дебета и кредита правила, исключения одной стороны возвращались через маску другой — Кт-строки 76.* попадали в правила 5–8 и висели в нераспределённых (MULTIPLE_RULES); то же в candidate_rule_ids. Теперь account_owners ведёт стороны раздельно, правило строки — по её стороне (row_side, rules_for); - golden-тест правила 6 на фикстурах РБ: 76.АВ вне правила, ΣA 108 062 446,46, 1,65 % (совпадает с эталоном, OQ-164). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
371c38c17c
commit
d13a9554a1
@ -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"]
|
||||||
|
|||||||
@ -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,7 +100,7 @@ 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()
|
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
|
row.entry_ids not in data.manual_keys
|
||||||
and len(rule_ids) == 1
|
and len(rule_ids) == 1
|
||||||
|
|||||||
@ -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")),
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,94 @@ 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}
|
||||||
|
|
||||||
|
|
||||||
|
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 +305,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] == [
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user