feat(directories): ГСК/ГСЗ и состав (SCD2, одна группа каждого вида на ЮЛ)
Создание с кодом GSK-/GSZ-NNNNNN, изменение name/created_date, удаление только пустой группы (ASSUMPTION(OQ-34), закрытые строки состава удаляются вместе с группой), добавление члена (та же ГК, kind группы, MEMBER_OF_OTHER_GROUP / MEMBER_EXISTS), исключение SCD2. Видимость группы — через явный JOIN ГК и company_groups_visible (T4.1). Аудит MEMBERSHIP_CHANGE. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
e3ea074e4d
commit
88a4b6d7a2
@ -11,6 +11,7 @@ from src.domain.policy import VisibilityScope, scope_of
|
||||
from src.services.aliases import AliasService
|
||||
from src.services.company_groups import CompanyGroupService
|
||||
from src.services.legal_entities import LegalEntityService
|
||||
from src.services.related_groups import RelatedGroupService
|
||||
|
||||
|
||||
def get_scope(principal: Annotated[Principal, Depends(current_user)]) -> VisibilityScope:
|
||||
@ -49,3 +50,14 @@ def get_alias_service(
|
||||
|
||||
|
||||
AliasServiceDep = Annotated[AliasService, Depends(get_alias_service)]
|
||||
|
||||
|
||||
def get_related_group_service(
|
||||
session: SessionDep,
|
||||
audit: Annotated[RequestAudit, Depends(get_request_audit)],
|
||||
scope: Annotated[VisibilityScope, Depends(get_scope)],
|
||||
) -> RelatedGroupService:
|
||||
return RelatedGroupService(session, audit, scope)
|
||||
|
||||
|
||||
RelatedGroupServiceDep = Annotated[RelatedGroupService, Depends(get_related_group_service)]
|
||||
|
||||
@ -2,9 +2,10 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.api.endpoints.directories import company_groups, legal_entities, regional_offices
|
||||
from src.api.endpoints.directories import company_groups, legal_entities, regional_offices, related_groups
|
||||
|
||||
router = APIRouter(prefix="/directories", tags=["directories"])
|
||||
router.include_router(regional_offices.router)
|
||||
router.include_router(company_groups.router)
|
||||
router.include_router(legal_entities.router)
|
||||
router.include_router(related_groups.router)
|
||||
|
||||
108
api/src/api/endpoints/directories/related_groups.py
Normal file
108
api/src/api/endpoints/directories/related_groups.py
Normal file
@ -0,0 +1,108 @@
|
||||
"""Маршруты ГСК/ГСЗ (spec directories-api «ГСК и ГСЗ — группы и состав»): список и создание в ГК
|
||||
(`/company-groups/{gk_id}/related-groups`), карточка, изменение, удаление, состав (`/related-groups/{id}…`).
|
||||
|
||||
Право (`require`) проверяется до видимости; группа видна через её ГК, чужая и несуществующая — одинаковый 404
|
||||
(spec directory-policy). `DIRECTORY_DELETE` — только для удаления группы; исключение члена — `DIRECTORY_EDIT`.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
|
||||
from src.api.dependencies.auth import require
|
||||
from src.api.dependencies.directories import RelatedGroupServiceDep
|
||||
from src.schemas.directories import (
|
||||
MemberAdd,
|
||||
RelatedGroupCreate,
|
||||
RelatedGroupDetail,
|
||||
RelatedGroupMemberOut,
|
||||
RelatedGroupOut,
|
||||
RelatedGroupUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
VIEW = [Depends(require("DIRECTORY_VIEW"))]
|
||||
EDIT = [Depends(require("DIRECTORY_EDIT"))]
|
||||
DELETE = [Depends(require("DIRECTORY_DELETE"))]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/company-groups/{company_group_id}/related-groups",
|
||||
response_model=list[RelatedGroupOut],
|
||||
dependencies=VIEW,
|
||||
summary="ГСК/ГСЗ группы компаний",
|
||||
)
|
||||
async def list_related_groups(company_group_id: uuid.UUID, service: RelatedGroupServiceDep) -> list[RelatedGroupOut]:
|
||||
"""ГСК и ГСЗ ГК по имени с числом текущих членов."""
|
||||
return await service.list_for(company_group_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/company-groups/{company_group_id}/related-groups",
|
||||
response_model=RelatedGroupOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=EDIT,
|
||||
summary="Создать ГСК/ГСЗ",
|
||||
)
|
||||
async def create_related_group(
|
||||
company_group_id: uuid.UUID, data: RelatedGroupCreate, service: RelatedGroupServiceDep
|
||||
) -> RelatedGroupOut:
|
||||
"""Создаёт ГСК (`kind = gsk`, код `GSK-NNNNNN`) или ГСЗ (`gsz`, `GSZ-NNNNNN`) в ГК."""
|
||||
return await service.summary(await service.create(company_group_id, data))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/related-groups/{group_id}", response_model=RelatedGroupDetail, dependencies=VIEW, summary="Карточка ГСК/ГСЗ"
|
||||
)
|
||||
async def get_related_group(group_id: uuid.UUID, service: RelatedGroupServiceDep) -> RelatedGroupDetail:
|
||||
"""Группа со списком текущих членов (по имени ЮЛ)."""
|
||||
return await service.get(group_id)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/related-groups/{group_id}", response_model=RelatedGroupOut, dependencies=EDIT, summary="Изменить ГСК/ГСЗ"
|
||||
)
|
||||
async def update_related_group(
|
||||
group_id: uuid.UUID, data: RelatedGroupUpdate, service: RelatedGroupServiceDep
|
||||
) -> RelatedGroupOut:
|
||||
"""Меняет `name`/`created_date`; вид и ГК группы не меняются."""
|
||||
return await service.summary(await service.update(group_id, data))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/related-groups/{group_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=DELETE,
|
||||
summary="Удалить пустую ГСК/ГСЗ",
|
||||
)
|
||||
async def delete_related_group(group_id: uuid.UUID, service: RelatedGroupServiceDep) -> None:
|
||||
"""Удаляет группу без текущих членов (иначе 409 `GROUP_NOT_EMPTY`) вместе с историей состава."""
|
||||
await service.delete(group_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/related-groups/{group_id}/members",
|
||||
response_model=RelatedGroupMemberOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=EDIT,
|
||||
summary="Добавить ЮЛ в ГСК/ГСЗ",
|
||||
)
|
||||
async def add_member(group_id: uuid.UUID, data: MemberAdd, service: RelatedGroupServiceDep) -> RelatedGroupMemberOut:
|
||||
"""ЮЛ той же ГК (иначе 422 `DIFFERENT_COMPANY_GROUP`); уже в группе этого вида — 409 `MEMBER_OF_OTHER_GROUP`
|
||||
(с `related_group_id`), в этой же — 409 `MEMBER_EXISTS`."""
|
||||
member, entity = await service.add_member(group_id, data)
|
||||
return RelatedGroupMemberOut(
|
||||
legal_entity_id=entity.id, inn=entity.inn, name=entity.name, valid_from=member.valid_from
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/related-groups/{group_id}/members/{legal_entity_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=EDIT,
|
||||
summary="Исключить ЮЛ из ГСК/ГСЗ",
|
||||
)
|
||||
async def remove_member(group_id: uuid.UUID, legal_entity_id: uuid.UUID, service: RelatedGroupServiceDep) -> None:
|
||||
"""Закрывает текущее членство (`valid_to = now`, `is_current = false`); строка остаётся в истории."""
|
||||
await service.remove_member(group_id, legal_entity_id)
|
||||
@ -9,7 +9,7 @@ import datetime
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Select, Sequence, func, or_, select
|
||||
from sqlalchemy import Select, Sequence, delete, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.domain.policy import VisibilityScope, company_groups_visible, legal_entities_visible
|
||||
@ -23,6 +23,7 @@ from src.models.directories import (
|
||||
RelatedGroup,
|
||||
RelatedGroupMember,
|
||||
)
|
||||
from src.models.enums import RelatedGroupKind
|
||||
|
||||
LIKE_ESCAPE = "\\"
|
||||
|
||||
@ -234,6 +235,105 @@ async def close_memberships(
|
||||
return members
|
||||
|
||||
|
||||
def _current_members_count() -> Any:
|
||||
"""Скалярный подзапрос: число текущих членов группы `RelatedGroup` внешнего запроса."""
|
||||
return (
|
||||
select(func.count(RelatedGroupMember.id))
|
||||
.where(RelatedGroupMember.related_group_id == RelatedGroup.id, RelatedGroupMember.is_current.is_(True))
|
||||
.correlate(RelatedGroup)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
|
||||
async def list_related_groups(session: AsyncSession, company_group_id: uuid.UUID) -> list[tuple[RelatedGroup, int]]:
|
||||
"""ГСК/ГСЗ ГК (по `name`, `id`) с числом текущих членов; видимость ГК проверяет вызывающий."""
|
||||
stmt = (
|
||||
select(RelatedGroup, _current_members_count())
|
||||
.where(RelatedGroup.company_group_id == company_group_id)
|
||||
.order_by(RelatedGroup.name, RelatedGroup.id)
|
||||
)
|
||||
return [(group, count) for group, count in (await session.execute(stmt)).all()]
|
||||
|
||||
|
||||
async def get_related_group(session: AsyncSession, scope: VisibilityScope, group_id: uuid.UUID) -> RelatedGroup | None:
|
||||
"""ГСК/ГСЗ по id; видимость — через ГК (§10.1); `None` и для несуществующей, и для невидимой.
|
||||
|
||||
`CompanyGroup` присоединяется явно: `company_groups_visible` фильтрует по колонке ГК и без JOIN дала бы
|
||||
декартово произведение (T4.1).
|
||||
"""
|
||||
stmt = (
|
||||
select(RelatedGroup)
|
||||
.join(CompanyGroup, CompanyGroup.id == RelatedGroup.company_group_id)
|
||||
.where(RelatedGroup.id == group_id)
|
||||
)
|
||||
return (await session.execute(company_groups_visible(stmt, scope))).scalar_one_or_none()
|
||||
|
||||
|
||||
async def count_current_members(session: AsyncSession, related_group_id: uuid.UUID) -> int:
|
||||
"""Число текущих членов группы."""
|
||||
stmt = select(func.count(RelatedGroupMember.id)).where(
|
||||
RelatedGroupMember.related_group_id == related_group_id, RelatedGroupMember.is_current.is_(True)
|
||||
)
|
||||
return (await session.execute(stmt)).scalar_one()
|
||||
|
||||
|
||||
async def has_current_members(session: AsyncSession, related_group_id: uuid.UUID) -> bool:
|
||||
"""Есть ли у группы хотя бы один текущий член."""
|
||||
return await count_current_members(session, related_group_id) > 0
|
||||
|
||||
|
||||
async def current_membership_of_kind(
|
||||
session: AsyncSession, entity_id: uuid.UUID, kind: RelatedGroupKind
|
||||
) -> RelatedGroupMember | None:
|
||||
"""Текущее членство ЮЛ в группе вида `kind` (не больше одного — частичный уникальный индекс v5.14)."""
|
||||
stmt = select(RelatedGroupMember).where(
|
||||
RelatedGroupMember.legal_entity_id == entity_id,
|
||||
RelatedGroupMember.kind == kind,
|
||||
RelatedGroupMember.is_current.is_(True),
|
||||
)
|
||||
return (await session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def current_membership_in_group(
|
||||
session: AsyncSession, related_group_id: uuid.UUID, entity_id: uuid.UUID
|
||||
) -> RelatedGroupMember | None:
|
||||
"""Текущее членство ЮЛ в конкретной группе."""
|
||||
stmt = select(RelatedGroupMember).where(
|
||||
RelatedGroupMember.related_group_id == related_group_id,
|
||||
RelatedGroupMember.legal_entity_id == entity_id,
|
||||
RelatedGroupMember.is_current.is_(True),
|
||||
)
|
||||
return (await session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def current_members(
|
||||
session: AsyncSession, related_group_id: uuid.UUID
|
||||
) -> list[tuple[RelatedGroupMember, LegalEntity]]:
|
||||
"""Текущие члены группы вместе с ЮЛ, по имени ЮЛ."""
|
||||
stmt = (
|
||||
select(RelatedGroupMember, LegalEntity)
|
||||
.join(LegalEntity, LegalEntity.id == RelatedGroupMember.legal_entity_id)
|
||||
.where(RelatedGroupMember.related_group_id == related_group_id, RelatedGroupMember.is_current.is_(True))
|
||||
.order_by(LegalEntity.name, LegalEntity.id)
|
||||
)
|
||||
return [(member, entity) for member, entity in (await session.execute(stmt)).all()]
|
||||
|
||||
|
||||
async def list_group_member_rows(session: AsyncSession, related_group_id: uuid.UUID) -> list[RelatedGroupMember]:
|
||||
"""Все строки состава группы (текущие и закрытые), по `valid_from`."""
|
||||
stmt = (
|
||||
select(RelatedGroupMember)
|
||||
.where(RelatedGroupMember.related_group_id == related_group_id)
|
||||
.order_by(RelatedGroupMember.valid_from, RelatedGroupMember.id)
|
||||
)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
async def delete_group_member_rows(session: AsyncSession, related_group_id: uuid.UUID) -> None:
|
||||
"""Удаляет все строки состава группы (вызывать только для группы без текущих членов)."""
|
||||
await session.execute(delete(RelatedGroupMember).where(RelatedGroupMember.related_group_id == related_group_id))
|
||||
|
||||
|
||||
async def list_aliases(session: AsyncSession, entity_id: uuid.UUID) -> list[LegalEntityAlias]:
|
||||
"""Алиасы ЮЛ по времени добавления."""
|
||||
stmt = (
|
||||
|
||||
@ -293,3 +293,75 @@ class LegalEntityFilter(BaseModel):
|
||||
@classmethod
|
||||
def _strip_inn(cls, value: str | None) -> str | None:
|
||||
return _blank_to_none(value)
|
||||
|
||||
|
||||
class RelatedGroupCreate(BaseModel):
|
||||
"""Создание ГСК/ГСЗ в ГК: `code` (`GSK-`/`GSZ-NNNNNN`) назначает сервис."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
kind: RelatedGroupKind
|
||||
name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH)
|
||||
created_date: datetime.date | None = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _strip_name(cls, value: str) -> str | None:
|
||||
return _strip_required_name(value)
|
||||
|
||||
|
||||
class RelatedGroupUpdate(BaseModel):
|
||||
"""Изменение ГСК/ГСЗ: `name`, `created_date` (вид и ГК не меняются); переданные поля — `model_fields_set`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=NAME_MAX_LENGTH)
|
||||
created_date: datetime.date | None = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _strip_name(cls, value: str | None) -> str | None:
|
||||
return _strip_required_name(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _name_is_not_null(self) -> Self:
|
||||
if "name" in self.model_fields_set and self.name is None:
|
||||
raise ValueError("название нельзя обнулить")
|
||||
return self
|
||||
|
||||
|
||||
class MemberAdd(BaseModel):
|
||||
"""Тело `POST …/related-groups/{id}/members`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
legal_entity_id: uuid.UUID
|
||||
|
||||
|
||||
class RelatedGroupOut(BaseModel):
|
||||
"""ГСК/ГСЗ; `members_count` — число текущих членов."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
code: str
|
||||
company_group_id: uuid.UUID
|
||||
kind: RelatedGroupKind
|
||||
name: str
|
||||
created_date: datetime.date | None
|
||||
members_count: int = 0
|
||||
|
||||
|
||||
class RelatedGroupMemberOut(BaseModel):
|
||||
"""Текущий член ГСК/ГСЗ."""
|
||||
|
||||
legal_entity_id: uuid.UUID
|
||||
inn: str
|
||||
name: str
|
||||
valid_from: datetime.datetime
|
||||
|
||||
|
||||
class RelatedGroupDetail(RelatedGroupOut):
|
||||
"""Карточка ГСК/ГСЗ со списком текущих членов (по имени ЮЛ)."""
|
||||
|
||||
members: list[RelatedGroupMemberOut] = []
|
||||
|
||||
238
api/src/services/related_groups.py
Normal file
238
api/src/services/related_groups.py
Normal file
@ -0,0 +1,238 @@
|
||||
"""Сервис ГСК/ГСЗ и их состава (spec directories-api «ГСК и ГСЗ — группы и состав», directory-policy, audit-trail).
|
||||
|
||||
Порядок операции: право (`require` на маршруте) → видимость (группа — через её ГК, ЮЛ — по `scope`; невидимое =
|
||||
несуществующее, 404) → проверки → запись. Состав — SCD2: исключение закрывает строку (`valid_to = now`,
|
||||
`is_current = false`), повторное включение — новая строка. `related_group_member.kind` всегда равен `kind` группы
|
||||
(v5.14): ЮЛ может одновременно состоять в одной ГСК и одной ГСЗ. Каждая мутация пишет `MEMBERSHIP_CHANGE`
|
||||
(`object_type` `related_group` / `related_group_member`) в транзакции запроса через `RequestAudit`.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.dependencies.audit import RequestAudit
|
||||
from src.common.clock import utc_now
|
||||
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError
|
||||
from src.domain.policy import VisibilityScope
|
||||
from src.models.directories import RELATED_GROUP_CODE_SEQ, CompanyGroup, LegalEntity, RelatedGroup, RelatedGroupMember
|
||||
from src.models.enums import AuditAction, AuditSection, RelatedGroupKind
|
||||
from src.repositories import directories as repo
|
||||
from src.schemas.directories import (
|
||||
MemberAdd,
|
||||
RelatedGroupCreate,
|
||||
RelatedGroupDetail,
|
||||
RelatedGroupMemberOut,
|
||||
RelatedGroupOut,
|
||||
RelatedGroupUpdate,
|
||||
)
|
||||
from src.services.company_groups import COMPANY_GROUP_NOT_FOUND
|
||||
from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND
|
||||
|
||||
RELATED_GROUP_NOT_FOUND = "ГСК/ГСЗ не найдена" # одинаково для чужой и несуществующей (spec directory-policy)
|
||||
MEMBERSHIP_NOT_FOUND = "ЮЛ не входит в эту группу"
|
||||
OBJECT_TYPE = "related_group"
|
||||
MEMBER_OBJECT_TYPE = "related_group_member"
|
||||
CODE_PREFIXES = {RelatedGroupKind.gsk: "GSK", RelatedGroupKind.gsz: "GSZ"}
|
||||
TRACKED_FIELDS = ("name", "created_date") # поля группы, изменяемые PATCH
|
||||
|
||||
|
||||
class RelatedGroupService:
|
||||
"""Операции над ГСК/ГСЗ и их составом от имени текущего пользователя в его области видимости."""
|
||||
|
||||
def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None:
|
||||
self._session = session
|
||||
self._audit = audit
|
||||
self._scope = scope
|
||||
|
||||
async def create(self, company_group_id: uuid.UUID, data: RelatedGroupCreate) -> RelatedGroup:
|
||||
"""Новая ГСК/ГСЗ видимой ГК с кодом `GSK-`/`GSZ-NNNNNN`; аудит `MEMBERSHIP_CHANGE`."""
|
||||
company_group = await self._visible_company_group(company_group_id)
|
||||
group = RelatedGroup(
|
||||
code=await repo.next_code(self._session, RELATED_GROUP_CODE_SEQ, CODE_PREFIXES[data.kind]),
|
||||
company_group_id=company_group.id,
|
||||
kind=data.kind,
|
||||
name=data.name,
|
||||
created_date=data.created_date,
|
||||
)
|
||||
self._session.add(group)
|
||||
await self._session.flush()
|
||||
await self._audit.record(
|
||||
action=AuditAction.MEMBERSHIP_CHANGE,
|
||||
section=AuditSection.DIRECTORIES,
|
||||
object_type=OBJECT_TYPE,
|
||||
object_id=group.id,
|
||||
after_value=self._group_payload(group),
|
||||
)
|
||||
return group
|
||||
|
||||
async def update(self, group_id: uuid.UUID, data: RelatedGroupUpdate) -> RelatedGroup:
|
||||
"""Изменение `name`/`created_date`; без фактических изменений ничего не пишется."""
|
||||
group = await self._get_group(group_id)
|
||||
changes: dict[str, Any] = {
|
||||
field: getattr(data, field)
|
||||
for field in TRACKED_FIELDS
|
||||
if field in data.model_fields_set and getattr(data, field) != getattr(group, field)
|
||||
}
|
||||
if not changes:
|
||||
return group
|
||||
before = {field: getattr(group, field) for field in changes}
|
||||
for field, value in changes.items():
|
||||
setattr(group, field, value)
|
||||
await self._session.flush()
|
||||
await self._audit.record(
|
||||
action=AuditAction.MEMBERSHIP_CHANGE,
|
||||
section=AuditSection.DIRECTORIES,
|
||||
object_type=OBJECT_TYPE,
|
||||
object_id=group.id,
|
||||
before_value=before,
|
||||
after_value=changes,
|
||||
changed_field=next(iter(changes)) if len(changes) == 1 else None,
|
||||
)
|
||||
return group
|
||||
|
||||
async def delete(self, group_id: uuid.UUID) -> None:
|
||||
"""Удаляет группу без текущих членов вместе с закрытыми строками состава; `before_value` — группа и состав.
|
||||
|
||||
ASSUMPTION(OQ-34): удаляется только пустая ГСК/ГСЗ (есть текущие члены → 409 `GROUP_NOT_EMPTY`); история
|
||||
состава исчезает вместе с группой и сохраняется только в `before_value` аудита.
|
||||
"""
|
||||
group = await self._get_group(group_id)
|
||||
if await repo.has_current_members(self._session, group.id):
|
||||
raise ConflictError("GROUP_NOT_EMPTY", "В группе есть текущие члены — сначала исключите их")
|
||||
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],
|
||||
}
|
||||
await repo.delete_group_member_rows(self._session, group.id)
|
||||
await self._session.delete(group)
|
||||
await self._session.flush()
|
||||
await self._audit.record(
|
||||
action=AuditAction.MEMBERSHIP_CHANGE,
|
||||
section=AuditSection.DIRECTORIES,
|
||||
object_type=OBJECT_TYPE,
|
||||
object_id=group_id,
|
||||
before_value=before,
|
||||
)
|
||||
|
||||
async def add_member(self, group_id: uuid.UUID, data: MemberAdd) -> tuple[RelatedGroupMember, LegalEntity]:
|
||||
"""Текущее членство ЮЛ с `kind` группы; ЮЛ — той же ГК; не более одной группы каждого вида у ЮЛ."""
|
||||
group = await self._get_group(group_id)
|
||||
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
|
||||
|
||||
async def remove_member(self, group_id: uuid.UUID, legal_entity_id: uuid.UUID) -> None:
|
||||
"""Исключение (SCD2): текущая строка закрывается `valid_to = now`, `is_current = false`; нет членства — 404."""
|
||||
group = await self._get_group(group_id)
|
||||
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),
|
||||
)
|
||||
|
||||
async def get(self, group_id: uuid.UUID) -> RelatedGroupDetail:
|
||||
"""Карточка группы с текущими членами; невидимая и несуществующая — один и тот же 404."""
|
||||
group = await self._get_group(group_id)
|
||||
members = [
|
||||
RelatedGroupMemberOut(
|
||||
legal_entity_id=entity.id, inn=entity.inn, name=entity.name, valid_from=member.valid_from
|
||||
)
|
||||
for member, entity in await repo.current_members(self._session, group.id)
|
||||
]
|
||||
detail = RelatedGroupDetail.model_validate(group)
|
||||
detail.members = members
|
||||
detail.members_count = len(members)
|
||||
return detail
|
||||
|
||||
async def summary(self, group: RelatedGroup) -> RelatedGroupOut:
|
||||
"""Группа с числом текущих членов (ответ на создание/изменение)."""
|
||||
out = RelatedGroupOut.model_validate(group)
|
||||
out.members_count = await repo.count_current_members(self._session, group.id)
|
||||
return out
|
||||
|
||||
async def list_for(self, company_group_id: uuid.UUID) -> list[RelatedGroupOut]:
|
||||
"""ГСК/ГСЗ видимой ГК по имени с числом текущих членов."""
|
||||
company_group = await self._visible_company_group(company_group_id)
|
||||
result = []
|
||||
for group, count in await repo.list_related_groups(self._session, company_group.id):
|
||||
out = RelatedGroupOut.model_validate(group)
|
||||
out.members_count = count
|
||||
result.append(out)
|
||||
return result
|
||||
|
||||
async def _get_group(self, group_id: uuid.UUID) -> RelatedGroup:
|
||||
group = await repo.get_related_group(self._session, self._scope, group_id)
|
||||
if group is None:
|
||||
raise NotFoundError(RELATED_GROUP_NOT_FOUND)
|
||||
return group
|
||||
|
||||
async def _visible_company_group(self, company_group_id: uuid.UUID) -> CompanyGroup:
|
||||
company_group = await repo.get_company_group(self._session, self._scope, company_group_id)
|
||||
if company_group is None:
|
||||
raise NotFoundError(COMPANY_GROUP_NOT_FOUND)
|
||||
return company_group
|
||||
|
||||
@staticmethod
|
||||
def _group_payload(group: RelatedGroup) -> dict[str, Any]:
|
||||
return {
|
||||
"code": group.code,
|
||||
"company_group_id": group.company_group_id,
|
||||
"kind": group.kind,
|
||||
"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,
|
||||
}
|
||||
458
api/tests/test_api_related_groups.py
Normal file
458
api/tests/test_api_related_groups.py
Normal file
@ -0,0 +1,458 @@
|
||||
"""API ГСК/ГСЗ и их состава (spec directories-api «ГСК и ГСЗ — группы и состав», directory-policy, audit-trail).
|
||||
|
||||
Сценарии спеки: ГСК+ГСЗ одновременно, вторая ГСК, повторное включение, удаление непустой группы; Review Focus 4 —
|
||||
закрытый ЮЛ (`close_date`, без ГК) → 422 `DIFFERENT_COMPANY_GROUP`.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import re
|
||||
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 RegionalOffice, RelatedGroup, RelatedGroupMember
|
||||
from src.models.enums import AuditAction, AuditSection, RelatedGroupKind
|
||||
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_NO_CONTENT = 204
|
||||
HTTP_FORBIDDEN = 403
|
||||
HTTP_NOT_FOUND = 404
|
||||
HTTP_CONFLICT = 409
|
||||
HTTP_UNPROCESSABLE = 422
|
||||
GROUPS = "/directories/company-groups"
|
||||
RELATED = "/directories/related-groups"
|
||||
ENTITIES = "/directories/legal-entities"
|
||||
KRASNODAR = "Краснодарский региональный филиал"
|
||||
GO = "ka_go@armka.local"
|
||||
ADMIN = "admin@armka.local"
|
||||
KA_RF = "ka_rf@armka.local"
|
||||
EXPERT = "expert@armka.local"
|
||||
|
||||
|
||||
async def _events(session: AsyncSession, object_type: str | None = None) -> Sequence[AuditEvent]:
|
||||
stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.MEMBERSHIP_CHANGE)
|
||||
if object_type is not None:
|
||||
stmt = stmt.where(AuditEvent.object_type == object_type)
|
||||
return (await session.execute(stmt.order_by(AuditEvent.occurred_at, AuditEvent.id))).scalars().all()
|
||||
|
||||
|
||||
async def _member_rows(session: AsyncSession, **filters: Any) -> list[RelatedGroupMember]:
|
||||
stmt = select(RelatedGroupMember).filter_by(**filters).order_by(RelatedGroupMember.valid_from)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
async def _office(session: AsyncSession, name_like: str) -> RegionalOffice:
|
||||
return (await session.execute(select(RegionalOffice).where(RegionalOffice.name.like(name_like)))).scalar_one()
|
||||
|
||||
|
||||
async def _create(
|
||||
client: httpx.AsyncClient, group_id: Any, kind: str = "gsk", name: str = "ГСК А", **extra: Any
|
||||
) -> httpx.Response:
|
||||
return await client.post(f"{GROUPS}/{group_id}/related-groups", json={"kind": kind, "name": name, **extra})
|
||||
|
||||
|
||||
async def _add(client: httpx.AsyncClient, related_id: Any, entity_id: Any) -> httpx.Response:
|
||||
return await client.post(f"{RELATED}/{related_id}/members", json={"legal_entity_id": str(entity_id)})
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_create_gsk_and_gsz_codes_and_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group = await make_company_group(seeded, name="ГК Бисквит")
|
||||
client = await auth_client(GO)
|
||||
|
||||
gsk = await _create(client, group.id, "gsk", " ГСК Бисквит ", created_date="2025-01-15")
|
||||
gsz = await _create(client, group.id, "gsz", "ГСЗ Бисквит")
|
||||
assert gsk.status_code == HTTP_CREATED, gsk.text
|
||||
assert gsz.status_code == HTTP_CREATED, gsz.text
|
||||
gsk_body, gsz_body = gsk.json(), gsz.json()
|
||||
assert re.fullmatch(r"GSK-\d{6}", gsk_body["code"])
|
||||
assert re.fullmatch(r"GSZ-\d{6}", gsz_body["code"])
|
||||
assert gsk_body == {
|
||||
"id": gsk_body["id"],
|
||||
"code": gsk_body["code"],
|
||||
"company_group_id": str(group.id),
|
||||
"kind": "gsk",
|
||||
"name": "ГСК Бисквит",
|
||||
"created_date": "2025-01-15",
|
||||
"members_count": 0,
|
||||
}
|
||||
assert gsz_body["created_date"] is None
|
||||
|
||||
events = await _events(seeded, "related_group")
|
||||
assert [str(event.object_id) for event in events] == [gsk_body["id"], gsz_body["id"]]
|
||||
first = events[0]
|
||||
assert (first.section, first.before_value) == (AuditSection.DIRECTORIES, None)
|
||||
assert first.after_value == {
|
||||
"code": gsk_body["code"],
|
||||
"company_group_id": str(group.id),
|
||||
"kind": "gsk",
|
||||
"name": "ГСК Бисквит",
|
||||
"created_date": "2025-01-15",
|
||||
}
|
||||
assert first.user_id is not None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_create_validation(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group = await make_company_group(seeded)
|
||||
client = await auth_client(GO)
|
||||
assert (await _create(client, group.id, "gsk", " ")).status_code == HTTP_UNPROCESSABLE
|
||||
assert (await _create(client, group.id, "gk", "Имя")).status_code == HTTP_UNPROCESSABLE
|
||||
missing = await _create(client, uuid.uuid4())
|
||||
assert missing.status_code == HTTP_NOT_FOUND
|
||||
assert missing.json()["detail"]["message"] == "ГК не найдена"
|
||||
assert (await seeded.execute(select(func.count()).select_from(RelatedGroup))).scalar_one() == 0
|
||||
assert not await _events(seeded)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_member_of_gsk_and_gsz_at_the_same_time(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
"""Сценарий спеки «Одновременно в ГСК и ГСЗ»; `kind` строки = `kind` группы (в БД)."""
|
||||
group = await make_company_group(seeded)
|
||||
entity = await make_legal_entity(seeded, name="АО Русский бисквит", group=group)
|
||||
client = await auth_client(GO)
|
||||
gsk = (await _create(client, group.id, "gsk", "ГСК")).json()
|
||||
gsz = (await _create(client, group.id, "gsz", "ГСЗ")).json()
|
||||
|
||||
first = await _add(client, gsk["id"], entity.id)
|
||||
second = await _add(client, gsz["id"], entity.id)
|
||||
assert first.status_code == HTTP_CREATED, first.text
|
||||
assert second.status_code == HTTP_CREATED, second.text
|
||||
assert first.json() == {
|
||||
"legal_entity_id": str(entity.id),
|
||||
"inn": entity.inn,
|
||||
"name": "АО Русский бисквит",
|
||||
"valid_from": first.json()["valid_from"],
|
||||
}
|
||||
|
||||
rows = await _member_rows(seeded, legal_entity_id=entity.id, is_current=True)
|
||||
assert sorted((str(row.related_group_id), row.kind) for row in rows) == sorted(
|
||||
[(gsk["id"], RelatedGroupKind.gsk), (gsz["id"], RelatedGroupKind.gsz)]
|
||||
)
|
||||
for row in rows:
|
||||
related = await seeded.get(RelatedGroup, row.related_group_id)
|
||||
assert related is not None
|
||||
assert row.kind == related.kind
|
||||
assert row.valid_to is None
|
||||
assert row.created_by is not None
|
||||
|
||||
card = (await client.get(f"{ENTITIES}/{entity.id}")).json()
|
||||
assert sorted(membership["kind"] for membership in card["memberships"]) == ["gsk", "gsz"]
|
||||
|
||||
events = await _events(seeded, "related_group_member")
|
||||
assert len(events) == 2 # noqa: PLR2004
|
||||
assert {event.object_id for event in events} == {row.id for row in rows}
|
||||
assert events[0].before_value is None
|
||||
assert events[0].after_value is not None
|
||||
assert events[0].after_value["legal_entity_id"] == str(entity.id)
|
||||
assert events[0].after_value["is_current"] is True
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_second_gsk_is_conflict_with_current_group_id(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
"""Сценарий спеки «Вторая ГСК запрещена»: 409 с id ГСК A, данные не меняются."""
|
||||
group = await make_company_group(seeded)
|
||||
entity = await make_legal_entity(seeded, group=group)
|
||||
client = await auth_client(GO)
|
||||
gsk_a = (await _create(client, group.id, "gsk", "ГСК A")).json()
|
||||
gsk_b = (await _create(client, group.id, "gsk", "ГСК B")).json()
|
||||
assert (await _add(client, gsk_a["id"], entity.id)).status_code == HTTP_CREATED
|
||||
|
||||
response = await _add(client, gsk_b["id"], entity.id)
|
||||
assert response.status_code == HTTP_CONFLICT
|
||||
detail = response.json()["detail"]
|
||||
assert detail["code"] == "MEMBER_OF_OTHER_GROUP"
|
||||
assert detail["related_group_id"] == gsk_a["id"]
|
||||
rows = await _member_rows(seeded, legal_entity_id=entity.id)
|
||||
assert [str(row.related_group_id) for row in rows] == [gsk_a["id"]]
|
||||
assert len(await _events(seeded, "related_group_member")) == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_repeat_in_same_group_is_member_exists(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group = await make_company_group(seeded)
|
||||
entity = await make_legal_entity(seeded, group=group)
|
||||
client = await auth_client(GO)
|
||||
gsz = (await _create(client, group.id, "gsz", "ГСЗ")).json()
|
||||
assert (await _add(client, gsz["id"], entity.id)).status_code == HTTP_CREATED
|
||||
|
||||
response = await _add(client, gsz["id"], entity.id)
|
||||
assert response.status_code == HTTP_CONFLICT
|
||||
assert response.json()["detail"]["code"] == "MEMBER_EXISTS"
|
||||
assert len(await _member_rows(seeded, legal_entity_id=entity.id)) == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_remove_and_readd_creates_new_row(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
"""Сценарий спеки «Повторное включение после исключения» (SCD2)."""
|
||||
group = await make_company_group(seeded)
|
||||
entity = await make_legal_entity(seeded, group=group)
|
||||
client = await auth_client(GO)
|
||||
gsk = (await _create(client, group.id)).json()
|
||||
assert (await _add(client, gsk["id"], entity.id)).status_code == HTTP_CREATED
|
||||
|
||||
removed = await client.delete(f"{RELATED}/{gsk['id']}/members/{entity.id}")
|
||||
assert removed.status_code == HTTP_NO_CONTENT, removed.text
|
||||
(closed,) = await _member_rows(seeded, legal_entity_id=entity.id)
|
||||
assert closed.is_current is False
|
||||
assert closed.valid_to is not None
|
||||
detail = (await client.get(f"{RELATED}/{gsk['id']}")).json()
|
||||
assert (detail["members_count"], detail["members"]) == (0, [])
|
||||
|
||||
again = await _add(client, gsk["id"], entity.id)
|
||||
assert again.status_code == HTTP_CREATED, again.text
|
||||
rows = await _member_rows(seeded, legal_entity_id=entity.id)
|
||||
assert len(rows) == 2 # noqa: PLR2004
|
||||
old, new = rows
|
||||
assert (old.id, old.is_current) == (closed.id, False)
|
||||
assert old.valid_to is not None
|
||||
assert (new.is_current, new.valid_to, new.kind) == (True, None, RelatedGroupKind.gsk)
|
||||
|
||||
events = await _events(seeded, "related_group_member")
|
||||
assert len(events) == 3 # noqa: PLR2004 - добавление, исключение, повторное добавление
|
||||
removal = events[1]
|
||||
assert removal.object_id == closed.id
|
||||
assert removal.before_value is not None
|
||||
assert removal.after_value is not None
|
||||
assert (removal.before_value["is_current"], removal.after_value["is_current"]) == (True, False)
|
||||
assert removal.after_value["valid_to"] is not None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_remove_not_a_member_is_404(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group = await make_company_group(seeded)
|
||||
entity = await make_legal_entity(seeded, group=group)
|
||||
related = await make_related_group(seeded, group=group)
|
||||
await make_membership(seeded, related_group=related, entity=entity, is_current=False)
|
||||
client = await auth_client(GO)
|
||||
for entity_id in (entity.id, uuid.uuid4()):
|
||||
response = await client.delete(f"{RELATED}/{related.id}/members/{entity_id}")
|
||||
assert response.status_code == HTTP_NOT_FOUND
|
||||
assert not await _events(seeded)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_delete_non_empty_group_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
"""Сценарий спеки «Удаление непустой группы»."""
|
||||
group = await make_company_group(seeded)
|
||||
entity = await make_legal_entity(seeded, group=group)
|
||||
related = await make_related_group(seeded, group=group)
|
||||
await make_membership(seeded, related_group=related, entity=entity)
|
||||
client = await auth_client(ADMIN)
|
||||
|
||||
response = await client.delete(f"{RELATED}/{related.id}")
|
||||
assert response.status_code == HTTP_CONFLICT
|
||||
assert response.json()["detail"]["code"] == "GROUP_NOT_EMPTY"
|
||||
assert await seeded.get(RelatedGroup, related.id) is not None
|
||||
assert not await _events(seeded)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_delete_empty_group_removes_closed_rows(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group = await make_company_group(seeded)
|
||||
entity = await make_legal_entity(seeded, group=group)
|
||||
related = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsz, name="ГСЗ пустая")
|
||||
closed = await make_membership(seeded, related_group=related, entity=entity, is_current=False)
|
||||
related_id, code = related.id, related.code
|
||||
client = await auth_client(ADMIN)
|
||||
|
||||
response = await client.delete(f"{RELATED}/{related_id}")
|
||||
assert response.status_code == HTTP_NO_CONTENT, response.text
|
||||
seeded.expire_all()
|
||||
assert (await seeded.execute(select(RelatedGroup).where(RelatedGroup.id == related_id))).first() is None
|
||||
assert not await _member_rows(seeded, related_group_id=related_id)
|
||||
|
||||
(event,) = await _events(seeded, "related_group")
|
||||
assert event.object_id == related_id
|
||||
assert event.after_value is None
|
||||
assert event.before_value is not None
|
||||
assert event.before_value["code"] == code
|
||||
assert event.before_value["kind"] == "gsz"
|
||||
assert event.before_value["name"] == "ГСЗ пустая"
|
||||
assert [row["id"] for row in event.before_value["members"]] == [str(closed.id)]
|
||||
|
||||
assert (await client.get(f"{RELATED}/{related_id}")).status_code == HTTP_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_delete_requires_directory_delete(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group = await make_company_group(seeded)
|
||||
related = await make_related_group(seeded, group=group)
|
||||
client = await auth_client(GO) # KA_GO: VIEW + EDIT, без DELETE
|
||||
assert (await client.delete(f"{RELATED}/{related.id}")).status_code == HTTP_FORBIDDEN
|
||||
assert await seeded.get(RelatedGroup, related.id) is not None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_entity_of_other_company_group_is_422(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group = await make_company_group(seeded, name="ГК 1")
|
||||
other = await make_company_group(seeded, name="ГК 2")
|
||||
foreign = await make_legal_entity(seeded, group=other)
|
||||
mono = await make_legal_entity(seeded, name="ООО Монозаёмщик")
|
||||
client = await auth_client(GO)
|
||||
gsk = (await _create(client, group.id)).json()
|
||||
for entity in (foreign, mono):
|
||||
response = await _add(client, gsk["id"], entity.id)
|
||||
assert response.status_code == HTTP_UNPROCESSABLE
|
||||
assert response.json()["detail"]["code"] == "DIFFERENT_COMPANY_GROUP"
|
||||
assert not await _member_rows(seeded)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_closed_entity_without_group_is_422(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
"""Review Focus 4: закрытый ЮЛ (`close_date`, `company_group_id = NULL`) не в той же ГК."""
|
||||
group = await make_company_group(seeded)
|
||||
closed = await make_legal_entity(
|
||||
seeded, name="ООО Закрытое", close_date=datetime.date(2025, 1, 1), office=await _office(seeded, "Головной%")
|
||||
)
|
||||
assert closed.company_group_id is None
|
||||
client = await auth_client(GO)
|
||||
gsk = (await _create(client, group.id)).json()
|
||||
|
||||
response = await _add(client, gsk["id"], closed.id)
|
||||
assert response.status_code == HTTP_UNPROCESSABLE
|
||||
assert response.json()["detail"]["code"] == "DIFFERENT_COMPANY_GROUP"
|
||||
assert not await _member_rows(seeded)
|
||||
assert not await _events(seeded, "related_group_member")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_patch_name_and_created_date(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group = await make_company_group(seeded)
|
||||
client = await auth_client(GO)
|
||||
gsk = (await _create(client, group.id, "gsk", "Старое")).json()
|
||||
|
||||
response = await client.patch(f"{RELATED}/{gsk['id']}", json={"name": " Новое ", "created_date": "2024-12-31"})
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
body = response.json()
|
||||
assert (body["name"], body["created_date"], body["kind"], body["code"]) == (
|
||||
"Новое",
|
||||
"2024-12-31",
|
||||
"gsk",
|
||||
gsk["code"],
|
||||
)
|
||||
events = await _events(seeded, "related_group")
|
||||
assert len(events) == 2 # noqa: PLR2004 - создание и изменение
|
||||
assert events[1].before_value == {"name": "Старое", "created_date": None}
|
||||
assert events[1].after_value == {"name": "Новое", "created_date": "2024-12-31"}
|
||||
|
||||
same = await client.patch(f"{RELATED}/{gsk['id']}", json={"name": "Новое"})
|
||||
assert same.status_code == HTTP_OK
|
||||
assert len(await _events(seeded, "related_group")) == 2 # noqa: PLR2004 - no-op без аудита
|
||||
|
||||
one = await client.patch(f"{RELATED}/{gsk['id']}", json={"name": "Третье"})
|
||||
assert one.status_code == HTTP_OK
|
||||
assert (await _events(seeded, "related_group"))[-1].changed_field == "name"
|
||||
|
||||
for bad in ({"name": " "}, {"name": None}, {"kind": "gsz"}):
|
||||
assert (await client.patch(f"{RELATED}/{gsk['id']}", json=bad)).status_code == HTTP_UNPROCESSABLE
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_list_and_detail_with_current_members(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group = await make_company_group(seeded)
|
||||
other_group = await make_company_group(seeded, name="Другая ГК")
|
||||
first = await make_legal_entity(seeded, name="Б ЮЛ", group=group)
|
||||
second = await make_legal_entity(seeded, name="А ЮЛ", group=group)
|
||||
gone = await make_legal_entity(seeded, name="В ЮЛ", group=group)
|
||||
gsz = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsz, name="Б ГСЗ")
|
||||
gsk = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsk, name="А ГСК")
|
||||
await make_related_group(seeded, group=other_group, name="Чужая")
|
||||
await make_membership(seeded, related_group=gsk, entity=first)
|
||||
await make_membership(seeded, related_group=gsk, entity=second)
|
||||
await make_membership(seeded, related_group=gsk, entity=gone, is_current=False)
|
||||
client = await auth_client(EXPERT) # только DIRECTORY_VIEW, ГО
|
||||
|
||||
listed = await client.get(f"{GROUPS}/{group.id}/related-groups")
|
||||
assert listed.status_code == HTTP_OK, listed.text
|
||||
assert [(row["name"], row["members_count"]) for row in listed.json()] == [("А ГСК", 2), ("Б ГСЗ", 0)]
|
||||
|
||||
detail = await client.get(f"{RELATED}/{gsk.id}")
|
||||
assert detail.status_code == HTTP_OK
|
||||
body = detail.json()
|
||||
assert body["members_count"] == 2 # noqa: PLR2004
|
||||
assert [member["name"] for member in body["members"]] == ["А ЮЛ", "Б ЮЛ"]
|
||||
assert set(body["members"][0]) == {"legal_entity_id", "inn", "name", "valid_from"}
|
||||
assert body["members"][0]["inn"] == second.inn
|
||||
assert (await client.get(f"{RELATED}/{gsz.id}")).json()["members"] == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_expert_cannot_edit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group = await make_company_group(seeded)
|
||||
entity = await make_legal_entity(seeded, group=group)
|
||||
related = await make_related_group(seeded, group=group)
|
||||
client = await auth_client(EXPERT)
|
||||
assert (await _create(client, group.id)).status_code == HTTP_FORBIDDEN
|
||||
assert (await client.patch(f"{RELATED}/{related.id}", json={"name": "X"})).status_code == HTTP_FORBIDDEN
|
||||
assert (await _add(client, related.id, entity.id)).status_code == HTTP_FORBIDDEN
|
||||
assert (await client.delete(f"{RELATED}/{related.id}/members/{entity.id}")).status_code == HTTP_FORBIDDEN
|
||||
assert not await _member_rows(seeded)
|
||||
assert not await _events(seeded)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_branch_user_gets_404_for_foreign_company_group(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
"""`ka_rf` (Ростов) и группа ГК Краснодара — 404 с тем же телом, что у несуществующего id."""
|
||||
krasnodar = await _office(seeded, KRASNODAR)
|
||||
foreign_group = await make_company_group(seeded, name="ГК Краснодар", office=krasnodar)
|
||||
foreign_entity = await make_legal_entity(seeded, group=foreign_group)
|
||||
foreign = await make_related_group(seeded, group=foreign_group)
|
||||
await make_membership(seeded, related_group=foreign, entity=foreign_entity)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
missing = await client.get(f"{RELATED}/{uuid.uuid4()}")
|
||||
assert missing.status_code == HTTP_NOT_FOUND
|
||||
responses = [
|
||||
await client.get(f"{RELATED}/{foreign.id}"),
|
||||
await client.patch(f"{RELATED}/{foreign.id}", json={"name": "X"}),
|
||||
await _add(client, foreign.id, foreign_entity.id),
|
||||
await client.delete(f"{RELATED}/{foreign.id}/members/{foreign_entity.id}"),
|
||||
]
|
||||
for response in responses:
|
||||
assert response.status_code == HTTP_NOT_FOUND, response.text
|
||||
assert response.json() == missing.json()
|
||||
|
||||
missing_group = await client.get(f"{GROUPS}/{uuid.uuid4()}/related-groups")
|
||||
for response in (
|
||||
await client.get(f"{GROUPS}/{foreign_group.id}/related-groups"),
|
||||
await _create(client, foreign_group.id),
|
||||
):
|
||||
assert response.status_code == HTTP_NOT_FOUND
|
||||
assert response.json() == missing_group.json()
|
||||
assert (await seeded.get(RelatedGroup, foreign.id)) is not None
|
||||
assert len(await _member_rows(seeded, is_current=True)) == 1
|
||||
assert not await _events(seeded)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_branch_user_works_in_own_company_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
rostov = await _office(seeded, "Ростов%")
|
||||
krasnodar = await _office(seeded, KRASNODAR)
|
||||
own_group = await make_company_group(seeded, name="ГК Ростов", office=rostov)
|
||||
own = await make_legal_entity(seeded, group=own_group)
|
||||
foreign_group = await make_company_group(seeded, name="ГК Краснодар", office=krasnodar)
|
||||
foreign_entity = await make_legal_entity(seeded, group=foreign_group)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
gsk = await _create(client, own_group.id)
|
||||
assert gsk.status_code == HTTP_CREATED, gsk.text
|
||||
assert (await _add(client, gsk.json()["id"], own.id)).status_code == HTTP_CREATED
|
||||
invisible = await _add(client, gsk.json()["id"], foreign_entity.id)
|
||||
assert invisible.status_code == HTTP_NOT_FOUND # ЮЛ вне видимости — 404, не 422 и не утечка
|
||||
assert invisible.json()["detail"]["message"] == "ЮЛ не найдено"
|
||||
rows = await _member_rows(seeded, is_current=True)
|
||||
assert [row.legal_entity_id for row in rows] == [own.id]
|
||||
Loading…
x
Reference in New Issue
Block a user