arm-core/api/src/services/aliases.py
Andrey Epifancev a93ab10523 feat(directories): алиасы и ГСК/ГСЗ в POST/PATCH ЮЛ (#84)
Формы «Добавить/Изменить юридическое лицо» (макет 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>
2026-10-09 13:48:14 +04:00

121 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сервис алиасов ЮЛ (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