diff --git a/api/src/api/dependencies/directories.py b/api/src/api/dependencies/directories.py new file mode 100644 index 0000000..a284bef --- /dev/null +++ b/api/src/api/dependencies/directories.py @@ -0,0 +1,27 @@ +"""Зависимости API справочников: область видимости и сервисы (R-6: сессия — только `SessionDep`).""" + +from typing import Annotated + +from fastapi import Depends + +from src.api.dependencies.audit import RequestAudit, get_request_audit +from src.api.dependencies.auth import Principal, current_user +from src.api.dependencies.db import SessionDep +from src.domain.policy import VisibilityScope, scope_of +from src.services.company_groups import CompanyGroupService + + +def get_scope(principal: Annotated[Principal, Depends(current_user)]) -> VisibilityScope: + """Область видимости текущего пользователя (§10.1).""" + return scope_of(principal) + + +def get_company_group_service( + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + scope: Annotated[VisibilityScope, Depends(get_scope)], +) -> CompanyGroupService: + return CompanyGroupService(session, audit, scope) + + +CompanyGroupServiceDep = Annotated[CompanyGroupService, Depends(get_company_group_service)] diff --git a/api/src/api/endpoints/directories/__init__.py b/api/src/api/endpoints/directories/__init__.py new file mode 100644 index 0000000..0e27b94 --- /dev/null +++ b/api/src/api/endpoints/directories/__init__.py @@ -0,0 +1,9 @@ +"""Маршруты справочников `/api/v1/directories/*` (spec directories-api).""" + +from fastapi import APIRouter + +from src.api.endpoints.directories import company_groups, regional_offices + +router = APIRouter(prefix="/directories", tags=["directories"]) +router.include_router(regional_offices.router) +router.include_router(company_groups.router) diff --git a/api/src/api/endpoints/directories/company_groups.py b/api/src/api/endpoints/directories/company_groups.py new file mode 100644 index 0000000..4aba2f1 --- /dev/null +++ b/api/src/api/endpoints/directories/company_groups.py @@ -0,0 +1,94 @@ +"""Маршруты ГК `/directories/company-groups` (spec directories-api): список, карточка, создание, изменение, архив. + +Право (`require`) проверяется до видимости объекта; чужая и несуществующая ГК — одинаковый 404 (spec directory-policy). +""" + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, Query, status + +from src.api.dependencies.auth import require +from src.api.dependencies.directories import CompanyGroupServiceDep +from src.models.directories import CompanyGroup +from src.schemas.common import Page, PageParams +from src.schemas.directories import ( + CompanyGroupCreate, + CompanyGroupFilter, + CompanyGroupHistoryOut, + CompanyGroupOut, + CompanyGroupUpdate, +) + +router = APIRouter(prefix="/company-groups") + +VIEW = [Depends(require("DIRECTORY_VIEW"))] +EDIT = [Depends(require("DIRECTORY_EDIT"))] + + +class CompanyGroupListQuery(CompanyGroupFilter, PageParams): + """Query списка ГК: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут).""" + + +def _out(group: CompanyGroup, legal_entities_count: int) -> CompanyGroupOut: + out = CompanyGroupOut.model_validate(group) + out.legal_entities_count = legal_entities_count + return out + + +@router.get("", response_model=Page[CompanyGroupOut], dependencies=VIEW, summary="Список ГК") +async def list_company_groups( + service: CompanyGroupServiceDep, + query: Annotated[CompanyGroupListQuery, Query()], +) -> Page[CompanyGroupOut]: + """Видимые ГК: поиск `q` по name/crm_id/code, фильтр `is_archived`, пагинация, сортировка по имени.""" + page = PageParams(limit=query.limit, offset=query.offset) + rows, total = await service.page(CompanyGroupFilter(q=query.q, is_archived=query.is_archived), page) + return Page[CompanyGroupOut]( + items=[_out(group, count) for group, count in rows], total=total, limit=page.limit, offset=page.offset + ) + + +@router.post( + "", response_model=CompanyGroupOut, status_code=status.HTTP_201_CREATED, dependencies=EDIT, summary="Создать ГК" +) +async def create_company_group(data: CompanyGroupCreate, service: CompanyGroupServiceDep) -> CompanyGroupOut: + """Создаёт ГК с кодом `GK-NNNNNN`; без филиала пользователь филиала закрепляет ГК за своим.""" + return _out(await service.create(data), 0) + + +@router.get("/{group_id}", response_model=CompanyGroupOut, dependencies=VIEW, summary="Карточка ГК") +async def get_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut: + group = await service.get(group_id) + return _out(group, await service.legal_entities_count(group.id)) + + +@router.patch("/{group_id}", response_model=CompanyGroupOut, dependencies=EDIT, summary="Изменить ГК") +async def update_company_group( + group_id: uuid.UUID, data: CompanyGroupUpdate, service: CompanyGroupServiceDep +) -> CompanyGroupOut: + """Меняет `name`/`crm_id`/`base_regional_office_id`; прежние значения уходят в историю.""" + group = await service.update(group_id, data) + return _out(group, await service.legal_entities_count(group.id)) + + +@router.post("/{group_id}/archive", response_model=CompanyGroupOut, dependencies=EDIT, summary="Архивировать ГК") +async def archive_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut: + group = await service.set_archived(group_id, True) # noqa: FBT003 + return _out(group, await service.legal_entities_count(group.id)) + + +@router.post("/{group_id}/unarchive", response_model=CompanyGroupOut, dependencies=EDIT, summary="Вернуть ГК из архива") +async def unarchive_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut: + group = await service.set_archived(group_id, False) # noqa: FBT003 + return _out(group, await service.legal_entities_count(group.id)) + + +@router.get( + "/{group_id}/history", + response_model=list[CompanyGroupHistoryOut], + dependencies=VIEW, + summary="История ГК (новые первыми)", +) +async def company_group_history(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> list[CompanyGroupHistoryOut]: + return [CompanyGroupHistoryOut.model_validate(row) for row in await service.history(group_id)] diff --git a/api/src/api/endpoints/directories/regional_offices.py b/api/src/api/endpoints/directories/regional_offices.py new file mode 100644 index 0000000..f19f519 --- /dev/null +++ b/api/src/api/endpoints/directories/regional_offices.py @@ -0,0 +1,21 @@ +"""`GET /directories/regional-offices` — список филиалов (только чтение, spec directories-api).""" + +from fastapi import APIRouter, Depends + +from src.api.dependencies.auth import require +from src.api.dependencies.db import SessionDep +from src.repositories import directories as repo +from src.schemas.directories import RegionalOfficeOut + +router = APIRouter() + + +@router.get( + "/regional-offices", + response_model=list[RegionalOfficeOut], + dependencies=[Depends(require("DIRECTORY_VIEW"))], + summary="Филиалы", +) +async def list_regional_offices(session: SessionDep) -> list[RegionalOfficeOut]: + """Все филиалы (включая Головной офис) по имени.""" + return [RegionalOfficeOut.model_validate(office) for office in await repo.list_regional_offices(session)] diff --git a/api/src/api/router.py b/api/src/api/router.py index ce6ef43..a2d820b 100644 --- a/api/src/api/router.py +++ b/api/src/api/router.py @@ -1,12 +1,12 @@ """Корневой роутер API `/api/v1` (R-6: эндпоинты — `src/api/endpoints/`). -`build_api_router(settings)` собирает роутер под настройки: `/me` — всегда, dev-эмитент токенов — только при -`AUTH_DEV_ISSUER and DEBUG`. Модульный `router` собран по настройкам процесса и подключается в `src/app.py`. +`build_api_router(settings)` собирает роутер под настройки: `/me` и `/directories/*` — всегда, dev-эмитент токенов — +только при `AUTH_DEV_ISSUER and DEBUG`. Модульный `router` собран по настройкам процесса и подключается в `src/app.py`. """ from fastapi import APIRouter -from src.api.endpoints import me +from src.api.endpoints import directories, me from src.api.endpoints.dev_auth import build_dev_router from src.core.config import Settings, get_settings @@ -17,6 +17,7 @@ def build_api_router(settings: Settings) -> APIRouter: """Роутер `/api/v1` под настройки; dev-маршруты при любом другом сочетании флагов отсутствуют.""" api_router = APIRouter(prefix=API_PREFIX) api_router.include_router(me.router) + api_router.include_router(directories.router) if settings.AUTH_DEV_ISSUER and settings.DEBUG: api_router.include_router(build_dev_router(settings)) return api_router diff --git a/api/src/repositories/directories.py b/api/src/repositories/directories.py new file mode 100644 index 0000000..bae284c --- /dev/null +++ b/api/src/repositories/directories.py @@ -0,0 +1,109 @@ +"""Репозиторий справочников: ГК, история ГК, филиалы (SQL без бизнес-правил; видимость — `src.domain.policy`). + +Функции не делают `commit`: транзакцией запроса управляет `SessionDep`. +""" + +import uuid +from typing import Any + +from sqlalchemy import Select, Sequence, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.policy import VisibilityScope, company_groups_visible +from src.models.directories import CompanyGroup, CompanyGroupHistory, LegalEntity, RegionalOffice + +LIKE_ESCAPE = "\\" + + +def escape_like(text: str) -> str: + """Экранирует `\\`, `%` и `_`, чтобы пользовательская строка искалась как подстрока, а не как шаблон LIKE.""" + return text.replace(LIKE_ESCAPE, LIKE_ESCAPE * 2).replace("%", f"{LIKE_ESCAPE}%").replace("_", f"{LIKE_ESCAPE}_") + + +async def next_code(session: AsyncSession, seq: Sequence, prefix: str) -> str: + """Следующий код из последовательности PostgreSQL: `-NNNNNN` (6 цифр с нулями, D-5).""" + number = (await session.execute(select(seq.next_value()))).scalar_one() + return f"{prefix}-{number:06d}" + + +def _filtered_groups(stmt: Select[Any], scope: VisibilityScope, q: str | None, is_archived: bool | None) -> Select[Any]: + stmt = company_groups_visible(stmt, scope) + if q is not None and q.strip(): + pattern = f"%{escape_like(q.strip())}%" + stmt = stmt.where( + or_( + CompanyGroup.name.ilike(pattern, escape=LIKE_ESCAPE), + CompanyGroup.crm_id.ilike(pattern, escape=LIKE_ESCAPE), + CompanyGroup.code.ilike(pattern, escape=LIKE_ESCAPE), + ) + ) + if is_archived is not None: + stmt = stmt.where(CompanyGroup.is_archived.is_(is_archived)) + return stmt + + +async def list_company_groups( # noqa: PLR0913 - фильтры и пагинация списка передаются явными именованными аргументами + session: AsyncSession, + scope: VisibilityScope, + *, + q: str | None, + is_archived: bool | None, + limit: int, + offset: int, +) -> tuple[list[tuple[CompanyGroup, int]], int]: + """Страница видимых ГК (по `name`) со счётчиком ЮЛ и общее число записей по фильтру (отдельный `count()`).""" + legal_entities_count = ( + select(func.count(LegalEntity.id)) + .where(LegalEntity.company_group_id == CompanyGroup.id) + .correlate(CompanyGroup) + .scalar_subquery() + ) + page_stmt = ( + _filtered_groups(select(CompanyGroup, legal_entities_count), scope, q, is_archived) + .order_by(CompanyGroup.name, CompanyGroup.id) + .limit(limit) + .offset(offset) + ) + total_stmt = _filtered_groups(select(func.count(CompanyGroup.id)), scope, q, is_archived) + rows = (await session.execute(page_stmt)).all() + total = (await session.execute(total_stmt)).scalar_one() + return [(group, count) for group, count in rows], total + + +async def get_company_group(session: AsyncSession, scope: VisibilityScope, group_id: uuid.UUID) -> CompanyGroup | None: + """ГК по id; `None` и для несуществующей, и для невидимой (неразличимы для вызывающего).""" + stmt = company_groups_visible(select(CompanyGroup).where(CompanyGroup.id == group_id), scope) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def crm_id_taken(session: AsyncSession, crm_id: str, *, except_group_id: uuid.UUID | None = None) -> bool: + """Есть ли у какой-либо ГК (кроме `except_group_id`) такой `crm_id` — без фильтра видимости: уникален глобально.""" + stmt = select(CompanyGroup.id).where(CompanyGroup.crm_id == crm_id) + if except_group_id is not None: + stmt = stmt.where(CompanyGroup.id != except_group_id) + return (await session.execute(stmt.limit(1))).first() is not None + + +async def count_legal_entities(session: AsyncSession, group_id: uuid.UUID) -> int: + """Число ЮЛ/ИП с `company_group_id = group_id`.""" + stmt = select(func.count(LegalEntity.id)).where(LegalEntity.company_group_id == group_id) + return (await session.execute(stmt)).scalar_one() + + +async def list_company_group_history(session: AsyncSession, group_id: uuid.UUID) -> list[CompanyGroupHistory]: + """Снимки ГК по убыванию `valid_from` (новые первыми).""" + stmt = ( + select(CompanyGroupHistory) + .where(CompanyGroupHistory.company_group_id == group_id) + .order_by(CompanyGroupHistory.valid_from.desc(), CompanyGroupHistory.id) + ) + return list((await session.execute(stmt)).scalars().all()) + + +async def get_regional_office(session: AsyncSession, office_id: uuid.UUID) -> RegionalOffice | None: + return await session.get(RegionalOffice, office_id) + + +async def list_regional_offices(session: AsyncSession) -> list[RegionalOffice]: + """Все филиалы по имени.""" + return list((await session.execute(select(RegionalOffice).order_by(RegionalOffice.name))).scalars().all()) diff --git a/api/src/schemas/directories.py b/api/src/schemas/directories.py new file mode 100644 index 0000000..6565f7b --- /dev/null +++ b/api/src/schemas/directories.py @@ -0,0 +1,118 @@ +"""Схемы API справочников (spec directories-api): филиалы и ГК; поля — как в DBML v5.14.""" + +import datetime +import uuid +from typing import Self + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +NAME_MAX_LENGTH = 500 + + +def _blank_to_none(value: str | None) -> str | None: + """`crm_id`: пробелы по краям срезаются, пустая строка — «нет значения» (иначе пустые `crm_id` конфликтовали бы).""" + if value is None: + return None + stripped = value.strip() + return stripped or None + + +class RegionalOfficeOut(BaseModel): + """Филиал (`GET /directories/regional-offices`).""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + is_head_office: bool + is_active: bool + + +class CompanyGroupCreate(BaseModel): + """Создание ГК: `code` и `valid_from` назначает сервис.""" + + name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH) + crm_id: str | None = None + base_regional_office_id: uuid.UUID | None = None + + @field_validator("name") + @classmethod + def _strip_name(cls, value: str) -> str: + stripped = value.strip() + if not stripped: + raise ValueError("название не может быть пустым") + return stripped + + @field_validator("crm_id") + @classmethod + def _normalize_crm_id(cls, value: str | None) -> str | None: + return _blank_to_none(value) + + +class CompanyGroupUpdate(BaseModel): + """Изменение ГК: «поле передано» определяется по `model_fields_set` (явный `null` у `crm_id` очищает значение).""" + + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, min_length=1, max_length=NAME_MAX_LENGTH) + crm_id: str | None = None + base_regional_office_id: uuid.UUID | None = None + + @field_validator("name") + @classmethod + def _strip_name(cls, value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + if not stripped: + raise ValueError("название не может быть пустым") + return stripped + + @field_validator("crm_id") + @classmethod + def _normalize_crm_id(cls, value: str | None) -> str | None: + return _blank_to_none(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 CompanyGroupOut(BaseModel): + """Карточка ГК; `legal_entities_count` — число ЮЛ с `company_group_id` этой ГК.""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + code: str + crm_id: str | None + name: str + base_regional_office_id: uuid.UUID | None + is_archived: bool + valid_from: datetime.datetime + legal_entities_count: int = 0 + created_at: datetime.datetime | None + updated_at: datetime.datetime | None + + +class CompanyGroupHistoryOut(BaseModel): + """Прошлый снимок ГК (SCD4).""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + crm_id: str | None + name: str | None + base_regional_office_id: uuid.UUID | None + valid_from: datetime.datetime | None + valid_to: datetime.datetime | None + changed_by: uuid.UUID | None + + +class CompanyGroupFilter(BaseModel): + """Фильтры списка ГК: `q` — подстрока `name`/`crm_id`/`code` без учёта регистра.""" + + q: str | None = None + is_archived: bool | None = None diff --git a/api/src/services/company_groups.py b/api/src/services/company_groups.py new file mode 100644 index 0000000..17c4af1 --- /dev/null +++ b/api/src/services/company_groups.py @@ -0,0 +1,179 @@ +"""Сервис ГК (spec directories-api, directory-policy, audit-trail): создание, изменение со снимком истории, архив. + +Порядок операции: право (`require` на маршруте) → видимость (`get`) → изменение. Все записи — в транзакции запроса, +`commit` делает `SessionDep`; `audit_event` пишется в той же транзакции через `RequestAudit`. +""" + +import datetime +import uuid +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError +from src.domain.policy import VisibilityScope, office_for_create +from src.models.directories import COMPANY_GROUP_CODE_SEQ, CompanyGroup, CompanyGroupHistory +from src.models.enums import AuditAction, AuditSection +from src.repositories import directories as repo +from src.schemas.common import PageParams +from src.schemas.directories import CompanyGroupCreate, CompanyGroupFilter, CompanyGroupUpdate + +COMPANY_GROUP_NOT_FOUND = "ГК не найдена" # одинаково для чужой и несуществующей (spec directory-policy) +COMPANY_GROUP_CODE_PREFIX = "GK" +OBJECT_TYPE = "company_group" +TRACKED_FIELDS = ("name", "crm_id", "base_regional_office_id") # поля ГК, изменяемые PATCH и попадающие в историю + + +def _now() -> datetime.datetime: + return datetime.datetime.now(datetime.UTC) + + +class CompanyGroupService: + """Операции над ГК от имени текущего пользователя в его области видимости.""" + + def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None: + self._session = session + self._audit = audit + self._scope = scope + + async def create(self, data: CompanyGroupCreate) -> CompanyGroup: + """Новая ГК с кодом `GK-NNNNNN`; филиал — по policy (`office_for_create`); аудит `GROUP_CHANGE`.""" + office_id = office_for_create(self._scope, data.base_regional_office_id) + await self._check_office_exists(office_id) + if data.crm_id is not None: + await self._check_crm_id_free(data.crm_id) + now = _now() + group = CompanyGroup( + code=await repo.next_code(self._session, COMPANY_GROUP_CODE_SEQ, COMPANY_GROUP_CODE_PREFIX), + crm_id=data.crm_id, + name=data.name, + base_regional_office_id=office_id, + valid_from=now, + created_at=now, + updated_at=now, + updated_by=self._audit.user_id, + ) + self._session.add(group) + await self._session.flush() + await self._audit.record( + action=AuditAction.GROUP_CHANGE, + section=AuditSection.DIRECTORIES, + object_type=OBJECT_TYPE, + object_id=group.id, + after_value={ + "code": group.code, + "name": group.name, + "crm_id": group.crm_id, + "base_regional_office_id": group.base_regional_office_id, + }, + ) + return group + + async def update(self, group_id: uuid.UUID, data: CompanyGroupUpdate) -> CompanyGroup: + """Изменение `name`/`crm_id`/`base_regional_office_id`; без фактических изменений ничего не пишется (D-6).""" + group = await self.get(group_id) + changes = await self._changes(group, data) + if not changes: + return group + before = {field: getattr(group, field) for field in changes} + now = _now() + self._session.add( + CompanyGroupHistory( + company_group_id=group.id, + crm_id=group.crm_id, + name=group.name, + base_regional_office_id=group.base_regional_office_id, + valid_from=group.valid_from, + valid_to=now, + changed_by=self._audit.user_id, + ) + ) + for field, value in changes.items(): + setattr(group, field, value) + group.valid_from = now + group.updated_at = now + group.updated_by = self._audit.user_id + await self._session.flush() + await self._audit.record( + action=AuditAction.ASSIGNMENT_CHANGE if "base_regional_office_id" in changes else AuditAction.GROUP_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 set_archived(self, group_id: uuid.UUID, archived: bool) -> CompanyGroup: # noqa: FBT001 + """Архив/разархив ГК; повтор в том же состоянии ничего не пишет.""" + group = await self.get(group_id) + if group.is_archived == archived: + return group + group.is_archived = archived + group.updated_at = _now() + group.updated_by = self._audit.user_id + await self._session.flush() + await self._audit.record( + action=AuditAction.GROUP_CHANGE, + section=AuditSection.DIRECTORIES, + object_type=OBJECT_TYPE, + object_id=group.id, + before_value={"is_archived": not archived}, + after_value={"is_archived": archived}, + changed_field="is_archived", + ) + return group + + async def get(self, group_id: uuid.UUID) -> CompanyGroup: + """ГК в области видимости; невидимая и несуществующая — один и тот же 404.""" + group = await repo.get_company_group(self._session, self._scope, group_id) + if group is None: + raise NotFoundError(COMPANY_GROUP_NOT_FOUND) + return group + + async def legal_entities_count(self, group_id: uuid.UUID) -> int: + """Число ЮЛ ГК (для карточки).""" + return await repo.count_legal_entities(self._session, group_id) + + async def history(self, group_id: uuid.UUID) -> list[CompanyGroupHistory]: + """Снимки ГК по убыванию `valid_from`; видимость проверяется как для карточки.""" + group = await self.get(group_id) + return await repo.list_company_group_history(self._session, group.id) + + async def page(self, params: CompanyGroupFilter, page: PageParams) -> tuple[list[tuple[CompanyGroup, int]], int]: + """Страница видимых ГК с числом ЮЛ и общее число по фильтру.""" + return await repo.list_company_groups( + self._session, + self._scope, + q=params.q, + is_archived=params.is_archived, + limit=page.limit, + offset=page.offset, + ) + + async def _changes(self, group: CompanyGroup, data: CompanyGroupUpdate) -> dict[str, Any]: + """Только реально изменившиеся поля из переданных в PATCH (проверки — по ним же).""" + changes: dict[str, Any] = {} + for field in TRACKED_FIELDS: + if field not in data.model_fields_set: + continue + value = getattr(data, field) + if field == "base_regional_office_id": + value = office_for_create(self._scope, value) + if value != getattr(group, field): + changes[field] = value + if changes.get("base_regional_office_id") is not None: + await self._check_office_exists(changes["base_regional_office_id"]) + if changes.get("crm_id") is not None: + await self._check_crm_id_free(changes["crm_id"], except_group_id=group.id) + return changes + + async def _check_office_exists(self, office_id: uuid.UUID | None) -> None: + if office_id is not None and await repo.get_regional_office(self._session, office_id) is None: + raise DomainValidationError("OFFICE_NOT_FOUND", "Филиал не найден") + + async def _check_crm_id_free(self, crm_id: str, *, except_group_id: uuid.UUID | None = None) -> None: + if await repo.crm_id_taken(self._session, crm_id, except_group_id=except_group_id): + raise ConflictError("CRM_ID_EXISTS", "ГК с таким crm_id уже существует") diff --git a/api/tests/test_api_company_groups.py b/api/tests/test_api_company_groups.py new file mode 100644 index 0000000..817a27d --- /dev/null +++ b/api/tests/test_api_company_groups.py @@ -0,0 +1,283 @@ +"""API ГК и филиалов (spec directories-api, audit-trail): код, история, no-op PATCH, права, список, архив.""" + +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 CompanyGroupHistory +from src.models.enums import AuditAction, AuditSection +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_legal_entity + +HTTP_OK = 200 +HTTP_CREATED = 201 +HTTP_FORBIDDEN = 403 +HTTP_CONFLICT = 409 +HTTP_UNPROCESSABLE = 422 +CODE_PATTERN = re.compile(r"^GK-\d{6}$") +KRASNODAR = "Краснодарский региональный филиал" +HEAD_OFFICE = "Головной офис" +GROUPS = "/directories/company-groups" + + +async def _events(session: AsyncSession, *actions: AuditAction) -> Sequence[AuditEvent]: + stmt = select(AuditEvent).where(AuditEvent.action.in_(actions)).order_by(AuditEvent.occurred_at, AuditEvent.id) + return (await session.execute(stmt)).scalars().all() + + +async def _office_id(client: httpx.AsyncClient, name: str) -> str: + response = await client.get("/directories/regional-offices") + assert response.status_code == HTTP_OK, response.text + return next(item["id"] for item in response.json() if item["name"] == name) + + +async def _create(client: httpx.AsyncClient, **body: Any) -> dict[str, Any]: + response = await client.post(GROUPS, json=body) + assert response.status_code == HTTP_CREATED, response.text + created: dict[str, Any] = response.json() + return created + + +@pytest.mark.integration +async def test_regional_offices_list(auth_client: AuthClientFactory) -> None: + client = await auth_client("expert@armka.local") + response = await client.get("/directories/regional-offices") + assert response.status_code == HTTP_OK + offices = {item["name"]: item for item in response.json()} + assert offices[HEAD_OFFICE]["is_head_office"] is True + assert offices[KRASNODAR]["is_head_office"] is False + assert set(offices[KRASNODAR]) == {"id", "name", "is_head_office", "is_active"} + + +@pytest.mark.integration +async def test_create_generates_codes_and_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + first = await _create(client, name="ГК Первая") + second = await _create(client, name="ГК Вторая", crm_id="CRM-1") + assert CODE_PATTERN.match(first["code"]) and CODE_PATTERN.match(second["code"]) + assert first["code"] != second["code"] + assert first["valid_from"] and first["legal_entities_count"] == 0 + + events = await _events(seeded, AuditAction.GROUP_CHANGE) + assert len(events) == 2 # noqa: PLR2004 + assert {e.after_value["code"] for e in events} == {first["code"], second["code"]} + event = next(e for e in events if e.after_value["code"] == second["code"]) + assert (event.section, event.object_type, str(event.object_id)) == ( + AuditSection.DIRECTORIES, + "company_group", + second["id"], + ) + assert event.after_value == { + "code": second["code"], + "name": "ГК Вторая", + "crm_id": "CRM-1", + "base_regional_office_id": None, + } + assert event.user_id is not None + + +@pytest.mark.integration +async def test_patch_office_writes_history_and_assignment_audit( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + client = await auth_client("ka_go@armka.local") + rostov = await _office_id(client, "Ростовский региональный филиал") + krasnodar = await _office_id(client, KRASNODAR) + group = await _create(client, name="ГК Перевод", base_regional_office_id=rostov) + + response = await client.patch(f"{GROUPS}/{group['id']}", json={"base_regional_office_id": krasnodar}) + assert response.status_code == HTTP_OK, response.text + updated = response.json() + assert updated["base_regional_office_id"] == krasnodar + assert updated["valid_from"] != group["valid_from"] + + history = (await seeded.execute(select(CompanyGroupHistory))).scalars().all() + assert len(history) == 1 + snapshot = history[0] + assert str(snapshot.base_regional_office_id) == rostov + assert snapshot.name == "ГК Перевод" + assert snapshot.valid_to is not None + assert snapshot.valid_from == datetime.datetime.fromisoformat(group["valid_from"]) + + (event,) = await _events(seeded, AuditAction.ASSIGNMENT_CHANGE) + assert event.before_value == {"base_regional_office_id": rostov} + assert event.after_value == {"base_regional_office_id": krasnodar} + assert event.changed_field == "base_regional_office_id" + assert (event.section, event.object_type) == (AuditSection.DIRECTORIES, "company_group") + + +@pytest.mark.integration +async def test_patch_name_audit_is_group_change_with_diff(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + group = await _create(client, name="А") + response = await client.patch(f"{GROUPS}/{group['id']}", json={"name": "Б"}) + assert response.status_code == HTTP_OK + events = await _events(seeded, AuditAction.GROUP_CHANGE) + changed = events[-1] + assert (changed.changed_field, changed.before_value, changed.after_value) == ( + "name", + {"name": "А"}, + {"name": "Б"}, + ) + assert not await _events(seeded, AuditAction.ASSIGNMENT_CHANGE) + + +@pytest.mark.integration +async def test_duplicate_crm_id_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + await _create(client, name="ГК-1", crm_id="CRM-DUP") + response = await client.post(GROUPS, json={"name": "ГК-2", "crm_id": "CRM-DUP"}) + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "CRM_ID_EXISTS" + assert len(await _events(seeded, AuditAction.GROUP_CHANGE)) == 1 # аудит только у первой + + other = await _create(client, name="ГК-3") + clash = await client.patch(f"{GROUPS}/{other['id']}", json={"crm_id": "CRM-DUP"}) + assert clash.status_code == HTTP_CONFLICT + assert clash.json()["detail"]["code"] == "CRM_ID_EXISTS" + + +@pytest.mark.integration +async def test_noop_patch_writes_no_history_and_no_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + group = await _create(client, name="ГК Без изменений", crm_id="CRM-NOOP") + audit_before = len(await _events(seeded, AuditAction.GROUP_CHANGE, AuditAction.ASSIGNMENT_CHANGE)) + + response = await client.patch(f"{GROUPS}/{group['id']}", json={"name": "ГК Без изменений", "crm_id": "CRM-NOOP"}) + assert response.status_code == HTTP_OK + assert response.json()["valid_from"] == group["valid_from"] + assert (await seeded.execute(select(func.count()).select_from(CompanyGroupHistory))).scalar_one() == 0 + assert len(await _events(seeded, AuditAction.GROUP_CHANGE, AuditAction.ASSIGNMENT_CHANGE)) == audit_before + + +@pytest.mark.integration +async def test_view_only_user_cannot_create(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("expert@armka.local") + response = await client.post(GROUPS, json={"name": "ГК Эксперта"}) + assert response.status_code == HTTP_FORBIDDEN + assert not await _events(seeded, AuditAction.GROUP_CHANGE) + assert (await client.get(GROUPS)).status_code == HTTP_OK # чтение разрешено + + +@pytest.mark.integration +async def test_list_search_filters_and_pagination(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + await make_company_group(seeded, name="Русский Бисквит") + await make_company_group(seeded, name="Другая группа") + archived = await _create(client, name="Архивная бисквитная") + assert (await client.post(f"{GROUPS}/{archived['id']}/archive")).status_code == HTTP_OK + + by_name = (await client.get(GROUPS, params={"q": "БИСКВИТ"})).json() + assert {item["name"] for item in by_name["items"]} == {"Русский Бисквит", "Архивная бисквитная"} + assert by_name["total"] == 2 # noqa: PLR2004 + assert [item["name"] for item in by_name["items"]] == sorted(item["name"] for item in by_name["items"]) + + only_archived = (await client.get(GROUPS, params={"q": "бисквит", "is_archived": "true"})).json() + assert [item["name"] for item in only_archived["items"]] == ["Архивная бисквитная"] + active = (await client.get(GROUPS, params={"q": "бисквит", "is_archived": "false"})).json() + assert [item["name"] for item in active["items"]] == ["Русский Бисквит"] + + beyond = await client.get(GROUPS, params={"q": "бисквит", "offset": 10}) + assert beyond.status_code == HTTP_OK + assert beyond.json()["items"] == [] and beyond.json()["total"] == 2 # noqa: PLR2004 + assert beyond.json()["offset"] == 10 # noqa: PLR2004 + + assert (await client.get(GROUPS, params={"limit": 201})).status_code == HTTP_UNPROCESSABLE + assert (await client.get(GROUPS, params={"offset": -1})).status_code == HTTP_UNPROCESSABLE + + +@pytest.mark.integration +async def test_list_search_by_code_and_crm_id_escapes_like( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """R-2: `q` ищет по `name`/`crm_id`/`code`; `%` и `_` в запросе — обычные символы.""" + client = await auth_client("ka_go@armka.local") + group = await _create(client, name="ГК Поиск", crm_id="CRM-Zeta-77") + await _create(client, name="ГК Прочая", crm_id="CRM-Other") + await make_company_group(seeded, name="ГК 100%", code="GK-XPCT") + + by_code = (await client.get(GROUPS, params={"q": group["code"].lower()})).json() + assert [item["id"] for item in by_code["items"]] == [group["id"]] + by_crm = (await client.get(GROUPS, params={"q": "zeta-77"})).json() + assert [item["id"] for item in by_crm["items"]] == [group["id"]] + + percent = (await client.get(GROUPS, params={"q": "%"})).json() + assert [item["name"] for item in percent["items"]] == ["ГК 100%"] # `%` не «любая строка» + underscore = (await client.get(GROUPS, params={"q": "ГК_П"})).json() + assert underscore["items"] == [] and underscore["total"] == 0 # `_` не «любой символ» + + +@pytest.mark.integration +async def test_list_counts_legal_entities(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + group = await make_company_group(seeded, name="ГК с ЮЛ") + empty = await make_company_group(seeded, name="ГК пустая") + await make_legal_entity(seeded, group=group) + await make_legal_entity(seeded, group=group) + await make_legal_entity(seeded, group=None) + + items = {i["id"]: i for i in (await client.get(GROUPS)).json()["items"]} + assert items[str(group.id)]["legal_entities_count"] == 2 # noqa: PLR2004 + assert items[str(empty.id)]["legal_entities_count"] == 0 + card = (await client.get(f"{GROUPS}/{group.id}")).json() + assert card["legal_entities_count"] == 2 # noqa: PLR2004 + + +@pytest.mark.integration +async def test_archive_unarchive_toggle_and_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + group = await _create(client, name="ГК Архив") + archived = await client.post(f"{GROUPS}/{group['id']}/archive") + assert archived.status_code == HTTP_OK and archived.json()["is_archived"] is True + restored = await client.post(f"{GROUPS}/{group['id']}/unarchive") + assert restored.status_code == HTTP_OK and restored.json()["is_archived"] is False + + events = [e for e in await _events(seeded, AuditAction.GROUP_CHANGE) if e.changed_field == "is_archived"] + assert [(e.before_value, e.after_value) for e in events] == [ + ({"is_archived": False}, {"is_archived": True}), + ({"is_archived": True}, {"is_archived": False}), + ] + + +@pytest.mark.integration +async def test_history_is_ordered_by_valid_from_desc(auth_client: AuthClientFactory) -> None: + """R-3: снимки по убыванию `valid_from`.""" + client = await auth_client("ka_go@armka.local") + group = await _create(client, name="Имя 0") + for index in (1, 2, 3): + response = await client.patch(f"{GROUPS}/{group['id']}", json={"name": f"Имя {index}"}) + assert response.status_code == HTTP_OK + + history = (await client.get(f"{GROUPS}/{group['id']}/history")).json() + assert [row["name"] for row in history] == ["Имя 2", "Имя 1", "Имя 0"] + valid_from = [row["valid_from"] for row in history] + assert valid_from == sorted(valid_from, reverse=True) + assert set(history[0]) == { + "id", + "crm_id", + "name", + "base_regional_office_id", + "valid_from", + "valid_to", + "changed_by", + } + + +@pytest.mark.integration +async def test_validation_and_unknown_group(auth_client: AuthClientFactory) -> None: + client = await auth_client("ka_go@armka.local") + assert (await client.post(GROUPS, json={"name": ""})).status_code == HTTP_UNPROCESSABLE + group = await _create(client, name="ГК") + extra = await client.patch(f"{GROUPS}/{group['id']}", json={"code": "GK-000001"}) + assert extra.status_code == HTTP_UNPROCESSABLE # extra="forbid" + missing = await client.patch(f"{GROUPS}/{uuid.uuid4()}", json={"name": "X"}) + assert missing.status_code == 404 # noqa: PLR2004 + assert missing.json()["detail"]["message"] == "ГК не найдена" diff --git a/api/tests/test_api_policy.py b/api/tests/test_api_policy.py new file mode 100644 index 0000000..3b37734 --- /dev/null +++ b/api/tests/test_api_policy.py @@ -0,0 +1,82 @@ +"""Политика видимости на API ГК (spec directory-policy): филиал видит своё, чужое — 404, создание из филиала.""" + +import uuid + +import httpx +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.directories import RegionalOffice +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group + +HTTP_OK = 200 +HTTP_CREATED = 201 +HTTP_NOT_FOUND = 404 +HTTP_UNPROCESSABLE = 422 +ROSTOV = "Ростовский региональный филиал" +KRASNODAR = "Краснодарский региональный филиал" +GROUPS = "/directories/company-groups" + + +async def _office(session: AsyncSession, name: str) -> RegionalOffice: + return (await session.execute(select(RegionalOffice).where(RegionalOffice.name == name))).scalar_one() + + +@pytest.mark.integration +async def test_branch_user_sees_only_own_groups(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + rostov, krasnodar = await _office(seeded, ROSTOV), await _office(seeded, KRASNODAR) + group_a = await make_company_group(seeded, name="ГК А", office=rostov) + group_b = await make_company_group(seeded, name="ГК Б", office=krasnodar) + + branch = await auth_client("ka_rf@armka.local") # Ростовский филиал + page = (await branch.get(GROUPS)).json() + assert page["total"] == 1 + assert [item["id"] for item in page["items"]] == [str(group_a.id)] + + head = await auth_client("ka_go@armka.local") + ids = {item["id"] for item in (await head.get(GROUPS)).json()["items"]} + assert {str(group_a.id), str(group_b.id)} <= ids + + +@pytest.mark.integration +async def test_foreign_group_is_indistinguishable_from_missing( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + krasnodar = await _office(seeded, KRASNODAR) + group_b = await make_company_group(seeded, name="ГК Б", office=krasnodar) + branch = await auth_client("ka_rf@armka.local") + + foreign = await branch.get(f"{GROUPS}/{group_b.id}") + missing = await branch.get(f"{GROUPS}/{uuid.uuid4()}") + assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND + assert foreign.json() == missing.json() + + # то же для остальных операций над чужой ГК + for call in ( + branch.patch(f"{GROUPS}/{group_b.id}", json={"name": "Взлом"}), + branch.post(f"{GROUPS}/{group_b.id}/archive"), + branch.post(f"{GROUPS}/{group_b.id}/unarchive"), + branch.get(f"{GROUPS}/{group_b.id}/history"), + ): + response = await call + assert response.status_code == HTTP_NOT_FOUND + assert response.json() == missing.json() + + +@pytest.mark.integration +async def test_branch_user_creates_group_in_own_office_only( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + rostov, krasnodar = await _office(seeded, ROSTOV), await _office(seeded, KRASNODAR) + branch: httpx.AsyncClient = await auth_client("ka_rf@armka.local") + + created = await branch.post(GROUPS, json={"name": "ГК Из филиала"}) + assert created.status_code == HTTP_CREATED + assert created.json()["base_regional_office_id"] == str(rostov.id) + assert (await branch.get(f"{GROUPS}/{created.json()['id']}")).status_code == HTTP_OK # видна создателю + + foreign = await branch.post(GROUPS, json={"name": "ГК Чужая", "base_regional_office_id": str(krasnodar.id)}) + assert foreign.status_code == HTTP_UNPROCESSABLE + assert foreign.json()["detail"]["code"] == "OFFICE_NOT_ALLOWED" diff --git a/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md index cf5ae46..51d1e3d 100644 --- a/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md +++ b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md @@ -316,7 +316,7 @@ class CompanyGroupService: - Эндпоинты (`/api/v1/directories/...`, все с `Depends(require("DIRECTORY_VIEW"|"DIRECTORY_EDIT"))`): `GET /regional-offices` → `list[RegionalOfficeOut]`; `GET /company-groups` → `Page[CompanyGroupOut]`; `POST /company-groups` → 201; `GET /company-groups/{id}`; `PATCH /company-groups/{id}`; `POST /company-groups/{id}/archive`, `/unarchive`; `GET /company-groups/{id}/history`. -- [ ] **Step 1: Тесты** (`tests/test_api_company_groups.py`, `@pytest.mark.integration`, `auth_client`): +- [x] **Step 1: Тесты** (`tests/test_api_company_groups.py`, `@pytest.mark.integration`, `auth_client`): 1. `ka_go` создаёт две ГК → коды `GK-` + 6 цифр, различаются; в `audit_event` две `GROUP_CHANGE` с `after_value["code"]`. 2. `PATCH base_regional_office_id` (на Краснодарский РФ по id из `/regional-offices`) → `company_group_history` 1 строка с прежним филиалом и `valid_to`; `valid_from` ГК изменился; `audit_event(ASSIGNMENT_CHANGE)` с `before_value`/`after_value`. 3. Дубликат `crm_id` → 409 `{"detail": {"code": "CRM_ID_EXISTS", ...}}`. @@ -328,8 +328,8 @@ class CompanyGroupService: 1. ГК А (Ростовский РФ) и ГК Б (Краснодарский РФ) через фабрики; `ka_rf` видит только А (`total == 1`); `ka_go` — обе. 2. `ka_rf` `GET /company-groups/{Б}` → 404, тело совпадает с `GET /company-groups/{uuid4()}`. 3. `ka_rf` создаёт ГК без филиала → `base_regional_office_id` = Ростовский; с чужим → 422 `OFFICE_NOT_ALLOWED`. -- [ ] **Step 2:** падает → **Step 3:** реализация → **Step 4:** `pytest -m integration tests/test_api_company_groups.py tests/test_api_policy.py -v`; `ruff`, `mypy` → PASS. -- [ ] **Step 5: Commit** `feat(directories): филиалы и ГК — API, история, аудит, видимость`. +- [x] **Step 2:** падает → **Step 3:** реализация → **Step 4:** `pytest -m integration tests/test_api_company_groups.py tests/test_api_policy.py -v`; `ruff`, `mypy` → PASS. +- [x] **Step 5: Commit** `feat(directories): филиалы и ГК — API, история, аудит, видимость`. --- diff --git a/openspec/changes/slice-1-directories/tasks.md b/openspec/changes/slice-1-directories/tasks.md index b41380a..28723db 100644 --- a/openspec/changes/slice-1-directories/tasks.md +++ b/openspec/changes/slice-1-directories/tasks.md @@ -29,7 +29,7 @@ test-integration`). Пути — от корня репозитория. Доп - [x] 2.1 **Policy.** `api/src/domain/policy.py` (strict mypy): `VisibilityScope`, `scope_for(principal)`, `apply_scope_company_groups(stmt, scope)`, `apply_scope_legal_entities(stmt, scope)`, `assert_office_allowed(scope, office_id)`; unit-тесты без БД на все ветки (ГО, филиал, монозаёмщик, чужой филиал → `OFFICE_NOT_ALLOWED`). -- [ ] 2.2 **Филиалы и ГК.** `repositories/directories.py` (запросы с scope), `services/company_groups.py` (создание с `code` из +- [x] 2.2 **Филиалы и ГК.** `repositories/directories.py` (запросы с scope), `services/company_groups.py` (создание с `code` из последовательности, изменение со снимком `company_group_history`, архив, аудит `GROUP_CHANGE`/`ASSIGNMENT_CHANGE`), `schemas/directories.py`, `api/endpoints/directories/regional_offices.py`, `company_groups.py`. Интеграционные тесты: сценарии спеки `directories-api` (ГК) и `directory-policy` (видимость филиала/ГО, 404 вне scope, создание из филиала).