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:
parent
d85ecd22e4
commit
80e8905e68
27
api/src/api/dependencies/directories.py
Normal file
27
api/src/api/dependencies/directories.py
Normal 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)]
|
||||
9
api/src/api/endpoints/directories/__init__.py
Normal file
9
api/src/api/endpoints/directories/__init__.py
Normal 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)
|
||||
94
api/src/api/endpoints/directories/company_groups.py
Normal file
94
api/src/api/endpoints/directories/company_groups.py
Normal 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)]
|
||||
21
api/src/api/endpoints/directories/regional_offices.py
Normal file
21
api/src/api/endpoints/directories/regional_offices.py
Normal 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)]
|
||||
@ -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
|
||||
|
||||
109
api/src/repositories/directories.py
Normal file
109
api/src/repositories/directories.py
Normal 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())
|
||||
118
api/src/schemas/directories.py
Normal file
118
api/src/schemas/directories.py
Normal 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
|
||||
179
api/src/services/company_groups.py
Normal file
179
api/src/services/company_groups.py
Normal 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 уже существует")
|
||||
283
api/tests/test_api_company_groups.py
Normal file
283
api/tests/test_api_company_groups.py
Normal 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"] == "ГК не найдена"
|
||||
82
api/tests/test_api_policy.py
Normal file
82
api/tests/test_api_policy.py
Normal 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"
|
||||
@ -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, история, аудит, видимость`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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, создание из филиала).
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user