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:
parent
fb457fc06b
commit
d807380068
@ -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"
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user