diff --git a/README.md b/README.md index 3ecc8b1..1d6be1e 100644 --- a/README.md +++ b/README.md @@ -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` уже есть | diff --git a/api/src/api/dependencies/auth.py b/api/src/api/dependencies/auth.py index dedbadd..285e788 100644 --- a/api/src/api/dependencies/auth.py +++ b/api/src/api/dependencies/auth.py @@ -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 diff --git a/api/src/api/dependencies/directories.py b/api/src/api/dependencies/directories.py index e4cd836..0dc6450 100644 --- a/api/src/api/dependencies/directories.py +++ b/api/src/api/dependencies/directories.py @@ -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)], diff --git a/api/src/api/endpoints/directories/legal_entities.py b/api/src/api/endpoints/directories/legal_entities.py index 901c17c..97c2415 100644 --- a/api/src/api/endpoints/directories/legal_entities.py +++ b/api/src/api/endpoints/directories/legal_entities.py @@ -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="Перевести ЮЛ в другую ГК") diff --git a/api/src/common/exceptions.py b/api/src/common/exceptions.py index 2ddca46..bc8cee1 100644 --- a/api/src/common/exceptions.py +++ b/api/src/common/exceptions.py @@ -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 АУТЕНТИФИКАЦИИ (токен не принят; пользователь не найден, отключён, неоднозначен) — не авторизации. diff --git a/api/src/schemas/directories.py b/api/src/schemas/directories.py index b670f05..f0e60cb 100644 --- a/api/src/schemas/directories.py +++ b/api/src/schemas/directories.py @@ -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): diff --git a/api/src/services/aliases.py b/api/src/services/aliases.py index 8456531..d92cbb4 100644 --- a/api/src/services/aliases.py +++ b/api/src/services/aliases.py @@ -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) diff --git a/api/src/services/legal_entity_form.py b/api/src/services/legal_entity_form.py new file mode 100644 index 0000000..02813ee --- /dev/null +++ b/api/src/services/legal_entity_form.py @@ -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 diff --git a/api/src/services/related_groups.py b/api/src/services/related_groups.py index 812990f..bfd37b4 100644 --- a/api/src/services/related_groups.py +++ b/api/src/services/related_groups.py @@ -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, - } diff --git a/api/tests/test_api_legal_entity_form.py b/api/tests/test_api_legal_entity_form.py new file mode 100644 index 0000000..2e9794f --- /dev/null +++ b/api/tests/test_api_legal_entity_form.py @@ -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 diff --git a/openspec/specs/directories-api/spec.md b/openspec/specs/directories-api/spec.md index 4dcd4c3..eb6c42b 100644 --- a/openspec/specs/directories-api/spec.md +++ b/openspec/specs/directories-api/spec.md @@ -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}` — менять