Merge pull request 'Список ЮЛ: текущие членства ГСК/ГСЗ в LegalEntityOut (#109)' (#110) from feat/109-le-list-memberships into main
Reviewed-on: #110
This commit is contained in:
commit
f5ddba7005
@ -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(
|
||||
|
||||
@ -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] = []
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
125
api/tests/test_api_le_list_memberships.py
Normal file
125
api/tests/test_api_le_list_memberships.py
Normal 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
|
||||
@ -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`
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user