631 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Подготовка строк сводной ОСВ для распределения ВГО по правилам."""
import uuid
from collections import defaultdict
from collections.abc import Mapping, Sequence, Set
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Literal, cast
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.audit import RequestAudit
from src.common.clock import utc_now
from src.common.exceptions import DomainValidationError
from src.domain.consolidated_osv import ConsolidatedRow
from src.domain.elimination_packing import calculate_rule_totals
from src.domain.policy import VisibilityScope
from src.domain.vgo_distribution import (
Distribution,
DistributionInput,
account_owners,
distribute_vgo_rows,
select_exact_aggregates,
)
from src.models.accounting import EliminationRule
from src.models.enums import AuditAction, AuditSection, StatementType
from src.models.kfo import VgoAssignment
from src.repositories import directories as directories_repo
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,
VgoAssignmentAttachRuleIn,
VgoAssignmentCandidatesOut,
VgoAssignmentDetachRuleIn,
VgoAssignmentExcludeIn,
VgoAssignmentGroupOut,
VgoAssignmentOut,
VgoAssignmentPositionOut,
VgoAssignmentRowOut,
VgoUnallocatedPositionOut,
)
from src.services.consolidated_osv import ConsolidatedOsvBuildResult, ConsolidatedOsvService
from src.services.consolidation_guard import lock_editable_version
from src.services.intercompany_matrix import IntercompanyMatrixService, _make_account_tree
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(
entry_ids=list(row.entry_ids),
legal_entity_id=row.legal_entity_id,
legal_entity_name=row.legal_entity_name,
counterparty_id=row.counterparty_id,
counterparty_name=row.counterparty_entity_name or row.counterparty_name,
account_code=row.account_code,
period=row.period,
opening_debit=row.opening_debit,
opening_credit=row.opening_credit,
turnover_debit=row.turnover_debit,
turnover_credit=row.turnover_credit,
closing_debit=row.closing_debit,
closing_credit=row.closing_credit,
)
if assignment is not 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]
partial_rows: set[tuple[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()
partial_rows: set[tuple[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):
by_row[row.entry_ids] = found[0]
partial_rows.add(row.entry_ids)
continue
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, partial_rows)
def _assign_unpaired(
rows: Sequence[ConsolidatedRow],
manual: _ManualResult,
auto_rules: Mapping[tuple[uuid.UUID, ...], uuid.UUID],
rules: Mapping[uuid.UUID, EliminationRule],
) -> None:
"""Отнести оставшиеся без агрегата строки к единственному правилу их счёта."""
for row in rows:
rule_id = auto_rules.get(row.entry_ids)
if rule_id is None:
continue
rule = rules[rule_id]
key, amount_a, amount_b = _manual_key_amount(row, rule)
output = _assignment_row(row)
output.action = "ASSIGN"
output.is_exclude = False
output.is_auto_assigned = True
manual.groups.setdefault(key, []).append((output, amount_a, amount_b))
manual.assigned_ids.update(row.entry_ids)
def _unassigned_rows(
rows: Sequence[ConsolidatedRow],
manual: _ManualResult,
distribution: Distribution,
) -> list[VgoAssignmentRowOut]:
result = []
for row in rows:
if row.entry_ids not in distribution.unassigned or not manual.assigned_ids.isdisjoint(row.entry_ids):
continue
if row.entry_ids in manual.partial_rows:
item = _assignment_row(row)
item.unassigned_reason = "PARTIAL_MANUAL_ASSIGNMENT"
else:
item = _assignment_row(row, manual.by_row.get(row.entry_ids))
result.append(item)
return result
@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, distribution: Distribution
) -> dict[GroupKey, list[EliminationMatchedPairOut]]:
"""Сгруппировать выбранные доменной функцией расхождения."""
diff_aggregates: defaultdict[GroupKey, list[EliminationMatchedPairOut]] = defaultdict(list)
for index in distribution.selected_diff:
aggregate = search.diff_aggregates[index]
key = (aggregate.rule_id, aggregate.company_a_id, aggregate.company_b_id)
diff_aggregates[key].append(aggregate)
return diff_aggregates
def _position(
row: ConsolidatedRow | VgoAssignmentRowOut,
side: Literal["D", "K"],
amount: Decimal,
*,
action: Literal["ASSIGN"] | None = None,
candidate_rule_ids: Sequence[uuid.UUID] = (),
) -> VgoAssignmentPositionOut | None:
if row.counterparty_id is None:
return None
return VgoAssignmentPositionOut(
entry_ids=list(row.entry_ids),
legal_entity_id=row.legal_entity_id,
counterparty_id=row.counterparty_id,
account_code=row.account_code or "",
side=side,
amount=amount,
action=action,
candidate_rule_ids=list(candidate_rule_ids),
)
def _build_groups( # noqa: PLR0913, PLR0917 - результат поиска и справочники передаются раздельно
search: _AggregateSearchResult,
diff_aggregates: dict[GroupKey, list[EliminationMatchedPairOut]],
rules: Sequence[EliminationRule],
manual: dict[GroupKey, list[ManualItem]],
names: Mapping[uuid.UUID, str],
rows: Sequence[ConsolidatedRow],
owners: Mapping[uuid.UUID, Set[uuid.UUID]],
) -> list[VgoAssignmentGroupOut]:
"""Сгруппировать принятые агрегаты и посчитать суммы по правилу и компаниям."""
rule_by_id = {rule.id: rule for rule in rules}
rows_by_entry_id = {entry_id: row for row in rows for entry_id in row.entry_ids}
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, [])
rule = rule_by_id[rule_id]
if rule.side_1 not in ("D", "K"):
raise DomainValidationError("INVALID_VGO_RULE_SIDE", "У правила задана неизвестная сторона")
side_a = cast(Literal["D", "K"], rule.side_1)
side_b: Literal["D", "K"] = "K" if side_a == "D" else "D"
positions: dict[tuple[tuple[uuid.UUID, ...], str], VgoAssignmentPositionOut] = {}
for aggregate in aggregates:
for entry_ids, side in ((aggregate.osv_entry_ids_a, side_a), (aggregate.osv_entry_ids_b, side_b)):
selected = {
tuple(rows_by_entry_id[entry_id].entry_ids): rows_by_entry_id[entry_id] for entry_id in entry_ids
}
for source_position in selected.values():
if rule.measure == "turnover":
amount = (
source_position.turnover_debit if side == "D" else source_position.turnover_credit
) or ZERO
else:
amount = (
source_position.closing_debit if side == "D" else source_position.closing_credit
) or ZERO
candidate_ids = (
owners.get(source_position.account_id, frozenset())
if source_position.account_id is not None
else frozenset()
)
position = _position(
source_position,
side,
amount,
candidate_rule_ids=sorted(
candidate_ids - {rule_id},
key=lambda item: (rule_by_id[item].rule_no, str(item)),
),
)
if position is not None:
positions[(tuple(position.entry_ids), position.side)] = position
for manual_row, amount_a, amount_b in manual_rows:
side = side_a if amount_a != ZERO or amount_b == ZERO else side_b
source_row = rows_by_entry_id.get(manual_row.entry_ids[0]) if manual_row.entry_ids else None
candidate_ids = (
owners.get(source_row.account_id, frozenset())
if source_row is not None and source_row.account_id is not None
else frozenset()
)
position = _position(
manual_row,
side,
amount_a if side == side_a else amount_b,
action=None if manual_row.is_auto_assigned else "ASSIGN",
candidate_rule_ids=sorted(
candidate_ids - {rule_id},
key=lambda item: (rule_by_id[item].rule_no, str(item)),
),
)
if position is not None:
positions[(tuple(position.entry_ids), position.side)] = position
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((item_amount_a for _, item_amount_a, _ in manual_rows), ZERO)
amount_b += sum((item_amount_b for _, _, item_amount_b in manual_rows), 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],
positions=list(positions.values()),
)
)
return groups
def _unallocated_positions(
rows: Sequence[VgoAssignmentRowOut],
source_rows: Sequence[ConsolidatedRow],
owners: Mapping[uuid.UUID, Set[uuid.UUID]],
rule_order: Mapping[uuid.UUID, int],
) -> list[VgoUnallocatedPositionOut]:
"""Дополнить результат разнесения представлением нераспределённых позиций."""
source_by_key = {row.entry_ids: row for row in source_rows}
result = []
for row in rows:
source = source_by_key.get(tuple(row.entry_ids))
if source is None or row.counterparty_id is None:
continue
debit, credit = source.closing_debit or ZERO, source.closing_credit or ZERO
side: Literal["D", "K"] = "D" if debit != ZERO or credit == ZERO else "K"
candidate_ids = owners.get(source.account_id, frozenset()) if source.account_id is not None else frozenset()
reason: Literal["MULTIPLE_RULES", "UNASSIGNED"] = (
"UNASSIGNED" if row.action == "UNASSIGNED" or row.unassigned_reason is not None else "MULTIPLE_RULES"
)
result.append(
VgoUnallocatedPositionOut(
entry_ids=row.entry_ids,
legal_entity_id=row.legal_entity_id,
legal_entity_name=row.legal_entity_name,
counterparty_id=row.counterparty_id,
counterparty_name=row.counterparty_name or str(row.counterparty_id),
account_code=row.account_code or "",
side=side,
amount=debit if side == "D" else credit,
candidate_rule_ids=sorted(candidate_ids, key=lambda item: (rule_order.get(item, 0), str(item))),
reason=reason,
excluded=row.is_exclude,
)
)
return result
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 _rule_accounts(
self, osv_result: ConsolidatedOsvBuildResult, rules: Sequence[EliminationRule]
) -> dict[uuid.UUID, frozenset[uuid.UUID]]:
"""Раскрыть дочерние счета и определить их принадлежность правилам."""
accounts = await directories_repo.list_accounts(self.session)
by_id, children = _make_account_tree(accounts)
selections = []
for rule in rules:
selection, _ = await self._matrix_service._resolve_preset_selection(
rule.id, osv_result.version, by_id, children
)
selections.append((rule.id, selection.company_1_account_ids | selection.company_2_account_ids))
return account_owners(selections)
async def _find_aggregates(
self,
version_id: uuid.UUID,
osv_result: ConsolidatedOsvBuildResult,
rules: Sequence[EliminationRule],
rows: Sequence[ConsolidatedRow],
) -> _AggregateSearchResult:
"""Перебрать правила и компании, сразу закрепляя только точные агрегаты."""
search = _AggregateSearchResult()
for rule in rules:
for company_a in osv_result.members:
for company_b in osv_result.members:
if company_a == company_b:
continue
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
)
accepted, used_ids = select_exact_aggregates(
search.used_ids, [_ids_from_aggregate(aggregate) for aggregate in result.matched]
)
search.used_ids.update(used_ids)
for index in accepted:
aggregate = result.matched[index]
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 = sorted(
(rule for rule in all_rules if rule.statement_type == StatementType.balance and rule.measure == "closing"),
key=lambda rule: rule.rule_no,
)
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]
owners = await self._rule_accounts(osv_result, rules) if any(row.account_id is not None for row in rows) else {}
search = await self._find_aggregates(version_id, osv_result, rules, free_rows)
candidate_keys = {row.entry_ids for row in rows}
decision_rows = [*rows]
decision_rows.extend(
row
for row in osv_result.consolidated.rows
if row.entry_ids not in candidate_keys and row.entry_ids in manual.by_row
)
distribution = distribute_vgo_rows(
DistributionInput(
rows=decision_rows,
exact_ids=search.used_ids,
diff_ids=[_ids_from_aggregate(aggregate) for aggregate in search.diff_aggregates],
manual_keys=set(manual.by_row),
owners=owners,
members=set(osv_result.members),
)
)
diff_aggregates = _select_diff_aggregates(search, distribution)
_assign_unpaired(free_rows, manual, distribution.auto_rules, {rule.id: rule for rule in rules})
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, osv_result.consolidated.rows, owners
)
unassigned = _unassigned_rows(decision_rows, manual, distribution)
unallocated = _unallocated_positions(
unassigned,
osv_result.consolidated.rows,
owners,
{rule.id: rule.rule_no for rule in rules},
)
return VgoAssignmentCandidatesOut(groups=groups, unassigned=unassigned, unallocated=unallocated)
class VgoAssignmentCrudService:
"""Ручная правка назначений исходных строк ОСВ одной версии."""
def __init__(
self,
session: AsyncSession,
audit: RequestAudit,
scope: VisibilityScope,
osv_service: ConsolidatedOsvService,
) -> None:
self.session = session
self.audit = audit
self.scope = scope
self.osv_service = osv_service
async def _check_rows(self, version_id: uuid.UUID, entry_ids: list[uuid.UUID]) -> None:
"""Проверить, что ID образуют одну или несколько полных строк свода."""
osv_result = await self.osv_service.build_with_members(version_id)
requested = set(entry_ids)
matched_rows = [row for row in osv_result.consolidated.rows if requested.intersection(row.entry_ids)]
matched_ids = {entry_id for row in matched_rows for entry_id in row.entry_ids}
if requested != matched_ids:
raise DomainValidationError(
"INVALID_OSV_ENTRY_IDS", "entry_ids должны содержать все исходные ID выбранных строк сводной ОСВ"
)
async def attach_rule(self, version_id: uuid.UUID, data: VgoAssignmentAttachRuleIn) -> list[VgoAssignmentOut]:
"""Привязать выбранные полные строки сводной ОСВ версии к правилу."""
version, _ = await lock_editable_version(self.session, self.scope, version_id, self.audit.user_id)
rule = await self.session.get(EliminationRule, data.rule_id)
if rule is None or rule.version_no != version.elimination_rules_version:
raise DomainValidationError("INVALID_VGO_RULE", "правило не относится к версии консолидации")
await self._check_rows(version_id, data.entry_ids)
existing = await vgo_assignments_repo.for_entries(self.session, version_id, rule.statement_type, data.entry_ids)
return await self._persist_rule(version_id, rule, data.entry_ids, existing)
async def set_exclusion(self, version_id: uuid.UUID, data: VgoAssignmentExcludeIn) -> list[VgoAssignmentOut]:
"""Установить признак исключения, не меняя назначенное правило."""
await lock_editable_version(self.session, self.scope, version_id, self.audit.user_id)
await self._check_rows(version_id, data.entry_ids)
existing = await vgo_assignments_repo.for_entries(self.session, version_id, data.statement_type, data.entry_ids)
if data.is_exclude and any(item.rule_id is not None for item in existing.values()):
raise DomainValidationError("VGO_RULE_ASSIGNED", "строку с правилом нельзя исключить из КФО")
before = {str(entry_id): _assignment_snapshot(item) for entry_id, item in existing.items()}
result = []
for entry_id in data.entry_ids:
item = existing.get(entry_id)
if item is None:
item = VgoAssignment(
consolidation_version_id=version_id,
statement_type=data.statement_type,
osv_entry_id=entry_id,
action="UNASSIGNED",
is_exclude=data.is_exclude,
created_by=self.audit.user_id,
)
self.session.add(item)
else:
item.is_exclude = data.is_exclude
item.updated_by = self.audit.user_id
item.updated_at = utc_now()
result.append(item)
await self.session.flush()
await self._audit(version_id, before, {str(item.osv_entry_id): _assignment_snapshot(item) for item in result})
return [VgoAssignmentOut.model_validate(item, from_attributes=True) for item in result]
async def detach_rule(self, version_id: uuid.UUID, data: VgoAssignmentDetachRuleIn) -> list[VgoAssignmentOut]:
"""Снять правило и создать отсутствующие записи как исключённые."""
await lock_editable_version(self.session, self.scope, version_id, self.audit.user_id)
await self._check_rows(version_id, data.entry_ids)
existing = await vgo_assignments_repo.for_entries(self.session, version_id, data.statement_type, data.entry_ids)
before = {str(entry_id): _assignment_snapshot(item) for entry_id, item in existing.items()}
result = []
for entry_id in data.entry_ids:
item = existing.get(entry_id)
if item is None:
item = VgoAssignment(
consolidation_version_id=version_id,
statement_type=data.statement_type,
osv_entry_id=entry_id,
created_by=self.audit.user_id,
)
self.session.add(item)
else:
item.updated_by = self.audit.user_id
item.updated_at = utc_now()
item.action = "UNASSIGNED"
item.rule_id = None
item.is_exclude = True
result.append(item)
await self.session.flush()
await self._audit(version_id, before, {str(item.osv_entry_id): _assignment_snapshot(item) for item in result})
return [VgoAssignmentOut.model_validate(item, from_attributes=True) for item in result]
async def _persist_rule(
self,
version_id: uuid.UUID,
rule: EliminationRule,
entry_ids: list[uuid.UUID],
existing: dict[uuid.UUID, VgoAssignment],
) -> list[VgoAssignmentOut]:
"""Сохранить привязку к правилу и аудит для проверенных ID."""
before = {str(entry_id): _assignment_snapshot(item) for entry_id, item in existing.items()}
result = []
for entry_id in entry_ids:
item = existing.get(entry_id)
if item is None:
item = VgoAssignment(
consolidation_version_id=version_id,
statement_type=rule.statement_type,
osv_entry_id=entry_id,
created_by=self.audit.user_id,
)
self.session.add(item)
else:
item.updated_by = self.audit.user_id
item.updated_at = utc_now()
item.action = "ASSIGN"
item.rule_id = rule.id
item.is_exclude = False
result.append(item)
await self.session.flush()
await self._audit(version_id, before, {str(item.osv_entry_id): _assignment_snapshot(item) for item in result})
return [VgoAssignmentOut.model_validate(item, from_attributes=True) for item in result]
async def _audit(self, version_id: uuid.UUID, before: Mapping[str, object], after: Mapping[str, object]) -> None:
await self.audit.record(
action=AuditAction.ASSIGNMENT_CHANGE,
section=AuditSection.KFO,
object_type="vgo_assignment",
object_id=version_id,
consolidation_version_id=version_id,
before_value={"entries": before},
after_value={"entries": after},
)
def _assignment_snapshot(item: VgoAssignment) -> dict[str, object]:
return {"action": item.action, "rule_id": item.rule_id, "is_exclude": item.is_exclude}