Формы «Добавить/Изменить юридическое лицо» (макет 51-02, 79) отправляют всё одним запросом. POST/PATCH /directories/legal-entities принимают `aliases`, `gsk_id`, `gsz_id` и применяют их в той же транзакции, что и поля ЮЛ. - POST: алиасы и членства создаются вместе с ЮЛ; ошибка откатывает всё. - PATCH: `aliases` — итоговый набор (сверка по normalized_alias; удаление — с правом DIRECTORY_DELETE, иначе 403), `gsk_id`/`gsz_id` — итоговые группы (`null` — выйти, другая — сменить); не переданное поле не меняется. - Новая ошибка 422 WRONG_GROUP_KIND; дубль в списке — 409 ALIAS_EXISTS. - Ответ POST и PATCH — LegalEntityDetail (надмножество LegalEntityOut). - Логика ручек алиасов и состава ГСК/ГСЗ вынесена в функции модулей (add_alias/remove_alias, add_membership/close_membership) — форма и ручки ведут себя одинаково; PermissionDeniedError — единый 403 «недостаточно прав». - Спека directories-api и README обновлены. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
6.1 KiB
Python
121 lines
6.1 KiB
Python
"""Сервис алиасов ЮЛ (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
|