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