feat(directories): филиалы и ГК — API, история, аудит, видимость

Репозиторий, сервис и эндпоинты /api/v1/directories/{regional-offices,company-groups}:
код GK-NNNNNN из последовательности, снимки в company_group_history, no-op PATCH
без истории и аудита, поиск q по name/crm_id/code с экранированием LIKE, видимость
по филиалу (чужая ГК = 404 как несуществующая), аудит GROUP_CHANGE/ASSIGNMENT_CHANGE.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-04 21:28:06 +04:00
parent d85ecd22e4
commit 80e8905e68
12 changed files with 930 additions and 7 deletions

View File

@ -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)]

View File

@ -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)

View File

@ -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)]

View File

@ -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)]

View File

@ -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

View File

@ -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: `<prefix>-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())

View File

@ -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

View File

@ -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 уже существует")

View File

@ -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"] == "ГК не найдена"

View File

@ -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"

View File

@ -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, история, аудит, видимость`.
---

View File

@ -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, создание из филиала).