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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 09:44:43 +08:00

192 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""报表统计接口(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