From 347b46e32c3b55ee6c7034df891b63450b7c698e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 21:41:17 +0400 Subject: [PATCH] =?UTF-8?q?feat(directories):=20=D0=AE=D0=9B/=D0=98=D0=9F?= =?UTF-8?q?=20=E2=80=94=20CRUD,=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE?= =?UTF-8?q?=D0=B4,=20=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D1=81=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=B0,=20=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D1=8F,=20=D0=B0=D1=83=D0=B4=D0=B8?= =?UTF-8?q?=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/dependencies/directories.py | 12 + api/src/api/endpoints/directories/__init__.py | 3 +- .../endpoints/directories/legal_entities.py | 101 ++++ api/src/domain/inn.py | 31 + api/src/repositories/directories.py | 150 ++++- api/src/schemas/directories.py | 177 +++++- api/src/services/legal_entities.py | 314 ++++++++++ api/tests/test_api_legal_entities.py | 535 ++++++++++++++++++ api/tests/test_domain_inn.py | 49 ++ .../2026-10-04-slice-1-directories-phase1.md | 6 +- openspec/changes/slice-1-directories/tasks.md | 2 +- 11 files changed, 1369 insertions(+), 11 deletions(-) create mode 100644 api/src/api/endpoints/directories/legal_entities.py create mode 100644 api/src/domain/inn.py create mode 100644 api/src/services/legal_entities.py create mode 100644 api/tests/test_api_legal_entities.py create mode 100644 api/tests/test_domain_inn.py diff --git a/api/src/api/dependencies/directories.py b/api/src/api/dependencies/directories.py index a284bef..f009d80 100644 --- a/api/src/api/dependencies/directories.py +++ b/api/src/api/dependencies/directories.py @@ -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)] diff --git a/api/src/api/endpoints/directories/__init__.py b/api/src/api/endpoints/directories/__init__.py index 0e27b94..18b8274 100644 --- a/api/src/api/endpoints/directories/__init__.py +++ b/api/src/api/endpoints/directories/__init__.py @@ -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) diff --git a/api/src/api/endpoints/directories/legal_entities.py b/api/src/api/endpoints/directories/legal_entities.py new file mode 100644 index 0000000..c220eda --- /dev/null +++ b/api/src/api/endpoints/directories/legal_entities.py @@ -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)] diff --git a/api/src/domain/inn.py b/api/src/domain/inn.py new file mode 100644 index 0000000..0fbecf8 --- /dev/null +++ b/api/src/domain/inn.py @@ -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 diff --git a/api/src/repositories/directories.py b/api/src/repositories/directories.py index bae284c..7fdc1d2 100644 --- a/api/src/repositories/directories.py +++ b/api/src/repositories/directories.py @@ -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) diff --git a/api/src/schemas/directories.py b/api/src/schemas/directories.py index 6565f7b..00c53eb 100644 --- a/api/src/schemas/directories.py +++ b/api/src/schemas/directories.py @@ -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) diff --git a/api/src/services/legal_entities.py b/api/src/services/legal_entities.py new file mode 100644 index 0000000..b90f1e9 --- /dev/null +++ b/api/src/services/legal_entities.py @@ -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 уже существует") diff --git a/api/tests/test_api_legal_entities.py b/api/tests/test_api_legal_entities.py new file mode 100644 index 0000000..02bd935 --- /dev/null +++ b/api/tests/test_api_legal_entities.py @@ -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) diff --git a/api/tests/test_domain_inn.py b/api/tests/test_domain_inn.py new file mode 100644 index 0000000..5ddff28 --- /dev/null +++ b/api/tests/test_domain_inn.py @@ -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} diff --git a/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md index 51d1e3d..6d55cba 100644 --- a/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md +++ b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md @@ -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, перевод, вывод из состава, история, аудит`. --- diff --git a/openspec/changes/slice-1-directories/tasks.md b/openspec/changes/slice-1-directories/tasks.md index 28723db..6191b19 100644 --- a/openspec/changes/slice-1-directories/tasks.md +++ b/openspec/changes/slice-1-directories/tasks.md @@ -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`. Тесты: сценарии спеки (ИНН, монозаёмщик, перевод закрывает членства, закрытие, откат).