feat: «Основной РФ» объектом в ответах ЮЛ и ГК (#47)

В LegalEntityOut/Detail/HistoryOut добавлено regional_office, в CompanyGroupOut/HistoryOut — base_regional_office
(RegionalOfficeBrief {id, name, is_head_office} или null); *_id оставлены. Филиалы подгружаются одним батч-запросом
на набор id (списки, карточки, history без FK); неактивный филиал отдаётся. Тесты: совпадение с *_id, null,
число запросов списка не растёт, history — один запрос на филиалы.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
This commit is contained in:
Andrey Epifancev 2026-10-07 18:46:59 +04:00
parent 3b4f329f16
commit 9cceb84827
10 changed files with 319 additions and 39 deletions

View File

@ -223,6 +223,9 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб
`src/domain/policy.py`: пользователь головного офиса видит всё, пользователь филиала — ГК своего филиала, их ЮЛ и `src/domain/policy.py`: пользователь головного офиса видит всё, пользователь филиала — ГК своего филиала, их ЮЛ и
ГСК/ГСЗ и монозаёмщиков своего филиала. Объект вне видимости — **404 с тем же телом, что у несуществующего** (не 403). ГСК/ГСЗ и монозаёмщиков своего филиала. Объект вне видимости — **404 с тем же телом, что у несуществующего** (не 403).
Филиал в ответах ЮЛ и ГК приходит объектом рядом с `*_id`: `regional_office` (ЮЛ, их история) и `base_regional_office`
(ГК, их история) — `{id, name, is_head_office}` или `null`; неактивный филиал тоже отдаётся (имя нужно для истории).
| Метод и путь | Право | Что делает | | Метод и путь | Право | Что делает |
|---|---|---| |---|---|---|
| `GET /directories/regional-offices` | `DIRECTORY_VIEW` | филиалы (включая Головной офис) | | `GET /directories/regional-offices` | `DIRECTORY_VIEW` | филиалы (включая Головной офис) |

View File

@ -10,7 +10,6 @@ from fastapi import APIRouter, Depends, Query, status
from src.api.dependencies.auth import require from src.api.dependencies.auth import require
from src.api.dependencies.directories import CompanyGroupServiceDep from src.api.dependencies.directories import CompanyGroupServiceDep
from src.models.directories import CompanyGroup
from src.schemas.common import Page, PageParams from src.schemas.common import Page, PageParams
from src.schemas.directories import ( from src.schemas.directories import (
CompanyGroupCreate, CompanyGroupCreate,
@ -30,12 +29,6 @@ class CompanyGroupListQuery(CompanyGroupFilter, PageParams):
"""Query списка ГК: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут).""" """Query списка ГК: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут)."""
def _out(group: CompanyGroup, legal_entities_count: int) -> CompanyGroupOut:
out = CompanyGroupOut.model_validate(group)
out.legal_entities_count = legal_entities_count
return out
@router.get("", response_model=Page[CompanyGroupOut], dependencies=VIEW, summary="Список ГК") @router.get("", response_model=Page[CompanyGroupOut], dependencies=VIEW, summary="Список ГК")
async def list_company_groups( async def list_company_groups(
service: CompanyGroupServiceDep, service: CompanyGroupServiceDep,
@ -44,9 +37,7 @@ async def list_company_groups(
"""Видимые ГК: поиск `q` по name/crm_id/code, фильтр `is_archived`, пагинация, сортировка по имени.""" """Видимые ГК: поиск `q` по name/crm_id/code, фильтр `is_archived`, пагинация, сортировка по имени."""
page = PageParams(limit=query.limit, offset=query.offset) page = PageParams(limit=query.limit, offset=query.offset)
rows, total = await service.page(CompanyGroupFilter(q=query.q, is_archived=query.is_archived), page) rows, total = await service.page(CompanyGroupFilter(q=query.q, is_archived=query.is_archived), page)
return Page[CompanyGroupOut]( return Page[CompanyGroupOut](items=await service.to_outs(rows), total=total, limit=page.limit, offset=page.offset)
items=[_out(group, count) for group, count in rows], total=total, limit=page.limit, offset=page.offset
)
@router.post( @router.post(
@ -54,13 +45,13 @@ async def list_company_groups(
) )
async def create_company_group(data: CompanyGroupCreate, service: CompanyGroupServiceDep) -> CompanyGroupOut: async def create_company_group(data: CompanyGroupCreate, service: CompanyGroupServiceDep) -> CompanyGroupOut:
"""Создаёт ГК с кодом `GK-NNNNNN`; без филиала пользователь филиала закрепляет ГК за своим.""" """Создаёт ГК с кодом `GK-NNNNNN`; без филиала пользователь филиала закрепляет ГК за своим."""
return _out(await service.create(data), 0) return await service.to_out(await service.create(data), 0)
@router.get("/{group_id}", response_model=CompanyGroupOut, dependencies=VIEW, summary="Карточка ГК") @router.get("/{group_id}", response_model=CompanyGroupOut, dependencies=VIEW, summary="Карточка ГК")
async def get_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut: async def get_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut:
group = await service.get(group_id) group = await service.get(group_id)
return _out(group, await service.legal_entities_count(group.id)) return await service.to_out(group, await service.legal_entities_count(group.id))
@router.patch("/{group_id}", response_model=CompanyGroupOut, dependencies=EDIT, summary="Изменить ГК") @router.patch("/{group_id}", response_model=CompanyGroupOut, dependencies=EDIT, summary="Изменить ГК")
@ -69,19 +60,19 @@ async def update_company_group(
) -> CompanyGroupOut: ) -> CompanyGroupOut:
"""Меняет `name`/`crm_id`/`base_regional_office_id`; прежние значения уходят в историю.""" """Меняет `name`/`crm_id`/`base_regional_office_id`; прежние значения уходят в историю."""
group = await service.update(group_id, data) group = await service.update(group_id, data)
return _out(group, await service.legal_entities_count(group.id)) return await service.to_out(group, await service.legal_entities_count(group.id))
@router.post("/{group_id}/archive", response_model=CompanyGroupOut, dependencies=EDIT, summary="Архивировать ГК") @router.post("/{group_id}/archive", response_model=CompanyGroupOut, dependencies=EDIT, summary="Архивировать ГК")
async def archive_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut: async def archive_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut:
group = await service.set_archived(group_id, True) # noqa: FBT003 group = await service.set_archived(group_id, True) # noqa: FBT003
return _out(group, await service.legal_entities_count(group.id)) return await service.to_out(group, await service.legal_entities_count(group.id))
@router.post("/{group_id}/unarchive", response_model=CompanyGroupOut, dependencies=EDIT, summary="Вернуть ГК из архива") @router.post("/{group_id}/unarchive", response_model=CompanyGroupOut, dependencies=EDIT, summary="Вернуть ГК из архива")
async def unarchive_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut: async def unarchive_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut:
group = await service.set_archived(group_id, False) # noqa: FBT003 group = await service.set_archived(group_id, False) # noqa: FBT003
return _out(group, await service.legal_entities_count(group.id)) return await service.to_out(group, await service.legal_entities_count(group.id))
@router.get( @router.get(
@ -91,4 +82,4 @@ async def unarchive_company_group(group_id: uuid.UUID, service: CompanyGroupServ
summary="История ГК (новые первыми)", summary="История ГК (новые первыми)",
) )
async def company_group_history(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> list[CompanyGroupHistoryOut]: async def company_group_history(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> list[CompanyGroupHistoryOut]:
return [CompanyGroupHistoryOut.model_validate(row) for row in await service.history(group_id)] return await service.history(group_id)

View File

@ -11,7 +11,6 @@ from fastapi import APIRouter, Depends, Query, status
from src.api.dependencies.auth import require from src.api.dependencies.auth import require
from src.api.dependencies.directories import AliasServiceDep, LegalEntityServiceDep from src.api.dependencies.directories import AliasServiceDep, LegalEntityServiceDep
from src.models.directories import LegalEntity
from src.schemas.common import Page, PageParams from src.schemas.common import Page, PageParams
from src.schemas.directories import ( from src.schemas.directories import (
AliasCreate, AliasCreate,
@ -37,13 +36,6 @@ class LegalEntityListQuery(LegalEntityFilter, PageParams):
"""Query списка ЮЛ: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут).""" """Query списка ЮЛ: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут)."""
def _out(entity: LegalEntity, documents_count: int) -> LegalEntityOut:
"""Конвертирует ORM ЮЛ в LegalEntityOut с подсчитанным количеством документов."""
out = LegalEntityOut.model_validate(entity)
out.documents_count = documents_count
return out
@router.get("", response_model=Page[LegalEntityOut], dependencies=VIEW, summary="Список ЮЛ/ИП") @router.get("", response_model=Page[LegalEntityOut], dependencies=VIEW, summary="Список ЮЛ/ИП")
async def list_legal_entities( async def list_legal_entities(
service: LegalEntityServiceDep, service: LegalEntityServiceDep,
@ -56,7 +48,7 @@ async def list_legal_entities(
entity_ids = [entity.id for entity in items] entity_ids = [entity.id for entity in items]
counts = await service.documents_count_by_entities(entity_ids) counts = await service.documents_count_by_entities(entity_ids)
return Page[LegalEntityOut]( return Page[LegalEntityOut](
items=[_out(entity, counts.get(entity.id, 0)) for entity in items], items=await service.to_outs(items, counts),
total=total, total=total,
limit=page.limit, limit=page.limit,
offset=page.offset, offset=page.offset,
@ -69,7 +61,7 @@ async def list_legal_entities(
async def create_legal_entity(data: LegalEntityCreate, service: LegalEntityServiceDep) -> LegalEntityOut: async def create_legal_entity(data: LegalEntityCreate, service: LegalEntityServiceDep) -> LegalEntityOut:
"""Создаёт ЮЛ/ИП; без `company_group_id` — монозаёмщик; ИНН: ЮЛ — 10 цифр, ИП — 12.""" """Создаёт ЮЛ/ИП; без `company_group_id` — монозаёмщик; ИНН: ЮЛ — 10 цифр, ИП — 12."""
entity = await service.create(data) entity = await service.create(data)
return _out(entity, 0) return await service.to_out(entity, 0)
@router.get("/{entity_id}", response_model=LegalEntityDetail, dependencies=VIEW, summary="Карточка ЮЛ/ИП") @router.get("/{entity_id}", response_model=LegalEntityDetail, dependencies=VIEW, summary="Карточка ЮЛ/ИП")
@ -86,7 +78,7 @@ async def update_legal_entity(
) -> LegalEntityOut: ) -> LegalEntityOut:
"""Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`; прежние значения уходят в историю (`UPDATE`).""" """Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`; прежние значения уходят в историю (`UPDATE`)."""
entity = await service.update(entity_id, data) entity = await service.update(entity_id, data)
return _out(entity, await service.documents_count(entity_id)) return await service.to_out(entity, await service.documents_count(entity_id))
@router.post("/{entity_id}/move", response_model=LegalEntityOut, dependencies=EDIT, summary="Перевести ЮЛ в другую ГК") @router.post("/{entity_id}/move", response_model=LegalEntityOut, dependencies=EDIT, summary="Перевести ЮЛ в другую ГК")
@ -95,7 +87,7 @@ async def move_legal_entity(
) -> LegalEntityOut: ) -> LegalEntityOut:
"""Переводит ЮЛ в другую ГК с `effective_date` (≥ сегодня); текущие членства ГСК/ГСЗ закрываются.""" """Переводит ЮЛ в другую ГК с `effective_date` (≥ сегодня); текущие членства ГСК/ГСЗ закрываются."""
entity = await service.move(entity_id, data) entity = await service.move(entity_id, data)
return _out(entity, await service.documents_count(entity_id)) return await service.to_out(entity, await service.documents_count(entity_id))
@router.post( @router.post(
@ -107,7 +99,7 @@ async def withdraw_legal_entity(
"""`CLOSE` — закрытие (`close_date = effective_date`), `REMOVE_FROM_GROUP` — вывод; ГК снимается, членства """`CLOSE` — закрытие (`close_date = effective_date`), `REMOVE_FROM_GROUP` — вывод; ГК снимается, членства
закрываются.""" закрываются."""
entity = await service.withdraw(entity_id, data) entity = await service.withdraw(entity_id, data)
return _out(entity, await service.documents_count(entity_id)) return await service.to_out(entity, await service.documents_count(entity_id))
@router.get( @router.get(
@ -117,7 +109,7 @@ async def withdraw_legal_entity(
summary="История ЮЛ/ИП (новые первыми)", summary="История ЮЛ/ИП (новые первыми)",
) )
async def legal_entity_history(entity_id: uuid.UUID, service: LegalEntityServiceDep) -> list[LegalEntityHistoryOut]: async def legal_entity_history(entity_id: uuid.UUID, service: LegalEntityServiceDep) -> list[LegalEntityHistoryOut]:
return [LegalEntityHistoryOut.model_validate(row) for row in await service.history(entity_id)] return await service.history(entity_id)
@router.post( @router.post(

View File

@ -7,6 +7,7 @@ SQL без бизнес-правил; видимость — `src.domain.policy`
import datetime import datetime
import uuid import uuid
from collections.abc import Iterable
from typing import Any from typing import Any
from sqlalchemy import Select, Sequence, and_, delete, func, or_, select from sqlalchemy import Select, Sequence, and_, delete, func, or_, select
@ -415,6 +416,17 @@ async def get_regional_office(session: AsyncSession, office_id: uuid.UUID) -> Re
return await session.get(RegionalOffice, office_id) return await session.get(RegionalOffice, office_id)
async def regional_offices_by_ids(
session: AsyncSession, office_ids: Iterable[uuid.UUID | None]
) -> dict[uuid.UUID, RegionalOffice]:
"""Филиалы по набору id одним запросом (включая неактивные); `None` и дубли игнорируются."""
ids = {office_id for office_id in office_ids if office_id is not None}
if not ids:
return {}
rows = (await session.execute(select(RegionalOffice).where(RegionalOffice.id.in_(ids)))).scalars().all()
return {office.id: office for office in rows}
async def list_regional_offices(session: AsyncSession) -> list[RegionalOffice]: async def list_regional_offices(session: AsyncSession) -> list[RegionalOffice]:
"""Все филиалы по имени.""" """Все филиалы по имени."""
return list((await session.execute(select(RegionalOffice).order_by(RegionalOffice.name))).scalars().all()) return list((await session.execute(select(RegionalOffice).order_by(RegionalOffice.name))).scalars().all())

View File

@ -99,6 +99,9 @@ class CompanyGroupOut(BaseModel):
crm_id: str | None crm_id: str | None
name: str name: str
base_regional_office_id: uuid.UUID | None base_regional_office_id: uuid.UUID | None
base_regional_office: RegionalOfficeBrief | None = (
None # объект филиала; неактивный тоже отдаётся (имя для истории)
)
is_archived: bool is_archived: bool
valid_from: datetime.datetime valid_from: datetime.datetime
legal_entities_count: int = 0 legal_entities_count: int = 0
@ -115,6 +118,7 @@ class CompanyGroupHistoryOut(BaseModel):
crm_id: str | None crm_id: str | None
name: str | None name: str | None
base_regional_office_id: uuid.UUID | None base_regional_office_id: uuid.UUID | None
base_regional_office: RegionalOfficeBrief | None = None # филиал снимка (в history нет FK — батч по id)
valid_from: datetime.datetime | None valid_from: datetime.datetime | None
valid_to: datetime.datetime | None valid_to: datetime.datetime | None
changed_by: uuid.UUID | None changed_by: uuid.UUID | None
@ -222,6 +226,7 @@ class LegalEntityOut(BaseModel):
name: str name: str
company_group_id: uuid.UUID | None company_group_id: uuid.UUID | None
regional_office_id: uuid.UUID | None regional_office_id: uuid.UUID | None
regional_office: RegionalOfficeBrief | None = None # объект филиала; неактивный тоже отдаётся
close_date: datetime.date | None close_date: datetime.date | None
valid_from: datetime.datetime valid_from: datetime.datetime
created_at: datetime.datetime | None created_at: datetime.datetime | None
@ -285,6 +290,7 @@ class LegalEntityHistoryOut(BaseModel):
name: str | None name: str | None
company_group_id: uuid.UUID | None company_group_id: uuid.UUID | None
regional_office_id: uuid.UUID | None regional_office_id: uuid.UUID | None
regional_office: RegionalOfficeBrief | None = None # филиал снимка (в history нет FK — батч по id)
close_date: datetime.date | None close_date: datetime.date | None
event_type: LeHistoryEvent | None event_type: LeHistoryEvent | None
effective_date: datetime.date | None effective_date: datetime.date | None

View File

@ -6,7 +6,7 @@
import datetime import datetime
import uuid import uuid
from collections.abc import Mapping from collections.abc import Iterable, Mapping, Sequence
from typing import Any from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -19,7 +19,14 @@ from src.models.directories import COMPANY_GROUP_CODE_SEQ, CompanyGroup, Company
from src.models.enums import AuditAction, AuditSection from src.models.enums import AuditAction, AuditSection
from src.repositories import directories as repo from src.repositories import directories as repo
from src.schemas.common import PageParams from src.schemas.common import PageParams
from src.schemas.directories import CompanyGroupCreate, CompanyGroupFilter, CompanyGroupUpdate from src.schemas.directories import (
CompanyGroupCreate,
CompanyGroupFilter,
CompanyGroupHistoryOut,
CompanyGroupOut,
CompanyGroupUpdate,
RegionalOfficeBrief,
)
COMPANY_GROUP_NOT_FOUND = "ГК не найдена" # одинаково для чужой и несуществующей (spec directory-policy) COMPANY_GROUP_NOT_FOUND = "ГК не найдена" # одинаково для чужой и несуществующей (spec directory-policy)
COMPANY_GROUP_CODE_PREFIX = "GK" COMPANY_GROUP_CODE_PREFIX = "GK"
@ -149,14 +156,43 @@ class CompanyGroupService:
raise NotFoundError(COMPANY_GROUP_NOT_FOUND) raise NotFoundError(COMPANY_GROUP_NOT_FOUND)
return group return group
async def offices_by_ids(self, office_ids: Iterable[uuid.UUID | None]) -> dict[uuid.UUID, 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_out(self, group: CompanyGroup, legal_entities_count: int) -> CompanyGroupOut:
"""Ответ по одной ГК с объектом филиала."""
return (await self.to_outs([(group, legal_entities_count)]))[0]
async def to_outs(self, rows: Sequence[tuple[CompanyGroup, int]]) -> list[CompanyGroupOut]:
"""Ответы по набору ГК; филиалы — одним батч-запросом."""
offices = await self.offices_by_ids(group.base_regional_office_id for group, _ in rows)
result: list[CompanyGroupOut] = []
for group, count in rows:
out = CompanyGroupOut.model_validate(group)
out.legal_entities_count = count
out.base_regional_office = (
offices.get(group.base_regional_office_id) if group.base_regional_office_id else None
)
result.append(out)
return result
async def legal_entities_count(self, group_id: uuid.UUID) -> int: async def legal_entities_count(self, group_id: uuid.UUID) -> int:
"""Число ЮЛ ГК (для карточки).""" """Число ЮЛ ГК (для карточки)."""
return await repo.count_legal_entities(self._session, group_id) return await repo.count_legal_entities(self._session, group_id)
async def history(self, group_id: uuid.UUID) -> list[CompanyGroupHistory]: async def history(self, group_id: uuid.UUID) -> list[CompanyGroupHistoryOut]:
"""Снимки ГК по убыванию `valid_from`; видимость проверяется как для карточки.""" """Снимки ГК по убыванию `valid_from`; видимость — как для карточки; филиалы снимков — одним батчем."""
group = await self.get(group_id) group = await self.get(group_id)
return await repo.list_company_group_history(self._session, group.id) rows = await repo.list_company_group_history(self._session, group.id)
offices = await self.offices_by_ids(row.base_regional_office_id for row in rows)
result: list[CompanyGroupHistoryOut] = []
for row in rows:
out = CompanyGroupHistoryOut.model_validate(row)
out.base_regional_office = offices.get(row.base_regional_office_id) if row.base_regional_office_id else None
result.append(out)
return result
async def page(self, params: CompanyGroupFilter, page: PageParams) -> tuple[list[tuple[CompanyGroup, int]], int]: async def page(self, params: CompanyGroupFilter, page: PageParams) -> tuple[list[tuple[CompanyGroup, int]], int]:
"""Страница видимых ГК с числом ЮЛ и общее число по фильтру.""" """Страница видимых ГК с числом ЮЛ и общее число по фильтру."""

View File

@ -8,7 +8,7 @@
import datetime import datetime
import uuid import uuid
from collections.abc import Mapping from collections.abc import Iterable, Mapping, Sequence
from typing import Any from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -27,10 +27,13 @@ from src.schemas.directories import (
LegalEntityCreate, LegalEntityCreate,
LegalEntityDetail, LegalEntityDetail,
LegalEntityFilter, LegalEntityFilter,
LegalEntityHistoryOut,
LegalEntityMove, LegalEntityMove,
LegalEntityOut,
LegalEntityUpdate, LegalEntityUpdate,
LegalEntityWithdraw, LegalEntityWithdraw,
MembershipOut, MembershipOut,
RegionalOfficeBrief,
) )
from src.services.company_groups import COMPANY_GROUP_NOT_FOUND from src.services.company_groups import COMPANY_GROUP_NOT_FOUND
@ -243,6 +246,7 @@ class LegalEntityService:
"""Карточка: ЮЛ, текущие членства ГСК/ГСЗ и алиасы; невидимое и несуществующее — один и тот же 404.""" """Карточка: ЮЛ, текущие членства ГСК/ГСЗ и алиасы; невидимое и несуществующее — один и тот же 404."""
entity = await self._get_entity(entity_id) entity = await self._get_entity(entity_id)
detail = LegalEntityDetail.model_validate(entity) detail = LegalEntityDetail.model_validate(entity)
detail.regional_office = (await self.offices_by_ids([entity.regional_office_id])).get(entity.regional_office_id)
detail.memberships = [ detail.memberships = [
MembershipOut( MembershipOut(
related_group_id=group.id, related_group_id=group.id,
@ -256,10 +260,39 @@ class LegalEntityService:
detail.aliases = [AliasOut.model_validate(alias) for alias in await repo.list_aliases(self._session, entity.id)] detail.aliases = [AliasOut.model_validate(alias) for alias in await repo.list_aliases(self._session, entity.id)]
return detail return detail
async def history(self, entity_id: uuid.UUID) -> list[LegalEntityHistory]: async def history(self, entity_id: uuid.UUID) -> list[LegalEntityHistoryOut]:
"""Снимки ЮЛ по убыванию `valid_from` (R-3); видимость — как для карточки.""" """Снимки ЮЛ по убыванию `valid_from` (R-3); видимость — как для карточки; филиалы снимков — одним батчем."""
entity = await self._get_entity(entity_id) entity = await self._get_entity(entity_id)
return await repo.list_legal_entity_history(self._session, 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)
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)
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]: async def page(self, flt: LegalEntityFilter, page: PageParams) -> tuple[list[LegalEntity], int]:
"""Страница видимых ЮЛ и общее число по фильтру.""" """Страница видимых ЮЛ и общее число по фильтру."""

View File

@ -265,6 +265,7 @@ async def test_history_is_ordered_by_valid_from_desc(auth_client: AuthClientFact
"crm_id", "crm_id",
"name", "name",
"base_regional_office_id", "base_regional_office_id",
"base_regional_office",
"valid_from", "valid_from",
"valid_to", "valid_to",
"changed_by", "changed_by",

View File

@ -388,6 +388,7 @@ async def test_patch_writes_update_snapshot_and_audit(auth_client: AuthClientFac
"company_group_id", "company_group_id",
"regional_office_id", "regional_office_id",
"close_date", "close_date",
"regional_office",
"event_type", "event_type",
"effective_date", "effective_date",
"valid_from", "valid_from",

View File

@ -0,0 +1,205 @@
"""Основной РФ объектом в ответах ЮЛ и ГК (arm-core#47): `regional_office` / `base_regional_office`, без N+1."""
import datetime
from collections.abc import Iterator
from typing import Any
import pytest
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from src.common.clock import moscow_date
from src.models.directories import CompanyGroupHistory, LegalEntityHistory
from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_legal_entity, make_regional_office
HTTP_OK = 200
HTTP_CREATED = 201
SMALL = 2
ENTITIES = "/directories/legal-entities"
GROUPS = "/directories/company-groups"
KA_GO = "ka_go@armka.local"
def _brief(office: Any) -> dict[str, Any]:
return {"id": str(office.id), "name": office.name, "is_head_office": office.is_head_office}
@pytest.fixture
def sql_log(db_engine: AsyncEngine) -> Iterator[list[str]]:
statements: list[str] = []
def record(_conn: Any, _cursor: Any, statement: str, *_rest: Any) -> None:
statements.append(statement)
event.listen(db_engine.sync_engine, "before_cursor_execute", record)
try:
yield statements
finally:
event.remove(db_engine.sync_engine, "before_cursor_execute", record)
def _office_selects(statements: list[str]) -> int:
return sum(1 for s in statements if s.lstrip().upper().startswith("SELECT") and "FROM dkb.regional_office" in s)
@pytest.mark.integration
async def test_legal_entity_responses_carry_office_object(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
office = await make_regional_office(seeded, name="Филиал Объект")
other = await make_regional_office(seeded, name="Филиал Другой")
group = await make_company_group(seeded, name="ГК Объект", office=office)
entity = await make_legal_entity(seeded, name="ООО Объект", group=group, office=office)
new_group = await make_company_group(seeded, name="ГК Новая цель", office=other)
mono = await make_legal_entity(seeded, name="ООО Без филиала")
client = await auth_client(KA_GO)
card = (await client.get(f"{ENTITIES}/{entity.id}")).json()
assert card["regional_office"] == _brief(office)
assert card["regional_office_id"] == str(office.id)
no_office = (await client.get(f"{ENTITIES}/{mono.id}")).json()
assert no_office["regional_office"] is None and no_office["regional_office_id"] is None
listing = (await client.get(ENTITIES, params={"q": "ООО"})).json()["items"]
by_name = {item["name"]: item for item in listing}
assert by_name["ООО Объект"]["regional_office"] == _brief(office)
assert by_name["ООО Без филиала"]["regional_office"] is None
patched = await client.patch(f"{ENTITIES}/{entity.id}", json={"regional_office_id": str(other.id)})
assert patched.status_code == HTTP_OK, patched.text
assert patched.json()["regional_office"] == _brief(other)
history = (await client.get(f"{ENTITIES}/{entity.id}/history")).json()
assert history[0]["regional_office_id"] == str(office.id)
assert history[0]["regional_office"] == _brief(office) # филиал снимка, не текущий
move = await client.post(
f"{ENTITIES}/{entity.id}/move",
json={
"company_group_id": str(new_group.id),
"effective_date": moscow_date(datetime.datetime.now(datetime.UTC)).isoformat(),
},
)
assert move.status_code == HTTP_OK, move.text
assert move.json()["regional_office"] == _brief(other)
withdraw = await client.post(
f"{ENTITIES}/{entity.id}/withdraw",
json={
"event": "REMOVE_FROM_GROUP",
"effective_date": moscow_date(datetime.datetime.now(datetime.UTC)).isoformat(),
},
)
assert withdraw.status_code == HTTP_OK, withdraw.text
assert withdraw.json()["regional_office"] == _brief(other)
created = await client.post(
ENTITIES,
json={"inn": "7701234567", "name": "ООО Новое", "entity_type": "ЮЛ", "regional_office_id": str(office.id)},
)
assert created.status_code == HTTP_CREATED, created.text
assert created.json()["regional_office"] == _brief(office)
@pytest.mark.integration
async def test_inactive_office_is_still_returned(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
office = await make_regional_office(seeded, name="Филиал Закрытый")
office.is_active = False
await seeded.flush()
group = await make_company_group(seeded, name="ГК Закрытая", office=office)
entity = await make_legal_entity(seeded, name="ООО Закрытое", group=group)
client = await auth_client(KA_GO)
assert (await client.get(f"{ENTITIES}/{entity.id}")).json()["regional_office"] == _brief(office)
assert (await client.get(f"{GROUPS}/{group.id}")).json()["base_regional_office"] == _brief(office)
@pytest.mark.integration
async def test_company_group_responses_carry_office_object(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
office = await make_regional_office(seeded, name="Филиал ГК")
other = await make_regional_office(seeded, name="Филиал ГК 2")
group = await make_company_group(seeded, name="ГК Один", office=office)
bare = await make_company_group(seeded, name="ГК Два")
client = await auth_client(KA_GO)
card = (await client.get(f"{GROUPS}/{group.id}")).json()
assert card["base_regional_office"] == _brief(office) and card["base_regional_office_id"] == str(office.id)
assert (await client.get(f"{GROUPS}/{bare.id}")).json()["base_regional_office"] is None
items = {i["name"]: i for i in (await client.get(GROUPS, params={"q": "ГК "})).json()["items"]}
assert items["ГК Один"]["base_regional_office"] == _brief(office)
assert items["ГК Два"]["base_regional_office"] is None
patched = await client.patch(f"{GROUPS}/{group.id}", json={"base_regional_office_id": str(other.id)})
assert patched.status_code == HTTP_OK, patched.text
assert patched.json()["base_regional_office"] == _brief(other)
for action in ("archive", "unarchive"):
resp = await client.post(f"{GROUPS}/{group.id}/{action}")
assert resp.json()["base_regional_office"] == _brief(other)
created = await client.post(GROUPS, json={"name": "ГК Новая", "base_regional_office_id": str(office.id)})
assert created.status_code == HTTP_CREATED, created.text
assert created.json()["base_regional_office"] == _brief(office)
history = (await client.get(f"{GROUPS}/{group.id}/history")).json()
assert history[0]["base_regional_office_id"] == str(office.id)
assert history[0]["base_regional_office"] == _brief(office)
@pytest.mark.integration
async def test_history_loads_offices_in_one_query(
auth_client: AuthClientFactory, seeded: AsyncSession, sql_log: list[str]
) -> None:
offices = [await make_regional_office(seeded, name=f"Филиал история {n}") for n in range(4)]
group = await make_company_group(seeded, name="ГК История", office=offices[0])
entity = await make_legal_entity(seeded, name="ООО История", group=group, office=offices[0])
now = datetime.datetime.now(datetime.UTC)
for n, office in enumerate(offices):
stamp = now - datetime.timedelta(days=n + 1)
seeded.add(
CompanyGroupHistory(
company_group_id=group.id, name="x", base_regional_office_id=office.id, valid_from=stamp, valid_to=now
)
)
seeded.add(
LegalEntityHistory(
legal_entity_id=entity.id, name="x", regional_office_id=office.id, valid_from=stamp, valid_to=now
)
)
await seeded.flush()
client = await auth_client(KA_GO)
sql_log.clear()
rows = (await client.get(f"{ENTITIES}/{entity.id}/history")).json()
assert {r["regional_office"]["name"] for r in rows} == {o.name for o in offices}
assert _office_selects(sql_log) == 1
sql_log.clear()
rows = (await client.get(f"{GROUPS}/{group.id}/history")).json()
assert {r["base_regional_office"]["name"] for r in rows} == {o.name for o in offices}
assert _office_selects(sql_log) == 1
@pytest.mark.integration
async def test_lists_do_not_grow_queries_with_rows(
auth_client: AuthClientFactory, seeded: AsyncSession, sql_log: list[str]
) -> None:
client = await auth_client(KA_GO)
await client.get(ENTITIES) # прогрев (первый запрос сессии тянет лишнее)
await client.get(GROUPS)
total_before: dict[str, int] = {}
for size in (2, 12):
for n in range(size):
office = await make_regional_office(seeded, name=f"Филиал список {size}-{n}")
group = await make_company_group(seeded, name=f"ГК список {size}-{n}", office=office)
await make_legal_entity(seeded, name=f"ООО список {size}-{n}", group=group, office=office)
for url, key in ((ENTITIES, "regional_office"), (GROUPS, "base_regional_office")):
sql_log.clear()
response = await client.get(url, params={"q": "список", "limit": 200})
assert response.status_code == HTTP_OK, response.text
items = response.json()["items"]
assert all(item[key] is not None for item in items)
total = len(sql_log)
if size == SMALL:
total_before[url] = total
else:
assert len(items) == 14 # noqa: PLR2004 - 2 + 12
assert total == total_before[url]
assert _office_selects(sql_log) == 1