2026-10-19 04:33:31 -04:00
|
|
|
|
"""报表统计接口(M4 ⑤):四张报表只读查询 + Excel 导出。
|
|
|
|
|
|
|
|
|
|
|
|
准入:管理员/财务/领导(FR-030/031 明确;FR-029 的营销后勤「授权范围内」
|
|
|
|
|
|
待权限清单 #6,暂不开放)。前后端双层:前端 allowedRoles 控菜单,本层
|
|
|
|
|
|
require_roles 兜底。
|
|
|
|
|
|
|
|
|
|
|
|
导出返回文件流(非统一信封),沿用参数导出先例:服务端文件名为 ASCII
|
|
|
|
|
|
(HTTP 头按 latin-1 编码,中文名会 UnicodeEncodeError),中文名由前端
|
|
|
|
|
|
``link.download`` 指定。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import date
|
|
|
|
|
|
from typing import Annotated, Literal
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, Path, Query
|
|
|
|
|
|
from fastapi.responses import StreamingResponse
|
2026-10-19 21:44:43 -04:00
|
|
|
|
from pydantic import BaseModel, ValidationError
|
2026-10-19 04:33:31 -04:00
|
|
|
|
|
|
|
|
|
|
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
|
|
|
|
|
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
|
2026-10-19 21:44:43 -04:00
|
|
|
|
from app.core.exceptions import BadRequestError
|
2026-10-19 04:33:31 -04:00
|
|
|
|
from app.core.response import ApiResponse, ok
|
|
|
|
|
|
from app.models import User
|
|
|
|
|
|
from app.schemas.report import (
|
|
|
|
|
|
BusinessStatsQuery,
|
|
|
|
|
|
BusinessStatsRow,
|
|
|
|
|
|
DepartmentSummaryOut,
|
|
|
|
|
|
DepartmentSummaryQuery,
|
|
|
|
|
|
PaymentDetailQuery,
|
|
|
|
|
|
PaymentDetailRow,
|
|
|
|
|
|
PaymentStatsOut,
|
|
|
|
|
|
PaymentStatsQuery,
|
|
|
|
|
|
PersonalDetailQuery,
|
|
|
|
|
|
PersonalDetailRow,
|
|
|
|
|
|
ReportPage,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.services import report_service
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/reports", tags=["绩效工资 · 报表统计"])
|
|
|
|
|
|
|
|
|
|
|
|
_ReportViewer = Annotated[
|
|
|
|
|
|
User,
|
|
|
|
|
|
Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问报表统计")),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
|
"/personal-detail",
|
|
|
|
|
|
response_model=ApiResponse[ReportPage[PersonalDetailRow]],
|
|
|
|
|
|
summary="个人绩效工资明细表",
|
|
|
|
|
|
)
|
|
|
|
|
|
def personal_detail(
|
|
|
|
|
|
session: DbSession,
|
|
|
|
|
|
_: _ReportViewer,
|
|
|
|
|
|
query: Annotated[PersonalDetailQuery, Query()],
|
|
|
|
|
|
) -> ApiResponse[ReportPage[PersonalDetailRow]]:
|
|
|
|
|
|
"""按人员/月份/客户类型/产品类别/岗位查明细(口径与台账一致)。"""
|
|
|
|
|
|
return ok(report_service.list_personal_detail(session, query))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
|
"/department-summary",
|
|
|
|
|
|
response_model=ApiResponse[DepartmentSummaryOut],
|
|
|
|
|
|
summary="部门绩效工资汇总表",
|
|
|
|
|
|
)
|
|
|
|
|
|
def department_summary(
|
|
|
|
|
|
session: DbSession,
|
|
|
|
|
|
_: _ReportViewer,
|
|
|
|
|
|
query: Annotated[DepartmentSummaryQuery, Query()],
|
|
|
|
|
|
) -> ApiResponse[DepartmentSummaryOut]:
|
|
|
|
|
|
"""按经理团队/月份汇总(团队基数与月结聚合行同源)。"""
|
|
|
|
|
|
return ok(report_service.department_summary(session, query))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
|
"/business-stats",
|
|
|
|
|
|
response_model=ApiResponse[ReportPage[BusinessStatsRow]],
|
|
|
|
|
|
summary="业务统计表",
|
|
|
|
|
|
)
|
|
|
|
|
|
def business_stats(
|
|
|
|
|
|
session: DbSession,
|
|
|
|
|
|
_: _ReportViewer,
|
|
|
|
|
|
query: Annotated[BusinessStatsQuery, Query()],
|
|
|
|
|
|
) -> ApiResponse[ReportPage[BusinessStatsRow]]:
|
|
|
|
|
|
"""按业务查询发货、回款、绩效(回款/绩效为业务累计值)。"""
|
|
|
|
|
|
return ok(report_service.business_stats(session, query))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
|
"/payment-stats",
|
|
|
|
|
|
response_model=ApiResponse[PaymentStatsOut],
|
|
|
|
|
|
summary="回款情况统计表",
|
|
|
|
|
|
)
|
|
|
|
|
|
def payment_stats(
|
|
|
|
|
|
session: DbSession,
|
|
|
|
|
|
_: _ReportViewer,
|
|
|
|
|
|
query: Annotated[PaymentStatsQuery, Query()],
|
|
|
|
|
|
) -> ApiResponse[PaymentStatsOut]:
|
|
|
|
|
|
"""按回款天数区间/客户类型/产品类别统计(空桶恒在且为 0)。"""
|
|
|
|
|
|
return ok(report_service.payment_stats(session, query))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
|
"/payment-detail",
|
|
|
|
|
|
response_model=ApiResponse[ReportPage[PaymentDetailRow]],
|
|
|
|
|
|
summary="回款区间下钻",
|
|
|
|
|
|
)
|
|
|
|
|
|
def payment_detail(
|
|
|
|
|
|
session: DbSession,
|
|
|
|
|
|
_: _ReportViewer,
|
|
|
|
|
|
query: Annotated[PaymentDetailQuery, Query()],
|
|
|
|
|
|
) -> ApiResponse[ReportPage[PaymentDetailRow]]:
|
|
|
|
|
|
"""区间桶下钻到单笔台账行(含回款金额与批次号)。"""
|
|
|
|
|
|
return ok(report_service.payment_detail(session, query))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
|
"/payment-day-buckets", response_model=ApiResponse[list[dict]], summary="回款天数区间档位"
|
|
|
|
|
|
)
|
|
|
|
|
|
def payment_day_buckets(_: _ReportViewer) -> ApiResponse[list[dict]]:
|
|
|
|
|
|
"""区间档位元数据(前端渲染下拉/表头,禁止前端硬编码档位)。"""
|
|
|
|
|
|
return ok(report_service.get_payment_day_buckets())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ExportQuery(BaseModel):
|
|
|
|
|
|
"""导出通用筛选参数(按报表编码取用相关字段,未用字段忽略)。"""
|
|
|
|
|
|
|
|
|
|
|
|
settlement_month: str | None = None
|
|
|
|
|
|
personnel_id: int | None = None
|
|
|
|
|
|
customer_type: str | None = None
|
|
|
|
|
|
product_category: str | None = None
|
|
|
|
|
|
role_code: str | None = None
|
|
|
|
|
|
manager_id: int | None = None
|
|
|
|
|
|
keyword: str | None = None
|
|
|
|
|
|
customer_id: int | None = None
|
|
|
|
|
|
owner_personnel_id: int | None = None
|
|
|
|
|
|
status: int | None = None
|
|
|
|
|
|
date_from: date | None = None
|
|
|
|
|
|
date_to: date | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{code}/export", summary="导出报表 Excel")
|
|
|
|
|
|
def export_report(
|
|
|
|
|
|
code: Annotated[
|
|
|
|
|
|
Literal["personal-detail", "department-summary", "business-stats", "payment-stats"],
|
|
|
|
|
|
Path(description="报表编码"),
|
|
|
|
|
|
],
|
|
|
|
|
|
session: DbSession,
|
|
|
|
|
|
current_user: _ReportViewer,
|
|
|
|
|
|
meta: RequestMetaDep,
|
|
|
|
|
|
query: Annotated[ExportQuery, Query()],
|
|
|
|
|
|
) -> StreamingResponse:
|
|
|
|
|
|
"""导出 xlsx(含公司页眉/财务复核栏/领导签字栏),行为入操作日志。"""
|
|
|
|
|
|
content, filename = report_service.export_report(
|
|
|
|
|
|
session,
|
|
|
|
|
|
code,
|
|
|
|
|
|
_build_export_query(code, query),
|
|
|
|
|
|
operator=current_user,
|
|
|
|
|
|
meta=meta,
|
|
|
|
|
|
)
|
|
|
|
|
|
return StreamingResponse(
|
|
|
|
|
|
iter([content]),
|
|
|
|
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"Content-Disposition": (f'attachment; filename="{filename}_{date.today():%Y%m%d}.xlsx"')
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_export_query(code: str, query: ExportQuery):
|
2026-10-19 21:44:43 -04:00
|
|
|
|
"""导出参数 → 各报表自己的查询模型(department-summary 月份必填由模型校验)。
|
|
|
|
|
|
|
|
|
|
|
|
手工构造模型抛出的是 `pydantic.ValidationError`,不是 FastAPI 的
|
|
|
|
|
|
`RequestValidationError`——不在这里转换就会落到全局 500。导出参数
|
|
|
|
|
|
非法与查询接口一样应当是 400。
|
|
|
|
|
|
"""
|
2026-10-19 04:33:31 -04:00
|
|
|
|
data = query.model_dump()
|
2026-10-19 21:44:43 -04:00
|
|
|
|
model = {
|
|
|
|
|
|
"personal-detail": PersonalDetailQuery,
|
|
|
|
|
|
"department-summary": DepartmentSummaryQuery,
|
|
|
|
|
|
"business-stats": BusinessStatsQuery,
|
|
|
|
|
|
"payment-stats": PaymentStatsQuery,
|
|
|
|
|
|
}[code]
|
|
|
|
|
|
try:
|
|
|
|
|
|
return model(**data)
|
|
|
|
|
|
except ValidationError as exc:
|
|
|
|
|
|
first = exc.errors()[0]
|
|
|
|
|
|
location = ".".join(str(part) for part in first.get("loc", ()))
|
|
|
|
|
|
message = first.get("msg", "参数错误")
|
|
|
|
|
|
detail = f"{location} {message}" if location else message
|
|
|
|
|
|
raise BadRequestError(f"参数错误:{detail}") from exc
|