"""Сервис ГСК/ГСЗ и их состава (spec directories-api «ГСК и ГСЗ — группы и состав», directory-policy, audit-trail). Порядок операции: право (`require` на маршруте) → видимость (группа — через её ГК, ЮЛ — по `scope`; невидимое = несуществующее, 404) → проверки → запись. Состав — SCD2: исключение закрывает строку (`valid_to = now`, `is_current = false`), повторное включение — новая строка. `related_group_member.kind` всегда равен `kind` группы (v5.14): ЮЛ может одновременно состоять в одной ГСК и одной ГСЗ. Каждая мутация пишет `MEMBERSHIP_CHANGE` (`object_type` `related_group` / `related_group_member`) в транзакции запроса через `RequestAudit`. """ import uuid from typing import Any 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 ConflictError, DomainValidationError, NotFoundError from src.domain.policy import VisibilityScope from src.models.directories import RELATED_GROUP_CODE_SEQ, CompanyGroup, LegalEntity, RelatedGroup, RelatedGroupMember from src.models.enums import AuditAction, AuditSection, RelatedGroupKind from src.repositories import directories as repo from src.schemas.directories import ( MemberAdd, RelatedGroupCreate, RelatedGroupDetail, RelatedGroupMemberOut, RelatedGroupOut, RelatedGroupUpdate, ) from src.services.company_groups import COMPANY_GROUP_NOT_FOUND from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND RELATED_GROUP_NOT_FOUND = "ГСК/ГСЗ не найдена" # одинаково для чужой и несуществующей (spec directory-policy) MEMBERSHIP_NOT_FOUND = "ЮЛ не входит в эту группу" OBJECT_TYPE = "related_group" MEMBER_OBJECT_TYPE = "related_group_member" CODE_PREFIXES = {RelatedGroupKind.gsk: "GSK", RelatedGroupKind.gsz: "GSZ"} TRACKED_FIELDS = ("name", "created_date", "crm_id") # поля группы, изменяемые PATCH def member_payload(member: RelatedGroupMember) -> dict[str, Any]: """`before`/`after` события `MEMBERSHIP_CHANGE` по членству (ручки состава и форма ЮЛ).""" return { "related_group_id": member.related_group_id, "legal_entity_id": member.legal_entity_id, "kind": member.kind, "valid_from": member.valid_from, "valid_to": member.valid_to, "is_current": member.is_current, } async def add_membership( session: AsyncSession, audit: RequestAudit, group: RelatedGroup, entity: LegalEntity ) -> RelatedGroupMember: """Текущее членство видимого ЮЛ в видимой группе: ЮЛ той же ГК (`DIFFERENT_COMPANY_GROUP`), не более одной группы каждого вида (`MEMBER_EXISTS` / `MEMBER_OF_OTHER_GROUP`); аудит `MEMBERSHIP_CHANGE`.""" # ЮЛ без ГК (монозаёмщик, закрытое — Review Focus 4) тоже «не в той же ГК» if entity.company_group_id != group.company_group_id: raise DomainValidationError("DIFFERENT_COMPANY_GROUP", "ЮЛ не входит в ГК этой группы") current = await repo.current_membership_of_kind(session, entity.id, group.kind) if current is not None: if current.related_group_id == group.id: raise ConflictError("MEMBER_EXISTS", "ЮЛ уже входит в эту группу") raise ConflictError( "MEMBER_OF_OTHER_GROUP", "ЮЛ уже входит в другую группу этого вида", related_group_id=current.related_group_id, ) member = RelatedGroupMember( related_group_id=group.id, legal_entity_id=entity.id, kind=group.kind, # v5.14: kind строки = kind группы valid_from=utc_now(), valid_to=None, is_current=True, created_by=audit.user_id, ) session.add(member) await session.flush() await audit.record( action=AuditAction.MEMBERSHIP_CHANGE, section=AuditSection.DIRECTORIES, object_type=MEMBER_OBJECT_TYPE, object_id=member.id, after_value=member_payload(member), ) return member async def close_membership(session: AsyncSession, audit: RequestAudit, member: RelatedGroupMember) -> None: """Исключение (SCD2): `valid_to = now`, `is_current = false`; аудит `MEMBERSHIP_CHANGE` с `before`/`after`.""" before = member_payload(member) member.valid_to = utc_now() member.is_current = False await session.flush() await audit.record( action=AuditAction.MEMBERSHIP_CHANGE, section=AuditSection.DIRECTORIES, object_type=MEMBER_OBJECT_TYPE, object_id=member.id, before_value=before, after_value=member_payload(member), ) class RelatedGroupService: """Операции над ГСК/ГСЗ и их составом от имени текущего пользователя в его области видимости.""" def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None: self._session = session self._audit = audit self._scope = scope async def create(self, company_group_id: uuid.UUID, data: RelatedGroupCreate) -> RelatedGroup: """Новая ГСК/ГСЗ видимой ГК с кодом `GSK-`/`GSZ-NNNNNN`; аудит `MEMBERSHIP_CHANGE`.""" company_group = await self._visible_company_group(company_group_id) if data.crm_id is not None: await self._check_crm_id_free(data.crm_id) group = RelatedGroup( code=await repo.next_code(self._session, RELATED_GROUP_CODE_SEQ, CODE_PREFIXES[data.kind]), company_group_id=company_group.id, kind=data.kind, name=data.name, created_date=data.created_date, crm_id=data.crm_id, ) self._session.add(group) await self._session.flush() await self._audit.record( action=AuditAction.MEMBERSHIP_CHANGE, section=AuditSection.DIRECTORIES, object_type=OBJECT_TYPE, object_id=group.id, after_value=self._group_payload(group), ) return group async def update(self, group_id: uuid.UUID, data: RelatedGroupUpdate) -> RelatedGroup: """Изменение `name`/`created_date`/`crm_id`; без фактических изменений ничего не пишется. Смена `crm_id` пишется как изменение группы — `GROUP_CHANGE` с `before`/`after` всех изменённых полей (Р-41, P3-18); прочие правки — `MEMBERSHIP_CHANGE`, как раньше. """ group = await self._get_group(group_id) changes: dict[str, Any] = { field: getattr(data, field) for field in TRACKED_FIELDS if field in data.model_fields_set and getattr(data, field) != getattr(group, field) } if not changes: return group if changes.get("crm_id") is not None: await self._check_crm_id_free(changes["crm_id"], except_group_id=group.id) before = {field: getattr(group, field) for field in changes} for field, value in changes.items(): setattr(group, field, value) await self._session.flush() await self._audit.record( action=AuditAction.GROUP_CHANGE if "crm_id" in changes else AuditAction.MEMBERSHIP_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 async def delete(self, group_id: uuid.UUID) -> None: """Удаляет группу без текущих членов вместе с закрытыми строками состава; `before_value` — группа и состав. ASSUMPTION(OQ-34): удаляется только пустая ГСК/ГСЗ (есть текущие члены → 409 `GROUP_NOT_EMPTY`); история состава исчезает вместе с группой и сохраняется только в `before_value` аудита. """ group = await self._get_group(group_id) if await repo.has_current_members(self._session, group.id): raise ConflictError("GROUP_NOT_EMPTY", "В группе есть текущие члены — сначала исключите их") rows = await repo.list_group_member_rows(self._session, group.id) before = { **self._group_payload(group), "members": [{"id": row.id, **member_payload(row)} for row in rows], } await repo.delete_group_member_rows(self._session, group.id) await self._session.delete(group) await self._session.flush() await self._audit.record( action=AuditAction.MEMBERSHIP_CHANGE, section=AuditSection.DIRECTORIES, object_type=OBJECT_TYPE, object_id=group_id, before_value=before, ) async def add_member(self, group_id: uuid.UUID, data: MemberAdd) -> tuple[RelatedGroupMember, LegalEntity]: """Текущее членство ЮЛ с `kind` группы; ЮЛ — той же ГК; не более одной группы каждого вида у ЮЛ.""" group = await self._get_group(group_id) entity = await repo.get_legal_entity(self._session, self._scope, data.legal_entity_id) if entity is None: raise NotFoundError(LEGAL_ENTITY_NOT_FOUND) return await add_membership(self._session, self._audit, group, entity), entity async def remove_member(self, group_id: uuid.UUID, legal_entity_id: uuid.UUID) -> None: """Исключение (SCD2): текущая строка закрывается `valid_to = now`, `is_current = false`; нет членства — 404.""" group = await self._get_group(group_id) member = await repo.current_membership_in_group(self._session, group.id, legal_entity_id) if member is None: raise NotFoundError(MEMBERSHIP_NOT_FOUND) await close_membership(self._session, self._audit, member) async def get(self, group_id: uuid.UUID) -> RelatedGroupDetail: """Карточка группы с текущими членами; невидимая и несуществующая — один и тот же 404.""" group = await self._get_group(group_id) members = [ RelatedGroupMemberOut( legal_entity_id=entity.id, inn=entity.inn, name=entity.name, valid_from=member.valid_from ) for member, entity in await repo.current_members(self._session, group.id) ] detail = RelatedGroupDetail.model_validate(group) detail.members = members detail.members_count = len(members) return detail async def summary(self, group: RelatedGroup) -> RelatedGroupOut: """Группа с числом текущих членов (ответ на создание/изменение).""" out = RelatedGroupOut.model_validate(group) out.members_count = await repo.count_current_members(self._session, group.id) return out async def list_for(self, company_group_id: uuid.UUID) -> list[RelatedGroupOut]: """ГСК/ГСЗ видимой ГК по имени с числом текущих членов.""" company_group = await self._visible_company_group(company_group_id) result = [] for group, count in await repo.list_related_groups(self._session, company_group.id): out = RelatedGroupOut.model_validate(group) out.members_count = count result.append(out) return result async def _get_group(self, group_id: uuid.UUID) -> RelatedGroup: group = await repo.get_related_group(self._session, self._scope, group_id) if group is None: raise NotFoundError(RELATED_GROUP_NOT_FOUND) return group async def _visible_company_group(self, company_group_id: uuid.UUID) -> CompanyGroup: company_group = await repo.get_company_group(self._session, self._scope, company_group_id) if company_group is None: raise NotFoundError(COMPANY_GROUP_NOT_FOUND) return company_group @staticmethod def _group_payload(group: RelatedGroup) -> dict[str, Any]: return { "code": group.code, "company_group_id": group.company_group_id, "kind": group.kind, "name": group.name, "created_date": group.created_date, "crm_id": group.crm_id, } async def _check_crm_id_free(self, crm_id: str, *, except_group_id: uuid.UUID | None = None) -> None: if await repo.related_group_crm_id_taken(self._session, crm_id, except_group_id=except_group_id): raise ConflictError("CRM_ID_EXISTS", "ГСК/ГСЗ с таким crm_id уже существует")