arm-core/api/src/services/legal_entities.py
Andrey Epifancev 64be2f2b5c feat: имя ГК (company_group_name) в ответах ЮЛ (#67)
В 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
2026-10-08 17:01:25 +04:00

397 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сервис ЮЛ/ИП (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 уже существует")