From d8073800689478197065ab9d201f03acdf881c66 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 13:39:37 +0400 Subject: [PATCH] =?UTF-8?q?refactor(directories,consolidations):=20documen?= =?UTF-8?q?ts=5Fcount=20=D0=BE=D0=B4=D0=BD=D0=B8=D0=BC=20=D1=85=D0=B5?= =?UTF-8?q?=D0=BB=D0=BF=D0=B5=D1=80=D0=BE=D0=BC=20=D0=B8=20=D0=BE=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5=20CSV=20=D1=87=D0=B5=D0=BA?= =?UTF-8?q?-=D0=BB=D0=B8=D1=81=D1=82=D0=B0=20(T11.1,=20T11.2,=20T12.2,=20T?= =?UTF-8?q?12.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ЮЛ: service.documents_count(entity_id), _out с типом LegalEntity и обязательным documents_count; - чек-лист: в OpenAPI описан ответ text/csv, BOM — константа, elif после return убраны, тест на полноту подписей CellStatus и на байты BOM. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/endpoints/consolidations/checklist.py | 13 +++++++++---- .../api/endpoints/directories/legal_entities.py | 15 ++++++--------- api/src/domain/checklist_csv.py | 14 +++++++------- api/src/services/legal_entities.py | 4 ++++ api/tests/test_api_legal_entities.py | 4 ++++ api/tests/test_domain_checklist_csv.py | 9 +++++++-- api/tests/test_openapi_routes.py | 9 +++++++++ 7 files changed, 46 insertions(+), 22 deletions(-) diff --git a/api/src/api/endpoints/consolidations/checklist.py b/api/src/api/endpoints/consolidations/checklist.py index 474f178..9293e3a 100644 --- a/api/src/api/endpoints/consolidations/checklist.py +++ b/api/src/api/endpoints/consolidations/checklist.py @@ -16,8 +16,13 @@ from src.schemas.consolidations import ChecklistOut router = APIRouter(prefix="/consolidations/versions") VIEW = [Depends(require("CONSOLIDATION_VIEW"))] -NOT_FOUND: dict[int | str, dict[str, Any]] = { - status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"} +CSV_MEDIA_TYPE = "text/csv" +RESPONSES: dict[int | str, dict[str, Any]] = { + status.HTTP_200_OK: { + "description": "чек-лист: JSON или (при `format=csv`) файл CSV — UTF-8 с BOM, разделитель `;`, CRLF", + "content": {CSV_MEDIA_TYPE: {"schema": {"type": "string", "format": "binary"}}}, + }, + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}, } @@ -25,7 +30,7 @@ NOT_FOUND: dict[int | str, dict[str, Any]] = { "/{version_id}/checklist", response_model=ChecklistOut, dependencies=VIEW, - responses=NOT_FOUND, + responses=RESPONSES, summary="Чек-лист комплекта документов версии", ) async def get_checklist( @@ -45,7 +50,7 @@ async def get_checklist( csv_content = render_checklist_csv(checklist) return Response( content=csv_content, - media_type="text/csv; charset=utf-8", + media_type=f"{CSV_MEDIA_TYPE}; charset=utf-8", headers={ "Content-Disposition": content_disposition( filename, disposition="attachment", ascii_fallback="checklist.csv" diff --git a/api/src/api/endpoints/directories/legal_entities.py b/api/src/api/endpoints/directories/legal_entities.py index 3a929c5..eda1317 100644 --- a/api/src/api/endpoints/directories/legal_entities.py +++ b/api/src/api/endpoints/directories/legal_entities.py @@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, Query, status from src.api.dependencies.auth import require from src.api.dependencies.directories import AliasServiceDep, LegalEntityServiceDep +from src.models.directories import LegalEntity from src.schemas.common import Page, PageParams from src.schemas.directories import ( AliasCreate, @@ -36,7 +37,7 @@ class LegalEntityListQuery(LegalEntityFilter, PageParams): """Query списка ЮЛ: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут).""" -def _out(entity, documents_count: int = 0) -> LegalEntityOut: +def _out(entity: LegalEntity, documents_count: int) -> LegalEntityOut: """Конвертирует ORM ЮЛ в LegalEntityOut с подсчитанным количеством документов.""" out = LegalEntityOut.model_validate(entity) out.documents_count = documents_count @@ -75,8 +76,7 @@ async def create_legal_entity(data: LegalEntityCreate, service: LegalEntityServi async def get_legal_entity(entity_id: uuid.UUID, service: LegalEntityServiceDep) -> LegalEntityDetail: """ЮЛ с текущими членствами ГСК/ГСЗ и алиасами.""" detail = await service.get(entity_id) - counts = await service.documents_count_by_entities([entity_id]) - detail.documents_count = counts.get(entity_id, 0) + detail.documents_count = await service.documents_count(entity_id) return detail @@ -86,8 +86,7 @@ async def update_legal_entity( ) -> LegalEntityOut: """Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`; прежние значения уходят в историю (`UPDATE`).""" entity = await service.update(entity_id, data) - counts = await service.documents_count_by_entities([entity_id]) - return _out(entity, counts.get(entity_id, 0)) + return _out(entity, await service.documents_count(entity_id)) @router.post("/{entity_id}/move", response_model=LegalEntityOut, dependencies=EDIT, summary="Перевести ЮЛ в другую ГК") @@ -96,8 +95,7 @@ async def move_legal_entity( ) -> LegalEntityOut: """Переводит ЮЛ в другую ГК с `effective_date` (≥ сегодня); текущие членства ГСК/ГСЗ закрываются.""" entity = await service.move(entity_id, data) - counts = await service.documents_count_by_entities([entity_id]) - return _out(entity, counts.get(entity_id, 0)) + return _out(entity, await service.documents_count(entity_id)) @router.post( @@ -109,8 +107,7 @@ async def withdraw_legal_entity( """`CLOSE` — закрытие (`close_date = effective_date`), `REMOVE_FROM_GROUP` — вывод; ГК снимается, членства закрываются.""" entity = await service.withdraw(entity_id, data) - counts = await service.documents_count_by_entities([entity_id]) - return _out(entity, counts.get(entity_id, 0)) + return _out(entity, await service.documents_count(entity_id)) @router.get( diff --git a/api/src/domain/checklist_csv.py b/api/src/domain/checklist_csv.py index 487a910..0f33f86 100644 --- a/api/src/domain/checklist_csv.py +++ b/api/src/domain/checklist_csv.py @@ -11,6 +11,8 @@ from src.domain.checklist import CellStatus from src.models.enums import DocumentType from src.schemas.consolidations import ChecklistOut +BOM = "\ufeff" # UTF-8 BOM: Excel распознаёт кодировку + # Подписи статусов ячеек (макет UI) _CELL_STATUS_LABELS = { CellStatus.CONFIRMED: "Загружен", @@ -22,17 +24,15 @@ _CELL_STATUS_LABELS = { } -# Подписи правил def _rule_label(document_type: DocumentType, account: str | None) -> str: """Подпись правила для заголовка CSV.""" if document_type == DocumentType.BALANCE: return "Бух. баланс" - elif document_type == DocumentType.INCOME: + if document_type == DocumentType.INCOME: return "ОФР" - elif document_type == DocumentType.OSV: + if document_type == DocumentType.OSV: return f"ОСВ {account}" if account else "ОСВ" - else: - return document_type.value + return document_type.value def render_checklist_csv(checklist: ChecklistOut) -> bytes: @@ -40,7 +40,7 @@ def render_checklist_csv(checklist: ChecklistOut) -> bytes: Заголовок: Организация;ИНН;<правило>… Строки: имя ЮЛ, ИНН, ячейки по порядку правил. - Статус ячейки: подпись из CELL_STATUS_LABELS, с " (нет: Q1, Q2)" при missing_quarters. + Статус ячейки: подпись из `_CELL_STATUS_LABELS`, с " (нет: Q1, Q2)" при missing_quarters. """ buffer = io.StringIO() writer = csv.writer(buffer, delimiter=";", lineterminator="\r\n") @@ -68,7 +68,7 @@ def render_checklist_csv(checklist: ChecklistOut) -> bytes: # Кодировать в UTF-8 с BOM csv_text = buffer.getvalue() - return ("" + csv_text).encode("utf-8") + return (BOM + csv_text).encode("utf-8") def checklist_filename(group_name: str, year: int, quarter: str) -> str: diff --git a/api/src/services/legal_entities.py b/api/src/services/legal_entities.py index 6247b63..ffaa497 100644 --- a/api/src/services/legal_entities.py +++ b/api/src/services/legal_entities.py @@ -279,6 +279,10 @@ class LegalEntityService: """Число документов для каждой ЮЛ (файлы со статусом не SUPERSEDED).""" return await repo.documents_count_by_entity(self._session, entity_ids) + async def documents_count(self, entity_id: uuid.UUID) -> int: + """Число документов одной ЮЛ (как `documents_count_by_entities`).""" + return (await self.documents_count_by_entities([entity_id])).get(entity_id, 0) + 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 8ce5a1b..1de818d 100644 --- a/api/tests/test_api_legal_entities.py +++ b/api/tests/test_api_legal_entities.py @@ -614,3 +614,7 @@ async def test_documents_count(seeded: AsyncSession, auth_client: AuthClientFact card = (await client.get(f"/directories/legal-entities/{entity.id}")).json() assert card["documents_count"] == expected_count + + patched = await client.patch(f"/directories/legal-entities/{entity.id}", json={"name": "ООО Счётчик плюс"}) + assert patched.status_code == HTTP_OK, patched.text + assert patched.json()["documents_count"] == expected_count # T11.2: тот же подсчёт в PATCH diff --git a/api/tests/test_domain_checklist_csv.py b/api/tests/test_domain_checklist_csv.py index d5c554a..6adef73 100644 --- a/api/tests/test_domain_checklist_csv.py +++ b/api/tests/test_domain_checklist_csv.py @@ -3,7 +3,7 @@ import uuid from src.domain.checklist import CellStatus -from src.domain.checklist_csv import checklist_filename, render_checklist_csv +from src.domain.checklist_csv import _CELL_STATUS_LABELS, checklist_filename, render_checklist_csv from src.models.enums import DocumentType, PeriodQuarter from src.schemas.consolidations import ChecklistCellOut, ChecklistOut, ChecklistRowOut, ChecklistRuleOut @@ -27,7 +27,7 @@ def test_render() -> None: ], ) content = render_checklist_csv(checklist) - assert content.startswith("".encode()) + assert content.startswith(b"\xef\xbb\xbf") # UTF-8 BOM lines = content.decode("utf-8-sig").splitlines() assert lines[0] == "Организация;ИНН;Бух. баланс;ОСВ 60" assert lines[1] == '"ООО ""Альфа; Бета""";7700000000;Загружен;Отсутствует (нет: Q2)' @@ -35,3 +35,8 @@ def test_render() -> None: def test_filename() -> None: assert checklist_filename("Русский бисквит", 2025, "Q3") == "checklist-Русский бисквит-2025-Q3.csv" + + +def test_every_cell_status_has_label() -> None: + """Запасное значение статуса (`cell.status.value`) — безопасный фолбэк, но таблица подписей полна.""" + assert set(_CELL_STATUS_LABELS) == set(CellStatus) diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 8d426bb..90d2758 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -182,6 +182,15 @@ def test_osv_export_route_declares_binary_xlsx_response(schema: dict[str, Any]) assert not {"limit", "offset"} & names +def test_checklist_route_declares_json_and_csv_response(schema: dict[str, Any]) -> None: + """T12.2: чек-лист — 200 как JSON (`ChecklistOut`) и как CSV-файл (бинарный текст), 404/422.""" + checklist = schema["paths"]["/api/v1/consolidations/versions/{version_id}/checklist"]["get"] + assert {"200", "404", "422"} <= set(checklist["responses"]) + content = checklist["responses"]["200"]["content"] + assert content["application/json"]["schema"]["$ref"].endswith("/ChecklistOut") + assert content["text/csv"]["schema"] == {"type": "string", "format": "binary"} + + def test_lock_and_status_routes_declare_documented_responses(schema: dict[str, Any]) -> None: """Взятие — 201/200 с `LockOut`, 404/409/422; снятие — 204/404/409; смена статуса — карточка, 404/409/422.""" lock = schema["paths"]["/api/v1/consolidations/versions/{version_id}/lock"]