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`.
|
`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
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user