From 4973e47290529e95c55577927ad0dc07bea1dacb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 18:06:02 +0400 Subject: [PATCH] =?UTF-8?q?feat(directories):=20=D1=82=D0=B5=D0=BA=D1=83?= =?UTF-8?q?=D1=89=D0=B8=D0=B5=20=D1=87=D0=BB=D0=B5=D0=BD=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D0=B0=20=D0=93=D0=A1=D0=9A/=D0=93=D0=A1=D0=97=20=D0=B2=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B5=20=D0=AE=D0=9B=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `memberships` перенесено из `LegalEntityDetail` в `LegalEntityOut`: поле приходит в списке `GET /directories/legal-entities`, в ответах `move`/`withdraw` (после них — `[]`) и, как раньше, в карточке. Членства набора ЮЛ читаются одним запросом (`current_memberships_by_entities`), без N+1. Нужно карточке ГК для фильтра ЮЛ по ГСК/ГСЗ. Co-Authored-By: Claude Opus 5.5 --- api/src/repositories/directories.py | 19 ++-- api/src/schemas/directories.py | 27 ++--- api/src/services/legal_entities.py | 32 ++++-- api/tests/test_api_le_list_memberships.py | 125 ++++++++++++++++++++++ openspec/specs/directories-api/spec.md | 9 +- 5 files changed, 181 insertions(+), 31 deletions(-) create mode 100644 api/tests/test_api_le_list_memberships.py diff --git a/api/src/repositories/directories.py b/api/src/repositories/directories.py index 3b6a7ef..8881ee7 100644 --- a/api/src/repositories/directories.py +++ b/api/src/repositories/directories.py @@ -232,17 +232,24 @@ async def list_legal_entity_history(session: AsyncSession, entity_id: uuid.UUID) return list((await session.execute(stmt)).scalars().all()) -async def current_memberships( - session: AsyncSession, entity_id: uuid.UUID -) -> list[tuple[RelatedGroupMember, RelatedGroup]]: - """Текущие членства ЮЛ в ГСК/ГСЗ вместе с группой (для карточки), по виду и коду группы.""" +async def current_memberships_by_entities( + session: AsyncSession, entity_ids: Iterable[uuid.UUID] +) -> dict[uuid.UUID, list[tuple[RelatedGroupMember, RelatedGroup]]]: + """Текущие членства ЮЛ в ГСК/ГСЗ вместе с группой по набору ЮЛ одним запросом (#109); у каждого ЮЛ — по виду и + коду группы; ЮЛ без членств в словаре нет.""" + ids = set(entity_ids) + if not ids: + return {} stmt = ( select(RelatedGroupMember, RelatedGroup) .join(RelatedGroup, RelatedGroup.id == RelatedGroupMember.related_group_id) - .where(RelatedGroupMember.legal_entity_id == entity_id, RelatedGroupMember.is_current.is_(True)) + .where(RelatedGroupMember.legal_entity_id.in_(ids), RelatedGroupMember.is_current.is_(True)) .order_by(RelatedGroupMember.kind, RelatedGroup.code) ) - return [(member, group) for member, group in (await session.execute(stmt)).all()] + result: dict[uuid.UUID, list[tuple[RelatedGroupMember, RelatedGroup]]] = {} + for member, group in (await session.execute(stmt)).all(): + result.setdefault(member.legal_entity_id, []).append((member, group)) + return result async def close_memberships( diff --git a/api/src/schemas/directories.py b/api/src/schemas/directories.py index f0e60cb..0e0ea50 100644 --- a/api/src/schemas/directories.py +++ b/api/src/schemas/directories.py @@ -227,10 +227,21 @@ class LegalEntityWithdraw(BaseModel): comment: str | None = None +class MembershipOut(BaseModel): + """Текущее членство ЮЛ в ГСК/ГСЗ (список, карточка и ответы ЮЛ, #109).""" + + related_group_id: uuid.UUID + code: str + kind: RelatedGroupKind + name: str + valid_from: datetime.datetime + + class LegalEntityOut(BaseModel): """ЮЛ/ИП; `outside_group` — монозаёмщик (`company_group_id IS NULL`), `closed` — заполнен `close_date`. - `documents_count` — число документов файлов ЮЛ со статусом файла не SUPERSEDED (D-13). + `documents_count` — число документов файлов ЮЛ со статусом файла не SUPERSEDED (D-13); `memberships` — текущие + членства ГСК/ГСЗ (по виду и коду группы; после перевода и вывода из ГК — пусто, #109). """ model_config = ConfigDict(from_attributes=True) @@ -250,6 +261,7 @@ class LegalEntityOut(BaseModel): created_at: datetime.datetime | None updated_at: datetime.datetime | None documents_count: int = 0 + memberships: list[MembershipOut] = [] @computed_field # type: ignore[prop-decorator] @property @@ -262,16 +274,6 @@ class LegalEntityOut(BaseModel): return self.close_date is not None -class MembershipOut(BaseModel): - """Текущее членство ЮЛ в ГСК/ГСЗ (для карточки ЮЛ).""" - - related_group_id: uuid.UUID - code: str - kind: RelatedGroupKind - name: str - valid_from: datetime.datetime - - class AliasCreate(BaseModel): """Тело `POST …/aliases`: `alias` сохраняется как есть (как в ОСВ), `normalized_alias` считает сервис (§3).""" @@ -290,9 +292,8 @@ class AliasOut(BaseModel): class LegalEntityDetail(LegalEntityOut): - """Карточка ЮЛ: текущие членства ГСК/ГСЗ и алиасы.""" + """Карточка ЮЛ: поля списка (в т. ч. `memberships`) и алиасы.""" - memberships: list[MembershipOut] = [] aliases: list[AliasOut] = [] diff --git a/api/src/services/legal_entities.py b/api/src/services/legal_entities.py index fa3a086..4ae5b79 100644 --- a/api/src/services/legal_entities.py +++ b/api/src/services/legal_entities.py @@ -255,16 +255,7 @@ class LegalEntityService: 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, - code=group.code, - kind=group.kind, - name=group.name, - valid_from=member.valid_from, - ) - for member, group in await repo.current_memberships(self._session, entity.id) - ] + detail.memberships = (await self.memberships_by_entities([entity.id])).get(entity.id, []) detail.aliases = [AliasOut.model_validate(alias) for alias in await repo.list_aliases(self._session, entity.id)] return detail @@ -287,15 +278,34 @@ class LegalEntityService: 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 memberships_by_entities(self, entity_ids: Iterable[uuid.UUID]) -> dict[uuid.UUID, list[MembershipOut]]: + """Текущие членства ГСК/ГСЗ по набору ЮЛ — один запрос на набор, без N+1 (#109).""" + rows = await repo.current_memberships_by_entities(self._session, entity_ids) + return { + entity_id: [ + MembershipOut( + related_group_id=group.id, + code=group.code, + kind=group.kind, + name=group.name, + valid_from=member.valid_from, + ) + for member, group in pairs + ] + for entity_id, pairs in rows.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)) + memberships = await self.memberships_by_entities(entity.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) + out.memberships = memberships.get(entity.id, []) if entity.company_group_id is not None: out.company_group_name = group_names.get(entity.company_group_id) result.append(out) diff --git a/api/tests/test_api_le_list_memberships.py b/api/tests/test_api_le_list_memberships.py new file mode 100644 index 0000000..47f0d3c --- /dev/null +++ b/api/tests/test_api_le_list_memberships.py @@ -0,0 +1,125 @@ +"""Текущие членства ГСК/ГСЗ в ответах ЮЛ (arm-core#109): `memberships` в списке, `move`, `withdraw`, без 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.enums import RelatedGroupKind +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_legal_entity, make_membership, make_related_group + +HTTP_OK = 200 +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 _member_selects(statements: list[str]) -> int: + return sum( + 1 for s in statements if s.lstrip().upper().startswith("SELECT") and "FROM dkb.related_group_member " in s + ) + + +def _today() -> str: + return moscow_date(datetime.datetime.now(datetime.UTC)).isoformat() + + +@pytest.mark.integration +async def test_list_carries_current_memberships(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group = await make_company_group(seeded, name="ГК Членства") + both = await make_legal_entity(seeded, name="ООО Членства обе", group=group) + only_gsz = await make_legal_entity(seeded, name="ООО Членства ГСЗ", group=group) + none = await make_legal_entity(seeded, name="ООО Членства нет", group=group) + gsk = await make_related_group(seeded, group=group, name="ГСК Членства") + gsz = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsz, name="ГСЗ Членства") + old_gsk = await make_related_group(seeded, group=group, name="ГСК Прежняя") + await make_membership(seeded, related_group=gsk, entity=both) + await make_membership(seeded, related_group=gsz, entity=both) + await make_membership(seeded, related_group=gsz, entity=only_gsz) + await make_membership(seeded, related_group=old_gsk, entity=none, is_current=False) + client = await auth_client(KA_GO) + + response = await client.get(ENTITIES, params={"company_group_id": str(group.id)}) + assert response.status_code == HTTP_OK, response.text + by_id = {item["id"]: item for item in response.json()["items"]} + + def groups(entity_id: Any) -> list[tuple[str, str, str, str]]: + return [(m["related_group_id"], m["code"], m["kind"], m["name"]) for m in by_id[str(entity_id)]["memberships"]] + + assert groups(both.id) == [ + (str(gsk.id), gsk.code, "gsk", "ГСК Членства"), + (str(gsz.id), gsz.code, "gsz", "ГСЗ Членства"), + ] + assert groups(only_gsz.id) == [(str(gsz.id), gsz.code, "gsz", "ГСЗ Членства")] + assert groups(none.id) == [] # закрытое членство не отдаётся + assert all(m["valid_from"] for m in by_id[str(both.id)]["memberships"]) + + +@pytest.mark.integration +async def test_move_and_withdraw_responses_have_no_memberships( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + group = await make_company_group(seeded, name="ГК Откуда членства") + new_group = await make_company_group(seeded, name="ГК Куда членства") + moved = await make_legal_entity(seeded, name="ООО Переезд членства", group=group) + withdrawn = await make_legal_entity(seeded, name="ООО Вывод членства", group=group) + gsk = await make_related_group(seeded, group=group, name="ГСК Откуда") + await make_membership(seeded, related_group=gsk, entity=moved) + await make_membership(seeded, related_group=gsk, entity=withdrawn) + client = await auth_client(KA_GO) + + move = await client.post( + f"{ENTITIES}/{moved.id}/move", json={"company_group_id": str(new_group.id), "effective_date": _today()} + ) + assert move.status_code == HTTP_OK, move.text + assert move.json()["memberships"] == [] + withdraw = await client.post( + f"{ENTITIES}/{withdrawn.id}/withdraw", json={"event": "REMOVE_FROM_GROUP", "effective_date": _today()} + ) + assert withdraw.status_code == HTTP_OK, withdraw.text + assert withdraw.json()["memberships"] == [] + + +@pytest.mark.integration +async def test_list_loads_memberships_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): + group = await make_company_group(seeded, name=f"ГК членства список {size}") + gsk = await make_related_group(seeded, group=group, name=f"ГСК список {size}") + for n in range(size): + entity = await make_legal_entity(seeded, name=f"ООО членства список {size}-{n}", group=group) + await make_membership(seeded, related_group=gsk, entity=entity) + 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(len(item["memberships"]) == 1 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 _member_selects(sql_log) == 1 diff --git a/openspec/specs/directories-api/spec.md b/openspec/specs/directories-api/spec.md index 844f8fc..46b6364 100644 --- a/openspec/specs/directories-api/spec.md +++ b/openspec/specs/directories-api/spec.md @@ -52,7 +52,10 @@ API справочников ядра: группы компаний (ГК), ю членствами ГСК/ГСЗ и алиасами; `GET …/{id}/history` — снимки. В списке и карточке SHALL быть `documents_count` — число документов файлов ЮЛ, кроме файлов `SUPERSEDED`. Во всех ответах ЮЛ, кроме снимков истории, SHALL быть `company_group_name` — текущее имя ГК `company_group_id` (архивной тоже) или `null` у монозаёмщика; имена набора ЮЛ — одним запросом. -Ответ `POST` и `PATCH` — карточка (`LegalEntityDetail`: поля списка + `memberships`, `aliases`); поля формы +Во всех ответах ЮЛ, кроме снимков истории, SHALL быть `memberships` — текущие членства ЮЛ в ГСК/ГСЗ +(`related_group_id`, `code`, `kind`, `name`, `valid_from`; по виду и коду группы; закрытые не отдаются; нет — `[]`); +членства набора ЮЛ — одним запросом (#109: фильтр ЮЛ по ГСК/ГСЗ в карточке ГК без запроса карточки каждого ЮЛ). +Ответ `POST` и `PATCH` — карточка (`LegalEntityDetail`: поля списка + `aliases`); поля формы `aliases`, `gsk_id`, `gsz_id` — требование «Форма ЮЛ одним запросом». #### Scenario: Неверная длина ИНН @@ -71,6 +74,10 @@ API справочников ядра: группы компаний (ГК), ю - **WHEN** ЮЛ создано без `company_group_id` - **THEN** оно отдаётся в списке с `outside_group = true` и видно пользователям филиала `regional_office_id` +#### Scenario: Членства в списке +- **WHEN** ЮЛ ГК состоит в ГСК и ГСЗ, другое ЮЛ той же ГК — только в закрытом членстве ГСК +- **THEN** в `GET …?company_group_id=…` у первого `memberships` — ГСК и ГСЗ, у второго — `[]` + #### Scenario: Число документов - **WHEN** у монозаёмщика файл с книгой НПЗ (ББ и ОФР) и заменённый файл с одной ОСВ - **THEN** `documents_count = 2`