- 部门汇总全公司 5% 按每位有台账聚合行的营销经理各计一次, 团队应发小计取聚合额 + 调整净额;与月结 total_payable 对拍 (有调整单时 + adjust_net_total),分列舍入差单列 - 人员归属按行类型拆分(普通/经理个人户/仅调整单),调整单全量并入, 团队与未分配不重复;按经理筛选只汇总展示团队 - 导出改单 sheet 分块签字稿,审计覆盖取数与超限;筛选页眉 ID→名称 - 月份校验抽 core/month,报表/月结/调整单非法月份统一 400; 岗位只读选项供财务/领导筛选 - 前端修复:部门下钻带入筛选、回款多桶独立状态、打印截断判定、 summary 请求序号保护 Co-Authored-By: Claude Code <noreply@anthropic.com>
192 lines
6.5 KiB
Python
192 lines
6.5 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, 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
|