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")
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"

View File

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

View File

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

View File

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

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

View File

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