получение разнесение осв по правилам автоматическое + наложение ручных корректировок сверху

This commit is contained in:
PotapovaA 2026-10-30 12:56:39 +03:00
parent 7194a55c15
commit 9bae931454
11 changed files with 762 additions and 8 deletions

View File

@ -16,6 +16,7 @@ from src.services.consolidations import ConsolidationService
from src.services.counterparty_mapping import CounterpartyMappingService
from src.services.elimination_rules import EliminationRuleService
from src.services.intercompany_matrix import IntercompanyMatrixService
from src.services.vgo import VgoService
def get_consolidation_service(
@ -97,3 +98,13 @@ def get_intercompany_matrix_service(
IntercompanyMatrixServiceDep = Annotated[IntercompanyMatrixService, Depends(get_intercompany_matrix_service)]
def get_vgo_service(
session: SessionDep, osv_service: ConsolidatedOsvServiceDep, matrix_service: IntercompanyMatrixServiceDep
) -> VgoService:
"""Сервис подготовки строк ВГО на основе полной сводной ОСВ версии."""
return VgoService(session, osv_service, matrix_service)
VgoServiceDep = Annotated[VgoService, Depends(get_vgo_service)]

View File

@ -14,6 +14,7 @@ from src.api.endpoints.consolidations import (
periods,
status,
versions,
vgo_assignment,
)
router = APIRouter(tags=["consolidations"])
@ -28,3 +29,4 @@ router.include_router(export.router)
router.include_router(mapping.router)
router.include_router(elimination_rule.router)
router.include_router(intercompany_matrix.router)
router.include_router(vgo_assignment.router)

View File

@ -0,0 +1,22 @@
"""Подготовка распределения строк ОСВ по статьям КФО."""
import uuid
from fastapi import APIRouter, Depends
from src.api.dependencies.auth import require
from src.api.dependencies.consolidations import VgoServiceDep
from src.schemas.consolidations import VgoAssignmentCandidatesOut
router = APIRouter(prefix="/consolidations/versions")
@router.post(
"/{version_id}/vgo-assignments/distribute",
dependencies=[Depends(require("CONSOLIDATION_EDIT"))],
response_model=VgoAssignmentCandidatesOut,
summary="Агрегаты ВГО по правилам и компаниям с ручными назначениями",
)
async def distribute_articles(version_id: uuid.UUID, service: VgoServiceDep) -> VgoAssignmentCandidatesOut:
"""Возвращает агрегаты и ручные назначения по группам, а также строки без назначения."""
return await service.assignment_candidates(version_id)

View File

@ -8,7 +8,7 @@ import datetime
import decimal
import uuid
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, text
from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@ -16,6 +16,50 @@ from .base import Base, UUIDPrimaryMixin
from .enums import CorrectionStatus, StatementType, pg_enum
class VgoAssignment(UUIDPrimaryMixin, Base):
"""Выбор правила для строки ОСВ в рамках версии и типа отчётности."""
__tablename__ = "vgo_assignment"
__table_args__ = (
Index(
"uq_vgo_assignment_version_type_entry",
"consolidation_version_id",
"statement_type",
"osv_entry_id",
unique=True,
),
Index("ix_vgo_assignment_osv_entry_id", "osv_entry_id"),
CheckConstraint("action IN ('ASSIGN', 'UNASSIGNED')", name="action_values"),
CheckConstraint(
"(action = 'ASSIGN' AND rule_id IS NOT NULL) OR (action = 'UNASSIGNED' AND rule_id IS NULL)",
name="action_rule_consistency",
),
)
consolidation_version_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("consolidation_version.id", name="fk_vgo_assignment_version"), nullable=False
)
statement_type: Mapped[StatementType] = mapped_column(pg_enum("statement_type"), nullable=False)
osv_entry_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("osv_entry.id", name="fk_vgo_assignment_entry"), nullable=False
)
rule_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("elimination_rule.id", name="fk_vgo_assignment_rule")
)
action: Mapped[str] = mapped_column(String, nullable=False)
is_exclude: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("app_user.id", name="fk_vgo_assignment_created_by")
)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=text("now()")
)
updated_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("app_user.id", name="fk_vgo_assignment_updated_by")
)
updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
class Correction(UUIDPrimaryMixin, Base):
"""Корректировка ВГО: итог = свод первички − Σ применённых корректировок; пара — по (rule_id, компании)."""

View File

@ -0,0 +1,24 @@
"""Ручные назначения строк ОСВ в версии консолидации."""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.enums import StatementType
from src.models.kfo import VgoAssignment
async def list_assignments(
session: AsyncSession, version_id: uuid.UUID, statement_type: StatementType
) -> list[VgoAssignment]:
"""Все ручные решения для версии и типа отчётности."""
stmt = (
select(VgoAssignment)
.where(
VgoAssignment.consolidation_version_id == version_id,
VgoAssignment.statement_type == statement_type,
)
.order_by(VgoAssignment.created_at, VgoAssignment.id)
)
return list((await session.scalars(stmt)).all())

View File

@ -425,6 +425,47 @@ class EliminationPairsOut(BaseModel):
return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP)
class VgoAssignmentRowOut(ConsolidatedRowOut):
"""Строка свода с данными ручного решения, если оно есть."""
osv_entry_id: uuid.UUID | None = None
action: Literal["ASSIGN", "UNASSIGNED"] | None = None
is_exclude: bool | None = None
class VgoAssignmentGroupOut(BaseModel):
"""Принятые агрегаты и ручные строки одного правила и направленной пары компаний."""
rule_id: uuid.UUID
rule_no: int
article_1_line: str
article_2_line: str
measure: Literal["closing", "turnover"]
company_a_id: uuid.UUID
company_a_name: str | None
company_b_id: uuid.UUID
company_b_name: str | None
amount_a: Decimal
amount_b: Decimal
deviation_amount: Decimal
deviation_percent: Decimal | None
matched: list[EliminationMatchedPairOut] = Field(default_factory=list)
with_discrepancy: list[EliminationMatchedPairOut] = Field(default_factory=list)
manual: list[VgoAssignmentRowOut] = Field(default_factory=list, exclude_if=lambda rows: not rows)
@field_validator("amount_a", "amount_b", "deviation_amount", "deviation_percent")
@classmethod
def _two_decimals(cls, value: Decimal | None) -> Decimal | None:
return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP)
class VgoAssignmentCandidatesOut(BaseModel):
"""Принятые агрегаты и общий список строк свода, оставшихся без назначения."""
groups: list[VgoAssignmentGroupOut]
unassigned: list[VgoAssignmentRowOut]
EliminationTotalStatus = Literal["AUTO", "DISCREPANCY", "ONE_SIDED", "MANUAL"]

View File

@ -43,7 +43,7 @@ from src.schemas.consolidations import (
IntercompanyResultTableOut,
UnpairedReason,
)
from src.services.consolidated_osv import ConsolidatedOsvService
from src.services.consolidated_osv import ConsolidatedOsvBuildResult, ConsolidatedOsvService
Measure = Literal["closing", "turnover"]
Side = Literal["D", "K"]
@ -553,11 +553,25 @@ class IntercompanyMatrixService:
self._scope = scope
self._osv_service = service_osv
async def build_pairs(
self, version_id: uuid.UUID, rule_id: uuid.UUID, company_a_id: uuid.UUID, company_b_id: uuid.UUID
async def build_pairs( # noqa: PLR0913 - четыре идентификатора маршрута и необязательный готовый свод со строками
self,
version_id: uuid.UUID,
rule_id: uuid.UUID,
company_a_id: uuid.UUID,
company_b_id: uuid.UUID,
rows: Sequence[ConsolidatedRow] | None = None,
*,
osv_result: ConsolidatedOsvBuildResult | None = None,
) -> EliminationPairsOut:
"""Счета правила ББ и строки одной пары компаний без сохранения в БД (§4–§5)."""
osv_result = await self._osv_service.build_with_members(version_id)
"""Счета правила ББ и строки одной пары компаний без сохранения в БД (§4–§5).
`rows` ограничивает исходные строки для поиска пар; без него используется весь свод. Готовый `osv_result`
позволяет повторно использовать уже собранный свод при расчёте нескольких правил и пар.
"""
if osv_result is None:
osv_result = await self._osv_service.build_with_members(version_id)
elif osv_result.version.id != version_id:
raise ValueError("Передан свод другой версии консолидации")
version = osv_result.version
member_ids = set(osv_result.members)
if company_a_id == company_b_id or company_a_id not in member_ids or company_b_id not in member_ids:
@ -572,7 +586,7 @@ class IntercompanyMatrixService:
# в результаты разных правил. Глобальное исключение повторного использования потребует изменения модели
# данных: нужно хранить/передавать принадлежность исходной строки конкретной паре между правилами.
packing = _pack_preset_rows(
osv_result.consolidated.rows,
osv_result.consolidated.rows if rows is None else rows,
selection,
PairContext(rule, company_a_id, company_b_id),
)

271
api/src/services/vgo.py Normal file
View File

@ -0,0 +1,271 @@
"""Подготовка строк сводной ОСВ для распределения ВГО по правилам."""
import uuid
from collections import defaultdict
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from decimal import Decimal
from sqlalchemy.ext.asyncio import AsyncSession
from src.common.exceptions import DomainValidationError
from src.domain.consolidated_osv import ConsolidatedRow
from src.domain.elimination_packing import calculate_rule_totals
from src.models.accounting import EliminationRule
from src.models.enums import StatementType
from src.models.kfo import VgoAssignment
from src.repositories import elimination_rules as elimination_rules_repo
from src.repositories import vgo_assignments as vgo_assignments_repo
from src.schemas.consolidations import (
EliminationMatchedPairOut,
VgoAssignmentCandidatesOut,
VgoAssignmentGroupOut,
VgoAssignmentRowOut,
)
from src.services.consolidated_osv import ConsolidatedOsvBuildResult, ConsolidatedOsvService
from src.services.intercompany_matrix import IntercompanyMatrixService
ZERO = Decimal("0")
GroupKey = tuple[uuid.UUID, uuid.UUID, uuid.UUID]
ManualItem = tuple[VgoAssignmentRowOut, Decimal, Decimal]
def assignment_candidates(rows: Sequence[ConsolidatedRow]) -> list[ConsolidatedRow]:
"""Оставить строки с контрагентом и ненулевым оборотом или конечным остатком.
Все четыре суммы проверяются вместе; `None` равнозначен нулю. Начальный остаток не учитывается.
"""
candidates = []
for row in rows:
if row.counterparty_id is None:
continue
amounts = (row.turnover_debit, row.turnover_credit, row.closing_debit, row.closing_credit)
for amount in amounts:
if amount not in (None, ZERO):
candidates.append(row)
break
return candidates
def _ids_from_aggregate(aggregate: EliminationMatchedPairOut) -> set[uuid.UUID]:
return set(aggregate.osv_entry_ids_a) | set(aggregate.osv_entry_ids_b)
def _assignment_row(row: ConsolidatedRow, assignment: VgoAssignment | None = None) -> VgoAssignmentRowOut:
output = VgoAssignmentRowOut.model_validate(row, from_attributes=True)
if assignment is not None:
output.osv_entry_id = assignment.osv_entry_id if len(row.entry_ids) == 1 else None
output.action = "ASSIGN" if assignment.action == "ASSIGN" else "UNASSIGNED"
output.is_exclude = assignment.is_exclude
return output
def _manual_key_amount(row: ConsolidatedRow, rule: EliminationRule) -> tuple[GroupKey, Decimal, Decimal]:
"""Определить сторону ручной строки по сумме и стороне A правила."""
if rule.measure == "turnover":
debit, credit = row.turnover_debit or ZERO, row.turnover_credit or ZERO
else:
debit, credit = row.closing_debit or ZERO, row.closing_credit or ZERO
first, second = (debit, credit) if rule.side_1 == "D" else (credit, debit)
assert row.counterparty_id is not None
if first != ZERO or second == ZERO:
return (rule.id, row.legal_entity_id, row.counterparty_id), first, ZERO
return (rule.id, row.counterparty_id, row.legal_entity_id), ZERO, second
@dataclass
class _ManualResult:
"""Ручные решения по строкам свода и группам правил."""
groups: dict[GroupKey, list[ManualItem]]
by_row: dict[tuple[uuid.UUID, ...], VgoAssignment]
assigned_ids: set[uuid.UUID]
def _manual_assignments(
rows: Sequence[ConsolidatedRow], assignments: Sequence[VgoAssignment], rules: Sequence[EliminationRule]
) -> _ManualResult:
by_entry = {assignment.osv_entry_id: assignment for assignment in assignments}
rules_by_id = {rule.id: rule for rule in rules}
groups: defaultdict[GroupKey, list[ManualItem]] = defaultdict(list)
by_row: dict[tuple[uuid.UUID, ...], VgoAssignment] = {}
assigned_ids: set[uuid.UUID] = set()
for row in rows:
found = [by_entry[entry_id] for entry_id in row.entry_ids if entry_id in by_entry]
if not found:
continue
if len(found) != len(row.entry_ids):
raise DomainValidationError(
"AMBIGUOUS_VGO_ASSIGNMENT",
"Для части исходных строк сводной ОСВ есть ручное назначение, а для остальных нет",
)
assignment = found[0]
if any(
(item.action, item.rule_id, item.is_exclude)
!= (assignment.action, assignment.rule_id, assignment.is_exclude)
for item in found[1:]
):
raise DomainValidationError(
"CONFLICTING_VGO_ASSIGNMENT",
"У исходных строк одной сводной ОСВ разные ручные назначения",
)
by_row[row.entry_ids] = assignment
rule = rules_by_id.get(assignment.rule_id) if assignment.rule_id is not None else None
if assignment.action == "ASSIGN" and rule is not None and row.counterparty_id is not None:
key, amount_a, amount_b = _manual_key_amount(row, rule)
groups[key].append((_assignment_row(row, assignment), amount_a, amount_b))
assigned_ids.update(row.entry_ids)
return _ManualResult(groups, by_row, assigned_ids)
def _unassigned_rows(
all_rows: Sequence[ConsolidatedRow],
candidates: Sequence[ConsolidatedRow],
manual: _ManualResult,
used_ids: set[uuid.UUID],
) -> list[VgoAssignmentRowOut]:
rows = [*candidates]
candidate_keys = {row.entry_ids for row in candidates}
rows.extend(row for row in all_rows if row.entry_ids not in candidate_keys and row.entry_ids in manual.by_row)
return [
_assignment_row(row, manual.by_row.get(row.entry_ids))
for row in rows
if used_ids.isdisjoint(row.entry_ids) and manual.assigned_ids.isdisjoint(row.entry_ids)
]
@dataclass
class _AggregateSearchResult:
"""Принятые агрегаты, кандидаты с расхождением и занятые строки."""
matched_aggregates: defaultdict[GroupKey, list[EliminationMatchedPairOut]] = field(
default_factory=lambda: defaultdict(list)
)
diff_aggregates: list[EliminationMatchedPairOut] = field(default_factory=list)
used_ids: set[uuid.UUID] = field(default_factory=set)
def _select_diff_aggregates(search: _AggregateSearchResult) -> dict[GroupKey, list[EliminationMatchedPairOut]]:
"""Принять только расхождения с единственным вариантом для набора исходных строк."""
by_ids: defaultdict[tuple[uuid.UUID, ...], list[EliminationMatchedPairOut]] = defaultdict(list)
for aggregate in search.diff_aggregates:
ids = _ids_from_aggregate(aggregate)
if ids and search.used_ids.isdisjoint(ids):
by_ids[tuple(sorted(ids))].append(aggregate)
diff_aggregates: defaultdict[GroupKey, list[EliminationMatchedPairOut]] = defaultdict(list)
for options in by_ids.values():
if len(options) == 1:
aggregate = options[0]
key = (aggregate.rule_id, aggregate.company_a_id, aggregate.company_b_id)
diff_aggregates[key].append(aggregate)
search.used_ids.update(_ids_from_aggregate(aggregate))
return diff_aggregates
def _build_groups(
search: _AggregateSearchResult,
diff_aggregates: dict[GroupKey, list[EliminationMatchedPairOut]],
rules: Sequence[EliminationRule],
manual: dict[GroupKey, list[ManualItem]],
names: Mapping[uuid.UUID, str],
) -> list[VgoAssignmentGroupOut]:
"""Сгруппировать принятые агрегаты и посчитать суммы по правилу и компаниям."""
rule_by_id = {rule.id: rule for rule in rules}
keys = list(dict.fromkeys([*search.matched_aggregates, *diff_aggregates, *manual]))
groups = []
for rule_id, company_a_id, company_b_id in keys:
key = (rule_id, company_a_id, company_b_id)
matched = search.matched_aggregates.get(key, [])
with_discrepancy = diff_aggregates.get(key, [])
aggregates = [*matched, *with_discrepancy]
manual_rows = manual.get(key, [])
amount_a = sum((aggregate.amount_a for aggregate in aggregates), ZERO)
amount_b = sum((aggregate.amount_b for aggregate in aggregates), ZERO)
amount_a += sum((amount_a for row, amount_a, _ in manual_rows if not row.is_exclude), ZERO)
amount_b += sum((amount_b for row, _, amount_b in manual_rows if not row.is_exclude), ZERO)
totals = calculate_rule_totals(amount_a, amount_b, manual=False)
groups.append(
VgoAssignmentGroupOut(
rule_id=rule_id,
rule_no=rule_by_id[rule_id].rule_no,
article_1_line=rule_by_id[rule_id].article_1_line,
article_2_line=rule_by_id[rule_id].article_2_line,
measure=rule_by_id[rule_id].measure,
company_a_id=company_a_id,
company_a_name=names.get(company_a_id),
company_b_id=company_b_id,
company_b_name=names.get(company_b_id),
amount_a=amount_a,
amount_b=amount_b,
deviation_amount=totals.deviation_amount,
deviation_percent=totals.deviation_percent,
matched=matched,
with_discrepancy=with_discrepancy,
manual=[row for row, _, _ in manual_rows],
)
)
return groups
class VgoService:
"""Операции ВГО над сводной ОСВ версии консолидации."""
def __init__(
self, session: AsyncSession, osv_service: ConsolidatedOsvService, matrix_service: IntercompanyMatrixService
) -> None:
self._osv_service = osv_service
self._matrix_service = matrix_service
self.session = session
async def _find_aggregates(
self,
version_id: uuid.UUID,
osv_result: ConsolidatedOsvBuildResult,
rules: Sequence[EliminationRule],
rows: Sequence[ConsolidatedRow],
) -> _AggregateSearchResult:
"""Перебрать правила и компании, сразу закрепляя только точные агрегаты."""
search = _AggregateSearchResult()
for company_a in osv_result.members:
for company_b in osv_result.members:
if company_a == company_b:
continue
for rule in rules:
free_rows = [row for row in rows if search.used_ids.isdisjoint(row.entry_ids)]
result = await self._matrix_service.build_pairs(
version_id, rule.id, company_a, company_b, free_rows, osv_result=osv_result
)
for aggregate in result.matched:
ids = _ids_from_aggregate(aggregate)
if search.used_ids.isdisjoint(ids):
search.used_ids.update(ids)
key = (aggregate.rule_id, aggregate.company_a_id, aggregate.company_b_id)
search.matched_aggregates[key].append(aggregate)
search.diff_aggregates.extend(result.with_discrepancy)
return search
async def assignment_candidates(self, version_id: uuid.UUID) -> VgoAssignmentCandidatesOut:
"""Найти агрегаты и общие для всех правил строки без назначения, не записывая их в БД."""
osv_result = await self._osv_service.build_with_members(version_id)
all_rules = await elimination_rules_repo.list_rules(self.session, osv_result.version.elimination_rules_version)
rules = [
rule for rule in all_rules if rule.statement_type == StatementType.balance and rule.measure == "closing"
]
rows = assignment_candidates(osv_result.consolidated.rows)
assignments = await vgo_assignments_repo.list_assignments(self.session, version_id, StatementType.balance)
manual = _manual_assignments(osv_result.consolidated.rows, assignments, rules)
free_rows = [row for row in rows if row.entry_ids not in manual.by_row]
search = await self._find_aggregates(version_id, osv_result, rules, free_rows)
diff_aggregates = _select_diff_aggregates(search)
names = {company_id: entity.name for company_id, entity in osv_result.members.items()}
for row in osv_result.consolidated.rows:
if row.counterparty_id is not None:
name = row.counterparty_name or row.counterparty_entity_name
if name is not None:
names.setdefault(row.counterparty_id, name)
groups = _build_groups(search, diff_aggregates, rules, manual.groups, names)
unassigned = _unassigned_rows(osv_result.consolidated.rows, rows, manual, search.used_ids)
return VgoAssignmentCandidatesOut(groups=groups, unassigned=unassigned)

View File

@ -27,6 +27,7 @@ from src.domain.consolidated_osv import (
)
from src.domain.osv_mapping import Resolution
from src.models.enums import CounterpartyMatchKind, PeriodQuarter
from src.services.vgo import assignment_candidates
Q1, Q2, Q3 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3
INN, NAME, ALIAS, MANUAL, PROPAGATED = (
@ -142,6 +143,22 @@ def _sums(row: ConsolidatedRow) -> tuple[Decimal | None, ...]:
)
def test_assignment_candidates_require_counterparty_and_any_nonzero_turnover_or_closing() -> None:
document = _doc(cumulative=True)
base = _build([document], [_row(document, counterparty_id=SCH.id)]).rows[0]
zero = dataclasses.replace(base, opening_debit=Decimal("100"))
empty = dataclasses.replace(
base, turnover_debit=None, turnover_credit=None, closing_debit=None, closing_credit=None
)
without_counterparty = dataclasses.replace(base, counterparty_id=None, turnover_debit=Decimal("1"))
nonzero = [
dataclasses.replace(base, **{field: Decimal("1")})
for field in ("turnover_debit", "turnover_credit", "closing_debit", "closing_credit")
]
assert assignment_candidates([zero, empty, without_counterparty, *nonzero]) == nonzero
# --------------------------------------------------------------------------- приведение периодов

View File

@ -4,7 +4,7 @@ import datetime
import uuid
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, Mock
import pytest
from fastapi import status as http_status
@ -89,6 +89,35 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None:
]
async def test_build_pairs_uses_supplied_rows_and_prebuilt_osv(monkeypatch: pytest.MonkeyPatch) -> None:
version_id, rule_id, company_a, company_b = (uuid.uuid4() for _ in range(4))
full_rows = [object()]
selected_rows: list[object] = []
osv_result = SimpleNamespace(
version=SimpleNamespace(id=version_id),
members={company_a: object(), company_b: object()},
consolidated=SimpleNamespace(rows=full_rows),
)
osv_service = SimpleNamespace(build_with_members=AsyncMock(side_effect=AssertionError("свод собран повторно")))
service = matrix.IntercompanyMatrixService(AsyncMock(), None, osv_service)
rule = SimpleNamespace(id=rule_id, rule_no=1, version_no=2, measure="closing")
service._resolve_preset_selection = AsyncMock( # type: ignore[method-assign]
return_value=(matrix.MatrixSelection(frozenset(), frozenset(), "closing"), rule)
)
monkeypatch.setattr(matrix.directory_repo, "list_accounts", AsyncMock(return_value=[]))
totals = matrix.RuleTotals(Decimal("0"), Decimal("0"), Decimal("0"), None, Decimal("0"), False)
pack_rows = Mock(return_value=matrix.RulePacking([], [], [], totals))
monkeypatch.setattr(matrix, "_pack_preset_rows", pack_rows)
response = await service.build_pairs(
version_id, rule_id, company_a, company_b, selected_rows, osv_result=osv_result
)
assert response.rule_id == rule_id
assert pack_rows.call_args.args[0] is selected_rows
osv_service.build_with_members.assert_not_awaited()
async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkeypatch: pytest.MonkeyPatch) -> None:
parent = _account(AccountKind.AP)
child = _account(AccountKind.AP, parent.id)

279
api/tests/test_vgo.py Normal file
View File

@ -0,0 +1,279 @@
"""Группировка найденных пар ВГО и единственность исходных строк во всей версии."""
import uuid
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from src.common.exceptions import DomainValidationError
from src.domain.consolidated_osv import CounterpartyType
from src.models.enums import StatementType
from src.schemas.consolidations import EliminationMatchedPairOut
from src.services import vgo
def _pair( # noqa: PLR0913 - фабрика тестовой пары с настраиваемой суммой
rule_id: uuid.UUID,
company_a: uuid.UUID,
company_b: uuid.UUID,
entry_a: uuid.UUID,
entry_b: uuid.UUID,
*,
amount_b: str = "10",
) -> EliminationMatchedPairOut:
deviation = Decimal("10") - Decimal(amount_b)
return EliminationMatchedPairOut(
rule_id=rule_id,
company_a_id=company_a,
company_b_id=company_b,
amount_a=Decimal("10"),
amount_b=Decimal(amount_b),
deviation_amount=deviation,
deviation_percent=deviation / Decimal(amount_b) * 100,
osv_entry_ids_a=[entry_a],
osv_entry_ids_b=[entry_b],
)
def _row(entry_id: uuid.UUID, company_a: uuid.UUID, company_b: uuid.UUID, index: int) -> SimpleNamespace:
return SimpleNamespace(
entry_ids=(entry_id,),
legal_entity_id=company_a,
legal_entity_name="A",
legal_entity_inn="1234567890",
account_code="76",
account_root="76",
period="2026 Q1",
is_cumulative=True,
row_number=index,
counterparty_name="B",
counterparty_inn="0987654321",
counterparty_id=company_b,
counterparty_entity_name="B",
counterparty_entity_inn="0987654321",
counterparty_type=CounterpartyType.GSK,
match_kind=None,
match_stored=True,
opening_debit=Decimal("0"),
opening_credit=Decimal("0"),
turnover_debit=Decimal("0"),
turnover_credit=Decimal("0"),
closing_debit=Decimal("1"),
closing_credit=Decimal("0"),
warnings=(),
)
def _members(company_a: uuid.UUID, company_b: uuid.UUID) -> dict[uuid.UUID, SimpleNamespace]:
return {company_a: SimpleNamespace(name="A"), company_b: SimpleNamespace(name="B")}
async def test_pairs_are_grouped_and_entry_ids_are_used_once(monkeypatch: pytest.MonkeyPatch) -> None:
version_id, company_a, company_b, rule_1, rule_2 = (uuid.uuid4() for _ in range(5))
entry_ids = [uuid.uuid4() for _ in range(11)]
rows = [_row(entry_id, company_a, company_b, index) for index, entry_id in enumerate(entry_ids)]
osv_result = SimpleNamespace(
version=SimpleNamespace(elimination_rules_version=2),
consolidated=SimpleNamespace(rows=rows),
members=_members(company_a, company_b),
)
rules = [
SimpleNamespace(
id=rule_1, rule_no=1, statement_type=StatementType.balance, measure="closing",
article_1_line="Статья A1", article_2_line="Статья B1",
),
SimpleNamespace(
id=rule_2, rule_no=2, statement_type=StatementType.balance, measure="closing",
article_1_line="Статья A2", article_2_line="Статья B2",
),
]
monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=rules))
monkeypatch.setattr(vgo.vgo_assignments_repo, "list_assignments", AsyncMock(return_value=[]))
exact_1 = _pair(rule_1, company_a, company_b, entry_ids[0], entry_ids[1])
exact_1_more = _pair(rule_1, company_a, company_b, entry_ids[9], entry_ids[10])
exact_conflict = _pair(rule_2, company_a, company_b, entry_ids[0], entry_ids[2])
exact_2 = _pair(rule_2, company_b, company_a, entry_ids[2], entry_ids[3])
discrepancy_1 = _pair(rule_1, company_a, company_b, entry_ids[4], entry_ids[5])
discrepancy_conflict = _pair(rule_2, company_b, company_a, entry_ids[5], entry_ids[4])
discrepancy_unique = _pair(rule_1, company_b, company_a, entry_ids[7], entry_ids[8], amount_b="9")
async def build_pairs(
_version: uuid.UUID, rule: uuid.UUID, a: uuid.UUID, b: uuid.UUID, available: list, **_kwargs: object
) -> SimpleNamespace:
if (rule, a, b) == (rule_1, company_a, company_b):
return SimpleNamespace(rule_no=1, matched=[exact_1, exact_1_more], with_discrepancy=[discrepancy_1])
if (rule, a, b) == (rule_2, company_a, company_b):
assert entry_ids[0] not in {row.entry_ids[0] for row in available}
return SimpleNamespace(rule_no=2, matched=[exact_conflict], with_discrepancy=[])
if (rule, a, b) == (rule_1, company_b, company_a):
return SimpleNamespace(rule_no=1, matched=[], with_discrepancy=[discrepancy_unique])
if (rule, a, b) == (rule_2, company_b, company_a):
return SimpleNamespace(rule_no=2, matched=[exact_2], with_discrepancy=[discrepancy_conflict])
return SimpleNamespace(rule_no=1, matched=[], with_discrepancy=[])
service = vgo.VgoService(
AsyncMock(),
SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)),
SimpleNamespace(build_pairs=AsyncMock(side_effect=build_pairs)),
)
response = await service.assignment_candidates(version_id)
groups = response.groups
assert [(group.rule_id, group.company_a_id, group.company_b_id) for group in groups] == [
(rule_1, company_a, company_b),
(rule_2, company_b, company_a),
(rule_1, company_b, company_a),
]
assert groups[0].matched == [exact_1, exact_1_more]
assert (groups[0].article_1_line, groups[0].article_2_line) == ("Статья A1", "Статья B1")
assert (groups[0].company_a_name, groups[0].company_b_name) == ("A", "B")
assert (groups[0].amount_a, groups[0].amount_b, groups[0].deviation_amount) == (
Decimal("20.00"),
Decimal("20.00"),
Decimal("0.00"),
)
assert groups[0].with_discrepancy == []
assert "manual" not in response.model_dump()["groups"][0]
assert groups[1].matched == [exact_2]
assert groups[1].with_discrepancy == []
assert groups[2].matched == []
assert groups[2].with_discrepancy == [discrepancy_unique]
assert (groups[2].amount_a, groups[2].amount_b, groups[2].deviation_amount, groups[2].deviation_percent) == (
Decimal("10.00"),
Decimal("9.00"),
Decimal("1.00"),
Decimal("11.11"),
)
assert [row.entry_ids for row in response.unassigned] == [
[entry_ids[4]],
[entry_ids[5]],
[entry_ids[6]],
]
async def test_manual_assignments_override_auto_and_keep_exclusion_flag(monkeypatch: pytest.MonkeyPatch) -> None:
version_id, company_a, company_b, rule_id = (uuid.uuid4() for _ in range(4))
entry_ids = [uuid.uuid4() for _ in range(4)]
rows = [_row(entry_id, company_a, company_b, index) for index, entry_id in enumerate(entry_ids)]
for row, amount in zip(rows, ("10", "20", "5", "1"), strict=True):
row.closing_debit = Decimal(amount)
osv_result = SimpleNamespace(
version=SimpleNamespace(elimination_rules_version=2),
consolidated=SimpleNamespace(rows=rows),
members=_members(company_a, company_b),
)
rule = SimpleNamespace(
id=rule_id, rule_no=1, statement_type=StatementType.balance, measure="closing", side_1="D",
article_1_line="Статья A", article_2_line="Статья B",
)
assignments = [
SimpleNamespace(osv_entry_id=entry_ids[0], rule_id=rule_id, action="ASSIGN", is_exclude=False),
SimpleNamespace(osv_entry_id=entry_ids[1], rule_id=rule_id, action="ASSIGN", is_exclude=True),
SimpleNamespace(osv_entry_id=entry_ids[2], rule_id=None, action="UNASSIGNED", is_exclude=True),
]
monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=[rule]))
monkeypatch.setattr(vgo.vgo_assignments_repo, "list_assignments", AsyncMock(return_value=assignments))
async def build_pairs(
_version: uuid.UUID, _rule: uuid.UUID, _a: uuid.UUID, _b: uuid.UUID, available: list, **_kwargs: object
) -> SimpleNamespace:
assert [row.entry_ids[0] for row in available] == [entry_ids[3]]
return SimpleNamespace(matched=[], with_discrepancy=[])
service = vgo.VgoService(
AsyncMock(),
SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)),
SimpleNamespace(build_pairs=AsyncMock(side_effect=build_pairs)),
)
response = await service.assignment_candidates(version_id)
assert len(response.groups) == 1
assert (response.groups[0].rule_id, response.groups[0].company_a_id, response.groups[0].company_b_id) == (
rule_id,
company_a,
company_b,
)
assert (response.groups[0].amount_a, response.groups[0].amount_b) == (Decimal("10.00"), Decimal("0.00"))
assert [(row.osv_entry_id, row.is_exclude) for row in response.groups[0].manual] == [
(entry_ids[0], False),
(entry_ids[1], True),
]
assert "manual" in response.model_dump()["groups"][0]
assert [(row.entry_ids, row.action, row.is_exclude) for row in response.unassigned] == [
([entry_ids[2]], "UNASSIGNED", True),
([entry_ids[3]], None, None),
]
async def test_glued_row_requires_assignments_for_all_source_entries(monkeypatch: pytest.MonkeyPatch) -> None:
version_id, company_a, company_b, rule_id, entry_a, entry_b = (uuid.uuid4() for _ in range(6))
row = _row(entry_a, company_a, company_b, 1)
row.entry_ids = (entry_a, entry_b)
osv_result = SimpleNamespace(
version=SimpleNamespace(elimination_rules_version=2),
consolidated=SimpleNamespace(rows=[row]),
members=_members(company_a, company_b),
)
assignment = SimpleNamespace(osv_entry_id=entry_a, rule_id=rule_id, action="ASSIGN", is_exclude=False)
monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=[]))
monkeypatch.setattr(vgo.vgo_assignments_repo, "list_assignments", AsyncMock(return_value=[assignment]))
service = vgo.VgoService(
AsyncMock(),
SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)),
SimpleNamespace(build_pairs=AsyncMock()),
)
with pytest.raises(DomainValidationError, match="для остальных нет"):
await service.assignment_candidates(version_id)
async def test_glued_row_with_same_assignments_is_counted_once(monkeypatch: pytest.MonkeyPatch) -> None:
version_id, company_a, company_b, rule_id, entry_a, entry_b = (uuid.uuid4() for _ in range(6))
row = _row(entry_a, company_a, company_b, 1)
row.entry_ids = (entry_a, entry_b)
row.closing_debit = Decimal("10")
osv_result = SimpleNamespace(
version=SimpleNamespace(elimination_rules_version=2),
consolidated=SimpleNamespace(rows=[row]),
members=_members(company_a, company_b),
)
rule = SimpleNamespace(
id=rule_id, rule_no=1, statement_type=StatementType.balance, measure="closing", side_1="D",
article_1_line="Статья A", article_2_line="Статья B",
)
assignments = [
SimpleNamespace(osv_entry_id=entry_id, rule_id=rule_id, action="ASSIGN", is_exclude=False)
for entry_id in (entry_a, entry_b)
]
monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=[rule]))
monkeypatch.setattr(vgo.vgo_assignments_repo, "list_assignments", AsyncMock(return_value=assignments))
empty_result = SimpleNamespace(matched=[], with_discrepancy=[])
matrix_service = SimpleNamespace(build_pairs=AsyncMock(return_value=empty_result))
service = vgo.VgoService(
AsyncMock(), SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)), matrix_service
)
response = await service.assignment_candidates(version_id)
assert len(response.groups) == 1
assert response.groups[0].amount_a == Decimal("10.00")
assert len(response.groups[0].manual) == 1
assert response.groups[0].manual[0].entry_ids == [entry_a, entry_b]
assert response.groups[0].manual[0].osv_entry_id is None
assert response.unassigned == []
assert all(call.args[4] == [] for call in matrix_service.build_pairs.await_args_list)
def test_glued_row_rejects_conflicting_manual_assignments() -> None:
company_a, company_b, rule_id, entry_a, entry_b = (uuid.uuid4() for _ in range(5))
row = _row(entry_a, company_a, company_b, 1)
row.entry_ids = (entry_a, entry_b)
assignments = [
SimpleNamespace(osv_entry_id=entry_a, rule_id=rule_id, action="ASSIGN", is_exclude=False),
SimpleNamespace(osv_entry_id=entry_b, rule_id=None, action="UNASSIGNED", is_exclude=False),
]
with pytest.raises(DomainValidationError, match="разные ручные назначения"):
vgo._manual_assignments([row], assignments, [])