feat(directories): ЮЛ/ИП — CRUD, перевод, вывод из состава, история, аудит
- domain/inn.py: нормализация и проверка ИНН (ЮЛ — 10, ИП — 12 цифр 0–9), ASSUMPTION(OQ-88)
- репозиторий: список ЮЛ с видимостью и фильтрами (company_group_id, outside_group, inn точно, q по name/inn),
карточка, история (по убыванию valid_from), текущие членства, close_memberships (SCD2), алиасы
- LegalEntityService: create (INN_EXISTS, CRM_ID_EXISTS, ГК через scope → 404, филиал по умолчанию — филиал ГК),
update (no-op без истории; снимок UPDATE), move (MOVE_TO_GROUP, закрытие членств, MEMBERSHIP_CHANGE),
withdraw (CLOSE / REMOVE_FROM_GROUP), effective_date ≥ сегодня — ASSUMPTION(OQ-21)
- эндпоинты /directories/legal-entities: GET/POST, GET/PATCH /{id}, /{id}/move, /{id}/withdraw, /{id}/history
- тесты: unit ИНН, интеграционные сценарии спеки, Review Focus 1–3, откат мутации
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
69769cc302
commit
347b46e32c
@ -9,6 +9,7 @@ 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
|
||||
from src.services.legal_entities import LegalEntityService
|
||||
|
||||
|
||||
def get_scope(principal: Annotated[Principal, Depends(current_user)]) -> VisibilityScope:
|
||||
@ -25,3 +26,14 @@ def get_company_group_service(
|
||||
|
||||
|
||||
CompanyGroupServiceDep = Annotated[CompanyGroupService, Depends(get_company_group_service)]
|
||||
|
||||
|
||||
def get_legal_entity_service(
|
||||
session: SessionDep,
|
||||
audit: Annotated[RequestAudit, Depends(get_request_audit)],
|
||||
scope: Annotated[VisibilityScope, Depends(get_scope)],
|
||||
) -> LegalEntityService:
|
||||
return LegalEntityService(session, audit, scope)
|
||||
|
||||
|
||||
LegalEntityServiceDep = Annotated[LegalEntityService, Depends(get_legal_entity_service)]
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.api.endpoints.directories import company_groups, regional_offices
|
||||
from src.api.endpoints.directories import company_groups, legal_entities, regional_offices
|
||||
|
||||
router = APIRouter(prefix="/directories", tags=["directories"])
|
||||
router.include_router(regional_offices.router)
|
||||
router.include_router(company_groups.router)
|
||||
router.include_router(legal_entities.router)
|
||||
|
||||
101
api/src/api/endpoints/directories/legal_entities.py
Normal file
101
api/src/api/endpoints/directories/legal_entities.py
Normal file
@ -0,0 +1,101 @@
|
||||
"""Маршруты ЮЛ/ИП `/directories/legal-entities` (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 LegalEntityServiceDep
|
||||
from src.schemas.common import Page, PageParams
|
||||
from src.schemas.directories import (
|
||||
LegalEntityCreate,
|
||||
LegalEntityDetail,
|
||||
LegalEntityFilter,
|
||||
LegalEntityHistoryOut,
|
||||
LegalEntityMove,
|
||||
LegalEntityOut,
|
||||
LegalEntityUpdate,
|
||||
LegalEntityWithdraw,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/legal-entities")
|
||||
|
||||
VIEW = [Depends(require("DIRECTORY_VIEW"))]
|
||||
EDIT = [Depends(require("DIRECTORY_EDIT"))]
|
||||
|
||||
|
||||
class LegalEntityListQuery(LegalEntityFilter, PageParams):
|
||||
"""Query списка ЮЛ: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут)."""
|
||||
|
||||
|
||||
@router.get("", response_model=Page[LegalEntityOut], dependencies=VIEW, summary="Список ЮЛ/ИП")
|
||||
async def list_legal_entities(
|
||||
service: LegalEntityServiceDep,
|
||||
query: Annotated[LegalEntityListQuery, Query()],
|
||||
) -> Page[LegalEntityOut]:
|
||||
"""Видимые ЮЛ: фильтры `company_group_id`, `outside_group`, `inn` (точно), `q` (name/ИНН), `closed`; по имени."""
|
||||
page = PageParams(limit=query.limit, offset=query.offset)
|
||||
flt = LegalEntityFilter.model_validate(query.model_dump(include=set(LegalEntityFilter.model_fields)))
|
||||
items, total = await service.page(flt, page)
|
||||
return Page[LegalEntityOut](
|
||||
items=[LegalEntityOut.model_validate(entity) for entity in items],
|
||||
total=total,
|
||||
limit=page.limit,
|
||||
offset=page.offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"", response_model=LegalEntityOut, status_code=status.HTTP_201_CREATED, dependencies=EDIT, summary="Создать ЮЛ/ИП"
|
||||
)
|
||||
async def create_legal_entity(data: LegalEntityCreate, service: LegalEntityServiceDep) -> LegalEntityOut:
|
||||
"""Создаёт ЮЛ/ИП; без `company_group_id` — монозаёмщик; ИНН: ЮЛ — 10 цифр, ИП — 12."""
|
||||
return LegalEntityOut.model_validate(await service.create(data))
|
||||
|
||||
|
||||
@router.get("/{entity_id}", response_model=LegalEntityDetail, dependencies=VIEW, summary="Карточка ЮЛ/ИП")
|
||||
async def get_legal_entity(entity_id: uuid.UUID, service: LegalEntityServiceDep) -> LegalEntityDetail:
|
||||
"""ЮЛ с текущими членствами ГСК/ГСЗ и алиасами."""
|
||||
return await service.get(entity_id)
|
||||
|
||||
|
||||
@router.patch("/{entity_id}", response_model=LegalEntityOut, dependencies=EDIT, summary="Изменить ЮЛ/ИП")
|
||||
async def update_legal_entity(
|
||||
entity_id: uuid.UUID, data: LegalEntityUpdate, service: LegalEntityServiceDep
|
||||
) -> LegalEntityOut:
|
||||
"""Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`; прежние значения уходят в историю (`UPDATE`)."""
|
||||
return LegalEntityOut.model_validate(await service.update(entity_id, data))
|
||||
|
||||
|
||||
@router.post("/{entity_id}/move", response_model=LegalEntityOut, dependencies=EDIT, summary="Перевести ЮЛ в другую ГК")
|
||||
async def move_legal_entity(
|
||||
entity_id: uuid.UUID, data: LegalEntityMove, service: LegalEntityServiceDep
|
||||
) -> LegalEntityOut:
|
||||
"""Переводит ЮЛ в другую ГК с `effective_date` (≥ сегодня); текущие членства ГСК/ГСЗ закрываются."""
|
||||
return LegalEntityOut.model_validate(await service.move(entity_id, data))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{entity_id}/withdraw", response_model=LegalEntityOut, dependencies=EDIT, summary="Вывести ЮЛ из состава ГК"
|
||||
)
|
||||
async def withdraw_legal_entity(
|
||||
entity_id: uuid.UUID, data: LegalEntityWithdraw, service: LegalEntityServiceDep
|
||||
) -> LegalEntityOut:
|
||||
"""`CLOSE` — закрытие (`close_date = effective_date`), `REMOVE_FROM_GROUP` — вывод; ГК снимается, членства
|
||||
закрываются."""
|
||||
return LegalEntityOut.model_validate(await service.withdraw(entity_id, data))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{entity_id}/history",
|
||||
response_model=list[LegalEntityHistoryOut],
|
||||
dependencies=VIEW,
|
||||
summary="История ЮЛ/ИП (новые первыми)",
|
||||
)
|
||||
async def legal_entity_history(entity_id: uuid.UUID, service: LegalEntityServiceDep) -> list[LegalEntityHistoryOut]:
|
||||
return [LegalEntityHistoryOut.model_validate(row) for row in await service.history(entity_id)]
|
||||
31
api/src/domain/inn.py
Normal file
31
api/src/domain/inn.py
Normal file
@ -0,0 +1,31 @@
|
||||
"""ИНН ЮЛ/ИП: нормализация и проверка формата (spec directories-api «Юридическое лицо / ИП», §13.2).
|
||||
|
||||
ИНН — строка (ведущие нули значимы: `0123456789` валиден), только ASCII-цифры 0–9; длина задаётся типом лица:
|
||||
ЮЛ — 10, ИП — 12. Нечисловые значения (`LV…`, `РНН…`, `нерез.`) — `INVALID_INN`. Контрольные разряды не
|
||||
проверяются (OQ-88).
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from src.common.exceptions import DomainValidationError
|
||||
from src.models.enums import EntityType
|
||||
|
||||
INN_LENGTH: dict[EntityType, int] = {EntityType.ЮЛ: 10, EntityType.ИП: 12}
|
||||
|
||||
_WHITESPACE_RE = re.compile(r"\s+")
|
||||
_ASCII_DIGITS_RE = re.compile(r"[0-9]+")
|
||||
|
||||
|
||||
def normalize_inn(raw: str) -> str:
|
||||
"""Убирает все пробельные символы (по краям и внутри): `" 3528 052 718 "` → `"3528052718"`."""
|
||||
return _WHITESPACE_RE.sub("", raw)
|
||||
|
||||
|
||||
def validate_inn(inn: str, entity_type: EntityType) -> str:
|
||||
"""Нормализованный ИНН, если он из ровно `INN_LENGTH[entity_type]` цифр 0–9; иначе 422 `INVALID_INN`."""
|
||||
# ASSUMPTION(OQ-88): контрольные разряды не проверяются — только длина и состав символов
|
||||
normalized = normalize_inn(inn)
|
||||
length = INN_LENGTH[entity_type]
|
||||
if len(normalized) != length or _ASCII_DIGITS_RE.fullmatch(normalized) is None:
|
||||
raise DomainValidationError("INVALID_INN", f"ИНН {entity_type.value} — ровно {length} цифр")
|
||||
return normalized
|
||||
@ -1,16 +1,28 @@
|
||||
"""Репозиторий справочников: ГК, история ГК, филиалы (SQL без бизнес-правил; видимость — `src.domain.policy`).
|
||||
"""Репозиторий справочников: ГК, ЮЛ, их история, членства ГСК/ГСЗ, алиасы, филиалы.
|
||||
|
||||
SQL без бизнес-правил; видимость — `src.domain.policy`.
|
||||
|
||||
Функции не делают `commit`: транзакцией запроса управляет `SessionDep`.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
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
|
||||
from src.domain.policy import VisibilityScope, company_groups_visible, legal_entities_visible
|
||||
from src.models.directories import (
|
||||
CompanyGroup,
|
||||
CompanyGroupHistory,
|
||||
LegalEntity,
|
||||
LegalEntityAlias,
|
||||
LegalEntityHistory,
|
||||
RegionalOffice,
|
||||
RelatedGroup,
|
||||
RelatedGroupMember,
|
||||
)
|
||||
|
||||
LIKE_ESCAPE = "\\"
|
||||
|
||||
@ -100,6 +112,138 @@ async def list_company_group_history(session: AsyncSession, group_id: uuid.UUID)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
def _filtered_entities( # noqa: PLR0913 - фильтры списка ЮЛ передаются явными именованными аргументами
|
||||
stmt: Select[Any],
|
||||
scope: VisibilityScope,
|
||||
*,
|
||||
company_group_id: uuid.UUID | None,
|
||||
outside_group: bool | None,
|
||||
inn: str | None,
|
||||
q: str | None,
|
||||
closed: bool | None,
|
||||
) -> Select[Any]:
|
||||
stmt = legal_entities_visible(stmt, scope)
|
||||
if company_group_id is not None:
|
||||
stmt = stmt.where(LegalEntity.company_group_id == company_group_id)
|
||||
if outside_group is not None:
|
||||
group_column = LegalEntity.company_group_id
|
||||
stmt = stmt.where(group_column.is_(None) if outside_group else group_column.is_not(None))
|
||||
if inn is not None:
|
||||
stmt = stmt.where(LegalEntity.inn == inn)
|
||||
if q is not None and q.strip():
|
||||
pattern = f"%{escape_like(q.strip())}%"
|
||||
stmt = stmt.where(
|
||||
or_(LegalEntity.name.ilike(pattern, escape=LIKE_ESCAPE), LegalEntity.inn.ilike(pattern, escape=LIKE_ESCAPE))
|
||||
)
|
||||
if closed is not None:
|
||||
stmt = stmt.where(LegalEntity.close_date.is_not(None) if closed else LegalEntity.close_date.is_(None))
|
||||
return stmt
|
||||
|
||||
|
||||
async def list_legal_entities( # noqa: PLR0913 - фильтры и пагинация списка передаются явными именованными аргументами
|
||||
session: AsyncSession,
|
||||
scope: VisibilityScope,
|
||||
*,
|
||||
company_group_id: uuid.UUID | None,
|
||||
outside_group: bool | None,
|
||||
inn: str | None,
|
||||
q: str | None,
|
||||
closed: bool | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> tuple[list[LegalEntity], int]:
|
||||
"""Страница видимых ЮЛ (по `name`, `id`) и общее число записей по фильтру.
|
||||
|
||||
`inn` — точное равенство (вызывающий уже сделал `strip`); `q` — подстрока `name`/`inn` без учёта регистра (R-4).
|
||||
"""
|
||||
filters: dict[str, Any] = {
|
||||
"company_group_id": company_group_id,
|
||||
"outside_group": outside_group,
|
||||
"inn": inn,
|
||||
"q": q,
|
||||
"closed": closed,
|
||||
}
|
||||
page_stmt = (
|
||||
_filtered_entities(select(LegalEntity), scope, **filters)
|
||||
.order_by(LegalEntity.name, LegalEntity.id)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
total_stmt = _filtered_entities(select(func.count(LegalEntity.id)), scope, **filters)
|
||||
items = list((await session.execute(page_stmt)).scalars().all())
|
||||
total = (await session.execute(total_stmt)).scalar_one()
|
||||
return items, total
|
||||
|
||||
|
||||
async def get_legal_entity(session: AsyncSession, scope: VisibilityScope, entity_id: uuid.UUID) -> LegalEntity | None:
|
||||
"""ЮЛ по id; `None` и для несуществующего, и для невидимого (неразличимы для вызывающего)."""
|
||||
stmt = legal_entities_visible(select(LegalEntity).where(LegalEntity.id == entity_id), scope)
|
||||
return (await session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_legal_entity_by_inn(session: AsyncSession, inn: str) -> LegalEntity | None:
|
||||
"""ЮЛ по ИНН без фильтра видимости — для проверки глобальной уникальности `inn`."""
|
||||
return (await session.execute(select(LegalEntity).where(LegalEntity.inn == inn))).scalar_one_or_none()
|
||||
|
||||
|
||||
async def legal_entity_crm_id_taken(
|
||||
session: AsyncSession, crm_id: str, *, except_entity_id: uuid.UUID | None = None
|
||||
) -> bool:
|
||||
"""Есть ли у какого-либо ЮЛ (кроме `except_entity_id`) такой `crm_id` — без фильтра видимости."""
|
||||
stmt = select(LegalEntity.id).where(LegalEntity.crm_id == crm_id)
|
||||
if except_entity_id is not None:
|
||||
stmt = stmt.where(LegalEntity.id != except_entity_id)
|
||||
return (await session.execute(stmt.limit(1))).first() is not None
|
||||
|
||||
|
||||
async def list_legal_entity_history(session: AsyncSession, entity_id: uuid.UUID) -> list[LegalEntityHistory]:
|
||||
"""Снимки ЮЛ по убыванию `valid_from` (новые первыми, R-3)."""
|
||||
stmt = (
|
||||
select(LegalEntityHistory)
|
||||
.where(LegalEntityHistory.legal_entity_id == entity_id)
|
||||
.order_by(LegalEntityHistory.valid_from.desc(), LegalEntityHistory.id)
|
||||
)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
async def current_memberships(
|
||||
session: AsyncSession, entity_id: uuid.UUID
|
||||
) -> list[tuple[RelatedGroupMember, RelatedGroup]]:
|
||||
"""Текущие членства ЮЛ в ГСК/ГСЗ вместе с группой (для карточки), по виду и коду группы."""
|
||||
stmt = (
|
||||
select(RelatedGroupMember, RelatedGroup)
|
||||
.join(RelatedGroup, RelatedGroup.id == RelatedGroupMember.related_group_id)
|
||||
.where(RelatedGroupMember.legal_entity_id == entity_id, RelatedGroupMember.is_current.is_(True))
|
||||
.order_by(RelatedGroupMember.kind, RelatedGroup.code)
|
||||
)
|
||||
return [(member, group) for member, group in (await session.execute(stmt)).all()]
|
||||
|
||||
|
||||
async def close_memberships(
|
||||
session: AsyncSession, entity_id: uuid.UUID, now: datetime.datetime
|
||||
) -> list[RelatedGroupMember]:
|
||||
"""Закрывает все текущие членства ЮЛ (SCD2: `valid_to = now`, `is_current = false`); возвращает закрытые."""
|
||||
stmt = select(RelatedGroupMember).where(
|
||||
RelatedGroupMember.legal_entity_id == entity_id, RelatedGroupMember.is_current.is_(True)
|
||||
)
|
||||
members = list((await session.execute(stmt)).scalars().all())
|
||||
for member in members:
|
||||
member.valid_to = now
|
||||
member.is_current = False
|
||||
await session.flush()
|
||||
return members
|
||||
|
||||
|
||||
async def list_aliases(session: AsyncSession, entity_id: uuid.UUID) -> list[LegalEntityAlias]:
|
||||
"""Алиасы ЮЛ по времени добавления."""
|
||||
stmt = (
|
||||
select(LegalEntityAlias)
|
||||
.where(LegalEntityAlias.legal_entity_id == entity_id)
|
||||
.order_by(LegalEntityAlias.created_at, LegalEntityAlias.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)
|
||||
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
"""Схемы API справочников (spec directories-api): филиалы и ГК; поля — как в DBML v5.14."""
|
||||
"""Схемы API справочников (spec directories-api): филиалы, ГК, ЮЛ/ИП; поля — как в DBML v5.14."""
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
from typing import Self
|
||||
from typing import Literal, Self
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator
|
||||
|
||||
from src.models.enums import EntityType, LeHistoryEvent, RelatedGroupKind
|
||||
|
||||
NAME_MAX_LENGTH = 500
|
||||
|
||||
@ -116,3 +118,172 @@ class CompanyGroupFilter(BaseModel):
|
||||
|
||||
q: str | None = None
|
||||
is_archived: bool | None = None
|
||||
|
||||
|
||||
def _strip_required_name(value: str | None) -> str | None:
|
||||
"""Имя: пробелы по краям срезаются; пустое после `strip` — ошибка валидации (`None` пропускается для PATCH)."""
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("название не может быть пустым")
|
||||
return stripped
|
||||
|
||||
|
||||
class LegalEntityCreate(BaseModel):
|
||||
"""Создание ЮЛ/ИП: `inn` проверяется по `entity_type` в сервисе (`INVALID_INN`); без ГК — монозаёмщик."""
|
||||
|
||||
inn: str
|
||||
entity_type: EntityType
|
||||
name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH)
|
||||
company_group_id: uuid.UUID | None = None
|
||||
regional_office_id: uuid.UUID | None = None
|
||||
crm_id: str | None = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _strip_name(cls, value: str) -> str | None:
|
||||
return _strip_required_name(value)
|
||||
|
||||
@field_validator("crm_id")
|
||||
@classmethod
|
||||
def _normalize_crm_id(cls, value: str | None) -> str | None:
|
||||
return _blank_to_none(value)
|
||||
|
||||
|
||||
class LegalEntityUpdate(BaseModel):
|
||||
"""Изменение ЮЛ: `inn` и ГК PATCH не меняет (ГК — `move`/`withdraw`); переданные поля — `model_fields_set`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=NAME_MAX_LENGTH)
|
||||
entity_type: EntityType | None = None
|
||||
crm_id: str | None = None
|
||||
regional_office_id: uuid.UUID | None = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _strip_name(cls, value: str | None) -> str | None:
|
||||
return _strip_required_name(value)
|
||||
|
||||
@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 _required_fields_are_not_null(self) -> Self:
|
||||
for field in ("name", "entity_type"):
|
||||
if field in self.model_fields_set and getattr(self, field) is None:
|
||||
raise ValueError(f"поле {field} нельзя обнулить")
|
||||
return self
|
||||
|
||||
|
||||
class LegalEntityMove(BaseModel):
|
||||
"""Перевод ЮЛ в другую ГК с даты `effective_date` (не раньше сегодняшней)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
company_group_id: uuid.UUID
|
||||
effective_date: datetime.date
|
||||
|
||||
|
||||
class LegalEntityWithdraw(BaseModel):
|
||||
"""Вывод ЮЛ из состава ГК: `CLOSE` (закрытие, `close_date = effective_date`) или `REMOVE_FROM_GROUP`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
event: Literal["CLOSE", "REMOVE_FROM_GROUP"]
|
||||
effective_date: datetime.date
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class LegalEntityOut(BaseModel):
|
||||
"""ЮЛ/ИП; `outside_group` — монозаёмщик (`company_group_id IS NULL`), `closed` — заполнен `close_date`."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
inn: str
|
||||
crm_id: str | None
|
||||
entity_type: EntityType | None
|
||||
name: str
|
||||
company_group_id: uuid.UUID | None
|
||||
regional_office_id: uuid.UUID | None
|
||||
close_date: datetime.date | None
|
||||
valid_from: datetime.datetime
|
||||
created_at: datetime.datetime | None
|
||||
updated_at: datetime.datetime | None
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def outside_group(self) -> bool:
|
||||
return self.company_group_id is None
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
return self.close_date is not None
|
||||
|
||||
|
||||
class MembershipOut(BaseModel):
|
||||
"""Текущее членство ЮЛ в ГСК/ГСЗ (для карточки ЮЛ)."""
|
||||
|
||||
related_group_id: uuid.UUID
|
||||
code: str
|
||||
kind: RelatedGroupKind
|
||||
name: str
|
||||
valid_from: datetime.datetime
|
||||
|
||||
|
||||
class AliasOut(BaseModel):
|
||||
"""Алиас ЮЛ: `alias` — как в ОСВ, `normalized_alias` — по §3."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
alias: str
|
||||
normalized_alias: str
|
||||
created_at: datetime.datetime | None
|
||||
|
||||
|
||||
class LegalEntityDetail(LegalEntityOut):
|
||||
"""Карточка ЮЛ: текущие членства ГСК/ГСЗ и алиасы."""
|
||||
|
||||
memberships: list[MembershipOut] = []
|
||||
aliases: list[AliasOut] = []
|
||||
|
||||
|
||||
class LegalEntityHistoryOut(BaseModel):
|
||||
"""Прошлый снимок ЮЛ (SCD4) с событием (`UPDATE`, `MOVE_TO_GROUP`, `CLOSE`, `REMOVE_FROM_GROUP`)."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
inn: str | None
|
||||
crm_id: str | None
|
||||
entity_type: EntityType | None
|
||||
name: str | None
|
||||
company_group_id: uuid.UUID | None
|
||||
regional_office_id: uuid.UUID | None
|
||||
close_date: datetime.date | None
|
||||
event_type: LeHistoryEvent | None
|
||||
effective_date: datetime.date | None
|
||||
valid_from: datetime.datetime | None
|
||||
valid_to: datetime.datetime | None
|
||||
changed_by: uuid.UUID | None
|
||||
|
||||
|
||||
class LegalEntityFilter(BaseModel):
|
||||
"""Фильтры списка ЮЛ: `inn` — точно (после `strip`), `q` — подстрока `name`/`inn`, `closed` — по `close_date`."""
|
||||
|
||||
company_group_id: uuid.UUID | None = None
|
||||
outside_group: bool | None = None
|
||||
inn: str | None = None
|
||||
q: str | None = None
|
||||
closed: bool | None = None
|
||||
|
||||
@field_validator("inn")
|
||||
@classmethod
|
||||
def _strip_inn(cls, value: str | None) -> str | None:
|
||||
return _blank_to_none(value)
|
||||
|
||||
314
api/src/services/legal_entities.py
Normal file
314
api/src/services/legal_entities.py
Normal file
@ -0,0 +1,314 @@
|
||||
"""Сервис ЮЛ/ИП (spec directories-api, directory-policy, audit-trail): создание, изменение, перевод, вывод, история.
|
||||
|
||||
Порядок операции: право (`require` на маршруте) → видимость (`get`) → проверки → изменение. Все записи — в транзакции
|
||||
запроса (`SessionDep`); история (SCD4, D-6) — снимок ПРЕЖНИХ значений в `legal_entity_history` с `event_type`
|
||||
(`valid_from` прежний, `valid_to = now`), у живой записи `valid_from = now`; при создании история не пишется.
|
||||
`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.inn import validate_inn
|
||||
from src.domain.policy import VisibilityScope, office_for_create
|
||||
from src.models.directories import CompanyGroup, LegalEntity, LegalEntityHistory
|
||||
from src.models.enums import AuditAction, AuditSection, LeHistoryEvent
|
||||
from src.repositories import directories as repo
|
||||
from src.schemas.common import PageParams
|
||||
from src.schemas.directories import (
|
||||
AliasOut,
|
||||
LegalEntityCreate,
|
||||
LegalEntityDetail,
|
||||
LegalEntityFilter,
|
||||
LegalEntityMove,
|
||||
LegalEntityUpdate,
|
||||
LegalEntityWithdraw,
|
||||
MembershipOut,
|
||||
)
|
||||
from src.services.company_groups import COMPANY_GROUP_NOT_FOUND
|
||||
|
||||
LEGAL_ENTITY_NOT_FOUND = "ЮЛ не найдено" # одинаково для чужого и несуществующего (spec directory-policy)
|
||||
OBJECT_TYPE = "legal_entity"
|
||||
MEMBER_OBJECT_TYPE = "related_group_member"
|
||||
TRACKED_FIELDS = ("name", "entity_type", "crm_id", "regional_office_id") # поля ЮЛ, изменяемые PATCH
|
||||
|
||||
|
||||
def _now() -> datetime.datetime:
|
||||
return datetime.datetime.now(datetime.UTC)
|
||||
|
||||
|
||||
def _today() -> datetime.date:
|
||||
"""«Сегодня» для проверки `effective_date` — дата UTC (часовой пояс пользователя в Срезе 1 не известен)."""
|
||||
return _now().date()
|
||||
|
||||
|
||||
class LegalEntityService:
|
||||
"""Операции над ЮЛ/ИП от имени текущего пользователя в его области видимости."""
|
||||
|
||||
def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None:
|
||||
self._session = session
|
||||
self._audit = audit
|
||||
self._scope = scope
|
||||
|
||||
async def create(self, data: LegalEntityCreate) -> LegalEntity:
|
||||
"""Новое ЮЛ/ИП; ГК — только видимая (иначе 404); филиал по умолчанию — филиал ГК; аудит LEGAL_ENTITY_CHANGE."""
|
||||
inn = validate_inn(data.inn, data.entity_type)
|
||||
group = await self._visible_group(data.company_group_id) if data.company_group_id is not None else None
|
||||
if await repo.get_legal_entity_by_inn(self._session, inn) is not None:
|
||||
raise ConflictError("INN_EXISTS", "ЮЛ с таким ИНН уже существует")
|
||||
if data.crm_id is not None:
|
||||
await self._check_crm_id_free(data.crm_id)
|
||||
requested_office = data.regional_office_id
|
||||
if requested_office is None and group is not None:
|
||||
requested_office = group.base_regional_office_id
|
||||
# Филиал не может завести ЮЛ (в т.ч. монозаёмщика) на чужой филиал; без филиала — свой (§10.1)
|
||||
office_id = office_for_create(self._scope, requested_office)
|
||||
await self._check_office_exists(office_id)
|
||||
now = _now()
|
||||
entity = LegalEntity(
|
||||
inn=inn,
|
||||
crm_id=data.crm_id,
|
||||
entity_type=data.entity_type,
|
||||
name=data.name,
|
||||
company_group_id=group.id if group is not None else None,
|
||||
regional_office_id=office_id,
|
||||
valid_from=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
updated_by=self._audit.user_id,
|
||||
)
|
||||
self._session.add(entity)
|
||||
await self._session.flush()
|
||||
await self._audit.record(
|
||||
action=AuditAction.LEGAL_ENTITY_CHANGE,
|
||||
section=AuditSection.DIRECTORIES,
|
||||
object_type=OBJECT_TYPE,
|
||||
object_id=entity.id,
|
||||
after_value={
|
||||
"inn": entity.inn,
|
||||
"entity_type": entity.entity_type,
|
||||
"name": entity.name,
|
||||
"crm_id": entity.crm_id,
|
||||
"company_group_id": entity.company_group_id,
|
||||
"regional_office_id": entity.regional_office_id,
|
||||
},
|
||||
)
|
||||
return entity
|
||||
|
||||
async def update(self, entity_id: uuid.UUID, data: LegalEntityUpdate) -> LegalEntity:
|
||||
"""Изменение `name`/`entity_type`/`crm_id`/`regional_office_id`; без фактических изменений ничего не пишется."""
|
||||
entity = await self._get_entity(entity_id)
|
||||
changes = await self._changes(entity, data)
|
||||
if not changes:
|
||||
return entity
|
||||
before = {field: getattr(entity, field) for field in changes}
|
||||
now = _now()
|
||||
self._snapshot(entity, LeHistoryEvent.UPDATE, now, effective_date=None)
|
||||
for field, value in changes.items():
|
||||
setattr(entity, field, value)
|
||||
await self._touch(entity, now)
|
||||
await self._audit.record(
|
||||
action=AuditAction.LEGAL_ENTITY_CHANGE,
|
||||
section=AuditSection.DIRECTORIES,
|
||||
object_type=OBJECT_TYPE,
|
||||
object_id=entity.id,
|
||||
before_value=before,
|
||||
after_value=changes,
|
||||
changed_field=next(iter(changes)) if len(changes) == 1 else None,
|
||||
)
|
||||
return entity
|
||||
|
||||
async def move(self, entity_id: uuid.UUID, data: LegalEntityMove) -> LegalEntity:
|
||||
"""Перевод в другую видимую ГК: снимок `MOVE_TO_GROUP`, закрытие членств ГСК/ГСЗ, аудит ЮЛ и членств."""
|
||||
entity = await self._get_entity(entity_id)
|
||||
self._check_effective_date(data.effective_date)
|
||||
target = await self._visible_group(data.company_group_id)
|
||||
if entity.close_date is not None:
|
||||
raise ConflictError("LEGAL_ENTITY_CLOSED", "ЮЛ закрыто — перевод невозможен")
|
||||
if entity.company_group_id == target.id:
|
||||
raise ConflictError("SAME_COMPANY_GROUP", "ЮЛ уже входит в эту ГК")
|
||||
before = {"company_group_id": entity.company_group_id}
|
||||
now = _now()
|
||||
self._snapshot(entity, LeHistoryEvent.MOVE_TO_GROUP, now, effective_date=data.effective_date)
|
||||
entity.company_group_id = target.id
|
||||
await self._touch(entity, now)
|
||||
await self._close_memberships(entity, now)
|
||||
await self._audit.record(
|
||||
action=AuditAction.LEGAL_ENTITY_CHANGE,
|
||||
section=AuditSection.DIRECTORIES,
|
||||
object_type=OBJECT_TYPE,
|
||||
object_id=entity.id,
|
||||
description=f"{LeHistoryEvent.MOVE_TO_GROUP.value}, effective_date={data.effective_date.isoformat()}",
|
||||
before_value=before,
|
||||
after_value={"company_group_id": entity.company_group_id},
|
||||
changed_field="company_group_id",
|
||||
)
|
||||
return entity
|
||||
|
||||
async def withdraw(self, entity_id: uuid.UUID, data: LegalEntityWithdraw) -> LegalEntity:
|
||||
"""Вывод из состава ГК (`REMOVE_FROM_GROUP`) или закрытие (`CLOSE`, `close_date = effective_date`)."""
|
||||
entity = await self._get_entity(entity_id)
|
||||
self._check_effective_date(data.effective_date)
|
||||
event = LeHistoryEvent(data.event)
|
||||
if event is LeHistoryEvent.CLOSE and entity.close_date is not None:
|
||||
raise ConflictError("ALREADY_CLOSED", "ЮЛ уже закрыто")
|
||||
if event is LeHistoryEvent.REMOVE_FROM_GROUP and entity.company_group_id is None:
|
||||
raise ConflictError("NOT_IN_COMPANY_GROUP", "ЮЛ не входит в ГК")
|
||||
changes: dict[str, Any] = {"company_group_id": None}
|
||||
if event is LeHistoryEvent.CLOSE:
|
||||
changes["close_date"] = data.effective_date
|
||||
changes = {field: value for field, value in changes.items() if getattr(entity, field) != value}
|
||||
before = {field: getattr(entity, field) for field in changes}
|
||||
now = _now()
|
||||
self._snapshot(entity, event, now, effective_date=data.effective_date)
|
||||
for field, value in changes.items():
|
||||
setattr(entity, field, value)
|
||||
await self._touch(entity, now)
|
||||
await self._close_memberships(entity, now, comment=data.comment)
|
||||
await self._audit.record(
|
||||
action=AuditAction.LEGAL_ENTITY_CHANGE,
|
||||
section=AuditSection.DIRECTORIES,
|
||||
object_type=OBJECT_TYPE,
|
||||
object_id=entity.id,
|
||||
description=f"{event.value}, effective_date={data.effective_date.isoformat()}",
|
||||
before_value=before,
|
||||
after_value=changes,
|
||||
comment=data.comment,
|
||||
changed_field=next(iter(changes)) if len(changes) == 1 else None,
|
||||
)
|
||||
return entity
|
||||
|
||||
async def get(self, entity_id: uuid.UUID) -> LegalEntityDetail:
|
||||
"""Карточка: ЮЛ, текущие членства ГСК/ГСЗ и алиасы; невидимое и несуществующее — один и тот же 404."""
|
||||
entity = await self._get_entity(entity_id)
|
||||
detail = LegalEntityDetail.model_validate(entity)
|
||||
detail.memberships = [
|
||||
MembershipOut(
|
||||
related_group_id=group.id,
|
||||
code=group.code,
|
||||
kind=group.kind,
|
||||
name=group.name,
|
||||
valid_from=member.valid_from,
|
||||
)
|
||||
for member, group in await repo.current_memberships(self._session, entity.id)
|
||||
]
|
||||
detail.aliases = [AliasOut.model_validate(alias) for alias in await repo.list_aliases(self._session, entity.id)]
|
||||
return detail
|
||||
|
||||
async def history(self, entity_id: uuid.UUID) -> list[LegalEntityHistory]:
|
||||
"""Снимки ЮЛ по убыванию `valid_from` (R-3); видимость — как для карточки."""
|
||||
entity = await self._get_entity(entity_id)
|
||||
return await repo.list_legal_entity_history(self._session, entity.id)
|
||||
|
||||
async def page(self, flt: LegalEntityFilter, page: PageParams) -> tuple[list[LegalEntity], int]:
|
||||
"""Страница видимых ЮЛ и общее число по фильтру."""
|
||||
return await repo.list_legal_entities(
|
||||
self._session,
|
||||
self._scope,
|
||||
company_group_id=flt.company_group_id,
|
||||
outside_group=flt.outside_group,
|
||||
inn=flt.inn,
|
||||
q=flt.q,
|
||||
closed=flt.closed,
|
||||
limit=page.limit,
|
||||
offset=page.offset,
|
||||
)
|
||||
|
||||
async def _get_entity(self, entity_id: uuid.UUID) -> LegalEntity:
|
||||
entity = await repo.get_legal_entity(self._session, self._scope, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError(LEGAL_ENTITY_NOT_FOUND)
|
||||
return entity
|
||||
|
||||
async def _visible_group(self, group_id: uuid.UUID) -> CompanyGroup:
|
||||
"""ГК в области видимости; чужая — тот же 404, что несуществующая (Review Focus 3: не 422)."""
|
||||
group = await repo.get_company_group(self._session, self._scope, group_id)
|
||||
if group is None:
|
||||
raise NotFoundError(COMPANY_GROUP_NOT_FOUND)
|
||||
return group
|
||||
|
||||
@staticmethod
|
||||
def _check_effective_date(effective_date: datetime.date) -> None:
|
||||
# ASSUMPTION(OQ-21): дата перевода/закрытия не раньше сегодняшней (ANL-046); «сегодня» — по UTC
|
||||
if effective_date < _today():
|
||||
raise DomainValidationError("EFFECTIVE_DATE_IN_PAST", "Дата не может быть раньше сегодняшней")
|
||||
|
||||
def _snapshot(
|
||||
self,
|
||||
entity: LegalEntity,
|
||||
event: LeHistoryEvent,
|
||||
now: datetime.datetime,
|
||||
*,
|
||||
effective_date: datetime.date | None,
|
||||
) -> LegalEntityHistory:
|
||||
"""Снимок ТЕКУЩИХ (ещё не изменённых) значений ЮЛ, `valid_to = now`; вызывать до изменения полей и `_touch`."""
|
||||
snapshot = LegalEntityHistory(
|
||||
legal_entity_id=entity.id,
|
||||
inn=entity.inn,
|
||||
crm_id=entity.crm_id,
|
||||
entity_type=entity.entity_type,
|
||||
name=entity.name,
|
||||
company_group_id=entity.company_group_id,
|
||||
regional_office_id=entity.regional_office_id,
|
||||
close_date=entity.close_date,
|
||||
event_type=event,
|
||||
effective_date=effective_date,
|
||||
valid_from=entity.valid_from,
|
||||
valid_to=now,
|
||||
changed_by=self._audit.user_id,
|
||||
)
|
||||
self._session.add(snapshot)
|
||||
return snapshot
|
||||
|
||||
async def _touch(self, entity: LegalEntity, now: datetime.datetime) -> None:
|
||||
"""Новая версия живой записи: `valid_from = now` (= `valid_to` снимка), `updated_*`; `flush`."""
|
||||
entity.valid_from = now
|
||||
entity.updated_at = now
|
||||
entity.updated_by = self._audit.user_id
|
||||
await self._session.flush()
|
||||
|
||||
async def _close_memberships(self, entity: LegalEntity, now: datetime.datetime, comment: str | None = None) -> None:
|
||||
"""Закрывает текущие членства ГСК/ГСЗ ЮЛ; по каждому — `MEMBERSHIP_CHANGE`."""
|
||||
for member in await repo.close_memberships(self._session, entity.id, now):
|
||||
identity = {"related_group_id": member.related_group_id, "legal_entity_id": member.legal_entity_id}
|
||||
await self._audit.record(
|
||||
action=AuditAction.MEMBERSHIP_CHANGE,
|
||||
section=AuditSection.DIRECTORIES,
|
||||
object_type=MEMBER_OBJECT_TYPE,
|
||||
object_id=member.id,
|
||||
before_value={**identity, "is_current": True, "valid_to": None},
|
||||
after_value={**identity, "is_current": False, "valid_to": member.valid_to},
|
||||
comment=comment,
|
||||
)
|
||||
|
||||
async def _changes(self, entity: LegalEntity, data: LegalEntityUpdate) -> 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 == "regional_office_id":
|
||||
value = office_for_create(self._scope, value)
|
||||
if value != getattr(entity, field):
|
||||
changes[field] = value
|
||||
if "entity_type" in changes:
|
||||
validate_inn(entity.inn, changes["entity_type"]) # длина ИНН должна подходить новому типу
|
||||
if changes.get("regional_office_id") is not None:
|
||||
await self._check_office_exists(changes["regional_office_id"])
|
||||
if changes.get("crm_id") is not None:
|
||||
await self._check_crm_id_free(changes["crm_id"], except_entity_id=entity.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_entity_id: uuid.UUID | None = None) -> None:
|
||||
if await repo.legal_entity_crm_id_taken(self._session, crm_id, except_entity_id=except_entity_id):
|
||||
raise ConflictError("CRM_ID_EXISTS", "ЮЛ с таким crm_id уже существует")
|
||||
535
api/tests/test_api_legal_entities.py
Normal file
535
api/tests/test_api_legal_entities.py
Normal file
@ -0,0 +1,535 @@
|
||||
"""API ЮЛ/ИП (spec directories-api, directory-policy, audit-trail): CRUD, перевод, вывод из состава, история, откат."""
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.audit_ex import AuditEvent
|
||||
from src.models.directories import (
|
||||
LegalEntity,
|
||||
LegalEntityAlias,
|
||||
LegalEntityHistory,
|
||||
RegionalOffice,
|
||||
RelatedGroupMember,
|
||||
)
|
||||
from src.models.enums import AuditAction, AuditSection, EntityType, RelatedGroupKind
|
||||
from tests.conftest import AuthClientFactory
|
||||
from tests.factories import make_company_group, make_legal_entity, make_membership, make_related_group
|
||||
|
||||
HTTP_OK = 200
|
||||
HTTP_CREATED = 201
|
||||
HTTP_FORBIDDEN = 403
|
||||
HTTP_NOT_FOUND = 404
|
||||
HTTP_CONFLICT = 409
|
||||
HTTP_UNPROCESSABLE = 422
|
||||
HTTP_SERVER_ERROR = 500
|
||||
ROSTOV = "Ростовский региональный филиал"
|
||||
KRASNODAR = "Краснодарский региональный филиал"
|
||||
ENTITIES = "/directories/legal-entities"
|
||||
ENTITY_NOT_FOUND = "ЮЛ не найдено"
|
||||
GROUP_NOT_FOUND = "ГК не найдена"
|
||||
|
||||
|
||||
def _today() -> datetime.date:
|
||||
"""«Сегодня» сервиса — дата UTC (тест не зависит от часового пояса машины)."""
|
||||
return datetime.datetime.now(datetime.UTC).date()
|
||||
|
||||
|
||||
async def _office(session: AsyncSession, name: str) -> RegionalOffice:
|
||||
return (await session.execute(select(RegionalOffice).where(RegionalOffice.name == name))).scalar_one()
|
||||
|
||||
|
||||
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 _history_count(session: AsyncSession) -> int:
|
||||
return (await session.execute(select(func.count()).select_from(LegalEntityHistory))).scalar_one()
|
||||
|
||||
|
||||
async def _create(client: httpx.AsyncClient, **body: Any) -> dict[str, Any]:
|
||||
response = await client.post(ENTITIES, json=body)
|
||||
assert response.status_code == HTTP_CREATED, response.text
|
||||
created: dict[str, Any] = response.json()
|
||||
return created
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_sole_proprietor_with_ten_digits_is_invalid(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
client = await auth_client("ka_go@armka.local")
|
||||
response = await client.post(ENTITIES, json={"inn": "3528052718", "entity_type": "ИП", "name": "ИП Иванов"})
|
||||
assert response.status_code == HTTP_UNPROCESSABLE
|
||||
assert response.json()["detail"]["code"] == "INVALID_INN"
|
||||
assert (await seeded.execute(select(func.count()).select_from(LegalEntity))).scalar_one() == 0
|
||||
assert not await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_duplicate_inn_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
client = await auth_client("ka_go@armka.local")
|
||||
await _create(client, inn="3528052718", entity_type="ЮЛ", name="ООО Первое")
|
||||
response = await client.post(ENTITIES, json={"inn": "35 2805 2718", "entity_type": "ЮЛ", "name": "ООО Второе"})
|
||||
assert response.status_code == HTTP_CONFLICT
|
||||
assert response.json()["detail"]["code"] == "INN_EXISTS"
|
||||
assert len(await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)) == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_create_audit_and_default_office_from_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
rostov = await _office(seeded, ROSTOV)
|
||||
group = await make_company_group(seeded, name="ГК Ростов", office=rostov)
|
||||
client = await auth_client("ka_go@armka.local")
|
||||
created = await _create(
|
||||
client, inn="0123456789", entity_type="ЮЛ", name=" ООО Ноль ", company_group_id=str(group.id), crm_id="1-AB"
|
||||
)
|
||||
assert created["inn"] == "0123456789" # Review Focus 2: строка, ведущий ноль сохранён
|
||||
assert created["name"] == "ООО Ноль"
|
||||
assert created["regional_office_id"] == str(rostov.id)
|
||||
assert (created["outside_group"], created["closed"]) == (False, False)
|
||||
assert created["valid_from"]
|
||||
|
||||
(event,) = await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)
|
||||
assert (event.section, event.object_type, str(event.object_id)) == (
|
||||
AuditSection.DIRECTORIES,
|
||||
"legal_entity",
|
||||
created["id"],
|
||||
)
|
||||
assert event.after_value == {
|
||||
"inn": "0123456789",
|
||||
"entity_type": "ЮЛ",
|
||||
"name": "ООО Ноль",
|
||||
"crm_id": "1-AB",
|
||||
"company_group_id": str(group.id),
|
||||
"regional_office_id": str(rostov.id),
|
||||
}
|
||||
assert event.before_value is None
|
||||
assert await _history_count(seeded) == 0 # при создании история не пишется (D-6)
|
||||
|
||||
duplicate_crm = await client.post(
|
||||
ENTITIES, json={"inn": "3528052718", "entity_type": "ЮЛ", "name": "Х", "crm_id": "1-AB"}
|
||||
)
|
||||
assert duplicate_crm.status_code == HTTP_CONFLICT
|
||||
assert duplicate_crm.json()["detail"]["code"] == "CRM_ID_EXISTS"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_mono_borrower_visible_to_own_branch_and_head_office(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
rostov = await _office(seeded, ROSTOV)
|
||||
branch = await auth_client("ka_rf@armka.local") # Ростовский филиал
|
||||
created = await _create(branch, inn="6164000001", entity_type="ЮЛ", name="ООО Монозаёмщик")
|
||||
assert created["company_group_id"] is None
|
||||
assert created["outside_group"] is True
|
||||
assert created["regional_office_id"] == str(rostov.id)
|
||||
|
||||
for email in ("ka_rf@armka.local", "ka_go@armka.local"):
|
||||
client = await auth_client(email)
|
||||
page = (await client.get(ENTITIES, params={"outside_group": "true"})).json()
|
||||
assert created["id"] in {item["id"] for item in page["items"]}, email
|
||||
assert (await client.get(f"{ENTITIES}/{created['id']}")).status_code == HTTP_OK
|
||||
|
||||
foreign = await auth_client("client_manager@armka.local") # Краснодарский филиал
|
||||
page = (await foreign.get(ENTITIES)).json()
|
||||
assert created["id"] not in {item["id"] for item in page["items"]}
|
||||
card = await foreign.get(f"{ENTITIES}/{created['id']}")
|
||||
missing = await foreign.get(f"{ENTITIES}/{uuid.uuid4()}")
|
||||
assert card.status_code == missing.status_code == HTTP_NOT_FOUND
|
||||
assert card.json() == missing.json()
|
||||
assert card.json()["detail"]["message"] == ENTITY_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_move_closes_memberships_and_writes_history(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
rostov = await _office(seeded, ROSTOV)
|
||||
old_group = await make_company_group(seeded, name="ГК Старая", office=rostov)
|
||||
new_group = await make_company_group(seeded, name="ГК Новая", office=rostov)
|
||||
entity = await make_legal_entity(seeded, name="ООО Переводимое", group=old_group)
|
||||
gsk = await make_related_group(seeded, group=old_group, kind=RelatedGroupKind.gsk)
|
||||
gsz = await make_related_group(seeded, group=old_group, kind=RelatedGroupKind.gsz, name="ГСЗ Тест")
|
||||
gsk_member = await make_membership(seeded, related_group=gsk, entity=entity)
|
||||
gsz_member = await make_membership(seeded, related_group=gsz, entity=entity)
|
||||
old_valid_from = entity.valid_from
|
||||
|
||||
client = await auth_client("ka_rf@armka.local")
|
||||
effective = _today()
|
||||
response = await client.post(
|
||||
f"{ENTITIES}/{entity.id}/move",
|
||||
json={"company_group_id": str(new_group.id), "effective_date": effective.isoformat()},
|
||||
)
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
assert response.json()["company_group_id"] == str(new_group.id)
|
||||
|
||||
for member in (gsk_member, gsz_member):
|
||||
await seeded.refresh(member)
|
||||
assert member.is_current is False
|
||||
assert member.valid_to is not None
|
||||
|
||||
history = (await client.get(f"{ENTITIES}/{entity.id}/history")).json()
|
||||
assert len(history) == 1
|
||||
snapshot = history[0]
|
||||
assert snapshot["event_type"] == "MOVE_TO_GROUP"
|
||||
assert snapshot["effective_date"] == effective.isoformat()
|
||||
assert snapshot["company_group_id"] == str(old_group.id) # снимок — прежнее состояние
|
||||
assert datetime.datetime.fromisoformat(snapshot["valid_from"]) == old_valid_from
|
||||
assert snapshot["valid_to"] is not None and snapshot["changed_by"] is not None
|
||||
|
||||
(change,) = await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)
|
||||
assert change.before_value == {"company_group_id": str(old_group.id)}
|
||||
assert change.after_value == {"company_group_id": str(new_group.id)}
|
||||
assert change.changed_field == "company_group_id"
|
||||
memberships = await _events(seeded, AuditAction.MEMBERSHIP_CHANGE)
|
||||
assert {(e.object_type, e.object_id) for e in memberships} == {
|
||||
("related_group_member", gsk_member.id),
|
||||
("related_group_member", gsz_member.id),
|
||||
}
|
||||
assert all(e.before_value["is_current"] is True and e.after_value["is_current"] is False for e in memberships)
|
||||
|
||||
card = (await client.get(f"{ENTITIES}/{entity.id}")).json()
|
||||
assert card["memberships"] == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_branch_user_cannot_move_to_foreign_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
"""Review Focus 3: целевая ГК чужого филиала — 404, как несуществующая (не 422, без утечки)."""
|
||||
rostov, krasnodar = await _office(seeded, ROSTOV), await _office(seeded, KRASNODAR)
|
||||
own = await make_company_group(seeded, name="ГК Своя", office=rostov)
|
||||
foreign = await make_company_group(seeded, name="ГК Чужая", office=krasnodar)
|
||||
entity = await make_legal_entity(seeded, group=own)
|
||||
client = await auth_client("ka_rf@armka.local")
|
||||
|
||||
body = {"effective_date": _today().isoformat()}
|
||||
to_foreign = await client.post(f"{ENTITIES}/{entity.id}/move", json={**body, "company_group_id": str(foreign.id)})
|
||||
to_missing = await client.post(f"{ENTITIES}/{entity.id}/move", json={**body, "company_group_id": str(uuid.uuid4())})
|
||||
assert to_foreign.status_code == to_missing.status_code == HTTP_NOT_FOUND
|
||||
assert to_foreign.json() == to_missing.json()
|
||||
assert to_foreign.json()["detail"]["message"] == GROUP_NOT_FOUND
|
||||
await seeded.refresh(entity)
|
||||
assert entity.company_group_id == own.id
|
||||
assert await _history_count(seeded) == 0
|
||||
assert not await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)
|
||||
|
||||
create_in_foreign = await client.post(
|
||||
ENTITIES, json={"inn": "6164000002", "entity_type": "ЮЛ", "name": "ООО Х", "company_group_id": str(foreign.id)}
|
||||
)
|
||||
assert create_in_foreign.status_code == HTTP_NOT_FOUND
|
||||
assert create_in_foreign.json() == to_missing.json()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_effective_date_in_past_is_invalid(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group = await make_company_group(seeded, name="ГК А")
|
||||
target = await make_company_group(seeded, name="ГК Б")
|
||||
entity = await make_legal_entity(seeded, group=group)
|
||||
client = await auth_client("ka_go@armka.local")
|
||||
yesterday = (_today() - datetime.timedelta(days=1)).isoformat()
|
||||
|
||||
move = await client.post(
|
||||
f"{ENTITIES}/{entity.id}/move", json={"company_group_id": str(target.id), "effective_date": yesterday}
|
||||
)
|
||||
assert move.status_code == HTTP_UNPROCESSABLE
|
||||
assert move.json()["detail"]["code"] == "EFFECTIVE_DATE_IN_PAST"
|
||||
withdraw = await client.post(
|
||||
f"{ENTITIES}/{entity.id}/withdraw", json={"event": "CLOSE", "effective_date": yesterday}
|
||||
)
|
||||
assert withdraw.status_code == HTTP_UNPROCESSABLE
|
||||
assert withdraw.json()["detail"]["code"] == "EFFECTIVE_DATE_IN_PAST"
|
||||
await seeded.refresh(entity)
|
||||
assert (entity.company_group_id, entity.close_date) == (group.id, None)
|
||||
assert await _history_count(seeded) == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_withdraw_close(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group = await make_company_group(seeded, name="ГК Закрытие")
|
||||
entity = await make_legal_entity(seeded, name="ООО Закрываемое", group=group)
|
||||
other = await make_legal_entity(seeded, name="ООО Живое", group=group)
|
||||
gsk = await make_related_group(seeded, group=group)
|
||||
member = await make_membership(seeded, related_group=gsk, entity=entity)
|
||||
client = await auth_client("ka_go@armka.local")
|
||||
close_date = _today() + datetime.timedelta(days=3)
|
||||
|
||||
response = await client.post(
|
||||
f"{ENTITIES}/{entity.id}/withdraw",
|
||||
json={"event": "CLOSE", "effective_date": close_date.isoformat(), "comment": "Ликвидация"},
|
||||
)
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
body = response.json()
|
||||
assert body["close_date"] == close_date.isoformat()
|
||||
assert body["company_group_id"] is None
|
||||
assert body["closed"] is True
|
||||
|
||||
closed = (await client.get(ENTITIES, params={"closed": "true"})).json()
|
||||
assert [item["id"] for item in closed["items"]] == [str(entity.id)]
|
||||
assert closed["items"][0]["closed"] is True
|
||||
active_ids = {item["id"] for item in (await client.get(ENTITIES, params={"closed": "false"})).json()["items"]}
|
||||
assert str(other.id) in active_ids and str(entity.id) not in active_ids
|
||||
|
||||
await seeded.refresh(member)
|
||||
assert member.is_current is False
|
||||
history = (await client.get(f"{ENTITIES}/{entity.id}/history")).json()
|
||||
assert [(row["event_type"], row["effective_date"], row["company_group_id"]) for row in history] == [
|
||||
("CLOSE", close_date.isoformat(), str(group.id))
|
||||
]
|
||||
(change,) = await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)
|
||||
assert change.comment == "Ликвидация"
|
||||
assert change.before_value == {"company_group_id": str(group.id), "close_date": None}
|
||||
assert change.after_value == {"company_group_id": None, "close_date": close_date.isoformat()}
|
||||
assert len(await _events(seeded, AuditAction.MEMBERSHIP_CHANGE)) == 1
|
||||
|
||||
again = await client.post(
|
||||
f"{ENTITIES}/{entity.id}/withdraw", json={"event": "CLOSE", "effective_date": close_date.isoformat()}
|
||||
)
|
||||
assert again.status_code == HTTP_CONFLICT
|
||||
assert again.json()["detail"]["code"] == "ALREADY_CLOSED"
|
||||
target = await make_company_group(seeded, name="ГК Куда")
|
||||
move_closed = await client.post(
|
||||
f"{ENTITIES}/{entity.id}/move",
|
||||
json={"company_group_id": str(target.id), "effective_date": _today().isoformat()},
|
||||
)
|
||||
assert move_closed.status_code == HTTP_CONFLICT
|
||||
assert move_closed.json()["detail"]["code"] == "LEGAL_ENTITY_CLOSED"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_withdraw_remove_from_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group = await make_company_group(seeded, name="ГК Вывод")
|
||||
entity = await make_legal_entity(seeded, group=group)
|
||||
client = await auth_client("ka_go@armka.local")
|
||||
today = _today().isoformat()
|
||||
|
||||
response = await client.post(
|
||||
f"{ENTITIES}/{entity.id}/withdraw", json={"event": "REMOVE_FROM_GROUP", "effective_date": today}
|
||||
)
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
assert (response.json()["company_group_id"], response.json()["close_date"]) == (None, None)
|
||||
assert response.json()["outside_group"] is True
|
||||
history = (await client.get(f"{ENTITIES}/{entity.id}/history")).json()
|
||||
assert [row["event_type"] for row in history] == ["REMOVE_FROM_GROUP"]
|
||||
|
||||
again = await client.post(
|
||||
f"{ENTITIES}/{entity.id}/withdraw", json={"event": "REMOVE_FROM_GROUP", "effective_date": today}
|
||||
)
|
||||
assert again.status_code == HTTP_CONFLICT
|
||||
assert again.json()["detail"]["code"] == "NOT_IN_COMPANY_GROUP"
|
||||
same = await client.post(f"{ENTITIES}/{entity.id}/withdraw", json={"event": "UNKNOWN", "effective_date": today})
|
||||
assert same.status_code == HTTP_UNPROCESSABLE
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_move_to_same_group_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group = await make_company_group(seeded, name="ГК Та же")
|
||||
entity = await make_legal_entity(seeded, group=group)
|
||||
client = await auth_client("ka_go@armka.local")
|
||||
response = await client.post(
|
||||
f"{ENTITIES}/{entity.id}/move", json={"company_group_id": str(group.id), "effective_date": _today().isoformat()}
|
||||
)
|
||||
assert response.status_code == HTTP_CONFLICT
|
||||
assert response.json()["detail"]["code"] == "SAME_COMPANY_GROUP"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_noop_patch_writes_no_history_and_no_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
"""Review Focus 1: тело с теми же значениями не плодит историю и аудит."""
|
||||
client = await auth_client("ka_go@armka.local")
|
||||
created = await _create(client, inn="3528052718", entity_type="ЮЛ", name="ООО Без изменений", crm_id="1-NOOP")
|
||||
audit_before = len(await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE))
|
||||
|
||||
response = await client.patch(
|
||||
f"{ENTITIES}/{created['id']}", json={"name": " ООО Без изменений ", "entity_type": "ЮЛ", "crm_id": "1-NOOP"}
|
||||
)
|
||||
assert response.status_code == HTTP_OK
|
||||
assert response.json()["valid_from"] == created["valid_from"]
|
||||
assert await _history_count(seeded) == 0
|
||||
assert len(await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)) == audit_before
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_patch_writes_update_snapshot_and_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
client = await auth_client("ka_go@armka.local")
|
||||
created = await _create(client, inn="3528052718", entity_type="ЮЛ", name="Имя 0")
|
||||
for index in (1, 2):
|
||||
response = await client.patch(f"{ENTITIES}/{created['id']}", json={"name": f"Имя {index}"})
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
assert response.json()["valid_from"] != created["valid_from"]
|
||||
|
||||
history = (await client.get(f"{ENTITIES}/{created['id']}/history")).json()
|
||||
assert [(row["name"], row["event_type"]) for row in history] == [("Имя 1", "UPDATE"), ("Имя 0", "UPDATE")] # R-3
|
||||
assert history[0]["effective_date"] is None
|
||||
assert set(history[0]) == {
|
||||
"id",
|
||||
"inn",
|
||||
"crm_id",
|
||||
"entity_type",
|
||||
"name",
|
||||
"company_group_id",
|
||||
"regional_office_id",
|
||||
"close_date",
|
||||
"event_type",
|
||||
"effective_date",
|
||||
"valid_from",
|
||||
"valid_to",
|
||||
"changed_by",
|
||||
}
|
||||
last = (await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE))[-1]
|
||||
assert (last.changed_field, last.before_value, last.after_value) == ("name", {"name": "Имя 1"}, {"name": "Имя 2"})
|
||||
|
||||
wrong_type = await client.patch(f"{ENTITIES}/{created['id']}", json={"entity_type": "ИП"})
|
||||
assert wrong_type.status_code == HTTP_UNPROCESSABLE
|
||||
assert wrong_type.json()["detail"]["code"] == "INVALID_INN" # 10 цифр не ИНН ИП
|
||||
extra = await client.patch(f"{ENTITIES}/{created['id']}", json={"inn": "0000000000"})
|
||||
assert extra.status_code == HTTP_UNPROCESSABLE # extra="forbid": ИНН не меняется PATCH
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_inn_filter_strips_spaces_and_q_searches_name_and_inn(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
"""Review Focus 2 и R-4: `inn` — точное сравнение после strip; `q` — подстрока name/inn, LIKE экранирован."""
|
||||
target = await make_legal_entity(seeded, name="ООО Вологодский", inn="3528052718")
|
||||
await make_legal_entity(seeded, name="ООО 100% Другое", inn="3528052719")
|
||||
client = await auth_client("ka_go@armka.local")
|
||||
|
||||
by_inn = (await client.get(ENTITIES, params={"inn": " 3528052718 "})).json()
|
||||
assert [item["id"] for item in by_inn["items"]] == [str(target.id)]
|
||||
assert by_inn["total"] == 1
|
||||
assert (await client.get(ENTITIES, params={"inn": "352805271"})).json()["total"] == 0 # не подстрока
|
||||
|
||||
assert {item["name"] for item in (await client.get(ENTITIES, params={"q": "вологод"})).json()["items"]} == {
|
||||
"ООО Вологодский"
|
||||
}
|
||||
by_inn_part = (await client.get(ENTITIES, params={"q": "52805271"})).json()
|
||||
assert by_inn_part["total"] == 2 # noqa: PLR2004
|
||||
assert [item["name"] for item in by_inn_part["items"]] == ["ООО 100% Другое", "ООО Вологодский"]
|
||||
percent = (await client.get(ENTITIES, params={"q": "%"})).json()
|
||||
assert [item["name"] for item in percent["items"]] == ["ООО 100% Другое"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_list_filters_by_group_and_outside_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group = await make_company_group(seeded, name="ГК Фильтр")
|
||||
in_group = await make_legal_entity(seeded, name="ООО В группе", group=group)
|
||||
mono = await make_legal_entity(seeded, name="ООО Сам по себе")
|
||||
client = await auth_client("ka_go@armka.local")
|
||||
|
||||
by_group = (await client.get(ENTITIES, params={"company_group_id": str(group.id)})).json()
|
||||
assert [item["id"] for item in by_group["items"]] == [str(in_group.id)]
|
||||
outside = {item["id"] for item in (await client.get(ENTITIES, params={"outside_group": "true"})).json()["items"]}
|
||||
assert str(mono.id) in outside and str(in_group.id) not in outside
|
||||
inside = {item["id"] for item in (await client.get(ENTITIES, params={"outside_group": "false"})).json()["items"]}
|
||||
assert str(in_group.id) in inside and str(mono.id) not in inside
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_card_has_current_memberships_and_aliases(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group = await make_company_group(seeded, name="ГК Карточка")
|
||||
entity = await make_legal_entity(seeded, name="ООО Карточка", group=group)
|
||||
gsk = await make_related_group(seeded, group=group, name="ГСК Карточка")
|
||||
old_gsz = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsz, name="ГСЗ Старая")
|
||||
await make_membership(seeded, related_group=gsk, entity=entity)
|
||||
await make_membership(seeded, related_group=old_gsz, entity=entity, is_current=False)
|
||||
seeded.add(
|
||||
LegalEntityAlias(
|
||||
legal_entity_id=entity.id,
|
||||
alias="ООО «Карточка»",
|
||||
normalized_alias="карточка",
|
||||
created_at=datetime.datetime.now(datetime.UTC),
|
||||
)
|
||||
)
|
||||
await seeded.flush()
|
||||
client = await auth_client("ka_go@armka.local")
|
||||
|
||||
card = (await client.get(f"{ENTITIES}/{entity.id}")).json()
|
||||
assert [(m["related_group_id"], m["code"], m["kind"], m["name"]) for m in card["memberships"]] == [
|
||||
(str(gsk.id), gsk.code, "gsk", "ГСК Карточка")
|
||||
]
|
||||
assert card["memberships"][0]["valid_from"]
|
||||
assert [(a["alias"], a["normalized_alias"]) for a in card["aliases"]] == [("ООО «Карточка»", "карточка")]
|
||||
assert card["entity_type"] == EntityType.ЮЛ.value
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_foreign_entity_operations_are_404(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
krasnodar = await _office(seeded, KRASNODAR)
|
||||
foreign_group = await make_company_group(seeded, name="ГК Краснодар", office=krasnodar)
|
||||
entity = await make_legal_entity(seeded, group=foreign_group)
|
||||
client = await auth_client("ka_rf@armka.local")
|
||||
missing = await client.get(f"{ENTITIES}/{uuid.uuid4()}")
|
||||
today = _today().isoformat()
|
||||
|
||||
for call in (
|
||||
client.get(f"{ENTITIES}/{entity.id}"),
|
||||
client.patch(f"{ENTITIES}/{entity.id}", json={"name": "Взлом"}),
|
||||
client.post(
|
||||
f"{ENTITIES}/{entity.id}/move", json={"company_group_id": str(foreign_group.id), "effective_date": today}
|
||||
),
|
||||
client.post(f"{ENTITIES}/{entity.id}/withdraw", json={"event": "CLOSE", "effective_date": today}),
|
||||
client.get(f"{ENTITIES}/{entity.id}/history"),
|
||||
):
|
||||
response = await call
|
||||
assert response.status_code == HTTP_NOT_FOUND
|
||||
assert response.json() == missing.json()
|
||||
|
||||
|
||||
@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(ENTITIES, json={"inn": "3528052718", "entity_type": "ЮЛ", "name": "ООО Эксперта"})
|
||||
assert response.status_code == HTTP_FORBIDDEN
|
||||
assert not await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)
|
||||
assert (await client.get(ENTITIES)).status_code == HTTP_OK
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_failed_move_rolls_back_everything(
|
||||
app: FastAPI, auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""spec «Ошибка внутри мутации»: сбой закрытия членств → 500, ЮЛ в прежней ГК, истории и аудита нет."""
|
||||
old_group = await make_company_group(seeded, name="ГК Откат")
|
||||
new_group = await make_company_group(seeded, name="ГК Цель")
|
||||
entity = await make_legal_entity(seeded, group=old_group)
|
||||
gsk = await make_related_group(seeded, group=old_group)
|
||||
member = await make_membership(seeded, related_group=gsk, entity=entity)
|
||||
entity_id, member_id, old_group_id = entity.id, member.id, old_group.id # откат SAVEPOINT протухит ORM-объекты
|
||||
authorized = await auth_client("ka_go@armka.local")
|
||||
|
||||
async def broken_close_memberships(*args: Any, **kwargs: Any) -> list[RelatedGroupMember]:
|
||||
raise RuntimeError("сбой БД при закрытии членств")
|
||||
|
||||
monkeypatch.setattr("src.repositories.directories.close_memberships", broken_close_memberships)
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url=authorized.base_url, headers=authorized.headers
|
||||
) as client:
|
||||
response = await client.post(
|
||||
f"{ENTITIES}/{entity_id}/move",
|
||||
json={"company_group_id": str(new_group.id), "effective_date": _today().isoformat()},
|
||||
)
|
||||
assert response.status_code == HTTP_SERVER_ERROR
|
||||
|
||||
# SAVEPOINT запроса откатился — проверяем в той же сессии (populate_existing: читать из БД, не из identity map)
|
||||
reloaded = (
|
||||
await seeded.execute(
|
||||
select(LegalEntity).where(LegalEntity.id == entity_id).execution_options(populate_existing=True)
|
||||
)
|
||||
).scalar_one()
|
||||
assert reloaded.company_group_id == old_group_id
|
||||
reloaded_member = (
|
||||
await seeded.execute(
|
||||
select(RelatedGroupMember)
|
||||
.where(RelatedGroupMember.id == member_id)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
).scalar_one()
|
||||
assert reloaded_member.is_current is True
|
||||
assert reloaded_member.valid_to is None
|
||||
assert await _history_count(seeded) == 0
|
||||
assert not await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE, AuditAction.MEMBERSHIP_CHANGE)
|
||||
49
api/tests/test_domain_inn.py
Normal file
49
api/tests/test_domain_inn.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""ИНН: нормализация и проверка длины по типу (spec directories-api; ASSUMPTION(OQ-88) — только длина)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common.exceptions import DomainValidationError
|
||||
from src.domain.inn import INN_LENGTH, normalize_inn, validate_inn
|
||||
from src.models.enums import EntityType
|
||||
|
||||
|
||||
def test_legal_entity_inn_has_ten_digits() -> None:
|
||||
assert validate_inn("3528052718", EntityType.ЮЛ) == "3528052718"
|
||||
|
||||
|
||||
def test_sole_proprietor_inn_has_twelve_digits() -> None:
|
||||
assert validate_inn("352805271812", EntityType.ИП) == "352805271812"
|
||||
|
||||
|
||||
def test_leading_zero_is_valid_and_kept() -> None:
|
||||
"""Review Focus 2: ИНН — строка, ведущий ноль не теряется."""
|
||||
assert validate_inn("0123456789", EntityType.ЮЛ) == "0123456789"
|
||||
|
||||
|
||||
def test_ten_digits_for_sole_proprietor_is_invalid() -> None:
|
||||
with pytest.raises(DomainValidationError) as error:
|
||||
validate_inn("3528052718", EntityType.ИП)
|
||||
assert error.value.code == "INVALID_INN"
|
||||
assert error.value.message == "ИНН ИП — ровно 12 цифр"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["35280527AB", "LV12345678", "нерез.", "", "35280527181"])
|
||||
def test_non_digits_or_wrong_length_is_invalid(raw: str) -> None:
|
||||
with pytest.raises(DomainValidationError) as error:
|
||||
validate_inn(raw, EntityType.ЮЛ)
|
||||
assert error.value.code == "INVALID_INN"
|
||||
|
||||
|
||||
def test_non_ascii_digits_are_invalid() -> None:
|
||||
"""Арабско-индийские цифры проходят `str.isdigit`, но ИНН — только 0–9."""
|
||||
with pytest.raises(DomainValidationError):
|
||||
validate_inn("٣٥٢٨٠٥٢٧١٨", EntityType.ЮЛ)
|
||||
|
||||
|
||||
def test_spaces_inside_are_removed() -> None:
|
||||
assert normalize_inn(" 3528 052\t718 ") == "3528052718"
|
||||
assert validate_inn("35 28 05 27 18", EntityType.ЮЛ) == "3528052718"
|
||||
|
||||
|
||||
def test_lengths_by_entity_type() -> None:
|
||||
assert INN_LENGTH == {EntityType.ЮЛ: 10, EntityType.ИП: 12}
|
||||
@ -358,9 +358,9 @@ class LegalEntityService:
|
||||
- Схемы: `LegalEntityCreate(inn: str, entity_type: EntityType, name: str, company_group_id: uuid | None = None, regional_office_id: uuid | None = None, crm_id: str | None = None)`; `LegalEntityUpdate` (name, entity_type, crm_id, regional_office_id — optional, `extra="forbid"`); `LegalEntityMove(company_group_id: uuid.UUID, effective_date: datetime.date)`; `LegalEntityWithdraw(event: Literal["CLOSE", "REMOVE_FROM_GROUP"], effective_date: datetime.date, comment: str | None = None)`; `LegalEntityOut(id, inn, crm_id, entity_type, name, company_group_id, regional_office_id, close_date, valid_from, outside_group: bool, closed: bool, created_at, updated_at)`; `MembershipOut(related_group_id, code, kind, name, valid_from)`; `AliasOut(id, alias, normalized_alias, created_at)`; `LegalEntityDetail(LegalEntityOut + memberships: list[MembershipOut], aliases: list[AliasOut])`; `LegalEntityHistoryOut(все поля снимка + event_type, effective_date, valid_from, valid_to, changed_by)`; `LegalEntityFilter(company_group_id, outside_group, inn, q, closed)`.
|
||||
- Эндпоинты: `GET/POST /legal-entities`, `GET/PATCH /legal-entities/{id}`, `POST /legal-entities/{id}/move`, `POST /legal-entities/{id}/withdraw`, `GET /legal-entities/{id}/history`.
|
||||
|
||||
- [ ] **Step 1: Тесты.** `tests/test_domain_inn.py` (unit): 10 цифр ЮЛ ок, 12 ИП ок, `0123456789` ок (Review Focus 2), 10 цифр для ИП → `INVALID_INN`, буквы → `INVALID_INN`, пробелы внутри убираются. `tests/test_api_legal_entities.py` (integration): создание ИП с 10 цифрами → 422 `INVALID_INN`; дубликат ИНН → 409 `INN_EXISTS`; монозаёмщик (`ka_rf`, без ГК) → `outside_group == true`, виден `ka_rf` и `ka_go`, не виден `client_manager`; `move` закрывает членство (фабрики `make_related_group` + `make_membership`), история `MOVE_TO_GROUP` с датой; `move` в ГК чужого филиала пользователем `ka_rf` → 404 (Review Focus 3); `effective_date` вчера → 422; `withdraw CLOSE` → `close_date`, `company_group_id is None`, `closed == true` в списке с фильтром `closed=true`; no-op PATCH без истории; фильтр `inn=" 3528052718 "` находит ЮЛ с `inn="3528052718"`; откат мутации — `monkeypatch` `close_memberships` на бросающую `RuntimeError` → 500, ЮЛ в прежней ГК, истории/аудита нет (SAVEPOINT откатился — проверяется в той же сессии).
|
||||
- [ ] **Step 2–4:** падает → реализация → `pytest tests/test_domain_inn.py && pytest -m integration tests/test_api_legal_entities.py -v`; `ruff`, `mypy`.
|
||||
- [ ] **Step 5: Commit** `feat(directories): ЮЛ/ИП — CRUD, перевод, вывод из состава, история, аудит`.
|
||||
- [x] **Step 1: Тесты.** `tests/test_domain_inn.py` (unit): 10 цифр ЮЛ ок, 12 ИП ок, `0123456789` ок (Review Focus 2), 10 цифр для ИП → `INVALID_INN`, буквы → `INVALID_INN`, пробелы внутри убираются. `tests/test_api_legal_entities.py` (integration): создание ИП с 10 цифрами → 422 `INVALID_INN`; дубликат ИНН → 409 `INN_EXISTS`; монозаёмщик (`ka_rf`, без ГК) → `outside_group == true`, виден `ka_rf` и `ka_go`, не виден `client_manager`; `move` закрывает членство (фабрики `make_related_group` + `make_membership`), история `MOVE_TO_GROUP` с датой; `move` в ГК чужого филиала пользователем `ka_rf` → 404 (Review Focus 3); `effective_date` вчера → 422; `withdraw CLOSE` → `close_date`, `company_group_id is None`, `closed == true` в списке с фильтром `closed=true`; no-op PATCH без истории; фильтр `inn=" 3528052718 "` находит ЮЛ с `inn="3528052718"`; откат мутации — `monkeypatch` `close_memberships` на бросающую `RuntimeError` → 500, ЮЛ в прежней ГК, истории/аудита нет (SAVEPOINT откатился — проверяется в той же сессии).
|
||||
- [x] **Step 2–4:** падает → реализация → `pytest tests/test_domain_inn.py && pytest -m integration tests/test_api_legal_entities.py -v`; `ruff`, `mypy`.
|
||||
- [x] **Step 5: Commit** `feat(directories): ЮЛ/ИП — CRUD, перевод, вывод из состава, история, аудит`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ test-integration`). Пути — от корня репозитория. Доп
|
||||
последовательности, изменение со снимком `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, создание из филиала).
|
||||
- [ ] 2.3 **ЮЛ/ИП: CRUD, перевод, вывод, история.** `domain/inn.py` (длина 10/12 по типу; `ASSUMPTION(OQ-88)`),
|
||||
- [x] 2.3 **ЮЛ/ИП: CRUD, перевод, вывод, история.** `domain/inn.py` (длина 10/12 по типу; `ASSUMPTION(OQ-88)`),
|
||||
`services/legal_entities.py` (создание, изменение со снимком `legal_entity_history(UPDATE)`, `move` (MOVE_TO_GROUP, закрытие
|
||||
членств), `withdraw` (CLOSE/REMOVE_FROM_GROUP), `effective_date ≥ today`), эндпоинты `legal_entities.py`, аудит
|
||||
`LEGAL_ENTITY_CHANGE` + `MEMBERSHIP_CHANGE`. Тесты: сценарии спеки (ИНН, монозаёмщик, перевод закрывает членства, закрытие, откат).
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user