"""Сервис алиасов ЮЛ (spec directories-api «Алиасы ЮЛ», audit-trail, §3 п. 4). Порядок: право (`require` на маршруте) → видимость ЮЛ (`scope`, невидимое = несуществующее) → проверки → запись. Аудит `LEGAL_ENTITY_CHANGE` / `object_type = legal_entity_alias` пишется в той же транзакции запроса. `add_alias`/`remove_alias`/`normalize_or_reject` — общие для ручек алиасов и формы ЮЛ (`legal_entity_form`, #84). """ import uuid 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.aliases import normalize_alias from src.domain.policy import VisibilityScope from src.models.directories import LegalEntity, LegalEntityAlias from src.models.enums import AuditAction, AuditSection from src.repositories import directories as repo from src.schemas.directories import AliasCreate from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND OBJECT_TYPE = "legal_entity_alias" ALIAS_NOT_FOUND = "Алиас не найден" def alias_payload(alias: LegalEntityAlias) -> dict[str, object]: """`before`/`after` события `LEGAL_ENTITY_CHANGE` по алиасу (одно определение для справочника и сопоставления).""" return { "legal_entity_id": alias.legal_entity_id, "alias": alias.alias, "normalized_alias": alias.normalized_alias, } def normalize_or_reject(raw: str) -> str: """`normalized_alias` по §3; пустой после нормализации (одни ОПФ и знаки) — 422 `EMPTY_ALIAS`.""" normalized = normalize_alias(raw) if not normalized: raise DomainValidationError("EMPTY_ALIAS", "Алиас пуст после нормализации (одни ОПФ и знаки препинания)") return normalized async def add_alias(session: AsyncSession, audit: RequestAudit, entity: LegalEntity, raw: str) -> LegalEntityAlias: """Новый алиас видимого ЮЛ: `alias` как есть, `normalized_alias` по §3; конфликт — `ALIAS_EXISTS`/`ALIAS_TAKEN`.""" normalized = normalize_or_reject(raw) await repo.lock_alias_key(session, normalized) # «проверка → вставка» без гонки с ручным сопоставлением owner = await repo.find_alias_owner(session, normalized) if owner is not None: if owner.legal_entity_id == entity.id: raise ConflictError("ALIAS_EXISTS", "У этого ЮЛ уже есть такой алиас") # Владелец может быть вне видимости пользователя: спека требует `legal_entity_id`, остальное не раскрываем raise ConflictError( "ALIAS_TAKEN", "Такой алиас уже закреплён за другим ЮЛ", legal_entity_id=owner.legal_entity_id ) alias = LegalEntityAlias( legal_entity_id=entity.id, alias=raw, normalized_alias=normalized, created_by=audit.user_id, created_at=utc_now(), ) session.add(alias) await session.flush() await audit.record( action=AuditAction.LEGAL_ENTITY_CHANGE, section=AuditSection.DIRECTORIES, object_type=OBJECT_TYPE, object_id=alias.id, after_value=alias_payload(alias), ) return alias async def remove_alias(session: AsyncSession, audit: RequestAudit, entity: LegalEntity, alias_id: uuid.UUID) -> None: """Удаляет алиас видимого ЮЛ (иначе 404); `before_value` аудита — удалённые значения.""" alias = await repo.get_alias(session, entity.id, alias_id) if alias is None: raise NotFoundError(ALIAS_NOT_FOUND) # T11.3: та же advisory-блокировка, что у `add` и ручного сопоставления (design №66); без неё удаление # гонялось бы с `move_alias`, и обе стороны писали бы событие об удалении одного алиаса await repo.lock_alias_key(session, alias.normalized_alias) alias = await repo.get_alias(session, entity.id, alias_id, refresh=True) # перенос мог удалить его if alias is None: raise NotFoundError(ALIAS_NOT_FOUND) before = alias_payload(alias) await session.delete(alias) await session.flush() await audit.record( action=AuditAction.LEGAL_ENTITY_CHANGE, section=AuditSection.DIRECTORIES, object_type=OBJECT_TYPE, object_id=alias_id, before_value=before, ) class AliasService: """Добавление и удаление алиасов ЮЛ/ИП от имени текущего пользователя в его области видимости.""" def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None: self._session = session self._audit = audit self._scope = scope async def add(self, entity_id: uuid.UUID, data: AliasCreate) -> LegalEntityAlias: """Новый алиас: `alias` как есть, `normalized_alias` по §3; конфликты — `ALIAS_EXISTS` / `ALIAS_TAKEN`.""" entity = await self._get_entity(entity_id) return await add_alias(self._session, self._audit, entity, data.alias) async def remove(self, entity_id: uuid.UUID, alias_id: uuid.UUID) -> None: """Удаляет алиас этого ЮЛ (иначе 404); `before_value` аудита — удалённые значения.""" entity = await self._get_entity(entity_id) await remove_alias(self._session, self._audit, entity, alias_id) async def _get_entity(self, entity_id: uuid.UUID) -> LegalEntity: entity = await repo.get_legal_entity(self._session, self._scope, entity_id) if entity is None: raise NotFoundError(LEGAL_ENTITY_NOT_FOUND) return entity