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")
|
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"
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,17 +24,15 @@ _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
|
|
||||||
|
|
||||||
|
|
||||||
def render_checklist_csv(checklist: ChecklistOut) -> bytes:
|
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()
|
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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user