arm-core/api/src/services/related_groups.py
Andrey Epifancev b7f45c6a47 feat(directories): crm_id ГСК/ГСЗ сохраняется, 409 CRM_ID_EXISTS (arm-core#112)
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
2026-10-18 19:04:40 +04:00

267 lines
13 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` на маршруте) → видимость (группа — через её ГК, ЮЛ — по `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 уже существует")