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-модель на маршрут)."""
|
"""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="Список ЮЛ/ИП")
|
@router.get("", response_model=Page[LegalEntityOut], dependencies=VIEW, summary="Список ЮЛ/ИП")
|
||||||
async def list_legal_entities(
|
async def list_legal_entities(
|
||||||
service: LegalEntityServiceDep,
|
service: LegalEntityServiceDep,
|
||||||
@ -45,8 +52,10 @@ async def list_legal_entities(
|
|||||||
page = PageParams(limit=query.limit, offset=query.offset)
|
page = PageParams(limit=query.limit, offset=query.offset)
|
||||||
flt = LegalEntityFilter.model_validate(query.model_dump(include=set(LegalEntityFilter.model_fields)))
|
flt = LegalEntityFilter.model_validate(query.model_dump(include=set(LegalEntityFilter.model_fields)))
|
||||||
items, total = await service.page(flt, page)
|
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](
|
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,
|
total=total,
|
||||||
limit=page.limit,
|
limit=page.limit,
|
||||||
offset=page.offset,
|
offset=page.offset,
|
||||||
@ -58,13 +67,17 @@ async def list_legal_entities(
|
|||||||
)
|
)
|
||||||
async def create_legal_entity(data: LegalEntityCreate, service: LegalEntityServiceDep) -> LegalEntityOut:
|
async def create_legal_entity(data: LegalEntityCreate, service: LegalEntityServiceDep) -> LegalEntityOut:
|
||||||
"""Создаёт ЮЛ/ИП; без `company_group_id` — монозаёмщик; ИНН: ЮЛ — 10 цифр, ИП — 12."""
|
"""Создаёт ЮЛ/ИП; без `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="Карточка ЮЛ/ИП")
|
@router.get("/{entity_id}", response_model=LegalEntityDetail, dependencies=VIEW, summary="Карточка ЮЛ/ИП")
|
||||||
async def get_legal_entity(entity_id: uuid.UUID, service: LegalEntityServiceDep) -> LegalEntityDetail:
|
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="Изменить ЮЛ/ИП")
|
@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
|
entity_id: uuid.UUID, data: LegalEntityUpdate, service: LegalEntityServiceDep
|
||||||
) -> LegalEntityOut:
|
) -> LegalEntityOut:
|
||||||
"""Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`; прежние значения уходят в историю (`UPDATE`)."""
|
"""Меняет `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="Перевести ЮЛ в другую ГК")
|
@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
|
entity_id: uuid.UUID, data: LegalEntityMove, service: LegalEntityServiceDep
|
||||||
) -> LegalEntityOut:
|
) -> LegalEntityOut:
|
||||||
"""Переводит ЮЛ в другую ГК с `effective_date` (≥ сегодня); текущие членства ГСК/ГСЗ закрываются."""
|
"""Переводит ЮЛ в другую ГК с `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(
|
@router.post(
|
||||||
@ -91,7 +108,9 @@ async def withdraw_legal_entity(
|
|||||||
) -> LegalEntityOut:
|
) -> LegalEntityOut:
|
||||||
"""`CLOSE` — закрытие (`close_date = effective_date`), `REMOVE_FROM_GROUP` — вывод; ГК снимается, членства
|
"""`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(
|
@router.get(
|
||||||
|
|||||||
@ -24,7 +24,8 @@ from src.models.directories import (
|
|||||||
RelatedGroup,
|
RelatedGroup,
|
||||||
RelatedGroupMember,
|
RelatedGroupMember,
|
||||||
)
|
)
|
||||||
from src.models.enums import RelatedGroupKind
|
from src.models.enums import FileStatus, RelatedGroupKind
|
||||||
|
from src.models.files import Document, StoredFile
|
||||||
|
|
||||||
LIKE_ESCAPE = "\\"
|
LIKE_ESCAPE = "\\"
|
||||||
ALIAS_LOCK_PREFIX = "legal-entity-alias" # пространство ключей advisory-блокировки алиасов
|
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()
|
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(
|
async def legal_entity_crm_id_taken(
|
||||||
session: AsyncSession, crm_id: str, *, except_entity_id: uuid.UUID | None = None
|
session: AsyncSession, crm_id: str, *, except_entity_id: uuid.UUID | None = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|||||||
@ -208,7 +208,10 @@ class LegalEntityWithdraw(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class LegalEntityOut(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)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@ -223,6 +226,7 @@ class LegalEntityOut(BaseModel):
|
|||||||
valid_from: datetime.datetime
|
valid_from: datetime.datetime
|
||||||
created_at: datetime.datetime | None
|
created_at: datetime.datetime | None
|
||||||
updated_at: datetime.datetime | None
|
updated_at: datetime.datetime | None
|
||||||
|
documents_count: int = 0
|
||||||
|
|
||||||
@computed_field # type: ignore[prop-decorator]
|
@computed_field # type: ignore[prop-decorator]
|
||||||
@property
|
@property
|
||||||
|
|||||||
@ -275,6 +275,10 @@ class LegalEntityService:
|
|||||||
offset=page.offset,
|
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:
|
async def _get_entity(self, entity_id: uuid.UUID) -> LegalEntity:
|
||||||
entity = await repo.get_legal_entity(self._session, self._scope, entity_id)
|
entity = await repo.get_legal_entity(self._session, self._scope, entity_id)
|
||||||
if entity is None:
|
if entity is None:
|
||||||
|
|||||||
@ -20,9 +20,23 @@ from src.models.directories import (
|
|||||||
RegionalOffice,
|
RegionalOffice,
|
||||||
RelatedGroupMember,
|
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.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_OK = 200
|
||||||
HTTP_CREATED = 201
|
HTTP_CREATED = 201
|
||||||
@ -577,3 +591,26 @@ async def test_create_rejects_unknown_fields(auth_client: AuthClientFactory, see
|
|||||||
assert isinstance(response.json()["detail"], list)
|
assert isinstance(response.json()["detail"], list)
|
||||||
count = (await seeded.execute(select(func.count()).select_from(LegalEntity))).scalar_one()
|
count = (await seeded.execute(select(func.count()).select_from(LegalEntity))).scalar_one()
|
||||||
assert count == before
|
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