"""报表统计接口(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 from pydantic import BaseModel, ValidationError from app.api.deps import DbSession, RequestMetaDep, require_roles from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER from app.core.exceptions import BadRequestError 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): """导出参数 → 各报表自己的查询模型(department-summary 月份必填由模型校验)。 手工构造模型抛出的是 `pydantic.ValidationError`,不是 FastAPI 的 `RequestValidationError`——不在这里转换就会落到全局 500。导出参数 非法与查询接口一样应当是 400。 """ data = query.model_dump() 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