PayrollManagementSystem/backend/app/services/dashboard_service.py
liuwenxin 6ff202d637 feat(dashboard): 工作台四区块实时聚合与风险预警参数
- 后端 /api/v1/dashboard 单端点返回四区块:经营总览(绩效/应付/人数/团队数/月结状态/近 6 个月趋势)、待办(待审批 + 回款未指定批次 + 同步显式降级)、风险预警(回款逾期/审批超时,阈值参数化)、最近动态(操作日志)
- dashboard_service 实时派生(不落表、不引调度器);参数快照一次解析;总览只做 SQL 聚合,不复用报表的重查询
- V10 迁移新增预警参数三项(warning.enabled / warning.receipt.overdue.days=90 / warning.approval.timeout.days=3),参数注册表 19→22,init-dev 与 V10 SQL 双轨同步
- 测试:test_dashboard 5 例(权限文案/总览对账+非法月份 400/待办三类/预警命中与参数关闭/动态来源);全量 852 passed
- 依赖闭包并入:core/month.py 月份校验、api/v1/base/business_roles.py(router 已挂载的岗位只读端点)、test_month_validation
- 修复:alembic --sql 在 Windows 按控制台 GBK 输出,V10 SQL 以 UTF-8 重新导出(README 已记)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 18:44:15 +08:00

246 lines
9.4 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 ⑧):四区块实时聚合编排。
设计决策(2026-10-19):
- **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合,
不复用报表的部门汇总(后者会加载整月台账,过重)
- **待办与预警实时派生**:不建待办/预警表、不引调度器;处置动作本身
已有操作日志可追溯。与 FR-035「预警产生/消除留痕」的差距记录在
计划开放项,待业务确认是否物化
- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以
``sync_available=false`` 显式降级,不返回伪造的 0 条
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
"""
from datetime import date, datetime, timedelta
from decimal import Decimal
from sqlalchemy.orm import Session
from app.core.exceptions import BadRequestError
from app.core.month import validate_month
from app.repositories import dashboard_repository, operation_log_repository
from app.repositories.query_utils import LogFilters
# 工作台只展示摘要,完整列表跳转对应管理页
_TODO_LIMIT = 5
_WARNING_LIMIT = 20
_ACTIVITY_LIMIT = 20
_TREND_MONTHS = 6
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
def get_dashboard(session: Session, month: str | None = None) -> dict:
"""四区块一次性出数(前端一次请求渲染整页)。"""
resolved = _normalize_month(month)
warning_params = _warning_params(session)
return {
"month": resolved,
"overview": _overview(session, resolved),
"todos": _todos(session),
"warnings": _warnings(session, warning_params),
"activities": _activities(session),
}
def _normalize_month(month: str | None) -> str:
"""未传月份按当月;非法月份在入口拦成 400(避免 500)。"""
if not month:
today = date.today()
return f"{today.year:04d}-{today.month:02d}"
try:
return validate_month(month)
except ValueError as exc:
raise BadRequestError(str(exc)) from exc
def _shift_month(month: str, delta: int) -> str:
"""YYYY-MM 平移 delta 个月(用于近 N 个月窗口)。"""
year, mon = int(month[:4]), int(month[5:7])
index = year * 12 + (mon - 1) + delta
return f"{index // 12:04d}-{index % 12 + 1:02d}"
def _trend_months(month: str) -> list[str]:
"""窗口为「当月及之前共 6 个月」,升序返回便于前端画图。"""
return [_shift_month(month, offset) for offset in range(-(_TREND_MONTHS - 1), 1)]
def _overview(session: Session, month: str) -> dict:
"""经营绩效总览(FR-033):口径与台账/月结一致。"""
sums = dashboard_repository.sum_month_performance(session, month)
settlement = dashboard_repository.get_month_settlement(session, month)
months = _trend_months(month)
performance = dashboard_repository.performance_trend(session, months)
receipts = dashboard_repository.receipt_trend(session, months)
return {
"month": month,
"performance_total": _dec(sums["performance_total"]),
"payable_total": _dec(sums["payable_total"]),
"person_count": int(sums["person_count"]),
"team_count": dashboard_repository.count_active_managers(session),
"settlement": {
"exists": settlement is not None,
"status": settlement.status if settlement else None,
"total_performance": settlement.total_performance if settlement else None,
"total_payable": settlement.total_payable if settlement else None,
"locked_at": settlement.locked_at if settlement else None,
},
"trend": [
{
"month": item,
"performance_total": _dec(performance.get(item)),
"receipt_total": _dec(receipts.get(item)),
}
for item in months
],
"note": "绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月(含未核算回款);不含调整单",
}
def _todos(session: Session) -> dict:
"""待办事项(FR-034,澄清 #11 起步范围)。"""
orders = dashboard_repository.list_pending_approvals(session, limit=_TODO_LIMIT)
receipts = dashboard_repository.list_unassigned_receipts(session, limit=_TODO_LIMIT)
return {
"pending_approval_total": dashboard_repository.count_pending_approvals(session),
"pending_approvals": [
{
"order_id": order.id,
"order_no": order.order_no,
"order_type": order.order_type,
"submitter_name": order.submitter_name,
"create_time": order.create_time,
}
for order in orders
],
"unassigned_receipt_total": dashboard_repository.count_unassigned_receipts(session),
"unassigned_receipts": [
{
"receipt_id": row[0].id,
"receipt_batch_no": row[0].receipt_batch_no,
"business_id": row[0].business_id,
"business_no": row.business_no,
"amount": row[0].amount,
"receipt_date": row[0].receipt_date,
}
for row in receipts
],
# ⑥ T+ 同步未建:显式降级,不伪造「无失败」
"sync_available": False,
"sync_failure_total": 0,
}
def _warning_params(session: Session) -> dict:
"""预警参数一次解析(阈值参数化,澄清 #10)。"""
from app.services.param_service import parse_fraction, resolve_snapshot
snapshot = resolve_snapshot(session, date.today())
return {
"enabled": snapshot.get("warning.enabled") == "1",
"receipt_overdue_days": int(parse_fraction(snapshot.get("warning.receipt.overdue.days"))),
"approval_timeout_days": int(
parse_fraction(snapshot.get("warning.approval.timeout.days"))
),
}
def _warnings(session: Session, params: dict) -> dict:
"""风险预警(FR-035):首批两条可算规则;同步规则待 ⑥。"""
if not params["enabled"]:
return {
"enabled": False,
"receipt_overdue_days": params["receipt_overdue_days"],
"approval_timeout_days": params["approval_timeout_days"],
"items": [],
"receipt_overdue_total": 0,
"approval_timeout_total": 0,
"sync_available": False,
}
receipt_cutoff = date.today() - timedelta(days=params["receipt_overdue_days"])
approval_cutoff = datetime.now() - timedelta(days=params["approval_timeout_days"])
shipments = dashboard_repository.list_overdue_shipments(
session, cutoff=receipt_cutoff, limit=_WARNING_LIMIT
)
orders = dashboard_repository.list_overdue_approvals(
session, cutoff=approval_cutoff, limit=_WARNING_LIMIT
)
items = [
{
"code": "receipt_overdue",
"level": "danger",
"object_type": "shipment",
"object_id": row[0].id,
"title": f"发货批次 {row[0].shipment_no} 逾期未回款",
"detail": (
f"业务 {row.business_no or row[0].business_id} 发货日 {row[0].ship_date},"
f"超过 {params['receipt_overdue_days']} 天无回款记录"
),
"suggest": "联系客户催收,或核对回款批次指定关系",
"link": "/business/shipments",
}
for row in shipments
]
items += [
{
"code": "approval_timeout",
"level": "warning",
"object_type": "approval_order",
"object_id": order.id,
"title": f"审批单 {order.order_no} 超时未处理",
"detail": (
f"{_ORDER_TYPE_LABELS.get(order.order_type, '审批')}由 "
f"{order.submitter_name or '—'} 提交于 {order.create_time:%Y-%m-%d %H:%M},"
f"已超过 {params['approval_timeout_days']} 天"
),
"suggest": "在审批管理中复核或退回",
"link": "/system/approvals",
}
for order in orders
]
return {
"enabled": True,
"receipt_overdue_days": params["receipt_overdue_days"],
"approval_timeout_days": params["approval_timeout_days"],
"items": items,
"receipt_overdue_total": dashboard_repository.count_overdue_shipments(
session, cutoff=receipt_cutoff
),
"approval_timeout_total": dashboard_repository.count_overdue_approvals(
session, cutoff=approval_cutoff
),
"sync_available": False,
}
def _activities(session: Session) -> list[dict]:
"""最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。"""
logs = operation_log_repository.list_logs(
session,
offset=0,
limit=_ACTIVITY_LIMIT,
filters=LogFilters(),
)
return [
{
"id": log.id,
"module": log.module,
"action": log.action,
"summary": log.summary,
"username": log.username,
"success": log.success,
"create_time": log.create_time,
}
for log in logs
]
def _dec(value) -> Decimal | None:
"""SQL 聚合结果转 Decimal(SUM 可能返回 int,None 原样返回)。"""
if value is None:
return None
return value if isinstance(value, Decimal) else Decimal(str(value))