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` | добавить ЮЛ (только из той же ГК) |
|
| `POST /directories/related-groups/{group_id}/members` | `DIRECTORY_EDIT` | добавить ЮЛ (только из той же ГК) |
|
||||||
| `DELETE /directories/related-groups/{group_id}/members/{legal_entity_id}` | `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` |
|
| `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`) |
|
| `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` очищает; прежнее значение уходит в историю) |
|
| `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}/move` | `DIRECTORY_EDIT` | перевести в другую ГК с датой `effective_date` ≥ сегодня (дата по Москве, Europe/Moscow); членства ГСК/ГСЗ закрываются |
|
||||||
| `POST /directories/legal-entities/{entity_id}/withdraw` | `DIRECTORY_EDIT` | вывести из ГК: `CLOSE` (с `close_date`) или `REMOVE_FROM_GROUP` |
|
| `POST /directories/legal-entities/{entity_id}/withdraw` | `DIRECTORY_EDIT` | вывести из ГК: `CLOSE` (с `close_date`) или `REMOVE_FROM_GROUP` |
|
||||||
| `GET /directories/legal-entities/{entity_id}/history` | `DIRECTORY_VIEW` | история ЮЛ/ИП, новые первыми |
|
| `GET /directories/legal-entities/{entity_id}/history` | `DIRECTORY_VIEW` | история ЮЛ/ИП, новые первыми |
|
||||||
@ -830,6 +830,7 @@ email без учёта регистра проверяет только сер
|
|||||||
| `EFFECTIVE_DATE_IN_PAST` | 422 | дата перевода/вывода раньше сегодняшней (по Москве) |
|
| `EFFECTIVE_DATE_IN_PAST` | 422 | дата перевода/вывода раньше сегодняшней (по Москве) |
|
||||||
| `EMPTY_ALIAS` | 422 | алиас пуст после нормализации (одни ОПФ и знаки препинания) |
|
| `EMPTY_ALIAS` | 422 | алиас пуст после нормализации (одни ОПФ и знаки препинания) |
|
||||||
| `DIFFERENT_COMPANY_GROUP` | 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`) |
|
| `UNKNOWN_ROLE` / `UNKNOWN_PERMISSION` | 422 | неизвестная или неактивная роль / неизвестный код права (поля `roles` / `permissions`) |
|
||||||
| `MISSING_COLUMNS` / `UNSUPPORTED_FILE` | 422 | файл импорта целиком (`missing` — перечень колонок) |
|
| `MISSING_COLUMNS` / `UNSUPPORTED_FILE` | 422 | файл импорта целиком (`missing` — перечень колонок) |
|
||||||
| `CRM_ID_EXISTS` | 409 | ГК или ЮЛ с таким `crm_id` уже есть |
|
| `CRM_ID_EXISTS` | 409 | ГК или ЮЛ с таким `crm_id` уже есть |
|
||||||
|
|||||||
@ -22,7 +22,7 @@ from fastapi import Depends, HTTPException, Request, status
|
|||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
from src.api.dependencies.db import SessionDep
|
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.auth import AuthError, Claims, TokenVerifier, build_verifier
|
||||||
from src.core.config import get_settings
|
from src.core.config import get_settings
|
||||||
from src.repositories.users import AmbiguousUserError, UserWithAccess, load_principal_data
|
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))
|
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:
|
def _token_rejected(detail: str) -> AuthenticationFailedError:
|
||||||
"""401 с LOGIN_FAILED: токен предъявлен, но не принят (в журнал — причина, токен не пишется)."""
|
"""401 с LOGIN_FAILED: токен предъявлен, но не принят (в журнал — причина, токен не пишется)."""
|
||||||
return AuthenticationFailedError(
|
return AuthenticationFailedError(
|
||||||
@ -155,7 +151,7 @@ def require(code: str) -> Callable[..., Awaitable[Principal]]:
|
|||||||
|
|
||||||
async def check(principal: Annotated[Principal, Depends(current_user)]) -> Principal:
|
async def check(principal: Annotated[Principal, Depends(current_user)]) -> Principal:
|
||||||
if code not in principal.permissions:
|
if code not in principal.permissions:
|
||||||
raise _forbidden(f"недостаточно прав: требуется {code}")
|
raise PermissionDeniedError(code)
|
||||||
return principal
|
return principal
|
||||||
|
|
||||||
return check
|
return check
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from src.services.aliases import AliasService
|
|||||||
from src.services.company_groups import CompanyGroupService
|
from src.services.company_groups import CompanyGroupService
|
||||||
from src.services.directory_import import DirectoryImportService
|
from src.services.directory_import import DirectoryImportService
|
||||||
from src.services.legal_entities import LegalEntityService
|
from src.services.legal_entities import LegalEntityService
|
||||||
|
from src.services.legal_entity_form import LegalEntityFormService
|
||||||
from src.services.related_groups import RelatedGroupService
|
from src.services.related_groups import RelatedGroupService
|
||||||
|
|
||||||
|
|
||||||
@ -43,6 +44,17 @@ def get_legal_entity_service(
|
|||||||
LegalEntityServiceDep = Annotated[LegalEntityService, Depends(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(
|
def get_alias_service(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
audit: Annotated[RequestAudit, Depends(get_request_audit)],
|
audit: Annotated[RequestAudit, Depends(get_request_audit)],
|
||||||
|
|||||||
@ -9,8 +9,8 @@ from typing import Annotated
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, status
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
|
||||||
from src.api.dependencies.auth import require
|
from src.api.dependencies.auth import Principal, require
|
||||||
from src.api.dependencies.directories import AliasServiceDep, LegalEntityServiceDep
|
from src.api.dependencies.directories import AliasServiceDep, LegalEntityFormServiceDep, LegalEntityServiceDep
|
||||||
from src.schemas.common import Page, PageParams
|
from src.schemas.common import Page, PageParams
|
||||||
from src.schemas.directories import (
|
from src.schemas.directories import (
|
||||||
AliasCreate,
|
AliasCreate,
|
||||||
@ -24,6 +24,7 @@ from src.schemas.directories import (
|
|||||||
LegalEntityUpdate,
|
LegalEntityUpdate,
|
||||||
LegalEntityWithdraw,
|
LegalEntityWithdraw,
|
||||||
)
|
)
|
||||||
|
from src.services.legal_entity_form import ALIAS_DELETE_PERMISSION
|
||||||
|
|
||||||
router = APIRouter(prefix="/legal-entities")
|
router = APIRouter(prefix="/legal-entities")
|
||||||
|
|
||||||
@ -56,12 +57,18 @@ async def list_legal_entities(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@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:
|
async def create_legal_entity(data: LegalEntityCreate, service: LegalEntityFormServiceDep) -> LegalEntityDetail:
|
||||||
"""Создаёт ЮЛ/ИП; без ГК — монозаёмщик; ИНН: ЮЛ — 10 цифр, ИП — 12; КПП необязателен, у ИП запрещён."""
|
"""Создаёт ЮЛ/ИП; без ГК — монозаёмщик; ИНН: ЮЛ — 10 цифр, ИП — 12; КПП необязателен, у ИП запрещён.
|
||||||
entity = await service.create(data)
|
|
||||||
return await service.to_out(entity, 0)
|
Форма целиком (#84): `aliases`, `gsk_id`, `gsz_id` — в той же транзакции; ответ — карточка.
|
||||||
|
"""
|
||||||
|
return await service.create(data)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{entity_id}", response_model=LegalEntityDetail, dependencies=VIEW, summary="Карточка ЮЛ/ИП")
|
@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
|
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(
|
async def update_legal_entity(
|
||||||
entity_id: uuid.UUID, data: LegalEntityUpdate, service: LegalEntityServiceDep
|
entity_id: uuid.UUID,
|
||||||
) -> LegalEntityOut:
|
data: LegalEntityUpdate,
|
||||||
"""Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`/`kpp`; прежние значения — в историю (`UPDATE`)."""
|
service: LegalEntityFormServiceDep,
|
||||||
entity = await service.update(entity_id, data)
|
principal: Annotated[Principal, Depends(require("DIRECTORY_EDIT"))],
|
||||||
return await service.to_out(entity, await service.documents_count(entity_id))
|
) -> 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="Перевести ЮЛ в другую ГК")
|
@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)
|
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):
|
class AuthenticationFailedError(HTTPException):
|
||||||
"""401/403 АУТЕНТИФИКАЦИИ (токен не принят; пользователь не найден, отключён, неоднозначен) — не авторизации.
|
"""401/403 АУТЕНТИФИКАЦИИ (токен не принят; пользователь не найден, отключён, неоднозначен) — не авторизации.
|
||||||
|
|
||||||
|
|||||||
@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import uuid
|
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 pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator
|
||||||
|
|
||||||
from src.models.enums import EntityType, LeHistoryEvent, RelatedGroupKind
|
from src.models.enums import EntityType, LeHistoryEvent, RelatedGroupKind
|
||||||
|
|
||||||
NAME_MAX_LENGTH = 500
|
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:
|
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):
|
class LegalEntityCreate(BaseModel):
|
||||||
"""Создание ЮЛ/ИП: `inn` и `kpp` проверяются по `entity_type` в сервисе (`INVALID_INN`, `INVALID_KPP`); без ГК —
|
"""Создание ЮЛ/ИП: `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")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@ -154,6 +158,9 @@ class LegalEntityCreate(BaseModel):
|
|||||||
company_group_id: uuid.UUID | None = None
|
company_group_id: uuid.UUID | None = None
|
||||||
regional_office_id: uuid.UUID | None = None
|
regional_office_id: uuid.UUID | None = None
|
||||||
crm_id: str | 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")
|
@field_validator("name")
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -167,7 +174,10 @@ class LegalEntityCreate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class LegalEntityUpdate(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")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@ -176,6 +186,9 @@ class LegalEntityUpdate(BaseModel):
|
|||||||
crm_id: str | None = None
|
crm_id: str | None = None
|
||||||
regional_office_id: uuid.UUID | None = None
|
regional_office_id: uuid.UUID | None = None
|
||||||
kpp: str | None = None # null очищает КПП; формат и запрет для ИП проверяет сервис (INVALID_KPP)
|
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")
|
@field_validator("name")
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -189,7 +202,7 @@ class LegalEntityUpdate(BaseModel):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _required_fields_are_not_null(self) -> Self:
|
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:
|
if field in self.model_fields_set and getattr(self, field) is None:
|
||||||
raise ValueError(f"поле {field} нельзя обнулить")
|
raise ValueError(f"поле {field} нельзя обнулить")
|
||||||
return self
|
return self
|
||||||
@ -262,7 +275,7 @@ class MembershipOut(BaseModel):
|
|||||||
class AliasCreate(BaseModel):
|
class AliasCreate(BaseModel):
|
||||||
"""Тело `POST …/aliases`: `alias` сохраняется как есть (как в ОСВ), `normalized_alias` считает сервис (§3)."""
|
"""Тело `POST …/aliases`: `alias` сохраняется как есть (как в ОСВ), `normalized_alias` считает сервис (§3)."""
|
||||||
|
|
||||||
alias: str = Field(min_length=1, max_length=NAME_MAX_LENGTH)
|
alias: AliasText
|
||||||
|
|
||||||
|
|
||||||
class AliasOut(BaseModel):
|
class AliasOut(BaseModel):
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
Порядок: право (`require` на маршруте) → видимость ЮЛ (`scope`, невидимое = несуществующее) → проверки → запись.
|
Порядок: право (`require` на маршруте) → видимость ЮЛ (`scope`, невидимое = несуществующее) → проверки → запись.
|
||||||
Аудит `LEGAL_ENTITY_CHANGE` / `object_type = legal_entity_alias` пишется в той же транзакции запроса.
|
Аудит `LEGAL_ENTITY_CHANGE` / `object_type = legal_entity_alias` пишется в той же транзакции запроса.
|
||||||
|
`add_alias`/`remove_alias`/`normalize_or_reject` — общие для ручек алиасов и формы ЮЛ (`legal_entity_form`, #84).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
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:
|
class AliasService:
|
||||||
"""Добавление и удаление алиасов ЮЛ/ИП от имени текущего пользователя в его области видимости."""
|
"""Добавление и удаление алиасов ЮЛ/ИП от имени текущего пользователя в его области видимости."""
|
||||||
|
|
||||||
@ -43,58 +106,12 @@ class AliasService:
|
|||||||
async def add(self, entity_id: uuid.UUID, data: AliasCreate) -> LegalEntityAlias:
|
async def add(self, entity_id: uuid.UUID, data: AliasCreate) -> LegalEntityAlias:
|
||||||
"""Новый алиас: `alias` как есть, `normalized_alias` по §3; конфликты — `ALIAS_EXISTS` / `ALIAS_TAKEN`."""
|
"""Новый алиас: `alias` как есть, `normalized_alias` по §3; конфликты — `ALIAS_EXISTS` / `ALIAS_TAKEN`."""
|
||||||
entity = await self._get_entity(entity_id)
|
entity = await self._get_entity(entity_id)
|
||||||
normalized = normalize_alias(data.alias)
|
return await add_alias(self._session, self._audit, entity, 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
|
|
||||||
|
|
||||||
async def remove(self, entity_id: uuid.UUID, alias_id: uuid.UUID) -> None:
|
async def remove(self, entity_id: uuid.UUID, alias_id: uuid.UUID) -> None:
|
||||||
"""Удаляет алиас этого ЮЛ (иначе 404); `before_value` аудита — удалённые значения."""
|
"""Удаляет алиас этого ЮЛ (иначе 404); `before_value` аудита — удалённые значения."""
|
||||||
entity = await self._get_entity(entity_id)
|
entity = await self._get_entity(entity_id)
|
||||||
alias = await repo.get_alias(self._session, entity.id, alias_id)
|
await remove_alias(self._session, self._audit, entity, 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _get_entity(self, entity_id: uuid.UUID) -> LegalEntity:
|
async def _get_entity(self, entity_id: uuid.UUID) -> LegalEntity:
|
||||||
entity = await repo.get_legal_entity(self._session, self._scope, entity_id)
|
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
|
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:
|
class RelatedGroupService:
|
||||||
"""Операции над ГСК/ГСЗ и их составом от имени текущего пользователя в его области видимости."""
|
"""Операции над ГСК/ГСЗ и их составом от имени текущего пользователя в его области видимости."""
|
||||||
|
|
||||||
@ -104,7 +170,7 @@ class RelatedGroupService:
|
|||||||
rows = await repo.list_group_member_rows(self._session, group.id)
|
rows = await repo.list_group_member_rows(self._session, group.id)
|
||||||
before = {
|
before = {
|
||||||
**self._group_payload(group),
|
**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 repo.delete_group_member_rows(self._session, group.id)
|
||||||
await self._session.delete(group)
|
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)
|
entity = await repo.get_legal_entity(self._session, self._scope, data.legal_entity_id)
|
||||||
if entity is None:
|
if entity is None:
|
||||||
raise NotFoundError(LEGAL_ENTITY_NOT_FOUND)
|
raise NotFoundError(LEGAL_ENTITY_NOT_FOUND)
|
||||||
# ЮЛ без ГК (монозаёмщик, закрытое — Review Focus 4) тоже «не в той же ГК»
|
return await add_membership(self._session, self._audit, group, entity), entity
|
||||||
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
|
|
||||||
|
|
||||||
async def remove_member(self, group_id: uuid.UUID, legal_entity_id: uuid.UUID) -> None:
|
async def remove_member(self, group_id: uuid.UUID, legal_entity_id: uuid.UUID) -> None:
|
||||||
"""Исключение (SCD2): текущая строка закрывается `valid_to = now`, `is_current = false`; нет членства — 404."""
|
"""Исключение (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)
|
member = await repo.current_membership_in_group(self._session, group.id, legal_entity_id)
|
||||||
if member is None:
|
if member is None:
|
||||||
raise NotFoundError(MEMBERSHIP_NOT_FOUND)
|
raise NotFoundError(MEMBERSHIP_NOT_FOUND)
|
||||||
before = self._member_payload(member)
|
await close_membership(self._session, self._audit, 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),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get(self, group_id: uuid.UUID) -> RelatedGroupDetail:
|
async def get(self, group_id: uuid.UUID) -> RelatedGroupDetail:
|
||||||
"""Карточка группы с текущими членами; невидимая и несуществующая — один и тот же 404."""
|
"""Карточка группы с текущими членами; невидимая и несуществующая — один и тот же 404."""
|
||||||
@ -225,14 +250,3 @@ class RelatedGroupService:
|
|||||||
"name": group.name,
|
"name": group.name,
|
||||||
"created_date": group.created_date,
|
"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` — число
|
членствами ГСК/ГСЗ и алиасами; `GET …/{id}/history` — снимки. В списке и карточке SHALL быть `documents_count` — число
|
||||||
документов файлов ЮЛ, кроме файлов `SUPERSEDED`. Во всех ответах ЮЛ, кроме снимков истории, SHALL быть `company_group_name` —
|
документов файлов ЮЛ, кроме файлов `SUPERSEDED`. Во всех ответах ЮЛ, кроме снимков истории, SHALL быть `company_group_name` —
|
||||||
текущее имя ГК `company_group_id` (архивной тоже) или `null` у монозаёмщика; имена набора ЮЛ — одним запросом.
|
текущее имя ГК `company_group_id` (архивной тоже) или `null` у монозаёмщика; имена набора ЮЛ — одним запросом.
|
||||||
|
Ответ `POST` и `PATCH` — карточка (`LegalEntityDetail`: поля списка + `memberships`, `aliases`); поля формы
|
||||||
|
`aliases`, `gsk_id`, `gsz_id` — требование «Форма ЮЛ одним запросом».
|
||||||
|
|
||||||
#### Scenario: Неверная длина ИНН
|
#### Scenario: Неверная длина ИНН
|
||||||
- **WHEN** создаётся ИП с ИНН из 10 цифр
|
- **WHEN** создаётся ИП с ИНН из 10 цифр
|
||||||
@ -99,6 +101,41 @@ SHALL установить `company_group_id = NULL`, при `CLOSE` — ещё
|
|||||||
- **WHEN** добавляется алиас `ООО «Пекарня Счастливочная»`
|
- **WHEN** добавляется алиас `ООО «Пекарня Счастливочная»`
|
||||||
- **THEN** `normalized_alias = "пекарня счастливочная"`, повторное добавление `Пекарня Счастливочная, ООО` → 409 `ALIAS_EXISTS`
|
- **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: ГСК и ГСЗ — группы и состав
|
### Requirement: ГСК и ГСЗ — группы и состав
|
||||||
`POST /api/v1/directories/company-groups/{gk_id}/related-groups` (`DIRECTORY_EDIT`; тело `kind` ∈ {gsk, gsz}, `name`,
|
`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}` — менять
|
`created_date`) SHALL создавать `related_group` с `code` `GSK-NNNNNN`/`GSZ-NNNNNN`; `PATCH …/related-groups/{id}` — менять
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user