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:
Andrey Epifancev 2026-10-07 13:16:47 +04:00
parent 58f039a91d
commit 6078532982
5 changed files with 92 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

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