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:
Andrey Epifancev 2026-10-04 21:41:17 +04:00
parent 69769cc302
commit 347b46e32c
11 changed files with 1369 additions and 11 deletions

View File

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

View File

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

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

View File

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

View File

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

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

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

View 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}

View File

@ -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, перевод, вывод из состава, история, аудит`.
---

View File

@ -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`. Тесты: сценарии спеки (ИНН, монозаёмщик, перевод закрывает членства, закрытие, откат).