- 后端 /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>
246 lines
9.4 KiB
Python
246 lines
9.4 KiB
Python
"""工作台服务(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))
|