From e373dfc187e361eaa9b41a22fd37266f933bf7ce Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 23:06:16 +0400 Subject: [PATCH] =?UTF-8?q?refactor(services):=20apply=5Fgroup=5Fupdate/ap?= =?UTF-8?q?ply=5Fentity=5Fupdate=20=E2=80=94=20=D0=BE=D0=B1=D1=89=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=93=D0=9A=20?= =?UTF-8?q?=D0=B8=20=D0=AE=D0=9B=20(R-14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Логика изменения со снимком истории и аудитом вынесена из CompanyGroupService.update и LegalEntityService.update в функции модуля, чтобы импорт ЕЦП (Task 10) применял UpdateGroup/ UpdateEntity той же функцией. Снимок и «новая версия» ЮЛ — snapshot_entity/touch_entity (используются также в move/withdraw). Поведение PATCH не меняется. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/services/company_groups.py | 76 +++++++++++------ api/src/services/legal_entities.py | 131 +++++++++++++++++------------ 2 files changed, 126 insertions(+), 81 deletions(-) diff --git a/api/src/services/company_groups.py b/api/src/services/company_groups.py index ce072be..868671e 100644 --- a/api/src/services/company_groups.py +++ b/api/src/services/company_groups.py @@ -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 diff --git a/api/src/services/legal_entities.py b/api/src/services/legal_entities.py index 13b7572..404e3ce 100644 --- a/api/src/services/legal_entities.py +++ b/api/src/services/legal_entities.py @@ -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):