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:
commit
b4c7a15e99
@ -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` уже есть |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)],
|
||||
|
||||
@ -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="Перевести ЮЛ в другую ГК")
|
||||
|
||||
@ -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 АУТЕНТИФИКАЦИИ (токен не принят; пользователь не найден, отключён, неоднозначен) — не авторизации.
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
113
api/src/services/legal_entity_form.py
Normal file
113
api/src/services/legal_entity_form.py
Normal 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
|
||||
@ -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,
|
||||
}
|
||||
|
||||
427
api/tests/test_api_legal_entity_form.py
Normal file
427
api/tests/test_api_legal_entity_form.py
Normal 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
|
||||
@ -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}` — менять
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user