crm_id пишется в колонку related_group.crm_id (v5.19), попадает в ответы и в payload аудита; занятый значением другой ГСК/ГСЗ даёт 409 CRM_ID_EXISTS (pre-check и uq_related_group_crm_id на гонку). Смена crm_id пишется как GROUP_CHANGE с before/after всех изменённых полей (Р-41, P3-18). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
267 lines
13 KiB
Python
267 lines
13 KiB
Python
"""Сервис ГСК/ГСЗ и их состава (spec directories-api «ГСК и ГСЗ — группы и состав», directory-policy, audit-trail).
|
||
|
||
Порядок операции: право (`require` на маршруте) → видимость (группа — через её ГК, ЮЛ — по `scope`; невидимое =
|
||
несуществующее, 404) → проверки → запись. Состав — SCD2: исключение закрывает строку (`valid_to = now`,
|
||
`is_current = false`), повторное включение — новая строка. `related_group_member.kind` всегда равен `kind` группы
|
||
(v5.14): ЮЛ может одновременно состоять в одной ГСК и одной ГСЗ. Каждая мутация пишет `MEMBERSHIP_CHANGE`
|
||
(`object_type` `related_group` / `related_group_member`) в транзакции запроса через `RequestAudit`.
|
||
"""
|
||
|
||
import uuid
|
||
from typing import Any
|
||
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from src.api.dependencies.audit import RequestAudit
|
||
from src.common.clock import utc_now
|
||
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError
|
||
from src.domain.policy import VisibilityScope
|
||
from src.models.directories import RELATED_GROUP_CODE_SEQ, CompanyGroup, LegalEntity, RelatedGroup, RelatedGroupMember
|
||
from src.models.enums import AuditAction, AuditSection, RelatedGroupKind
|
||
from src.repositories import directories as repo
|
||
from src.schemas.directories import (
|
||
MemberAdd,
|
||
RelatedGroupCreate,
|
||
RelatedGroupDetail,
|
||
RelatedGroupMemberOut,
|
||
RelatedGroupOut,
|
||
RelatedGroupUpdate,
|
||
)
|
||
from src.services.company_groups import COMPANY_GROUP_NOT_FOUND
|
||
from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND
|
||
|
||
RELATED_GROUP_NOT_FOUND = "ГСК/ГСЗ не найдена" # одинаково для чужой и несуществующей (spec directory-policy)
|
||
MEMBERSHIP_NOT_FOUND = "ЮЛ не входит в эту группу"
|
||
OBJECT_TYPE = "related_group"
|
||
MEMBER_OBJECT_TYPE = "related_group_member"
|
||
CODE_PREFIXES = {RelatedGroupKind.gsk: "GSK", RelatedGroupKind.gsz: "GSZ"}
|
||
TRACKED_FIELDS = ("name", "created_date", "crm_id") # поля группы, изменяемые PATCH
|
||
|
||
|
||
def member_payload(member: RelatedGroupMember) -> dict[str, Any]:
|
||
"""`before`/`after` события `MEMBERSHIP_CHANGE` по членству (ручки состава и форма ЮЛ)."""
|
||
return {
|
||
"related_group_id": member.related_group_id,
|
||
"legal_entity_id": member.legal_entity_id,
|
||
"kind": member.kind,
|
||
"valid_from": member.valid_from,
|
||
"valid_to": member.valid_to,
|
||
"is_current": member.is_current,
|
||
}
|
||
|
||
|
||
async def add_membership(
|
||
session: AsyncSession, audit: RequestAudit, group: RelatedGroup, entity: LegalEntity
|
||
) -> RelatedGroupMember:
|
||
"""Текущее членство видимого ЮЛ в видимой группе: ЮЛ той же ГК (`DIFFERENT_COMPANY_GROUP`), не более одной
|
||
группы каждого вида (`MEMBER_EXISTS` / `MEMBER_OF_OTHER_GROUP`); аудит `MEMBERSHIP_CHANGE`."""
|
||
# ЮЛ без ГК (монозаёмщик, закрытое — Review Focus 4) тоже «не в той же ГК»
|
||
if entity.company_group_id != group.company_group_id:
|
||
raise DomainValidationError("DIFFERENT_COMPANY_GROUP", "ЮЛ не входит в ГК этой группы")
|
||
current = await repo.current_membership_of_kind(session, entity.id, group.kind)
|
||
if current is not None:
|
||
if current.related_group_id == group.id:
|
||
raise ConflictError("MEMBER_EXISTS", "ЮЛ уже входит в эту группу")
|
||
raise ConflictError(
|
||
"MEMBER_OF_OTHER_GROUP",
|
||
"ЮЛ уже входит в другую группу этого вида",
|
||
related_group_id=current.related_group_id,
|
||
)
|
||
member = RelatedGroupMember(
|
||
related_group_id=group.id,
|
||
legal_entity_id=entity.id,
|
||
kind=group.kind, # v5.14: kind строки = kind группы
|
||
valid_from=utc_now(),
|
||
valid_to=None,
|
||
is_current=True,
|
||
created_by=audit.user_id,
|
||
)
|
||
session.add(member)
|
||
await session.flush()
|
||
await audit.record(
|
||
action=AuditAction.MEMBERSHIP_CHANGE,
|
||
section=AuditSection.DIRECTORIES,
|
||
object_type=MEMBER_OBJECT_TYPE,
|
||
object_id=member.id,
|
||
after_value=member_payload(member),
|
||
)
|
||
return member
|
||
|
||
|
||
async def close_membership(session: AsyncSession, audit: RequestAudit, member: RelatedGroupMember) -> None:
|
||
"""Исключение (SCD2): `valid_to = now`, `is_current = false`; аудит `MEMBERSHIP_CHANGE` с `before`/`after`."""
|
||
before = member_payload(member)
|
||
member.valid_to = utc_now()
|
||
member.is_current = False
|
||
await session.flush()
|
||
await audit.record(
|
||
action=AuditAction.MEMBERSHIP_CHANGE,
|
||
section=AuditSection.DIRECTORIES,
|
||
object_type=MEMBER_OBJECT_TYPE,
|
||
object_id=member.id,
|
||
before_value=before,
|
||
after_value=member_payload(member),
|
||
)
|
||
|
||
|
||
class RelatedGroupService:
|
||
"""Операции над ГСК/ГСЗ и их составом от имени текущего пользователя в его области видимости."""
|
||
|
||
def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None:
|
||
self._session = session
|
||
self._audit = audit
|
||
self._scope = scope
|
||
|
||
async def create(self, company_group_id: uuid.UUID, data: RelatedGroupCreate) -> RelatedGroup:
|
||
"""Новая ГСК/ГСЗ видимой ГК с кодом `GSK-`/`GSZ-NNNNNN`; аудит `MEMBERSHIP_CHANGE`."""
|
||
company_group = await self._visible_company_group(company_group_id)
|
||
if data.crm_id is not None:
|
||
await self._check_crm_id_free(data.crm_id)
|
||
group = RelatedGroup(
|
||
code=await repo.next_code(self._session, RELATED_GROUP_CODE_SEQ, CODE_PREFIXES[data.kind]),
|
||
company_group_id=company_group.id,
|
||
kind=data.kind,
|
||
name=data.name,
|
||
created_date=data.created_date,
|
||
crm_id=data.crm_id,
|
||
)
|
||
self._session.add(group)
|
||
await self._session.flush()
|
||
await self._audit.record(
|
||
action=AuditAction.MEMBERSHIP_CHANGE,
|
||
section=AuditSection.DIRECTORIES,
|
||
object_type=OBJECT_TYPE,
|
||
object_id=group.id,
|
||
after_value=self._group_payload(group),
|
||
)
|
||
return group
|
||
|
||
async def update(self, group_id: uuid.UUID, data: RelatedGroupUpdate) -> RelatedGroup:
|
||
"""Изменение `name`/`created_date`/`crm_id`; без фактических изменений ничего не пишется.
|
||
|
||
Смена `crm_id` пишется как изменение группы — `GROUP_CHANGE` с `before`/`after` всех изменённых полей
|
||
(Р-41, P3-18); прочие правки — `MEMBERSHIP_CHANGE`, как раньше.
|
||
"""
|
||
group = await self._get_group(group_id)
|
||
changes: dict[str, Any] = {
|
||
field: getattr(data, field)
|
||
for field in TRACKED_FIELDS
|
||
if field in data.model_fields_set and getattr(data, field) != getattr(group, field)
|
||
}
|
||
if not changes:
|
||
return group
|
||
if changes.get("crm_id") is not None:
|
||
await self._check_crm_id_free(changes["crm_id"], except_group_id=group.id)
|
||
before = {field: getattr(group, field) for field in changes}
|
||
for field, value in changes.items():
|
||
setattr(group, field, value)
|
||
await self._session.flush()
|
||
await self._audit.record(
|
||
action=AuditAction.GROUP_CHANGE if "crm_id" in changes else AuditAction.MEMBERSHIP_CHANGE,
|
||
section=AuditSection.DIRECTORIES,
|
||
object_type=OBJECT_TYPE,
|
||
object_id=group.id,
|
||
before_value=before,
|
||
after_value=changes,
|
||
changed_field=next(iter(changes)) if len(changes) == 1 else None,
|
||
)
|
||
return group
|
||
|
||
async def delete(self, group_id: uuid.UUID) -> None:
|
||
"""Удаляет группу без текущих членов вместе с закрытыми строками состава; `before_value` — группа и состав.
|
||
|
||
ASSUMPTION(OQ-34): удаляется только пустая ГСК/ГСЗ (есть текущие члены → 409 `GROUP_NOT_EMPTY`); история
|
||
состава исчезает вместе с группой и сохраняется только в `before_value` аудита.
|
||
"""
|
||
group = await self._get_group(group_id)
|
||
if await repo.has_current_members(self._session, group.id):
|
||
raise ConflictError("GROUP_NOT_EMPTY", "В группе есть текущие члены — сначала исключите их")
|
||
rows = await repo.list_group_member_rows(self._session, group.id)
|
||
before = {
|
||
**self._group_payload(group),
|
||
"members": [{"id": row.id, **member_payload(row)} for row in rows],
|
||
}
|
||
await repo.delete_group_member_rows(self._session, group.id)
|
||
await self._session.delete(group)
|
||
await self._session.flush()
|
||
await self._audit.record(
|
||
action=AuditAction.MEMBERSHIP_CHANGE,
|
||
section=AuditSection.DIRECTORIES,
|
||
object_type=OBJECT_TYPE,
|
||
object_id=group_id,
|
||
before_value=before,
|
||
)
|
||
|
||
async def add_member(self, group_id: uuid.UUID, data: MemberAdd) -> tuple[RelatedGroupMember, LegalEntity]:
|
||
"""Текущее членство ЮЛ с `kind` группы; ЮЛ — той же ГК; не более одной группы каждого вида у ЮЛ."""
|
||
group = await self._get_group(group_id)
|
||
entity = await repo.get_legal_entity(self._session, self._scope, data.legal_entity_id)
|
||
if entity is None:
|
||
raise NotFoundError(LEGAL_ENTITY_NOT_FOUND)
|
||
return await add_membership(self._session, self._audit, group, entity), entity
|
||
|
||
async def remove_member(self, group_id: uuid.UUID, legal_entity_id: uuid.UUID) -> None:
|
||
"""Исключение (SCD2): текущая строка закрывается `valid_to = now`, `is_current = false`; нет членства — 404."""
|
||
group = await self._get_group(group_id)
|
||
member = await repo.current_membership_in_group(self._session, group.id, legal_entity_id)
|
||
if member is None:
|
||
raise NotFoundError(MEMBERSHIP_NOT_FOUND)
|
||
await close_membership(self._session, self._audit, member)
|
||
|
||
async def get(self, group_id: uuid.UUID) -> RelatedGroupDetail:
|
||
"""Карточка группы с текущими членами; невидимая и несуществующая — один и тот же 404."""
|
||
group = await self._get_group(group_id)
|
||
members = [
|
||
RelatedGroupMemberOut(
|
||
legal_entity_id=entity.id, inn=entity.inn, name=entity.name, valid_from=member.valid_from
|
||
)
|
||
for member, entity in await repo.current_members(self._session, group.id)
|
||
]
|
||
detail = RelatedGroupDetail.model_validate(group)
|
||
detail.members = members
|
||
detail.members_count = len(members)
|
||
return detail
|
||
|
||
async def summary(self, group: RelatedGroup) -> RelatedGroupOut:
|
||
"""Группа с числом текущих членов (ответ на создание/изменение)."""
|
||
out = RelatedGroupOut.model_validate(group)
|
||
out.members_count = await repo.count_current_members(self._session, group.id)
|
||
return out
|
||
|
||
async def list_for(self, company_group_id: uuid.UUID) -> list[RelatedGroupOut]:
|
||
"""ГСК/ГСЗ видимой ГК по имени с числом текущих членов."""
|
||
company_group = await self._visible_company_group(company_group_id)
|
||
result = []
|
||
for group, count in await repo.list_related_groups(self._session, company_group.id):
|
||
out = RelatedGroupOut.model_validate(group)
|
||
out.members_count = count
|
||
result.append(out)
|
||
return result
|
||
|
||
async def _get_group(self, group_id: uuid.UUID) -> RelatedGroup:
|
||
group = await repo.get_related_group(self._session, self._scope, group_id)
|
||
if group is None:
|
||
raise NotFoundError(RELATED_GROUP_NOT_FOUND)
|
||
return group
|
||
|
||
async def _visible_company_group(self, company_group_id: uuid.UUID) -> CompanyGroup:
|
||
company_group = await repo.get_company_group(self._session, self._scope, company_group_id)
|
||
if company_group is None:
|
||
raise NotFoundError(COMPANY_GROUP_NOT_FOUND)
|
||
return company_group
|
||
|
||
@staticmethod
|
||
def _group_payload(group: RelatedGroup) -> dict[str, Any]:
|
||
return {
|
||
"code": group.code,
|
||
"company_group_id": group.company_group_id,
|
||
"kind": group.kind,
|
||
"name": group.name,
|
||
"created_date": group.created_date,
|
||
"crm_id": group.crm_id,
|
||
}
|
||
|
||
async def _check_crm_id_free(self, crm_id: str, *, except_group_id: uuid.UUID | None = None) -> None:
|
||
if await repo.related_group_crm_id_taken(self._session, crm_id, except_group_id=except_group_id):
|
||
raise ConflictError("CRM_ID_EXISTS", "ГСК/ГСЗ с таким crm_id уже существует")
|