Merge pull request 'Список ЮЛ: текущие членства ГСК/ГСЗ в LegalEntityOut (#109)' (#110) from feat/109-le-list-memberships into main

Reviewed-on: #110
This commit is contained in:
aep 2026-10-13 17:58:22 +03:00
commit f5ddba7005
5 changed files with 181 additions and 31 deletions

View File

@ -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(

View File

@ -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] = []

View File

@ -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)

View File

@ -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

View File

@ -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`