feat(directories): documents_count в списке и карточке ЮЛ (D-13)
Добавлено поле documents_count в LegalEntityOut, содержащее число документов файлов ЮЛ со статусом не SUPERSEDED. Функция documents_count_by_entity в репозитории получает счётчики одним подзапросом для эффективности. Используется в списке и карточке через сервис. Тест проверяет, что заменённые файлы не учитываются в подсчёте. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
58f039a91d
commit
6078532982
@ -36,6 +36,13 @@ class LegalEntityListQuery(LegalEntityFilter, PageParams):
|
||||
"""Query списка ЮЛ: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут)."""
|
||||
|
||||
|
||||
def _out(entity, documents_count: int = 0) -> LegalEntityOut:
|
||||
"""Конвертирует ORM ЮЛ в LegalEntityOut с подсчитанным количеством документов."""
|
||||
out = LegalEntityOut.model_validate(entity)
|
||||
out.documents_count = documents_count
|
||||
return out
|
||||
|
||||
|
||||
@router.get("", response_model=Page[LegalEntityOut], dependencies=VIEW, summary="Список ЮЛ/ИП")
|
||||
async def list_legal_entities(
|
||||
service: LegalEntityServiceDep,
|
||||
@ -45,8 +52,10 @@ async def list_legal_entities(
|
||||
page = PageParams(limit=query.limit, offset=query.offset)
|
||||
flt = LegalEntityFilter.model_validate(query.model_dump(include=set(LegalEntityFilter.model_fields)))
|
||||
items, total = await service.page(flt, page)
|
||||
entity_ids = [entity.id for entity in items]
|
||||
counts = await service.documents_count_by_entities(entity_ids)
|
||||
return Page[LegalEntityOut](
|
||||
items=[LegalEntityOut.model_validate(entity) for entity in items],
|
||||
items=[_out(entity, counts.get(entity.id, 0)) for entity in items],
|
||||
total=total,
|
||||
limit=page.limit,
|
||||
offset=page.offset,
|
||||
@ -58,13 +67,17 @@ async def list_legal_entities(
|
||||
)
|
||||
async def create_legal_entity(data: LegalEntityCreate, service: LegalEntityServiceDep) -> LegalEntityOut:
|
||||
"""Создаёт ЮЛ/ИП; без `company_group_id` — монозаёмщик; ИНН: ЮЛ — 10 цифр, ИП — 12."""
|
||||
return LegalEntityOut.model_validate(await service.create(data))
|
||||
entity = await service.create(data)
|
||||
return _out(entity, 0)
|
||||
|
||||
|
||||
@router.get("/{entity_id}", response_model=LegalEntityDetail, dependencies=VIEW, summary="Карточка ЮЛ/ИП")
|
||||
async def get_legal_entity(entity_id: uuid.UUID, service: LegalEntityServiceDep) -> LegalEntityDetail:
|
||||
"""ЮЛ с текущими членствами ГСК/ГСЗ и алиасами."""
|
||||
return await service.get(entity_id)
|
||||
detail = await service.get(entity_id)
|
||||
counts = await service.documents_count_by_entities([entity_id])
|
||||
detail.documents_count = counts.get(entity_id, 0)
|
||||
return detail
|
||||
|
||||
|
||||
@router.patch("/{entity_id}", response_model=LegalEntityOut, dependencies=EDIT, summary="Изменить ЮЛ/ИП")
|
||||
@ -72,7 +85,9 @@ async def update_legal_entity(
|
||||
entity_id: uuid.UUID, data: LegalEntityUpdate, service: LegalEntityServiceDep
|
||||
) -> LegalEntityOut:
|
||||
"""Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`; прежние значения уходят в историю (`UPDATE`)."""
|
||||
return LegalEntityOut.model_validate(await service.update(entity_id, data))
|
||||
entity = await service.update(entity_id, data)
|
||||
counts = await service.documents_count_by_entities([entity_id])
|
||||
return _out(entity, counts.get(entity_id, 0))
|
||||
|
||||
|
||||
@router.post("/{entity_id}/move", response_model=LegalEntityOut, dependencies=EDIT, summary="Перевести ЮЛ в другую ГК")
|
||||
@ -80,7 +95,9 @@ async def move_legal_entity(
|
||||
entity_id: uuid.UUID, data: LegalEntityMove, service: LegalEntityServiceDep
|
||||
) -> LegalEntityOut:
|
||||
"""Переводит ЮЛ в другую ГК с `effective_date` (≥ сегодня); текущие членства ГСК/ГСЗ закрываются."""
|
||||
return LegalEntityOut.model_validate(await service.move(entity_id, data))
|
||||
entity = await service.move(entity_id, data)
|
||||
counts = await service.documents_count_by_entities([entity_id])
|
||||
return _out(entity, counts.get(entity_id, 0))
|
||||
|
||||
|
||||
@router.post(
|
||||
@ -91,7 +108,9 @@ async def withdraw_legal_entity(
|
||||
) -> LegalEntityOut:
|
||||
"""`CLOSE` — закрытие (`close_date = effective_date`), `REMOVE_FROM_GROUP` — вывод; ГК снимается, членства
|
||||
закрываются."""
|
||||
return LegalEntityOut.model_validate(await service.withdraw(entity_id, data))
|
||||
entity = await service.withdraw(entity_id, data)
|
||||
counts = await service.documents_count_by_entities([entity_id])
|
||||
return _out(entity, counts.get(entity_id, 0))
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@ -24,7 +24,8 @@ from src.models.directories import (
|
||||
RelatedGroup,
|
||||
RelatedGroupMember,
|
||||
)
|
||||
from src.models.enums import RelatedGroupKind
|
||||
from src.models.enums import FileStatus, RelatedGroupKind
|
||||
from src.models.files import Document, StoredFile
|
||||
|
||||
LIKE_ESCAPE = "\\"
|
||||
ALIAS_LOCK_PREFIX = "legal-entity-alias" # пространство ключей advisory-блокировки алиасов
|
||||
@ -189,6 +190,23 @@ async def get_legal_entity_by_inn(session: AsyncSession, inn: str) -> LegalEntit
|
||||
return (await session.execute(select(LegalEntity).where(LegalEntity.inn == inn))).scalar_one_or_none()
|
||||
|
||||
|
||||
async def documents_count_by_entity(session: AsyncSession, entity_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
|
||||
"""Число документов файлов каждой ЮЛ со статусом файла не SUPERSEDED (D-13).
|
||||
|
||||
Возвращает словарь {entity_id: count}; отсутствующих ЮЛ — не в словаре.
|
||||
"""
|
||||
if not entity_ids:
|
||||
return {}
|
||||
stmt = (
|
||||
select(Document.legal_entity_id, func.count(Document.id).label("count"))
|
||||
.join(StoredFile, Document.stored_file_id == StoredFile.id)
|
||||
.where(Document.legal_entity_id.in_(entity_ids), StoredFile.status != FileStatus.SUPERSEDED)
|
||||
.group_by(Document.legal_entity_id)
|
||||
)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return {entity_id: count for entity_id, count in rows}
|
||||
|
||||
|
||||
async def legal_entity_crm_id_taken(
|
||||
session: AsyncSession, crm_id: str, *, except_entity_id: uuid.UUID | None = None
|
||||
) -> bool:
|
||||
|
||||
@ -208,7 +208,10 @@ class LegalEntityWithdraw(BaseModel):
|
||||
|
||||
|
||||
class LegalEntityOut(BaseModel):
|
||||
"""ЮЛ/ИП; `outside_group` — монозаёмщик (`company_group_id IS NULL`), `closed` — заполнен `close_date`."""
|
||||
"""ЮЛ/ИП; `outside_group` — монозаёмщик (`company_group_id IS NULL`), `closed` — заполнен `close_date`.
|
||||
|
||||
`documents_count` — число документов файлов ЮЛ со статусом файла не SUPERSEDED (D-13).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@ -223,6 +226,7 @@ class LegalEntityOut(BaseModel):
|
||||
valid_from: datetime.datetime
|
||||
created_at: datetime.datetime | None
|
||||
updated_at: datetime.datetime | None
|
||||
documents_count: int = 0
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
|
||||
@ -275,6 +275,10 @@ class LegalEntityService:
|
||||
offset=page.offset,
|
||||
)
|
||||
|
||||
async def documents_count_by_entities(self, entity_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
|
||||
"""Число документов для каждой ЮЛ (файлы со статусом не SUPERSEDED)."""
|
||||
return await repo.documents_count_by_entity(self._session, entity_ids)
|
||||
|
||||
async def _get_entity(self, entity_id: uuid.UUID) -> LegalEntity:
|
||||
entity = await repo.get_legal_entity(self._session, self._scope, entity_id)
|
||||
if entity is None:
|
||||
|
||||
@ -20,9 +20,23 @@ from src.models.directories import (
|
||||
RegionalOffice,
|
||||
RelatedGroupMember,
|
||||
)
|
||||
from src.models.enums import AuditAction, AuditSection, EntityType, RelatedGroupKind
|
||||
from src.models.enums import (
|
||||
AuditAction,
|
||||
AuditSection,
|
||||
EntityType,
|
||||
FileStatus,
|
||||
RecognitionStatus,
|
||||
RelatedGroupKind,
|
||||
)
|
||||
from tests.conftest import AuthClientFactory
|
||||
from tests.factories import make_company_group, make_legal_entity, make_membership, make_related_group
|
||||
from tests.factories import (
|
||||
make_company_group,
|
||||
make_document,
|
||||
make_legal_entity,
|
||||
make_membership,
|
||||
make_related_group,
|
||||
make_stored_file,
|
||||
)
|
||||
|
||||
HTTP_OK = 200
|
||||
HTTP_CREATED = 201
|
||||
@ -577,3 +591,26 @@ async def test_create_rejects_unknown_fields(auth_client: AuthClientFactory, see
|
||||
assert isinstance(response.json()["detail"], list)
|
||||
count = (await seeded.execute(select(func.count()).select_from(LegalEntity))).scalar_one()
|
||||
assert count == before
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_documents_count(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||
"""D-13: documents_count в списке и карточке ЮЛ — число документов файлов со статусом не SUPERSEDED."""
|
||||
entity = await make_legal_entity(seeded, name="ООО Счётчик") # монозаёмщик
|
||||
live, job = await make_stored_file(
|
||||
seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
|
||||
)
|
||||
await make_document(seeded, file=live, job=job, account="60")
|
||||
await make_document(seeded, file=live, job=job, account="62")
|
||||
old, old_job = await make_stored_file(
|
||||
seeded, entity=entity, status=FileStatus.SUPERSEDED, job_status=RecognitionStatus.done
|
||||
)
|
||||
await make_document(seeded, file=old, job=old_job, account="66")
|
||||
|
||||
client = await auth_client("ka_go@armka.local")
|
||||
listed = (await client.get("/directories/legal-entities", params={"q": "Счётчик"})).json()
|
||||
expected_count = 2 # два документа в не заменённом файле, один в заменённом (не считается)
|
||||
assert listed["items"][0]["documents_count"] == expected_count
|
||||
|
||||
card = (await client.get(f"/directories/legal-entities/{entity.id}")).json()
|
||||
assert card["documents_count"] == expected_count
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user