В LegalEntityOut (а значит и LegalEntityDetail) добавлено company_group_name: текущее имя ГК company_group_id, архивной тоже; у монозаёмщика null. Есть во всех ответах /directories/legal-entities: список, карточка, POST, PATCH, move, withdraw; в снимках истории нет. Имена набора ЮЛ подгружаются одним батч-запросом (repositories.directories.company_group_names_by_ids), число запросов списка от числа строк не зависит. Спека directories-api и README дополнены. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
397 lines
21 KiB
Python
397 lines
21 KiB
Python
"""Сервис ЮЛ/ИП (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 collections.abc import Iterable, Mapping, Sequence
|
||
from typing import Any
|
||
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from src.api.dependencies.audit import RequestAudit
|
||
from src.common.clock import moscow_date, utc_now
|
||
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError
|
||
from src.domain.inn import validate_inn
|
||
from src.domain.kpp import validate_kpp
|
||
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,
|
||
LegalEntityHistoryOut,
|
||
LegalEntityMove,
|
||
LegalEntityOut,
|
||
LegalEntityUpdate,
|
||
LegalEntityWithdraw,
|
||
MembershipOut,
|
||
RegionalOfficeBrief,
|
||
)
|
||
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", "kpp") # поля ЮЛ, изменяемые PATCH
|
||
|
||
|
||
def _today() -> datetime.date:
|
||
"""«Сегодня» для проверки `effective_date` — дата по Москве (Europe/Moscow, issue #9 п.6)."""
|
||
return moscow_date(utc_now())
|
||
|
||
|
||
def snapshot_entity( # noqa: PLR0913 - снимок ЮЛ: сессия, автор, ЮЛ, событие, момент и дата события
|
||
session: AsyncSession,
|
||
user_id: uuid.UUID,
|
||
entity: LegalEntity,
|
||
event: LeHistoryEvent,
|
||
now: datetime.datetime,
|
||
*,
|
||
effective_date: datetime.date | None,
|
||
) -> LegalEntityHistory:
|
||
"""Снимок ТЕКУЩИХ (ещё не изменённых) значений ЮЛ, `valid_to = now`; вызывать до изменений и `touch_entity`."""
|
||
snapshot = LegalEntityHistory(
|
||
legal_entity_id=entity.id,
|
||
inn=entity.inn,
|
||
kpp=entity.kpp,
|
||
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=user_id,
|
||
)
|
||
session.add(snapshot)
|
||
return snapshot
|
||
|
||
|
||
async def touch_entity(session: AsyncSession, user_id: uuid.UUID, entity: LegalEntity, now: datetime.datetime) -> None:
|
||
"""Новая версия живой записи: `valid_from = now` (= `valid_to` снимка), `updated_*`; `flush`."""
|
||
entity.valid_from = now
|
||
entity.updated_at = now
|
||
entity.updated_by = user_id
|
||
await session.flush()
|
||
|
||
|
||
async def apply_entity_update( # noqa: PLR0913 - сессия, аудит, ЮЛ, изменения и момент — без значений по умолчанию
|
||
session: AsyncSession,
|
||
audit: RequestAudit,
|
||
entity: LegalEntity,
|
||
changes: Mapping[str, Any],
|
||
*,
|
||
now: datetime.datetime,
|
||
description: str | None = None,
|
||
) -> None:
|
||
"""Изменение полей ЮЛ со снимком `legal_entity_history(UPDATE)` и аудитом `LEGAL_ENTITY_CHANGE`; общее для PATCH и
|
||
импорта (R-14). `changes` — только фактически изменившиеся поля (проверки — у вызывающего)."""
|
||
before = {field: getattr(entity, field) for field in changes}
|
||
snapshot_entity(session, audit.user_id, entity, LeHistoryEvent.UPDATE, now, effective_date=None)
|
||
for field, value in changes.items():
|
||
setattr(entity, field, value)
|
||
await touch_entity(session, audit.user_id, entity, now)
|
||
await audit.record(
|
||
action=AuditAction.LEGAL_ENTITY_CHANGE,
|
||
section=AuditSection.DIRECTORIES,
|
||
object_type=OBJECT_TYPE,
|
||
object_id=entity.id,
|
||
description=description,
|
||
before_value=before,
|
||
after_value=dict(changes),
|
||
changed_field=next(iter(changes)) if len(changes) == 1 else None,
|
||
)
|
||
|
||
|
||
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)
|
||
kpp = validate_kpp(data.kpp, 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 = utc_now()
|
||
entity = LegalEntity(
|
||
inn=inn,
|
||
kpp=kpp,
|
||
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,
|
||
"kpp": entity.kpp,
|
||
"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`/`kpp`; без фактических изменений — ничего."""
|
||
entity = await self._get_entity(entity_id)
|
||
changes = await self._changes(entity, data)
|
||
if not changes:
|
||
return entity
|
||
await apply_entity_update(self._session, self._audit, entity, changes, now=utc_now())
|
||
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 = utc_now()
|
||
snapshot_entity(
|
||
self._session,
|
||
self._audit.user_id,
|
||
entity,
|
||
LeHistoryEvent.MOVE_TO_GROUP,
|
||
now,
|
||
effective_date=data.effective_date,
|
||
)
|
||
entity.company_group_id = target.id
|
||
await touch_entity(self._session, self._audit.user_id, 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 = utc_now()
|
||
snapshot_entity(self._session, self._audit.user_id, entity, event, now, effective_date=data.effective_date)
|
||
for field, value in changes.items():
|
||
setattr(entity, field, value)
|
||
await touch_entity(self._session, self._audit.user_id, 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.regional_office = (await self.offices_by_ids([entity.regional_office_id])).get(entity.regional_office_id)
|
||
if entity.company_group_id is not None:
|
||
names = await repo.company_group_names_by_ids(self._session, [entity.company_group_id])
|
||
detail.company_group_name = names.get(entity.company_group_id)
|
||
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[LegalEntityHistoryOut]:
|
||
"""Снимки ЮЛ по убыванию `valid_from` (R-3); видимость — как для карточки; филиалы снимков — одним батчем."""
|
||
entity = await self._get_entity(entity_id)
|
||
rows = await repo.list_legal_entity_history(self._session, entity.id)
|
||
offices = await self.offices_by_ids(row.regional_office_id for row in rows)
|
||
result: list[LegalEntityHistoryOut] = []
|
||
for row in rows:
|
||
out = LegalEntityHistoryOut.model_validate(row)
|
||
out.regional_office = offices.get(row.regional_office_id)
|
||
result.append(out)
|
||
return result
|
||
|
||
async def offices_by_ids(
|
||
self, office_ids: Iterable[uuid.UUID | None]
|
||
) -> dict[uuid.UUID | None, RegionalOfficeBrief]:
|
||
"""Филиалы (в т.ч. неактивные) по набору id — один запрос на набор, без N+1."""
|
||
offices = await repo.regional_offices_by_ids(self._session, office_ids)
|
||
return {office_id: RegionalOfficeBrief.model_validate(office) for office_id, office in offices.items()}
|
||
|
||
async def to_outs(self, entities: Sequence[LegalEntity], counts: Mapping[uuid.UUID, int]) -> list[LegalEntityOut]:
|
||
"""Ответы по набору ЮЛ с числом документов; филиалы и имена ГК — по одному батч-запросу."""
|
||
offices = await self.offices_by_ids(entity.regional_office_id for entity in entities)
|
||
group_names = await repo.company_group_names_by_ids(self._session, (e.company_group_id for e in entities))
|
||
result: list[LegalEntityOut] = []
|
||
for entity in entities:
|
||
out = LegalEntityOut.model_validate(entity)
|
||
out.documents_count = counts.get(entity.id, 0)
|
||
out.regional_office = offices.get(entity.regional_office_id)
|
||
if entity.company_group_id is not None:
|
||
out.company_group_name = group_names.get(entity.company_group_id)
|
||
result.append(out)
|
||
return result
|
||
|
||
async def to_out(self, entity: LegalEntity, documents_count: int) -> LegalEntityOut:
|
||
"""Ответ по одному ЮЛ с объектом филиала и именем ГК."""
|
||
return (await self.to_outs([entity], {entity.id: documents_count}))[0]
|
||
|
||
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 documents_count_by_entities(self, entity_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
|
||
"""Число документов для каждой ЮЛ (файлы со статусом не SUPERSEDED)."""
|
||
return await repo.documents_count_by_entity(self._session, entity_ids)
|
||
|
||
async def documents_count(self, entity_id: uuid.UUID) -> int:
|
||
"""Число документов одной ЮЛ (как `documents_count_by_entities`)."""
|
||
return (await self.documents_count_by_entities([entity_id])).get(entity_id, 0)
|
||
|
||
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); «сегодня» — по Москве
|
||
if effective_date < _today():
|
||
raise DomainValidationError("EFFECTIVE_DATE_IN_PAST", "Дата не может быть раньше сегодняшней")
|
||
|
||
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 == "kpp":
|
||
# итоговая пара (тип, КПП): у ИП КПП запрещён; значение нормализуется до сравнения с хранимым
|
||
value = validate_kpp(
|
||
value, data.entity_type if "entity_type" in data.model_fields_set else entity.entity_type
|
||
)
|
||
if value == getattr(entity, field):
|
||
continue # то же значение — не изменение (Review Focus 1), даже если филиал ЮЛ чужой для пользователя
|
||
if field == "regional_office_id":
|
||
value = office_for_create(self._scope, value) # policy — только для фактической смены филиала
|
||
if value != getattr(entity, field):
|
||
changes[field] = value
|
||
if "entity_type" in changes:
|
||
validate_inn(entity.inn, changes["entity_type"]) # длина ИНН должна подходить новому типу
|
||
validate_kpp(changes.get("kpp", entity.kpp), 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 уже существует")
|