refactor(services): apply_group_update/apply_entity_update — общая логика изменения ГК и ЮЛ (R-14)

Логика изменения со снимком истории и аудитом вынесена из CompanyGroupService.update и
LegalEntityService.update в функции модуля, чтобы импорт ЕЦП (Task 10) применял UpdateGroup/
UpdateEntity той же функцией. Снимок и «новая версия» ЮЛ — snapshot_entity/touch_entity
(используются также в move/withdraw). Поведение PATCH не меняется.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-04 23:06:16 +04:00
parent 1364f43c61
commit e373dfc187
2 changed files with 126 additions and 81 deletions

View File

@ -4,7 +4,9 @@
`commit` делает `SessionDep`; `audit_event` пишется в той же транзакции через `RequestAudit`. `commit` делает `SessionDep`; `audit_event` пишется в той же транзакции через `RequestAudit`.
""" """
import datetime
import uuid import uuid
from collections.abc import Mapping
from typing import Any from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -25,6 +27,51 @@ OBJECT_TYPE = "company_group"
TRACKED_FIELDS = ("name", "crm_id", "base_regional_office_id") # поля ГК, изменяемые PATCH и попадающие в историю TRACKED_FIELDS = ("name", "crm_id", "base_regional_office_id") # поля ГК, изменяемые PATCH и попадающие в историю
async def apply_group_update( # noqa: PLR0913 - сессия, аудит, ГК, изменения и момент — без значений по умолчанию
session: AsyncSession,
audit: RequestAudit,
group: CompanyGroup,
changes: Mapping[str, Any],
*,
now: datetime.datetime,
description: str | None = None,
) -> None:
"""Изменение полей ГК со снимком истории (SCD4, D-6) и аудитом; общее для PATCH и импорта (R-14).
`changes` — только фактически изменившиеся поля (проверки — у вызывающего). Снимок ПРЕЖНИХ значений уходит в
`company_group_history` (`valid_to = now`), у живой записи `valid_from = now`; аудит — `ASSIGNMENT_CHANGE` при
смене филиала, иначе `GROUP_CHANGE`.
"""
before = {field: getattr(group, field) for field in changes}
session.add(
CompanyGroupHistory(
company_group_id=group.id,
crm_id=group.crm_id,
name=group.name,
base_regional_office_id=group.base_regional_office_id,
valid_from=group.valid_from,
valid_to=now,
changed_by=audit.user_id,
)
)
for field, value in changes.items():
setattr(group, field, value)
group.valid_from = now
group.updated_at = now
group.updated_by = audit.user_id
await session.flush()
await audit.record(
action=AuditAction.ASSIGNMENT_CHANGE if "base_regional_office_id" in changes else AuditAction.GROUP_CHANGE,
section=AuditSection.DIRECTORIES,
object_type=OBJECT_TYPE,
object_id=group.id,
description=description,
before_value=before,
after_value=dict(changes),
changed_field=next(iter(changes)) if len(changes) == 1 else None,
)
class CompanyGroupService: class CompanyGroupService:
"""Операции над ГК от имени текущего пользователя в его области видимости.""" """Операции над ГК от имени текущего пользователя в его области видимости."""
@ -72,34 +119,7 @@ class CompanyGroupService:
changes = await self._changes(group, data) changes = await self._changes(group, data)
if not changes: if not changes:
return group return group
before = {field: getattr(group, field) for field in changes} await apply_group_update(self._session, self._audit, group, changes, now=utc_now())
now = utc_now()
self._session.add(
CompanyGroupHistory(
company_group_id=group.id,
crm_id=group.crm_id,
name=group.name,
base_regional_office_id=group.base_regional_office_id,
valid_from=group.valid_from,
valid_to=now,
changed_by=self._audit.user_id,
)
)
for field, value in changes.items():
setattr(group, field, value)
group.valid_from = now
group.updated_at = now
group.updated_by = self._audit.user_id
await self._session.flush()
await self._audit.record(
action=AuditAction.ASSIGNMENT_CHANGE if "base_regional_office_id" in changes else AuditAction.GROUP_CHANGE,
section=AuditSection.DIRECTORIES,
object_type=OBJECT_TYPE,
object_id=group.id,
before_value=before,
after_value=changes,
changed_field=next(iter(changes)) if len(changes) == 1 else None,
)
return group return group
async def set_archived(self, group_id: uuid.UUID, archived: bool) -> CompanyGroup: # noqa: FBT001 async def set_archived(self, group_id: uuid.UUID, archived: bool) -> CompanyGroup: # noqa: FBT001

View File

@ -8,6 +8,7 @@
import datetime import datetime
import uuid import uuid
from collections.abc import Mapping
from typing import Any from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -44,6 +45,71 @@ def _today() -> datetime.date:
return utc_now().date() return utc_now().date()
def snapshot_entity( # noqa: PLR0913 - снимок ЮЛ: сессия, автор, ЮЛ, событие, момент и дата события
session: AsyncSession,
user_id: uuid.UUID,
entity: LegalEntity,
event: LeHistoryEvent,
now: datetime.datetime,
*,
effective_date: datetime.date | None,
) -> LegalEntityHistory:
"""Снимок ТЕКУЩИХ (ещё не изменённых) значений ЮЛ, `valid_to = now`; вызывать до изменений и `touch_entity`."""
snapshot = LegalEntityHistory(
legal_entity_id=entity.id,
inn=entity.inn,
crm_id=entity.crm_id,
entity_type=entity.entity_type,
name=entity.name,
company_group_id=entity.company_group_id,
regional_office_id=entity.regional_office_id,
close_date=entity.close_date,
event_type=event,
effective_date=effective_date,
valid_from=entity.valid_from,
valid_to=now,
changed_by=user_id,
)
session.add(snapshot)
return snapshot
async def touch_entity(session: AsyncSession, user_id: uuid.UUID, entity: LegalEntity, now: datetime.datetime) -> None:
"""Новая версия живой записи: `valid_from = now` (= `valid_to` снимка), `updated_*`; `flush`."""
entity.valid_from = now
entity.updated_at = now
entity.updated_by = user_id
await session.flush()
async def apply_entity_update( # noqa: PLR0913 - сессия, аудит, ЮЛ, изменения и момент — без значений по умолчанию
session: AsyncSession,
audit: RequestAudit,
entity: LegalEntity,
changes: Mapping[str, Any],
*,
now: datetime.datetime,
description: str | None = None,
) -> None:
"""Изменение полей ЮЛ со снимком `legal_entity_history(UPDATE)` и аудитом `LEGAL_ENTITY_CHANGE`; общее для PATCH и
импорта (R-14). `changes` — только фактически изменившиеся поля (проверки — у вызывающего)."""
before = {field: getattr(entity, field) for field in changes}
snapshot_entity(session, audit.user_id, entity, LeHistoryEvent.UPDATE, now, effective_date=None)
for field, value in changes.items():
setattr(entity, field, value)
await touch_entity(session, audit.user_id, entity, now)
await audit.record(
action=AuditAction.LEGAL_ENTITY_CHANGE,
section=AuditSection.DIRECTORIES,
object_type=OBJECT_TYPE,
object_id=entity.id,
description=description,
before_value=before,
after_value=dict(changes),
changed_field=next(iter(changes)) if len(changes) == 1 else None,
)
class LegalEntityService: class LegalEntityService:
"""Операции над ЮЛ/ИП от имени текущего пользователя в его области видимости.""" """Операции над ЮЛ/ИП от имени текущего пользователя в его области видимости."""
@ -103,21 +169,7 @@ class LegalEntityService:
changes = await self._changes(entity, data) changes = await self._changes(entity, data)
if not changes: if not changes:
return entity return entity
before = {field: getattr(entity, field) for field in changes} await apply_entity_update(self._session, self._audit, entity, changes, now=utc_now())
now = utc_now()
self._snapshot(entity, LeHistoryEvent.UPDATE, now, effective_date=None)
for field, value in changes.items():
setattr(entity, field, value)
await self._touch(entity, now)
await self._audit.record(
action=AuditAction.LEGAL_ENTITY_CHANGE,
section=AuditSection.DIRECTORIES,
object_type=OBJECT_TYPE,
object_id=entity.id,
before_value=before,
after_value=changes,
changed_field=next(iter(changes)) if len(changes) == 1 else None,
)
return entity return entity
async def move(self, entity_id: uuid.UUID, data: LegalEntityMove) -> LegalEntity: async def move(self, entity_id: uuid.UUID, data: LegalEntityMove) -> LegalEntity:
@ -131,9 +183,16 @@ class LegalEntityService:
raise ConflictError("SAME_COMPANY_GROUP", "ЮЛ уже входит в эту ГК") raise ConflictError("SAME_COMPANY_GROUP", "ЮЛ уже входит в эту ГК")
before = {"company_group_id": entity.company_group_id} before = {"company_group_id": entity.company_group_id}
now = utc_now() now = utc_now()
self._snapshot(entity, LeHistoryEvent.MOVE_TO_GROUP, now, effective_date=data.effective_date) snapshot_entity(
self._session,
self._audit.user_id,
entity,
LeHistoryEvent.MOVE_TO_GROUP,
now,
effective_date=data.effective_date,
)
entity.company_group_id = target.id entity.company_group_id = target.id
await self._touch(entity, now) await touch_entity(self._session, self._audit.user_id, entity, now)
await self._close_memberships(entity, now) await self._close_memberships(entity, now)
await self._audit.record( await self._audit.record(
action=AuditAction.LEGAL_ENTITY_CHANGE, action=AuditAction.LEGAL_ENTITY_CHANGE,
@ -162,10 +221,10 @@ class LegalEntityService:
changes = {field: value for field, value in changes.items() if getattr(entity, field) != value} changes = {field: value for field, value in changes.items() if getattr(entity, field) != value}
before = {field: getattr(entity, field) for field in changes} before = {field: getattr(entity, field) for field in changes}
now = utc_now() now = utc_now()
self._snapshot(entity, event, now, effective_date=data.effective_date) snapshot_entity(self._session, self._audit.user_id, entity, event, now, effective_date=data.effective_date)
for field, value in changes.items(): for field, value in changes.items():
setattr(entity, field, value) setattr(entity, field, value)
await self._touch(entity, now) await touch_entity(self._session, self._audit.user_id, entity, now)
await self._close_memberships(entity, now, comment=data.comment) await self._close_memberships(entity, now, comment=data.comment)
await self._audit.record( await self._audit.record(
action=AuditAction.LEGAL_ENTITY_CHANGE, action=AuditAction.LEGAL_ENTITY_CHANGE,
@ -235,40 +294,6 @@ class LegalEntityService:
if effective_date < _today(): if effective_date < _today():
raise DomainValidationError("EFFECTIVE_DATE_IN_PAST", "Дата не может быть раньше сегодняшней") raise DomainValidationError("EFFECTIVE_DATE_IN_PAST", "Дата не может быть раньше сегодняшней")
def _snapshot(
self,
entity: LegalEntity,
event: LeHistoryEvent,
now: datetime.datetime,
*,
effective_date: datetime.date | None,
) -> LegalEntityHistory:
"""Снимок ТЕКУЩИХ (ещё не изменённых) значений ЮЛ, `valid_to = now`; вызывать до изменения полей и `_touch`."""
snapshot = LegalEntityHistory(
legal_entity_id=entity.id,
inn=entity.inn,
crm_id=entity.crm_id,
entity_type=entity.entity_type,
name=entity.name,
company_group_id=entity.company_group_id,
regional_office_id=entity.regional_office_id,
close_date=entity.close_date,
event_type=event,
effective_date=effective_date,
valid_from=entity.valid_from,
valid_to=now,
changed_by=self._audit.user_id,
)
self._session.add(snapshot)
return snapshot
async def _touch(self, entity: LegalEntity, now: datetime.datetime) -> None:
"""Новая версия живой записи: `valid_from = now` (= `valid_to` снимка), `updated_*`; `flush`."""
entity.valid_from = now
entity.updated_at = now
entity.updated_by = self._audit.user_id
await self._session.flush()
async def _close_memberships(self, entity: LegalEntity, now: datetime.datetime, comment: str | None = None) -> None: async def _close_memberships(self, entity: LegalEntity, now: datetime.datetime, comment: str | None = None) -> None:
"""Закрывает текущие членства ГСК/ГСЗ ЮЛ; по каждому — `MEMBERSHIP_CHANGE`.""" """Закрывает текущие членства ГСК/ГСЗ ЮЛ; по каждому — `MEMBERSHIP_CHANGE`."""
for member in await repo.close_memberships(self._session, entity.id, now): for member in await repo.close_memberships(self._session, entity.id, now):