feat: имя ГК (company_group_name) в ответах ЮЛ (#67)

В LegalEntityOut (а значит и LegalEntityDetail) добавлено company_group_name: текущее имя ГК company_group_id,
архивной тоже; у монозаёмщика null. Есть во всех ответах /directories/legal-entities: список, карточка, POST,
PATCH, move, withdraw; в снимках истории нет. Имена набора ЮЛ подгружаются одним батч-запросом
(repositories.directories.company_group_names_by_ids), число запросов списка от числа строк не зависит.
Спека directories-api и README дополнены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-08 17:01:25 +04:00
parent 631a80d533
commit 64be2f2b5c
6 changed files with 140 additions and 3 deletions

View File

@ -225,6 +225,8 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб
Филиал в ответах ЮЛ и ГК приходит объектом рядом с `*_id`: `regional_office` (ЮЛ, их история) и `base_regional_office`
(ГК, их история) — `{id, name, is_head_office}` или `null`; неактивный филиал тоже отдаётся (имя нужно для истории).
Имя ГК в ответах ЮЛ (список, карточка, `POST`/`PATCH`/`move`/`withdraw`; не в истории) — `company_group_name` рядом с
`company_group_id`: текущее имя ГК, архивной тоже; у монозаёмщика `null` (arm-core#67).
| Метод и путь | Право | Что делает |
|---|---|---|

View File

@ -431,6 +431,17 @@ async def regional_offices_by_ids(
return {office.id: office for office in rows}
async def company_group_names_by_ids(
session: AsyncSession, group_ids: Iterable[uuid.UUID | None]
) -> dict[uuid.UUID, str]:
"""Имена ГК по набору id одним запросом (включая архивные); `None` и дубли игнорируются."""
ids = {group_id for group_id in group_ids if group_id is not None}
if not ids:
return {}
rows = (await session.execute(select(CompanyGroup.id, CompanyGroup.name).where(CompanyGroup.id.in_(ids)))).all()
return {group_id: name for group_id, name in rows}
async def list_regional_offices(session: AsyncSession) -> list[RegionalOffice]:
"""Все филиалы по имени."""
return list((await session.execute(select(RegionalOffice).order_by(RegionalOffice.name))).scalars().all())

View File

@ -229,6 +229,7 @@ class LegalEntityOut(BaseModel):
entity_type: EntityType | None
name: str
company_group_id: uuid.UUID | None
company_group_name: str | None = None # текущее имя ГК (архивной тоже); у монозаёмщика — null
regional_office_id: uuid.UUID | None
regional_office: RegionalOfficeBrief | None = None # объект филиала; неактивный тоже отдаётся
close_date: datetime.date | None

View File

@ -252,6 +252,9 @@ class LegalEntityService:
entity = await self._get_entity(entity_id)
detail = LegalEntityDetail.model_validate(entity)
detail.regional_office = (await self.offices_by_ids([entity.regional_office_id])).get(entity.regional_office_id)
if entity.company_group_id is not None:
names = await repo.company_group_names_by_ids(self._session, [entity.company_group_id])
detail.company_group_name = names.get(entity.company_group_id)
detail.memberships = [
MembershipOut(
related_group_id=group.id,
@ -285,18 +288,21 @@ class LegalEntityService:
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)
group_names = await repo.company_group_names_by_ids(self._session, (e.company_group_id for e 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)
if entity.company_group_id is not None:
out.company_group_name = group_names.get(entity.company_group_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]:

View File

@ -0,0 +1,116 @@
"""Имя ГК в ответах ЮЛ (arm-core#67): `company_group_name` рядом с `company_group_id`, без 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 tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_legal_entity
HTTP_OK = 200
HTTP_CREATED = 201
SMALL = 2
ENTITIES = "/directories/legal-entities"
KA_GO = "ka_go@armka.local"
@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 _group_selects(statements: list[str]) -> int:
return sum(1 for s in statements if s.lstrip().upper().startswith("SELECT") and "FROM dkb.company_group " in s)
@pytest.mark.integration
async def test_legal_entity_responses_carry_group_name(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group = await make_company_group(seeded, name="ГК Имя")
new_group = await make_company_group(seeded, name="ГК Имя новая")
entity = await make_legal_entity(seeded, name="ООО С группой", group=group)
mono = await make_legal_entity(seeded, name="ООО Монозаёмщик")
client = await auth_client(KA_GO)
today = moscow_date(datetime.datetime.now(datetime.UTC)).isoformat()
card = (await client.get(f"{ENTITIES}/{entity.id}")).json()
assert card["company_group_name"] == "ГК Имя"
assert card["company_group_id"] == str(group.id)
no_group = (await client.get(f"{ENTITIES}/{mono.id}")).json()
assert no_group["company_group_name"] is None and no_group["company_group_id"] is None
listing = (await client.get(ENTITIES, params={"q": "ООО"})).json()["items"]
by_name = {item["name"]: item for item in listing}
assert by_name["ООО С группой"]["company_group_name"] == "ГК Имя"
assert by_name["ООО Монозаёмщик"]["company_group_name"] is None
patched = await client.patch(f"{ENTITIES}/{entity.id}", json={"name": "ООО С группой плюс"})
assert patched.status_code == HTTP_OK, patched.text
assert patched.json()["company_group_name"] == "ГК Имя"
move = await client.post(
f"{ENTITIES}/{entity.id}/move", json={"company_group_id": str(new_group.id), "effective_date": today}
)
assert move.status_code == HTTP_OK, move.text
assert move.json()["company_group_name"] == "ГК Имя новая"
withdraw = await client.post(
f"{ENTITIES}/{entity.id}/withdraw", json={"event": "REMOVE_FROM_GROUP", "effective_date": today}
)
assert withdraw.status_code == HTTP_OK, withdraw.text
assert withdraw.json()["company_group_name"] is None
created = await client.post(
ENTITIES,
json={"inn": "7701234567", "name": "ООО Новое", "entity_type": "ЮЛ", "company_group_id": str(group.id)},
)
assert created.status_code == HTTP_CREATED, created.text
assert created.json()["company_group_name"] == "ГК Имя"
@pytest.mark.integration
async def test_archived_group_name_is_still_returned(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group = await make_company_group(seeded, name="ГК Архивная")
entity = await make_legal_entity(seeded, name="ООО В архивной", group=group)
client = await auth_client(KA_GO)
archived = await client.post(f"/directories/company-groups/{group.id}/archive")
assert archived.status_code == HTTP_OK, archived.text
assert (await client.get(f"{ENTITIES}/{entity.id}")).json()["company_group_name"] == "ГК Архивная"
@pytest.mark.integration
async def test_list_loads_group_names_in_one_query(
auth_client: AuthClientFactory, seeded: AsyncSession, sql_log: list[str]
) -> None:
client = await auth_client(KA_GO)
await client.get(ENTITIES) # прогрев (первый запрос сессии тянет лишнее)
total_before = 0
for size in (2, 12):
for n in range(size):
group = await make_company_group(seeded, name=f"ГК имя список {size}-{n}")
await make_legal_entity(seeded, name=f"ООО имя список {size}-{n}", group=group)
sql_log.clear()
response = await client.get(ENTITIES, params={"q": "имя список", "limit": 200})
assert response.status_code == HTTP_OK, response.text
items = response.json()["items"]
assert all(item["company_group_name"] is not None for item in items)
if size == SMALL:
total_before = len(sql_log)
else:
assert len(items) == 14 # noqa: PLR2004 - 2 + 12
assert len(sql_log) == total_before
assert _group_selects(sql_log) == 1

View File

@ -50,7 +50,8 @@ API справочников ядра: группы компаний (ГК), ю
`GET …` SHALL давать список с фильтрами `company_group_id`, `outside_group` (`company_group_id IS NULL`), `inn`
(точно), `q` (подстрока имени/ИНН/КПП), `closed` (по `close_date`), пагинацией; `GET …/{id}` — карточку с текущими
членствами ГСК/ГСЗ и алиасами; `GET …/{id}/history` — снимки. В списке и карточке SHALL быть `documents_count` — число
документов файлов ЮЛ, кроме файлов `SUPERSEDED`.
документов файлов ЮЛ, кроме файлов `SUPERSEDED`. Во всех ответах ЮЛ, кроме снимков истории, SHALL быть `company_group_name` —
текущее имя ГК `company_group_id` (архивной тоже) или `null` у монозаёмщика; имена набора ЮЛ — одним запросом.
#### Scenario: Неверная длина ИНН
- **WHEN** создаётся ИП с ИНН из 10 цифр