diff --git a/README.md b/README.md index a06bb15..abf7ba8 100644 --- a/README.md +++ b/README.md @@ -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). | Метод и путь | Право | Что делает | |---|---|---| diff --git a/api/src/repositories/directories.py b/api/src/repositories/directories.py index 2f57836..3b6a7ef 100644 --- a/api/src/repositories/directories.py +++ b/api/src/repositories/directories.py @@ -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()) diff --git a/api/src/schemas/directories.py b/api/src/schemas/directories.py index 30b387f..b670f05 100644 --- a/api/src/schemas/directories.py +++ b/api/src/schemas/directories.py @@ -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 diff --git a/api/src/services/legal_entities.py b/api/src/services/legal_entities.py index 5d64d96..fa3a086 100644 --- a/api/src/services/legal_entities.py +++ b/api/src/services/legal_entities.py @@ -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]: diff --git a/api/tests/test_api_company_group_name.py b/api/tests/test_api_company_group_name.py new file mode 100644 index 0000000..c7c7196 --- /dev/null +++ b/api/tests/test_api_company_group_name.py @@ -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 diff --git a/openspec/specs/directories-api/spec.md b/openspec/specs/directories-api/spec.md index c3a1574..4dcd4c3 100644 --- a/openspec/specs/directories-api/spec.md +++ b/openspec/specs/directories-api/spec.md @@ -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 цифр