From 60785329829222b0d6bea455da68c14f3c156d96 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 13:16:47 +0400 Subject: [PATCH] =?UTF-8?q?feat(directories):=20documents=5Fcount=20=D0=B2?= =?UTF-8?q?=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B5=20=D0=B8=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20=D0=AE=D0=9B=20(D-13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлено поле documents_count в LegalEntityOut, содержащее число документов файлов ЮЛ со статусом не SUPERSEDED. Функция documents_count_by_entity в репозитории получает счётчики одним подзапросом для эффективности. Используется в списке и карточке через сервис. Тест проверяет, что заменённые файлы не учитываются в подсчёте. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- .../endpoints/directories/legal_entities.py | 31 +++++++++++--- api/src/repositories/directories.py | 20 ++++++++- api/src/schemas/directories.py | 6 ++- api/src/services/legal_entities.py | 4 ++ api/tests/test_api_legal_entities.py | 41 ++++++++++++++++++- 5 files changed, 92 insertions(+), 10 deletions(-) diff --git a/api/src/api/endpoints/directories/legal_entities.py b/api/src/api/endpoints/directories/legal_entities.py index 62f1c86..3a929c5 100644 --- a/api/src/api/endpoints/directories/legal_entities.py +++ b/api/src/api/endpoints/directories/legal_entities.py @@ -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( diff --git a/api/src/repositories/directories.py b/api/src/repositories/directories.py index b91aafa..f60d681 100644 --- a/api/src/repositories/directories.py +++ b/api/src/repositories/directories.py @@ -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: diff --git a/api/src/schemas/directories.py b/api/src/schemas/directories.py index 9368c60..a731caa 100644 --- a/api/src/schemas/directories.py +++ b/api/src/schemas/directories.py @@ -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 diff --git a/api/src/services/legal_entities.py b/api/src/services/legal_entities.py index e2732e8..6247b63 100644 --- a/api/src/services/legal_entities.py +++ b/api/src/services/legal_entities.py @@ -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: diff --git a/api/tests/test_api_legal_entities.py b/api/tests/test_api_legal_entities.py index 95396f8..8ce5a1b 100644 --- a/api/tests/test_api_legal_entities.py +++ b/api/tests/test_api_legal_entities.py @@ -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