"""报表统计接口(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 from app.api.deps import DbSession, RequestMetaDep, require_roles from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER 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 月份必填由模型校验)。""" data = query.model_dump() if code == "personal-detail": return PersonalDetailQuery(**data) if code == "department-summary": return DepartmentSummaryQuery(**data) if code == "business-stats": return BusinessStatsQuery(**data) return PaymentStatsQuery(**data)