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:
parent
1364f43c61
commit
e373dfc187
@ -4,7 +4,9 @@
|
||||
`commit` делает `SessionDep`; `audit_event` пишется в той же транзакции через `RequestAudit`.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@ -25,6 +27,51 @@ OBJECT_TYPE = "company_group"
|
||||
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:
|
||||
"""Операции над ГК от имени текущего пользователя в его области видимости."""
|
||||
|
||||
@ -72,34 +119,7 @@ class CompanyGroupService:
|
||||
changes = await self._changes(group, data)
|
||||
if not changes:
|
||||
return group
|
||||
before = {field: getattr(group, field) for field in changes}
|
||||
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,
|
||||
)
|
||||
await apply_group_update(self._session, self._audit, group, changes, now=utc_now())
|
||||
return group
|
||||
|
||||
async def set_archived(self, group_id: uuid.UUID, archived: bool) -> CompanyGroup: # noqa: FBT001
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@ -44,6 +45,71 @@ def _today() -> datetime.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:
|
||||
"""Операции над ЮЛ/ИП от имени текущего пользователя в его области видимости."""
|
||||
|
||||
@ -103,21 +169,7 @@ class LegalEntityService:
|
||||
changes = await self._changes(entity, data)
|
||||
if not changes:
|
||||
return entity
|
||||
before = {field: getattr(entity, field) for field in changes}
|
||||
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,
|
||||
)
|
||||
await apply_entity_update(self._session, self._audit, entity, changes, now=utc_now())
|
||||
return entity
|
||||
|
||||
async def move(self, entity_id: uuid.UUID, data: LegalEntityMove) -> LegalEntity:
|
||||
@ -131,9 +183,16 @@ class LegalEntityService:
|
||||
raise ConflictError("SAME_COMPANY_GROUP", "ЮЛ уже входит в эту ГК")
|
||||
before = {"company_group_id": entity.company_group_id}
|
||||
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
|
||||
await self._touch(entity, now)
|
||||
await touch_entity(self._session, self._audit.user_id, entity, now)
|
||||
await self._close_memberships(entity, now)
|
||||
await self._audit.record(
|
||||
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}
|
||||
before = {field: getattr(entity, field) for field in changes}
|
||||
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():
|
||||
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._audit.record(
|
||||
action=AuditAction.LEGAL_ENTITY_CHANGE,
|
||||
@ -235,40 +294,6 @@ class LegalEntityService:
|
||||
if effective_date < _today():
|
||||
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:
|
||||
"""Закрывает текущие членства ГСК/ГСЗ ЮЛ; по каждому — `MEMBERSHIP_CHANGE`."""
|
||||
for member in await repo.close_memberships(self._session, entity.id, now):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user