refactor(directories,consolidations): documents_count одним хелпером и описание CSV чек-листа (T11.1, T11.2, T12.2, T12.6)

- ЮЛ: service.documents_count(entity_id), _out с типом LegalEntity и обязательным documents_count;
- чек-лист: в OpenAPI описан ответ text/csv, BOM — константа, elif после return убраны,
  тест на полноту подписей CellStatus и на байты BOM.

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:39:37 +04:00
parent fb457fc06b
commit d807380068
7 changed files with 46 additions and 22 deletions

View File

@ -16,8 +16,13 @@ from src.schemas.consolidations import ChecklistOut
router = APIRouter(prefix="/consolidations/versions") router = APIRouter(prefix="/consolidations/versions")
VIEW = [Depends(require("CONSOLIDATION_VIEW"))] VIEW = [Depends(require("CONSOLIDATION_VIEW"))]
NOT_FOUND: dict[int | str, dict[str, Any]] = { CSV_MEDIA_TYPE = "text/csv"
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"} 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", "/{version_id}/checklist",
response_model=ChecklistOut, response_model=ChecklistOut,
dependencies=VIEW, dependencies=VIEW,
responses=NOT_FOUND, responses=RESPONSES,
summary="Чек-лист комплекта документов версии", summary="Чек-лист комплекта документов версии",
) )
async def get_checklist( async def get_checklist(
@ -45,7 +50,7 @@ async def get_checklist(
csv_content = render_checklist_csv(checklist) csv_content = render_checklist_csv(checklist)
return Response( return Response(
content=csv_content, content=csv_content,
media_type="text/csv; charset=utf-8", media_type=f"{CSV_MEDIA_TYPE}; charset=utf-8",
headers={ headers={
"Content-Disposition": content_disposition( "Content-Disposition": content_disposition(
filename, disposition="attachment", ascii_fallback="checklist.csv" filename, disposition="attachment", ascii_fallback="checklist.csv"

View File

@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, Query, status
from src.api.dependencies.auth import require from src.api.dependencies.auth import require
from src.api.dependencies.directories import AliasServiceDep, LegalEntityServiceDep from src.api.dependencies.directories import AliasServiceDep, LegalEntityServiceDep
from src.models.directories import LegalEntity
from src.schemas.common import Page, PageParams from src.schemas.common import Page, PageParams
from src.schemas.directories import ( from src.schemas.directories import (
AliasCreate, AliasCreate,
@ -36,7 +37,7 @@ class LegalEntityListQuery(LegalEntityFilter, PageParams):
"""Query списка ЮЛ: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут).""" """Query списка ЮЛ: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут)."""
def _out(entity, documents_count: int = 0) -> LegalEntityOut: def _out(entity: LegalEntity, documents_count: int) -> LegalEntityOut:
"""Конвертирует ORM ЮЛ в LegalEntityOut с подсчитанным количеством документов.""" """Конвертирует ORM ЮЛ в LegalEntityOut с подсчитанным количеством документов."""
out = LegalEntityOut.model_validate(entity) out = LegalEntityOut.model_validate(entity)
out.documents_count = documents_count 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: async def get_legal_entity(entity_id: uuid.UUID, service: LegalEntityServiceDep) -> LegalEntityDetail:
"""ЮЛ с текущими членствами ГСК/ГСЗ и алиасами.""" """ЮЛ с текущими членствами ГСК/ГСЗ и алиасами."""
detail = await service.get(entity_id) detail = await service.get(entity_id)
counts = await service.documents_count_by_entities([entity_id]) detail.documents_count = await service.documents_count(entity_id)
detail.documents_count = counts.get(entity_id, 0)
return detail return detail
@ -86,8 +86,7 @@ async def update_legal_entity(
) -> LegalEntityOut: ) -> LegalEntityOut:
"""Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`; прежние значения уходят в историю (`UPDATE`).""" """Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`; прежние значения уходят в историю (`UPDATE`)."""
entity = 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, await service.documents_count(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="Перевести ЮЛ в другую ГК")
@ -96,8 +95,7 @@ async def move_legal_entity(
) -> LegalEntityOut: ) -> LegalEntityOut:
"""Переводит ЮЛ в другую ГК с `effective_date` (≥ сегодня); текущие членства ГСК/ГСЗ закрываются.""" """Переводит ЮЛ в другую ГК с `effective_date` (≥ сегодня); текущие членства ГСК/ГСЗ закрываются."""
entity = 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, await service.documents_count(entity_id))
return _out(entity, counts.get(entity_id, 0))
@router.post( @router.post(
@ -109,8 +107,7 @@ async def withdraw_legal_entity(
"""`CLOSE` — закрытие (`close_date = effective_date`), `REMOVE_FROM_GROUP` — вывод; ГК снимается, членства """`CLOSE` — закрытие (`close_date = effective_date`), `REMOVE_FROM_GROUP` — вывод; ГК снимается, членства
закрываются.""" закрываются."""
entity = 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, await service.documents_count(entity_id))
return _out(entity, counts.get(entity_id, 0))
@router.get( @router.get(

View File

@ -11,6 +11,8 @@ from src.domain.checklist import CellStatus
from src.models.enums import DocumentType from src.models.enums import DocumentType
from src.schemas.consolidations import ChecklistOut from src.schemas.consolidations import ChecklistOut
BOM = "\ufeff" # UTF-8 BOM: Excel распознаёт кодировку
# Подписи статусов ячеек (макет UI) # Подписи статусов ячеек (макет UI)
_CELL_STATUS_LABELS = { _CELL_STATUS_LABELS = {
CellStatus.CONFIRMED: "Загружен", CellStatus.CONFIRMED: "Загружен",
@ -22,16 +24,14 @@ _CELL_STATUS_LABELS = {
} }
# Подписи правил
def _rule_label(document_type: DocumentType, account: str | None) -> str: def _rule_label(document_type: DocumentType, account: str | None) -> str:
"""Подпись правила для заголовка CSV.""" """Подпись правила для заголовка CSV."""
if document_type == DocumentType.BALANCE: if document_type == DocumentType.BALANCE:
return "Бух. баланс" return "Бух. баланс"
elif document_type == DocumentType.INCOME: if document_type == DocumentType.INCOME:
return "ОФР" return "ОФР"
elif document_type == DocumentType.OSV: if document_type == DocumentType.OSV:
return f"ОСВ {account}" if account else "ОСВ" return f"ОСВ {account}" if account else "ОСВ"
else:
return document_type.value return document_type.value
@ -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() buffer = io.StringIO()
writer = csv.writer(buffer, delimiter=";", lineterminator="\r\n") writer = csv.writer(buffer, delimiter=";", lineterminator="\r\n")
@ -68,7 +68,7 @@ def render_checklist_csv(checklist: ChecklistOut) -> bytes:
# Кодировать в UTF-8 с BOM # Кодировать в UTF-8 с BOM
csv_text = buffer.getvalue() 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: def checklist_filename(group_name: str, year: int, quarter: str) -> str:

View File

@ -279,6 +279,10 @@ class LegalEntityService:
"""Число документов для каждой ЮЛ (файлы со статусом не SUPERSEDED).""" """Число документов для каждой ЮЛ (файлы со статусом не SUPERSEDED)."""
return await repo.documents_count_by_entity(self._session, entity_ids) 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: 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:

View File

@ -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() card = (await client.get(f"/directories/legal-entities/{entity.id}")).json()
assert card["documents_count"] == expected_count 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

View File

@ -3,7 +3,7 @@
import uuid import uuid
from src.domain.checklist import CellStatus 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.models.enums import DocumentType, PeriodQuarter
from src.schemas.consolidations import ChecklistCellOut, ChecklistOut, ChecklistRowOut, ChecklistRuleOut from src.schemas.consolidations import ChecklistCellOut, ChecklistOut, ChecklistRowOut, ChecklistRuleOut
@ -27,7 +27,7 @@ def test_render() -> None:
], ],
) )
content = render_checklist_csv(checklist) 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() lines = content.decode("utf-8-sig").splitlines()
assert lines[0] == "Организация;ИНН;Бух. баланс;ОСВ 60" assert lines[0] == "Организация;ИНН;Бух. баланс;ОСВ 60"
assert lines[1] == '"ООО ""Альфа; Бета""";7700000000;Загружен;Отсутствует (нет: Q2)' assert lines[1] == '"ООО ""Альфа; Бета""";7700000000;Загружен;Отсутствует (нет: Q2)'
@ -35,3 +35,8 @@ def test_render() -> None:
def test_filename() -> None: def test_filename() -> None:
assert checklist_filename("Русский бисквит", 2025, "Q3") == "checklist-Русский бисквит-2025-Q3.csv" 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)

View File

@ -182,6 +182,15 @@ def test_osv_export_route_declares_binary_xlsx_response(schema: dict[str, Any])
assert not {"limit", "offset"} & names 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: 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.""" """Взятие — 201/200 с `LockOut`, 404/409/422; снятие — 204/404/409; смена статуса — карточка, 404/409/422."""
lock = schema["paths"]["/api/v1/consolidations/versions/{version_id}/lock"] lock = schema["paths"]["/api/v1/consolidations/versions/{version_id}/lock"]