- 四表接口 /api/v1/reports/*:个人明细(全筛选 summary)、部门汇总(团队基数与月结聚合同源、12.5%/5% 分列、零行经理、冻结对照)、业务统计(排除聚合行)、回款统计(8 档区间恒在、回款金额按 receipt_id 去重防分摊拆行翻倍) - Excel 导出:公司页眉/财务复核栏/领导签字栏/日期/版本、Decimal 原值 + number_format、文本防公式注入、2 万行超限 400、导出行为入操作日志 - manager_share 共享模块:月结聚合与报表部门汇总同源取参与分桶(薄委托保留测试入口) - 区间元数据落 core/report_meta(repository 需要类型,避免反向依赖 service 层) - 准入 require_roles(admin/finance/leader),支持自定义 403 文案 Co-Authored-By: Claude Code <noreply@anthropic.com>
179 lines
5.9 KiB
Python
179 lines
5.9 KiB
Python
"""报表统计接口(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)
|