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:
parent
631a80d533
commit
64be2f2b5c
@ -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).
|
||||
|
||||
| Метод и путь | Право | Что делает |
|
||||
|---|---|---|
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]:
|
||||
|
||||
116
api/tests/test_api_company_group_name.py
Normal file
116
api/tests/test_api_company_group_name.py
Normal 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
|
||||
@ -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 цифр
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user