arm-core/api/src/repositories/directories.py
Andrey Epifancev 88a4b6d7a2 feat(directories): ГСК/ГСЗ и состав (SCD2, одна группа каждого вида на ЮЛ)
Создание с кодом GSK-/GSZ-NNNNNN, изменение name/created_date, удаление только пустой группы
(ASSUMPTION(OQ-34), закрытые строки состава удаляются вместе с группой), добавление члена
(та же ГК, kind группы, MEMBER_OF_OTHER_GROUP / MEMBER_EXISTS), исключение SCD2. Видимость
группы — через явный JOIN ГК и company_groups_visible (T4.1). Аудит MEMBERSHIP_CHANGE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-04 22:08:04 +04:00

373 lines
17 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.

"""Репозиторий справочников: ГК, ЮЛ, их история, членства ГСК/ГСЗ, алиасы, филиалы.
SQL без бизнес-правил; видимость — `src.domain.policy`.
Функции не делают `commit`: транзакцией запроса управляет `SessionDep`.
"""
import datetime
import uuid
from typing import Any
from sqlalchemy import Select, Sequence, delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.policy import VisibilityScope, company_groups_visible, legal_entities_visible
from src.models.directories import (
CompanyGroup,
CompanyGroupHistory,
LegalEntity,
LegalEntityAlias,
LegalEntityHistory,
RegionalOffice,
RelatedGroup,
RelatedGroupMember,
)
from src.models.enums import RelatedGroupKind
LIKE_ESCAPE = "\\"
def escape_like(text: str) -> str:
"""Экранирует `\\`, `%` и `_`, чтобы пользовательская строка искалась как подстрока, а не как шаблон LIKE."""
return text.replace(LIKE_ESCAPE, LIKE_ESCAPE * 2).replace("%", f"{LIKE_ESCAPE}%").replace("_", f"{LIKE_ESCAPE}_")
async def next_code(session: AsyncSession, seq: Sequence, prefix: str) -> str:
"""Следующий код из последовательности PostgreSQL: `<prefix>-NNNNNN` (6 цифр с нулями, D-5)."""
number = (await session.execute(select(seq.next_value()))).scalar_one()
return f"{prefix}-{number:06d}"
def _filtered_groups(stmt: Select[Any], scope: VisibilityScope, q: str | None, is_archived: bool | None) -> Select[Any]:
stmt = company_groups_visible(stmt, scope)
if q is not None and q.strip():
pattern = f"%{escape_like(q.strip())}%"
stmt = stmt.where(
or_(
CompanyGroup.name.ilike(pattern, escape=LIKE_ESCAPE),
CompanyGroup.crm_id.ilike(pattern, escape=LIKE_ESCAPE),
CompanyGroup.code.ilike(pattern, escape=LIKE_ESCAPE),
)
)
if is_archived is not None:
stmt = stmt.where(CompanyGroup.is_archived.is_(is_archived))
return stmt
async def list_company_groups( # noqa: PLR0913 - фильтры и пагинация списка передаются явными именованными аргументами
session: AsyncSession,
scope: VisibilityScope,
*,
q: str | None,
is_archived: bool | None,
limit: int,
offset: int,
) -> tuple[list[tuple[CompanyGroup, int]], int]:
"""Страница видимых ГК (по `name`) со счётчиком ЮЛ и общее число записей по фильтру (отдельный `count()`)."""
legal_entities_count = (
select(func.count(LegalEntity.id))
.where(LegalEntity.company_group_id == CompanyGroup.id)
.correlate(CompanyGroup)
.scalar_subquery()
)
page_stmt = (
_filtered_groups(select(CompanyGroup, legal_entities_count), scope, q, is_archived)
.order_by(CompanyGroup.name, CompanyGroup.id)
.limit(limit)
.offset(offset)
)
total_stmt = _filtered_groups(select(func.count(CompanyGroup.id)), scope, q, is_archived)
rows = (await session.execute(page_stmt)).all()
total = (await session.execute(total_stmt)).scalar_one()
return [(group, count) for group, count in rows], total
async def get_company_group(session: AsyncSession, scope: VisibilityScope, group_id: uuid.UUID) -> CompanyGroup | None:
"""ГК по id; `None` и для несуществующей, и для невидимой (неразличимы для вызывающего)."""
stmt = company_groups_visible(select(CompanyGroup).where(CompanyGroup.id == group_id), scope)
return (await session.execute(stmt)).scalar_one_or_none()
async def crm_id_taken(session: AsyncSession, crm_id: str, *, except_group_id: uuid.UUID | None = None) -> bool:
"""Есть ли у какой-либо ГК (кроме `except_group_id`) такой `crm_id` — без фильтра видимости: уникален глобально."""
stmt = select(CompanyGroup.id).where(CompanyGroup.crm_id == crm_id)
if except_group_id is not None:
stmt = stmt.where(CompanyGroup.id != except_group_id)
return (await session.execute(stmt.limit(1))).first() is not None
async def count_legal_entities(session: AsyncSession, group_id: uuid.UUID) -> int:
"""Число ЮЛ/ИП с `company_group_id = group_id`."""
stmt = select(func.count(LegalEntity.id)).where(LegalEntity.company_group_id == group_id)
return (await session.execute(stmt)).scalar_one()
async def list_company_group_history(session: AsyncSession, group_id: uuid.UUID) -> list[CompanyGroupHistory]:
"""Снимки ГК по убыванию `valid_from` (новые первыми)."""
stmt = (
select(CompanyGroupHistory)
.where(CompanyGroupHistory.company_group_id == group_id)
.order_by(CompanyGroupHistory.valid_from.desc(), CompanyGroupHistory.id)
)
return list((await session.execute(stmt)).scalars().all())
def _filtered_entities( # noqa: PLR0913 - фильтры списка ЮЛ передаются явными именованными аргументами
stmt: Select[Any],
scope: VisibilityScope,
*,
company_group_id: uuid.UUID | None,
outside_group: bool | None,
inn: str | None,
q: str | None,
closed: bool | None,
) -> Select[Any]:
stmt = legal_entities_visible(stmt, scope)
if company_group_id is not None:
stmt = stmt.where(LegalEntity.company_group_id == company_group_id)
if outside_group is not None:
group_column = LegalEntity.company_group_id
stmt = stmt.where(group_column.is_(None) if outside_group else group_column.is_not(None))
if inn is not None:
stmt = stmt.where(LegalEntity.inn == inn)
if q is not None and q.strip():
pattern = f"%{escape_like(q.strip())}%"
stmt = stmt.where(
or_(LegalEntity.name.ilike(pattern, escape=LIKE_ESCAPE), LegalEntity.inn.ilike(pattern, escape=LIKE_ESCAPE))
)
if closed is not None:
stmt = stmt.where(LegalEntity.close_date.is_not(None) if closed else LegalEntity.close_date.is_(None))
return stmt
async def list_legal_entities( # noqa: PLR0913 - фильтры и пагинация списка передаются явными именованными аргументами
session: AsyncSession,
scope: VisibilityScope,
*,
company_group_id: uuid.UUID | None,
outside_group: bool | None,
inn: str | None,
q: str | None,
closed: bool | None,
limit: int,
offset: int,
) -> tuple[list[LegalEntity], int]:
"""Страница видимых ЮЛ (по `name`, `id`) и общее число записей по фильтру.
`inn` — точное равенство (вызывающий уже сделал `strip`); `q` — подстрока `name`/`inn` без учёта регистра (R-4).
"""
filters: dict[str, Any] = {
"company_group_id": company_group_id,
"outside_group": outside_group,
"inn": inn,
"q": q,
"closed": closed,
}
page_stmt = (
_filtered_entities(select(LegalEntity), scope, **filters)
.order_by(LegalEntity.name, LegalEntity.id)
.limit(limit)
.offset(offset)
)
total_stmt = _filtered_entities(select(func.count(LegalEntity.id)), scope, **filters)
items = list((await session.execute(page_stmt)).scalars().all())
total = (await session.execute(total_stmt)).scalar_one()
return items, total
async def get_legal_entity(session: AsyncSession, scope: VisibilityScope, entity_id: uuid.UUID) -> LegalEntity | None:
"""ЮЛ по id; `None` и для несуществующего, и для невидимого (неразличимы для вызывающего)."""
stmt = legal_entities_visible(select(LegalEntity).where(LegalEntity.id == entity_id), scope)
return (await session.execute(stmt)).scalar_one_or_none()
async def get_legal_entity_by_inn(session: AsyncSession, inn: str) -> LegalEntity | None:
"""ЮЛ по ИНН без фильтра видимости — для проверки глобальной уникальности `inn`."""
return (await session.execute(select(LegalEntity).where(LegalEntity.inn == inn))).scalar_one_or_none()
async def legal_entity_crm_id_taken(
session: AsyncSession, crm_id: str, *, except_entity_id: uuid.UUID | None = None
) -> bool:
"""Есть ли у какого-либо ЮЛ (кроме `except_entity_id`) такой `crm_id` — без фильтра видимости."""
stmt = select(LegalEntity.id).where(LegalEntity.crm_id == crm_id)
if except_entity_id is not None:
stmt = stmt.where(LegalEntity.id != except_entity_id)
return (await session.execute(stmt.limit(1))).first() is not None
async def list_legal_entity_history(session: AsyncSession, entity_id: uuid.UUID) -> list[LegalEntityHistory]:
"""Снимки ЮЛ по убыванию `valid_from` (новые первыми, R-3)."""
stmt = (
select(LegalEntityHistory)
.where(LegalEntityHistory.legal_entity_id == entity_id)
.order_by(LegalEntityHistory.valid_from.desc(), LegalEntityHistory.id)
)
return list((await session.execute(stmt)).scalars().all())
async def current_memberships(
session: AsyncSession, entity_id: uuid.UUID
) -> list[tuple[RelatedGroupMember, RelatedGroup]]:
"""Текущие членства ЮЛ в ГСК/ГСЗ вместе с группой (для карточки), по виду и коду группы."""
stmt = (
select(RelatedGroupMember, RelatedGroup)
.join(RelatedGroup, RelatedGroup.id == RelatedGroupMember.related_group_id)
.where(RelatedGroupMember.legal_entity_id == entity_id, RelatedGroupMember.is_current.is_(True))
.order_by(RelatedGroupMember.kind, RelatedGroup.code)
)
return [(member, group) for member, group in (await session.execute(stmt)).all()]
async def close_memberships(
session: AsyncSession, entity_id: uuid.UUID, now: datetime.datetime
) -> list[RelatedGroupMember]:
"""Закрывает все текущие членства ЮЛ (SCD2: `valid_to = now`, `is_current = false`); возвращает закрытые."""
stmt = select(RelatedGroupMember).where(
RelatedGroupMember.legal_entity_id == entity_id, RelatedGroupMember.is_current.is_(True)
)
members = list((await session.execute(stmt)).scalars().all())
for member in members:
member.valid_to = now
member.is_current = False
await session.flush()
return members
def _current_members_count() -> Any:
"""Скалярный подзапрос: число текущих членов группы `RelatedGroup` внешнего запроса."""
return (
select(func.count(RelatedGroupMember.id))
.where(RelatedGroupMember.related_group_id == RelatedGroup.id, RelatedGroupMember.is_current.is_(True))
.correlate(RelatedGroup)
.scalar_subquery()
)
async def list_related_groups(session: AsyncSession, company_group_id: uuid.UUID) -> list[tuple[RelatedGroup, int]]:
"""ГСК/ГСЗ ГК (по `name`, `id`) с числом текущих членов; видимость ГК проверяет вызывающий."""
stmt = (
select(RelatedGroup, _current_members_count())
.where(RelatedGroup.company_group_id == company_group_id)
.order_by(RelatedGroup.name, RelatedGroup.id)
)
return [(group, count) for group, count in (await session.execute(stmt)).all()]
async def get_related_group(session: AsyncSession, scope: VisibilityScope, group_id: uuid.UUID) -> RelatedGroup | None:
"""ГСК/ГСЗ по id; видимость — через ГК (§10.1); `None` и для несуществующей, и для невидимой.
`CompanyGroup` присоединяется явно: `company_groups_visible` фильтрует по колонке ГК и без JOIN дала бы
декартово произведение (T4.1).
"""
stmt = (
select(RelatedGroup)
.join(CompanyGroup, CompanyGroup.id == RelatedGroup.company_group_id)
.where(RelatedGroup.id == group_id)
)
return (await session.execute(company_groups_visible(stmt, scope))).scalar_one_or_none()
async def count_current_members(session: AsyncSession, related_group_id: uuid.UUID) -> int:
"""Число текущих членов группы."""
stmt = select(func.count(RelatedGroupMember.id)).where(
RelatedGroupMember.related_group_id == related_group_id, RelatedGroupMember.is_current.is_(True)
)
return (await session.execute(stmt)).scalar_one()
async def has_current_members(session: AsyncSession, related_group_id: uuid.UUID) -> bool:
"""Есть ли у группы хотя бы один текущий член."""
return await count_current_members(session, related_group_id) > 0
async def current_membership_of_kind(
session: AsyncSession, entity_id: uuid.UUID, kind: RelatedGroupKind
) -> RelatedGroupMember | None:
"""Текущее членство ЮЛ в группе вида `kind` (не больше одного — частичный уникальный индекс v5.14)."""
stmt = select(RelatedGroupMember).where(
RelatedGroupMember.legal_entity_id == entity_id,
RelatedGroupMember.kind == kind,
RelatedGroupMember.is_current.is_(True),
)
return (await session.execute(stmt)).scalar_one_or_none()
async def current_membership_in_group(
session: AsyncSession, related_group_id: uuid.UUID, entity_id: uuid.UUID
) -> RelatedGroupMember | None:
"""Текущее членство ЮЛ в конкретной группе."""
stmt = select(RelatedGroupMember).where(
RelatedGroupMember.related_group_id == related_group_id,
RelatedGroupMember.legal_entity_id == entity_id,
RelatedGroupMember.is_current.is_(True),
)
return (await session.execute(stmt)).scalar_one_or_none()
async def current_members(
session: AsyncSession, related_group_id: uuid.UUID
) -> list[tuple[RelatedGroupMember, LegalEntity]]:
"""Текущие члены группы вместе с ЮЛ, по имени ЮЛ."""
stmt = (
select(RelatedGroupMember, LegalEntity)
.join(LegalEntity, LegalEntity.id == RelatedGroupMember.legal_entity_id)
.where(RelatedGroupMember.related_group_id == related_group_id, RelatedGroupMember.is_current.is_(True))
.order_by(LegalEntity.name, LegalEntity.id)
)
return [(member, entity) for member, entity in (await session.execute(stmt)).all()]
async def list_group_member_rows(session: AsyncSession, related_group_id: uuid.UUID) -> list[RelatedGroupMember]:
"""Все строки состава группы (текущие и закрытые), по `valid_from`."""
stmt = (
select(RelatedGroupMember)
.where(RelatedGroupMember.related_group_id == related_group_id)
.order_by(RelatedGroupMember.valid_from, RelatedGroupMember.id)
)
return list((await session.execute(stmt)).scalars().all())
async def delete_group_member_rows(session: AsyncSession, related_group_id: uuid.UUID) -> None:
"""Удаляет все строки состава группы (вызывать только для группы без текущих членов)."""
await session.execute(delete(RelatedGroupMember).where(RelatedGroupMember.related_group_id == related_group_id))
async def list_aliases(session: AsyncSession, entity_id: uuid.UUID) -> list[LegalEntityAlias]:
"""Алиасы ЮЛ по времени добавления."""
stmt = (
select(LegalEntityAlias)
.where(LegalEntityAlias.legal_entity_id == entity_id)
.order_by(LegalEntityAlias.created_at, LegalEntityAlias.id)
)
return list((await session.execute(stmt)).scalars().all())
async def find_alias_owner(session: AsyncSession, normalized: str) -> LegalEntityAlias | None:
"""Алиас с таким `normalized_alias` у любого ЮЛ — без фильтра видимости: уникальность глобальная (ANL-028)."""
stmt = (
select(LegalEntityAlias)
.where(LegalEntityAlias.normalized_alias == normalized)
.order_by(LegalEntityAlias.created_at, LegalEntityAlias.id)
.limit(1)
)
return (await session.execute(stmt)).scalar_one_or_none()
async def get_alias(session: AsyncSession, entity_id: uuid.UUID, alias_id: uuid.UUID) -> LegalEntityAlias | None:
"""Алиас по id, только если он принадлежит ЮЛ `entity_id`."""
stmt = select(LegalEntityAlias).where(
LegalEntityAlias.id == alias_id, LegalEntityAlias.legal_entity_id == entity_id
)
return (await session.execute(stmt)).scalar_one_or_none()
async def get_regional_office(session: AsyncSession, office_id: uuid.UUID) -> RegionalOffice | None:
return await session.get(RegionalOffice, office_id)
async def list_regional_offices(session: AsyncSession) -> list[RegionalOffice]:
"""Все филиалы по имени."""
return list((await session.execute(select(RegionalOffice).order_by(RegionalOffice.name))).scalars().all())