diff --git a/api/src/api/dependencies/directories.py b/api/src/api/dependencies/directories.py index a4aec0f..ca4390e 100644 --- a/api/src/api/dependencies/directories.py +++ b/api/src/api/dependencies/directories.py @@ -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)] diff --git a/api/src/api/endpoints/directories/__init__.py b/api/src/api/endpoints/directories/__init__.py index 18b8274..979ca2f 100644 --- a/api/src/api/endpoints/directories/__init__.py +++ b/api/src/api/endpoints/directories/__init__.py @@ -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) diff --git a/api/src/api/endpoints/directories/related_groups.py b/api/src/api/endpoints/directories/related_groups.py new file mode 100644 index 0000000..2704e83 --- /dev/null +++ b/api/src/api/endpoints/directories/related_groups.py @@ -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) diff --git a/api/src/repositories/directories.py b/api/src/repositories/directories.py index 970371d..5f883c4 100644 --- a/api/src/repositories/directories.py +++ b/api/src/repositories/directories.py @@ -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 = ( diff --git a/api/src/schemas/directories.py b/api/src/schemas/directories.py index a190f80..711a61e 100644 --- a/api/src/schemas/directories.py +++ b/api/src/schemas/directories.py @@ -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] = [] diff --git a/api/src/services/related_groups.py b/api/src/services/related_groups.py new file mode 100644 index 0000000..812990f --- /dev/null +++ b/api/src/services/related_groups.py @@ -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, + } diff --git a/api/tests/test_api_related_groups.py b/api/tests/test_api_related_groups.py new file mode 100644 index 0000000..5fc06ed --- /dev/null +++ b/api/tests/test_api_related_groups.py @@ -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]