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:
parent
3b4f329f16
commit
9cceb84827
@ -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` | филиалы (включая Головной офис) |
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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]:
|
||||||
"""Страница видимых ГК с числом ЮЛ и общее число по фильтру."""
|
"""Страница видимых ГК с числом ЮЛ и общее число по фильтру."""
|
||||||
|
|||||||
@ -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]:
|
||||||
"""Страница видимых ЮЛ и общее число по фильтру."""
|
"""Страница видимых ЮЛ и общее число по фильтру."""
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
205
api/tests/test_api_regional_office_object.py
Normal file
205
api/tests/test_api_regional_office_object.py
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user