Merge pull request 'ЮЛ: алиасы и ГСК/ГСЗ в POST/PATCH /directories/legal-entities (#84)' (#85) from feat/84-le-form-fields into main

Reviewed-on: #85
This commit is contained in:
aep 2026-10-09 13:03:05 +03:00
commit b4c7a15e99
11 changed files with 781 additions and 129 deletions

View File

@ -244,8 +244,8 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб
| `POST /directories/related-groups/{group_id}/members` | `DIRECTORY_EDIT` | добавить ЮЛ (только из той же ГК) |
| `DELETE /directories/related-groups/{group_id}/members/{legal_entity_id}` | `DIRECTORY_EDIT` | исключить ЮЛ (членство закрывается, не удаляется) |
| `GET /directories/legal-entities` | `DIRECTORY_VIEW` | видимые ЮЛ/ИП: `company_group_id`, `outside_group`, `inn`, `q` (подстрока имени, ИНН или КПП), `closed`, `limit`/`offset`; в каждой записи `documents_count` |
| `POST /directories/legal-entities` | `DIRECTORY_EDIT` | создать ЮЛ/ИП (ИНН: ЮЛ — 10 цифр, ИП — 12; `kpp` необязателен, 9 символов, у ИП запрещён — `INVALID_KPP`) |
| `GET`, `PATCH /directories/legal-entities/{entity_id}` | `DIRECTORY_VIEW` / `DIRECTORY_EDIT` | карточка (с членствами, алиасами и `documents_count`) / изменить (в т.ч. `kpp`; `null` очищает; прежнее значение уходит в историю) |
| `POST /directories/legal-entities` | `DIRECTORY_EDIT` | создать ЮЛ/ИП (ИНН: ЮЛ — 10 цифр, ИП — 12; `kpp` необязателен, 9 символов, у ИП запрещён — `INVALID_KPP`); форма целиком — `aliases`, `gsk_id`, `gsz_id` в той же транзакции; ответ — карточка |
| `GET`, `PATCH /directories/legal-entities/{entity_id}` | `DIRECTORY_VIEW` / `DIRECTORY_EDIT` | карточка (с членствами, алиасами и `documents_count`) / изменить (в т.ч. `kpp`; `null` очищает; прежнее значение уходит в историю; `aliases` — итоговый набор, удаление — с `DIRECTORY_DELETE`; `gsk_id`/`gsz_id` — итоговые ГСК/ГСЗ, `null` — выйти; ответ — карточка) |
| `POST /directories/legal-entities/{entity_id}/move` | `DIRECTORY_EDIT` | перевести в другую ГК с датой `effective_date` ≥ сегодня (дата по Москве, Europe/Moscow); членства ГСК/ГСЗ закрываются |
| `POST /directories/legal-entities/{entity_id}/withdraw` | `DIRECTORY_EDIT` | вывести из ГК: `CLOSE` (с `close_date`) или `REMOVE_FROM_GROUP` |
| `GET /directories/legal-entities/{entity_id}/history` | `DIRECTORY_VIEW` | история ЮЛ/ИП, новые первыми |
@ -830,6 +830,7 @@ email без учёта регистра проверяет только сер
| `EFFECTIVE_DATE_IN_PAST` | 422 | дата перевода/вывода раньше сегодняшней (по Москве) |
| `EMPTY_ALIAS` | 422 | алиас пуст после нормализации (одни ОПФ и знаки препинания) |
| `DIFFERENT_COMPANY_GROUP` | 422 | ЮЛ не входит в ГК этой ГСК/ГСЗ |
| `WRONG_GROUP_KIND` | 422 | в `gsk_id` формы ЮЛ передана ГСЗ или в `gsz_id` — ГСК (`POST`/`PATCH /directories/legal-entities`) |
| `UNKNOWN_ROLE` / `UNKNOWN_PERMISSION` | 422 | неизвестная или неактивная роль / неизвестный код права (поля `roles` / `permissions`) |
| `MISSING_COLUMNS` / `UNSUPPORTED_FILE` | 422 | файл импорта целиком (`missing` — перечень колонок) |
| `CRM_ID_EXISTS` | 409 | ГК или ЮЛ с таким `crm_id` уже есть |

View File

@ -22,7 +22,7 @@ from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from src.api.dependencies.db import SessionDep
from src.common.exceptions import AuthenticationFailedError
from src.common.exceptions import AuthenticationFailedError, PermissionDeniedError
from src.core.auth import AuthError, Claims, TokenVerifier, build_verifier
from src.core.config import get_settings
from src.repositories.users import AmbiguousUserError, UserWithAccess, load_principal_data
@ -41,10 +41,6 @@ def _unauthorized(detail: str) -> HTTPException:
return HTTPException(status.HTTP_401_UNAUTHORIZED, detail=detail, headers=dict(_UNAUTHORIZED_HEADERS))
def _forbidden(detail: str) -> HTTPException:
return HTTPException(status.HTTP_403_FORBIDDEN, detail=detail)
def _token_rejected(detail: str) -> AuthenticationFailedError:
"""401 с LOGIN_FAILED: токен предъявлен, но не принят (в журнал — причина, токен не пишется)."""
return AuthenticationFailedError(
@ -155,7 +151,7 @@ def require(code: str) -> Callable[..., Awaitable[Principal]]:
async def check(principal: Annotated[Principal, Depends(current_user)]) -> Principal:
if code not in principal.permissions:
raise _forbidden(f"недостаточно прав: требуется {code}")
raise PermissionDeniedError(code)
return principal
return check

View File

@ -13,6 +13,7 @@ from src.services.aliases import AliasService
from src.services.company_groups import CompanyGroupService
from src.services.directory_import import DirectoryImportService
from src.services.legal_entities import LegalEntityService
from src.services.legal_entity_form import LegalEntityFormService
from src.services.related_groups import RelatedGroupService
@ -43,6 +44,17 @@ def get_legal_entity_service(
LegalEntityServiceDep = Annotated[LegalEntityService, Depends(get_legal_entity_service)]
def get_legal_entity_form_service(
session: SessionDep,
audit: Annotated[RequestAudit, Depends(get_request_audit)],
scope: Annotated[VisibilityScope, Depends(get_scope)],
) -> LegalEntityFormService:
return LegalEntityFormService(session, audit, scope)
LegalEntityFormServiceDep = Annotated[LegalEntityFormService, Depends(get_legal_entity_form_service)]
def get_alias_service(
session: SessionDep,
audit: Annotated[RequestAudit, Depends(get_request_audit)],

View File

@ -9,8 +9,8 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query, status
from src.api.dependencies.auth import require
from src.api.dependencies.directories import AliasServiceDep, LegalEntityServiceDep
from src.api.dependencies.auth import Principal, require
from src.api.dependencies.directories import AliasServiceDep, LegalEntityFormServiceDep, LegalEntityServiceDep
from src.schemas.common import Page, PageParams
from src.schemas.directories import (
AliasCreate,
@ -24,6 +24,7 @@ from src.schemas.directories import (
LegalEntityUpdate,
LegalEntityWithdraw,
)
from src.services.legal_entity_form import ALIAS_DELETE_PERMISSION
router = APIRouter(prefix="/legal-entities")
@ -56,12 +57,18 @@ async def list_legal_entities(
@router.post(
"", response_model=LegalEntityOut, status_code=status.HTTP_201_CREATED, dependencies=EDIT, summary="Создать ЮЛ/ИП"
"",
response_model=LegalEntityDetail,
status_code=status.HTTP_201_CREATED,
dependencies=EDIT,
summary="Создать ЮЛ/ИП",
)
async def create_legal_entity(data: LegalEntityCreate, service: LegalEntityServiceDep) -> LegalEntityOut:
"""Создаёт ЮЛ/ИП; без ГК — монозаёмщик; ИНН: ЮЛ — 10 цифр, ИП — 12; КПП необязателен, у ИП запрещён."""
entity = await service.create(data)
return await service.to_out(entity, 0)
async def create_legal_entity(data: LegalEntityCreate, service: LegalEntityFormServiceDep) -> LegalEntityDetail:
"""Создаёт ЮЛ/ИП; без ГК — монозаёмщик; ИНН: ЮЛ — 10 цифр, ИП — 12; КПП необязателен, у ИП запрещён.
Форма целиком (#84): `aliases`, `gsk_id`, `gsz_id` — в той же транзакции; ответ — карточка.
"""
return await service.create(data)
@router.get("/{entity_id}", response_model=LegalEntityDetail, dependencies=VIEW, summary="Карточка ЮЛ/ИП")
@ -72,13 +79,19 @@ async def get_legal_entity(entity_id: uuid.UUID, service: LegalEntityServiceDep)
return detail
@router.patch("/{entity_id}", response_model=LegalEntityOut, dependencies=EDIT, summary="Изменить ЮЛ/ИП")
@router.patch("/{entity_id}", response_model=LegalEntityDetail, summary="Изменить ЮЛ/ИП")
async def update_legal_entity(
entity_id: uuid.UUID, data: LegalEntityUpdate, service: LegalEntityServiceDep
) -> LegalEntityOut:
"""Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`/`kpp`; прежние значения — в историю (`UPDATE`)."""
entity = await service.update(entity_id, data)
return await service.to_out(entity, await service.documents_count(entity_id))
entity_id: uuid.UUID,
data: LegalEntityUpdate,
service: LegalEntityFormServiceDep,
principal: Annotated[Principal, Depends(require("DIRECTORY_EDIT"))],
) -> LegalEntityDetail:
"""Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`/`kpp`; прежние значения — в историю (`UPDATE`).
Форма целиком (#84): переданные `aliases` (итоговый набор; удаление — с правом `DIRECTORY_DELETE`), `gsk_id`,
`gsz_id` (итоговые ГСК/ГСЗ, `null` — выйти); ответ — карточка.
"""
return await service.update(entity_id, data, can_delete_aliases=ALIAS_DELETE_PERMISSION in principal.permissions)
@router.post("/{entity_id}/move", response_model=LegalEntityOut, dependencies=EDIT, summary="Перевести ЮЛ в другую ГК")

View File

@ -49,6 +49,15 @@ class DomainValidationError(DomainError):
super().__init__(http_status.HTTP_422_UNPROCESSABLE_CONTENT, code, message, extra)
class PermissionDeniedError(HTTPException):
"""403 АВТОРИЗАЦИИ: у пользователя нет права `code` (`require` маршрута или право на часть операции, например
удаление алиаса формой ЮЛ, #84). Ответ строкой, как у любого 403 «недостаточно прав»."""
def __init__(self, code: str) -> None:
super().__init__(http_status.HTTP_403_FORBIDDEN, detail=f"недостаточно прав: требуется {code}")
self.permission = code
class AuthenticationFailedError(HTTPException):
"""401/403 АУТЕНТИФИКАЦИИ (токен не принят; пользователь не найден, отключён, неоднозначен) — не авторизации.

View File

@ -2,13 +2,16 @@
import datetime
import uuid
from typing import Literal, Self
from typing import Annotated, Literal, Self
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator
from src.models.enums import EntityType, LeHistoryEvent, RelatedGroupKind
NAME_MAX_LENGTH = 500
MAX_ALIASES = 100 # алиасов в одном теле формы ЮЛ (#84): граница входа, 422 до обращения к БД
AliasText = Annotated[str, Field(min_length=1, max_length=NAME_MAX_LENGTH)]
def _blank_to_none(value: str | None) -> str | None:
@ -143,7 +146,8 @@ def _strip_required_name(value: str | None) -> str | None:
class LegalEntityCreate(BaseModel):
"""Создание ЮЛ/ИП: `inn` и `kpp` проверяются по `entity_type` в сервисе (`INVALID_INN`, `INVALID_KPP`); без ГК —
монозаёмщик. `kpp` необязателен, у ИП запрещён (DBML v5.17)."""
монозаёмщик. `kpp` необязателен, у ИП запрещён (DBML v5.17). Форма целиком (#84): `aliases` — алиасы как в ОСВ,
`gsk_id`/`gsz_id` — ГСК/ГСЗ той же ГК; создаются в той же транзакции."""
model_config = ConfigDict(extra="forbid")
@ -154,6 +158,9 @@ class LegalEntityCreate(BaseModel):
company_group_id: uuid.UUID | None = None
regional_office_id: uuid.UUID | None = None
crm_id: str | None = None
aliases: list[AliasText] = Field(default_factory=list, max_length=MAX_ALIASES)
gsk_id: uuid.UUID | None = None
gsz_id: uuid.UUID | None = None
@field_validator("name")
@classmethod
@ -167,7 +174,10 @@ class LegalEntityCreate(BaseModel):
class LegalEntityUpdate(BaseModel):
"""Изменение ЮЛ: `inn` и ГК PATCH не меняет (ГК — `move`/`withdraw`); переданные поля — `model_fields_set`."""
"""Изменение ЮЛ: `inn` и ГК PATCH не меняет (ГК — `move`/`withdraw`); переданные поля — `model_fields_set`.
Форма целиком (#84): `aliases` — итоговый набор (сверка по `normalized_alias`), `gsk_id`/`gsz_id` — итоговые
ГСК/ГСЗ; не переданное поле не меняется."""
model_config = ConfigDict(extra="forbid")
@ -176,6 +186,9 @@ class LegalEntityUpdate(BaseModel):
crm_id: str | None = None
regional_office_id: uuid.UUID | None = None
kpp: str | None = None # null очищает КПП; формат и запрет для ИП проверяет сервис (INVALID_KPP)
aliases: list[AliasText] | None = Field(default=None, max_length=MAX_ALIASES) # итоговый набор; null запрещён
gsk_id: uuid.UUID | None = None # null — выйти из текущей ГСК
gsz_id: uuid.UUID | None = None # null — выйти из текущей ГСЗ
@field_validator("name")
@classmethod
@ -189,7 +202,7 @@ class LegalEntityUpdate(BaseModel):
@model_validator(mode="after")
def _required_fields_are_not_null(self) -> Self:
for field in ("name", "entity_type"):
for field in ("name", "entity_type", "aliases"):
if field in self.model_fields_set and getattr(self, field) is None:
raise ValueError(f"поле {field} нельзя обнулить")
return self
@ -262,7 +275,7 @@ class MembershipOut(BaseModel):
class AliasCreate(BaseModel):
"""Тело `POST …/aliases`: `alias` сохраняется как есть (как в ОСВ), `normalized_alias` считает сервис (§3)."""
alias: str = Field(min_length=1, max_length=NAME_MAX_LENGTH)
alias: AliasText
class AliasOut(BaseModel):

View File

@ -2,6 +2,7 @@
Порядок: право (`require` на маршруте) → видимость ЮЛ (`scope`, невидимое = несуществующее) → проверки → запись.
Аудит `LEGAL_ENTITY_CHANGE` / `object_type = legal_entity_alias` пишется в той же транзакции запроса.
`add_alias`/`remove_alias`/`normalize_or_reject` — общие для ручек алиасов и формы ЮЛ (`legal_entity_form`, #84).
"""
import uuid
@ -32,6 +33,68 @@ def alias_payload(alias: LegalEntityAlias) -> dict[str, object]:
}
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:
"""Добавление и удаление алиасов ЮЛ/ИП от имени текущего пользователя в его области видимости."""
@ -43,58 +106,12 @@ class AliasService:
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)
normalized = normalize_alias(data.alias)
if not normalized:
raise DomainValidationError("EMPTY_ALIAS", "Алиас пуст после нормализации (одни ОПФ и знаки препинания)")
await repo.lock_alias_key(self._session, normalized) # «проверка → вставка» без гонки с ручным сопоставлением
owner = await repo.find_alias_owner(self._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=data.alias,
normalized_alias=normalized,
created_by=self._audit.user_id,
created_at=utc_now(),
)
self._session.add(alias)
await self._session.flush()
await self._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
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)
alias = await repo.get_alias(self._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(self._session, alias.normalized_alias)
alias = await repo.get_alias(self._session, entity.id, alias_id, refresh=True) # перенос мог удалить его
if alias is None:
raise NotFoundError(ALIAS_NOT_FOUND)
before = alias_payload(alias)
await self._session.delete(alias)
await self._session.flush()
await self._audit.record(
action=AuditAction.LEGAL_ENTITY_CHANGE,
section=AuditSection.DIRECTORIES,
object_type=OBJECT_TYPE,
object_id=alias_id,
before_value=before,
)
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)

View File

@ -0,0 +1,113 @@
"""Форма ЮЛ одним запросом (issue #84, spec directories-api): `POST`/`PATCH /legal-entities` с алиасами и ГСК/ГСЗ.
Оркестрация поверх `LegalEntityService` (поля ЮЛ), `aliases.add_alias/remove_alias` и
`related_groups.add_membership/close_membership`: те же проверки, блокировки и события аудита, что у отдельных ручек
`…/aliases` и `…/related-groups/{id}/members`. Всё — в транзакции запроса: ошибка на любом шаге откатывает и поля ЮЛ,
и алиасы, и членства. Порядок шагов: поля ЮЛ → алиасы → ГСК → ГСЗ (в нём же идут ошибки).
"""
import uuid
from collections.abc import Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.audit import RequestAudit
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError, PermissionDeniedError
from src.domain.policy import VisibilityScope
from src.models.directories import LegalEntity, RelatedGroup
from src.models.enums import RelatedGroupKind
from src.repositories import directories as repo
from src.schemas.directories import LegalEntityCreate, LegalEntityDetail, LegalEntityUpdate
from src.services.aliases import add_alias, normalize_or_reject, remove_alias
from src.services.legal_entities import LegalEntityService
from src.services.related_groups import RELATED_GROUP_NOT_FOUND, add_membership, close_membership
ALIAS_DELETE_PERMISSION = "DIRECTORY_DELETE" # то же право, что у `DELETE …/aliases/{id}`
GROUP_FIELDS = {RelatedGroupKind.gsk: "gsk_id", RelatedGroupKind.gsz: "gsz_id"}
GROUP_TITLES = {RelatedGroupKind.gsk: "ГСК", RelatedGroupKind.gsz: "ГСЗ"}
def desired_aliases(raws: Sequence[str]) -> dict[str, str]:
"""Набор алиасов формы: `normalized_alias` → написание как пришло; пустой после §3 — 422 `EMPTY_ALIAS`, два
элемента с одним `normalized_alias` — 409 `ALIAS_EXISTS` (как повторное добавление через `POST …/aliases`)."""
result: dict[str, str] = {}
for raw in raws:
normalized = normalize_or_reject(raw)
if normalized in result:
raise ConflictError("ALIAS_EXISTS", "В списке алиасов есть повтор (совпадают после нормализации)")
result[normalized] = raw
return result
class LegalEntityFormService:
"""Создание и изменение ЮЛ/ИП вместе с алиасами и ГСК/ГСЗ от имени текущего пользователя."""
def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None:
self._session = session
self._audit = audit
self._scope = scope
self._entities = LegalEntityService(session, audit, scope)
async def create(self, data: LegalEntityCreate) -> LegalEntityDetail:
"""Новое ЮЛ (проверки `LegalEntityService.create`), затем его алиасы и членства в ГСК/ГСЗ."""
entity = await self._entities.create(data)
for raw in desired_aliases(data.aliases).values():
await add_alias(self._session, self._audit, entity, raw)
for kind, field in GROUP_FIELDS.items():
group_id = getattr(data, field)
if group_id is not None:
await add_membership(self._session, self._audit, await self._group(group_id, kind), entity)
return await self._detail(entity, documents_count=0)
async def update(
self, entity_id: uuid.UUID, data: LegalEntityUpdate, *, can_delete_aliases: bool
) -> LegalEntityDetail:
"""Поля ЮЛ (`LegalEntityService.update`), затем переданные `aliases` / `gsk_id` / `gsz_id` как итоговые."""
entity = await self._entities.update(entity_id, data)
if data.aliases is not None: # null запрещён схемой: `None` — поле не передано
await self._sync_aliases(entity, data.aliases, can_delete=can_delete_aliases)
for kind, field in GROUP_FIELDS.items():
if field in data.model_fields_set:
await self._sync_membership(entity, kind, getattr(data, field))
return await self._detail(entity, documents_count=await self._entities.documents_count(entity.id))
async def _sync_aliases(self, entity: LegalEntity, raws: Sequence[str], *, can_delete: bool) -> None:
"""Сверка по `normalized_alias`: совпавшие не трогаются (написание прежнее), лишние удаляются (нужно право
`DIRECTORY_DELETE`, иначе 403 и откат всего запроса), недостающие добавляются."""
desired = desired_aliases(raws)
existing = await repo.list_aliases(self._session, entity.id)
stale = [alias.id for alias in existing if alias.normalized_alias not in desired]
if stale and not can_delete:
raise PermissionDeniedError(ALIAS_DELETE_PERMISSION)
present = {alias.normalized_alias for alias in existing}
for alias_id in stale:
await remove_alias(self._session, self._audit, entity, alias_id)
for normalized, raw in desired.items():
if normalized not in present:
await add_alias(self._session, self._audit, entity, raw)
async def _sync_membership(self, entity: LegalEntity, kind: RelatedGroupKind, group_id: uuid.UUID | None) -> None:
"""Итоговая группа вида `kind`: `None` — закрыть текущее членство; та же — ничего; другая — сменить."""
group = await self._group(group_id, kind) if group_id is not None else None
current = await repo.current_membership_of_kind(self._session, entity.id, kind)
if current is not None and group is not None and current.related_group_id == group.id:
return
if current is not None:
await close_membership(self._session, self._audit, current)
if group is not None:
await add_membership(self._session, self._audit, group, entity)
async def _group(self, group_id: uuid.UUID, kind: RelatedGroupKind) -> RelatedGroup:
"""Видимая ГСК/ГСЗ нужного вида; невидимая и несуществующая — один 404, другой вид — 422 `WRONG_GROUP_KIND`."""
group = await repo.get_related_group(self._session, self._scope, group_id)
if group is None:
raise NotFoundError(RELATED_GROUP_NOT_FOUND)
if group.kind != kind:
expected = f"в {GROUP_FIELDS[kind]} передаётся только {GROUP_TITLES[kind]}"
raise DomainValidationError("WRONG_GROUP_KIND", f"Группа другого вида: {expected}")
return group
async def _detail(self, entity: LegalEntity, *, documents_count: int) -> LegalEntityDetail:
detail = await self._entities.get(entity.id)
detail.documents_count = documents_count
return detail

View File

@ -38,6 +38,72 @@ CODE_PREFIXES = {RelatedGroupKind.gsk: "GSK", RelatedGroupKind.gsz: "GSZ"}
TRACKED_FIELDS = ("name", "created_date") # поля группы, изменяемые 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:
"""Операции над ГСК/ГСЗ и их составом от имени текущего пользователя в его области видимости."""
@ -104,7 +170,7 @@ class RelatedGroupService:
rows = await repo.list_group_member_rows(self._session, group.id)
before = {
**self._group_payload(group),
"members": [{"id": row.id, **self._member_payload(row)} for row in rows],
"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)
@ -123,37 +189,7 @@ class RelatedGroupService:
entity = await repo.get_legal_entity(self._session, self._scope, data.legal_entity_id)
if entity is None:
raise NotFoundError(LEGAL_ENTITY_NOT_FOUND)
# ЮЛ без ГК (монозаёмщик, закрытое — Review Focus 4) тоже «не в той же ГК»
if entity.company_group_id != group.company_group_id:
raise DomainValidationError("DIFFERENT_COMPANY_GROUP", "ЮЛ не входит в ГК этой группы")
current = await repo.current_membership_of_kind(self._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=self._audit.user_id,
)
self._session.add(member)
await self._session.flush()
await self._audit.record(
action=AuditAction.MEMBERSHIP_CHANGE,
section=AuditSection.DIRECTORIES,
object_type=MEMBER_OBJECT_TYPE,
object_id=member.id,
after_value=self._member_payload(member),
)
return member, entity
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."""
@ -161,18 +197,7 @@ class RelatedGroupService:
member = await repo.current_membership_in_group(self._session, group.id, legal_entity_id)
if member is None:
raise NotFoundError(MEMBERSHIP_NOT_FOUND)
before = self._member_payload(member)
member.valid_to = utc_now()
member.is_current = False
await self._session.flush()
await self._audit.record(
action=AuditAction.MEMBERSHIP_CHANGE,
section=AuditSection.DIRECTORIES,
object_type=MEMBER_OBJECT_TYPE,
object_id=member.id,
before_value=before,
after_value=self._member_payload(member),
)
await close_membership(self._session, self._audit, member)
async def get(self, group_id: uuid.UUID) -> RelatedGroupDetail:
"""Карточка группы с текущими членами; невидимая и несуществующая — один и тот же 404."""
@ -225,14 +250,3 @@ class RelatedGroupService:
"name": group.name,
"created_date": group.created_date,
}
@staticmethod
def _member_payload(member: RelatedGroupMember) -> dict[str, Any]:
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,
}

View File

@ -0,0 +1,427 @@
"""Форма ЮЛ одним запросом (issue #84, spec directories-api): алиасы и ГСК/ГСЗ в `POST`/`PATCH /legal-entities`.
Поведение алиасов и членств — то же, что у отдельных ручек `…/aliases` и `…/related-groups/{id}/members`: те же коды
ошибок, блокировки и события аудита; ошибка на любом шаге откатывает весь запрос.
"""
import datetime
import uuid
from collections.abc import Sequence
from typing import Any
import httpx
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.audit_ex import AuditEvent
from src.models.directories import (
LegalEntity,
LegalEntityAlias,
LegalEntityHistory,
RegionalOffice,
RelatedGroupMember,
)
from src.models.enums import AuditAction, RelatedGroupKind
from src.schemas.directories import MAX_ALIASES
from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_legal_entity, make_membership, make_related_group
HTTP_OK = 200
HTTP_CREATED = 201
HTTP_FORBIDDEN = 403
HTTP_NOT_FOUND = 404
HTTP_CONFLICT = 409
HTTP_UNPROCESSABLE = 422
ENTITIES = "/directories/legal-entities"
KA_GO = "ka_go@armka.local"
ADMIN = "admin@armka.local"
KRASNODAR = "Краснодарский региональный филиал"
ALIAS_OBJECT = "legal_entity_alias"
MEMBER_OBJECT = "related_group_member"
INN = "3528052718"
async def _events(session: AsyncSession, object_type: str) -> Sequence[AuditEvent]:
stmt = (
select(AuditEvent).where(AuditEvent.object_type == object_type).order_by(AuditEvent.occurred_at, AuditEvent.id)
)
return (await session.execute(stmt)).scalars().all()
async def _count(session: AsyncSession, model: type[Any]) -> int:
return (await session.execute(select(func.count()).select_from(model))).scalar_one()
async def _alias(session: AsyncSession, entity: LegalEntity, alias: str, normalized: str) -> LegalEntityAlias:
row = LegalEntityAlias(
legal_entity_id=entity.id,
alias=alias,
normalized_alias=normalized,
created_at=datetime.datetime.now(datetime.UTC),
)
session.add(row)
await session.flush()
return row
async def _current_members(session: AsyncSession, entity_id: uuid.UUID) -> list[uuid.UUID]:
stmt = select(RelatedGroupMember.related_group_id).where(
RelatedGroupMember.legal_entity_id == entity_id, RelatedGroupMember.is_current.is_(True)
)
return sorted((await session.execute(stmt)).scalars().all())
def _detail(response: httpx.Response) -> dict[str, Any]:
detail: dict[str, Any] = response.json()["detail"]
return detail
# --- POST ---------------------------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_create_with_aliases_and_memberships(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group = await make_company_group(seeded, name="ГК Форма")
gsk = await make_related_group(seeded, group=group, name="ГСК Форма")
gsz = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsz, name="ГСЗ Форма")
client = await auth_client(KA_GO)
response = await client.post(
ENTITIES,
json={
"inn": INN,
"entity_type": "ЮЛ",
"name": "ООО Ромашка",
"company_group_id": str(group.id),
"aliases": ["ООО «Ромашка»", "Ромашка-Юг"],
"gsk_id": str(gsk.id),
"gsz_id": str(gsz.id),
},
)
assert response.status_code == HTTP_CREATED, response.text
body = response.json()
assert [(a["alias"], a["normalized_alias"]) for a in body["aliases"]] == [
("ООО «Ромашка»", "ромашка"),
("Ромашка-Юг", "ромашка юг"), # дефис — пунктуация по §3
]
assert sorted((m["kind"], m["related_group_id"]) for m in body["memberships"]) == [
("gsk", str(gsk.id)),
("gsz", str(gsz.id)),
]
assert (body["documents_count"], body["company_group_name"]) == (0, "ГК Форма")
alias_events = await _events(seeded, ALIAS_OBJECT)
assert [(e.action, e.before_value, e.after_value["alias"]) for e in alias_events] == [
(AuditAction.LEGAL_ENTITY_CHANGE, None, "ООО «Ромашка»"),
(AuditAction.LEGAL_ENTITY_CHANGE, None, "Ромашка-Юг"),
]
member_events = await _events(seeded, MEMBER_OBJECT)
assert [e.action for e in member_events] == [AuditAction.MEMBERSHIP_CHANGE] * 2
assert {e.after_value["related_group_id"] for e in member_events} == {str(gsk.id), str(gsz.id)}
assert await _count(seeded, LegalEntityHistory) == 0
@pytest.mark.integration
async def test_create_without_form_fields_returns_detail(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""Старое тело без новых полей работает как раньше; ответ — карточка (надмножество `LegalEntityOut`)."""
client = await auth_client(KA_GO)
response = await client.post(ENTITIES, json={"inn": INN, "entity_type": "ЮЛ", "name": "ООО Старое тело"})
assert response.status_code == HTTP_CREATED, response.text
body = response.json()
assert (body["aliases"], body["memberships"], body["outside_group"]) == ([], [], True)
assert not await _events(seeded, ALIAS_OBJECT)
assert not await _events(seeded, MEMBER_OBJECT)
@pytest.mark.integration
async def test_create_with_taken_alias_rolls_back_everything(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
owner = await make_legal_entity(seeded, name="ООО Владелец")
await _alias(seeded, owner, "ООО «Пекарня»", "пекарня")
group = await make_company_group(seeded, name="ГК Откат")
gsk = await make_related_group(seeded, group=group)
entities_before = await _count(seeded, LegalEntity)
client = await auth_client(KA_GO)
response = await client.post(
ENTITIES,
json={
"inn": INN,
"entity_type": "ЮЛ",
"name": "ООО Новое",
"company_group_id": str(group.id),
"gsk_id": str(gsk.id),
"aliases": ["Новое", "Пекарня, АО"],
},
)
assert response.status_code == HTTP_CONFLICT
assert _detail(response)["code"] == "ALIAS_TAKEN"
assert _detail(response)["legal_entity_id"] == str(owner.id)
assert await _count(seeded, LegalEntity) == entities_before
assert await _count(seeded, LegalEntityAlias) == 1
assert await _count(seeded, RelatedGroupMember) == 0
assert not await _events(seeded, "legal_entity")
assert not await _events(seeded, ALIAS_OBJECT)
@pytest.mark.integration
async def test_create_with_duplicate_aliases_in_list_is_conflict(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
client = await auth_client(KA_GO)
response = await client.post(
ENTITIES,
json={"inn": INN, "entity_type": "ЮЛ", "name": "ООО Дубль", "aliases": ["ООО «Ромашка»", "Ромашка, АО"]},
)
assert response.status_code == HTTP_CONFLICT
assert _detail(response)["code"] == "ALIAS_EXISTS"
assert await _count(seeded, LegalEntityAlias) == 0
assert not await _events(seeded, "legal_entity")
@pytest.mark.integration
async def test_create_with_empty_normalized_alias_is_unprocessable(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
client = await auth_client(KA_GO)
response = await client.post(ENTITIES, json={"inn": INN, "entity_type": "ЮЛ", "name": "ООО Х", "aliases": ["ООО"]})
assert response.status_code == HTTP_UNPROCESSABLE
assert _detail(response)["code"] == "EMPTY_ALIAS"
assert not await _events(seeded, "legal_entity")
@pytest.mark.integration
async def test_create_mono_borrower_with_gsk_is_unprocessable(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
group = await make_company_group(seeded, name="ГК Чужая")
gsk = await make_related_group(seeded, group=group)
client = await auth_client(KA_GO)
response = await client.post(
ENTITIES, json={"inn": INN, "entity_type": "ЮЛ", "name": "ООО Моно", "gsk_id": str(gsk.id)}
)
assert response.status_code == HTTP_UNPROCESSABLE
assert _detail(response)["code"] == "DIFFERENT_COMPANY_GROUP"
assert not await _events(seeded, "legal_entity")
@pytest.mark.integration
async def test_create_with_group_of_another_company_group_is_unprocessable(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
own = await make_company_group(seeded, name="ГК Своя")
other = await make_company_group(seeded, name="ГК Другая")
gsz = await make_related_group(seeded, group=other, kind=RelatedGroupKind.gsz)
client = await auth_client(KA_GO)
response = await client.post(
ENTITIES,
json={"inn": INN, "entity_type": "ЮЛ", "name": "ООО Х", "company_group_id": str(own.id), "gsz_id": str(gsz.id)},
)
assert response.status_code == HTTP_UNPROCESSABLE
assert _detail(response)["code"] == "DIFFERENT_COMPANY_GROUP"
@pytest.mark.integration
@pytest.mark.parametrize(("field", "kind"), [("gsk_id", RelatedGroupKind.gsz), ("gsz_id", RelatedGroupKind.gsk)])
async def test_create_with_group_of_wrong_kind_is_unprocessable(
auth_client: AuthClientFactory, seeded: AsyncSession, field: str, kind: RelatedGroupKind
) -> None:
group = await make_company_group(seeded, name="ГК Вид")
related = await make_related_group(seeded, group=group, kind=kind)
client = await auth_client(KA_GO)
body = {"inn": INN, "entity_type": "ЮЛ", "name": "ООО Х", "company_group_id": str(group.id), field: str(related.id)}
response = await client.post(ENTITIES, json=body)
assert response.status_code == HTTP_UNPROCESSABLE
assert _detail(response)["code"] == "WRONG_GROUP_KIND"
assert not await _events(seeded, "legal_entity")
@pytest.mark.integration
async def test_create_with_invisible_related_group_is_404_like_missing(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
krasnodar = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == KRASNODAR))).scalar_one()
foreign = await make_company_group(seeded, name="ГК Краснодар", office=krasnodar)
foreign_gsk = await make_related_group(seeded, group=foreign)
client = await auth_client("ka_rf@armka.local")
base = {"inn": INN, "entity_type": "ЮЛ", "name": "ООО Х"}
missing = await client.post(ENTITIES, json={**base, "gsk_id": str(uuid.uuid4())})
invisible = await client.post(ENTITIES, json={**base, "gsk_id": str(foreign_gsk.id)})
assert missing.status_code == HTTP_NOT_FOUND
assert invisible.status_code == HTTP_NOT_FOUND
assert invisible.json() == missing.json()
assert not await _events(seeded, "legal_entity")
@pytest.mark.integration
async def test_aliases_over_limit_and_blank_alias_are_422(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: # noqa: ARG001
client = await auth_client(KA_GO)
base = {"inn": INN, "entity_type": "ЮЛ", "name": "ООО Х"}
too_many = await client.post(ENTITIES, json={**base, "aliases": [f"Алиас {i}" for i in range(MAX_ALIASES + 1)]})
blank = await client.post(ENTITIES, json={**base, "aliases": [""]})
assert too_many.status_code == HTTP_UNPROCESSABLE
assert too_many.json()["detail"][0]["loc"] == ["body", "aliases"]
assert blank.status_code == HTTP_UNPROCESSABLE
assert blank.json()["detail"][0]["loc"] == ["body", "aliases", 0]
# --- PATCH --------------------------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_patch_aliases_is_final_set_by_normalized_alias(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""Совпавший по `normalized_alias` алиас не трогаем (написание прежнее), лишний удаляем, новый добавляем."""
entity = await make_legal_entity(seeded, name="ООО Альфа")
kept = await _alias(seeded, entity, "ООО «Альфа»", "альфа")
removed = await _alias(seeded, entity, "Бета", "бета")
client = await auth_client(ADMIN)
response = await client.patch(f"{ENTITIES}/{entity.id}", json={"aliases": ["альфа", "Гамма"]})
assert response.status_code == HTTP_OK, response.text
body = response.json()
assert [(a["id"], a["alias"]) for a in body["aliases"]][0] == (str(kept.id), "ООО «Альфа»")
assert sorted(a["normalized_alias"] for a in body["aliases"]) == ["альфа", "гамма"]
events = await _events(seeded, ALIAS_OBJECT)
assert [(e.before_value is None, e.after_value is None) for e in events] == [(False, True), (True, False)]
assert str(events[0].object_id) == str(removed.id)
assert events[1].after_value["alias"] == "Гамма"
assert await _count(seeded, LegalEntityHistory) == 0 # поля ЮЛ не менялись — снимка нет
assert not await _events(seeded, "legal_entity")
@pytest.mark.integration
async def test_patch_removing_alias_without_delete_permission_is_forbidden(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
entity = await make_legal_entity(seeded, name="ООО Старое")
await _alias(seeded, entity, "Старое", "старое")
client = await auth_client(KA_GO) # DIRECTORY_EDIT есть, DIRECTORY_DELETE — нет
response = await client.patch(f"{ENTITIES}/{entity.id}", json={"name": "ООО Новое", "aliases": ["Новое"]})
assert response.status_code == HTTP_FORBIDDEN
assert response.json() == {"detail": "недостаточно прав: требуется DIRECTORY_DELETE"}
await seeded.refresh(entity)
assert entity.name == "ООО Старое"
assert [a.normalized_alias for a in (await seeded.execute(select(LegalEntityAlias))).scalars()] == ["старое"]
assert await _count(seeded, LegalEntityHistory) == 0
assert not await _events(seeded, ALIAS_OBJECT)
adding_only = await client.patch(f"{ENTITIES}/{entity.id}", json={"aliases": ["Старое", "Новое"]})
assert adding_only.status_code == HTTP_OK, adding_only.text # без удаления право DELETE не нужно
@pytest.mark.integration
async def test_patch_without_form_fields_keeps_aliases_and_memberships(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
group = await make_company_group(seeded, name="ГК Без формы")
entity = await make_legal_entity(seeded, name="ООО Было", group=group)
gsk = await make_related_group(seeded, group=group)
await make_membership(seeded, related_group=gsk, entity=entity)
await _alias(seeded, entity, "Было", "было")
client = await auth_client(KA_GO)
response = await client.patch(f"{ENTITIES}/{entity.id}", json={"name": "ООО Стало"})
assert response.status_code == HTTP_OK, response.text
body = response.json()
assert body["name"] == "ООО Стало"
assert [a["alias"] for a in body["aliases"]] == ["Было"]
assert [m["related_group_id"] for m in body["memberships"]] == [str(gsk.id)]
assert not await _events(seeded, ALIAS_OBJECT)
assert not await _events(seeded, MEMBER_OBJECT)
@pytest.mark.integration
async def test_patch_memberships_switch_close_and_keep(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group = await make_company_group(seeded, name="ГК Смена")
entity = await make_legal_entity(seeded, group=group)
old_gsk = await make_related_group(seeded, group=group, name="ГСК Старая")
new_gsk = await make_related_group(seeded, group=group, name="ГСК Новая")
gsz = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsz)
await make_membership(seeded, related_group=old_gsk, entity=entity)
await make_membership(seeded, related_group=gsz, entity=entity)
client = await auth_client(KA_GO)
switched = await client.patch(f"{ENTITIES}/{entity.id}", json={"gsk_id": str(new_gsk.id), "gsz_id": None})
assert switched.status_code == HTTP_OK, switched.text
assert [m["related_group_id"] for m in switched.json()["memberships"]] == [str(new_gsk.id)]
assert await _current_members(seeded, entity.id) == [new_gsk.id]
events = await _events(seeded, MEMBER_OBJECT)
closed = sorted(e.before_value["related_group_id"] for e in events if e.before_value is not None)
assert closed == sorted([str(old_gsk.id), str(gsz.id)])
opened = [e.after_value["related_group_id"] for e in events if e.before_value is None]
assert opened == [str(new_gsk.id)]
same = await client.patch(f"{ENTITIES}/{entity.id}", json={"gsk_id": str(new_gsk.id), "gsz_id": None})
assert same.status_code == HTTP_OK
assert len(await _events(seeded, MEMBER_OBJECT)) == len(events) # та же группа и «нет ГСЗ» — без изменений
@pytest.mark.integration
async def test_patch_noop_form_writes_nothing(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""«Сохранить» без изменений: те же поля, алиасы и группы — ни истории, ни событий аудита."""
group = await make_company_group(seeded, name="ГК Ноль")
entity = await make_legal_entity(seeded, name="ООО Ноль", group=group)
gsk = await make_related_group(seeded, group=group)
await make_membership(seeded, related_group=gsk, entity=entity)
await _alias(seeded, entity, "ООО «Ноль»", "ноль")
client = await auth_client(KA_GO)
response = await client.patch(
f"{ENTITIES}/{entity.id}",
json={"name": "ООО Ноль", "aliases": ["ООО «Ноль»"], "gsk_id": str(gsk.id), "gsz_id": None},
)
assert response.status_code == HTTP_OK, response.text
assert await _count(seeded, LegalEntityHistory) == 0
for object_type in ("legal_entity", ALIAS_OBJECT, MEMBER_OBJECT):
assert not await _events(seeded, object_type)
@pytest.mark.integration
async def test_patch_failure_on_membership_rolls_back_name_and_aliases(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
group = await make_company_group(seeded, name="ГК Откат PATCH")
entity = await make_legal_entity(seeded, name="ООО Было", group=group)
wrong = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsz)
client = await auth_client(KA_GO)
response = await client.patch(
f"{ENTITIES}/{entity.id}", json={"name": "ООО Стало", "aliases": ["Стало"], "gsk_id": str(wrong.id)}
)
assert response.status_code == HTTP_UNPROCESSABLE
assert _detail(response)["code"] == "WRONG_GROUP_KIND"
await seeded.refresh(entity)
assert entity.name == "ООО Было"
assert await _count(seeded, LegalEntityAlias) == 0
assert await _count(seeded, LegalEntityHistory) == 0
@pytest.mark.integration
async def test_patch_aliases_null_and_duplicates(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
entity = await make_legal_entity(seeded)
client = await auth_client(KA_GO)
null = await client.patch(f"{ENTITIES}/{entity.id}", json={"aliases": None})
duplicate = await client.patch(f"{ENTITIES}/{entity.id}", json={"aliases": ["Пекарня", "ООО Пекарня"]})
assert null.status_code == HTTP_UNPROCESSABLE
assert duplicate.status_code == HTTP_CONFLICT
assert _detail(duplicate)["code"] == "ALIAS_EXISTS"
assert await _count(seeded, LegalEntityAlias) == 0

View File

@ -52,6 +52,8 @@ API справочников ядра: группы компаний (ГК), ю
членствами ГСК/ГСЗ и алиасами; `GET …/{id}/history` — снимки. В списке и карточке SHALL быть `documents_count` — число
документов файлов ЮЛ, кроме файлов `SUPERSEDED`. Во всех ответах ЮЛ, кроме снимков истории, SHALL быть `company_group_name` —
текущее имя ГК `company_group_id` (архивной тоже) или `null` у монозаёмщика; имена набора ЮЛ — одним запросом.
Ответ `POST` и `PATCH` — карточка (`LegalEntityDetail`: поля списка + `memberships`, `aliases`); поля формы
`aliases`, `gsk_id`, `gsz_id` — требование «Форма ЮЛ одним запросом».
#### Scenario: Неверная длина ИНН
- **WHEN** создаётся ИП с ИНН из 10 цифр
@ -99,6 +101,41 @@ SHALL установить `company_group_id = NULL`, при `CLOSE` — ещё
- **WHEN** добавляется алиас `ООО «Пекарня Счастливочная»`
- **THEN** `normalized_alias = "пекарня счастливочная"`, повторное добавление `Пекарня Счастливочная, ООО` → 409 `ALIAS_EXISTS`
### Requirement: Форма ЮЛ одним запросом
`POST /api/v1/directories/legal-entities` и `PATCH …/{id}` SHALL принимать поля формы (макет 51-02, 79): `aliases`
(список строк 1–500 символов, не больше 100 элементов; больше — 422 массивом), `gsk_id`, `gsz_id` — и применять их в той
же транзакции, что и поля ЮЛ; ошибка на любом шаге откатывает весь запрос. Шаги и порядок ошибок: поля ЮЛ → алиасы → ГСК →
ГСЗ. Алиасы и членства SHALL проверяться и аудироваться так же, как в ручках `…/aliases` и `…/related-groups/{id}/members`
(`EMPTY_ALIAS`, `ALIAS_TAKEN`, `DIFFERENT_COMPANY_GROUP`, `MEMBERSHIP_CHANGE`, `LEGAL_ENTITY_CHANGE` по алиасу); два
элемента `aliases` с одним `normalized_alias` — 409 `ALIAS_EXISTS`. Группа SHALL быть видимой (иначе 404, как
несуществующая) и нужного вида: в `gsk_id` — ГСК, в `gsz_id` — ГСЗ (иначе 422 `WRONG_GROUP_KIND`).
В `POST` не переданное поле — пустой список / без группы. В `PATCH` не переданное поле SHALL не меняться; `aliases: null`
— 422; `aliases` — итоговый набор: алиасы сверяются по `normalized_alias`, совпавшие не трогаются (написание прежнее),
недостающие добавляются, лишние удаляются — удаление требует права `DIRECTORY_DELETE` (иначе 403
«недостаточно прав: требуется DIRECTORY_DELETE», ничего не меняется); `gsk_id`/`gsz_id` — итоговая группа вида: `null`
закрывает текущее членство вида, та же группа — без изменений, другая — текущее закрывается, новое открывается. Снимок
`legal_entity_history(UPDATE)` пишется только при изменении полей ЮЛ.
#### Scenario: Создание с алиасами и группами
- **WHEN** ЮЛ ГК создаётся с `aliases = ["ООО «Ромашка»", "Ромашка-Юг"]`, `gsk_id` и `gsz_id` групп своей ГК
- **THEN** ответ 201 с двумя алиасами и двумя текущими членствами; в аудите — создание ЮЛ, два события по алиасам и два `MEMBERSHIP_CHANGE`
#### Scenario: Чужой алиас откатывает создание
- **WHEN** в `aliases` есть алиас, закреплённый за другим ЮЛ
- **THEN** ответ 409 `ALIAS_TAKEN` с `legal_entity_id` владельца; ни ЮЛ, ни алиасов, ни членств, ни событий аудита
#### Scenario: Итоговый набор алиасов
- **WHEN** у ЮЛ алиасы `ООО «Альфа»` и `Бета`, а `PATCH` администратора передаёт `aliases = ["альфа", "Гамма"]`
- **THEN** `ООО «Альфа»` остаётся с прежним `id` и написанием, `Бета` удалён, `Гамма` добавлен; снимка истории нет
#### Scenario: Удаление алиаса без права
- **WHEN** пользователь без `DIRECTORY_DELETE` передаёт `aliases` без одного из текущих алиасов
- **THEN** ответ 403, ни поля ЮЛ, ни алиасы не меняются
#### Scenario: Смена ГСК и выход из ГСЗ
- **WHEN** ЮЛ в ГСК A и ГСЗ C; `PATCH` с `gsk_id = B`, `gsz_id = null`
- **THEN** членства в A и C закрыты, открыто членство в B; повтор того же тела ничего не меняет
### Requirement: ГСК и ГСЗ — группы и состав
`POST /api/v1/directories/company-groups/{gk_id}/related-groups` (`DIRECTORY_EDIT`; тело `kind` ∈ {gsk, gsz}, `name`,
`created_date`) SHALL создавать `related_group` с `code` `GSK-NNNNNN`/`GSZ-NNNNNN`; `PATCH …/related-groups/{id}` — менять