PayrollManagementSystem/backend/app/services/dashboard_service.py
liuwenxin 1461a8d113 feat(后端): 添加授权体系、系统监控与试用期计提
按 2026-11-02 需求补齐计划交付后端能力:
- 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝;
  管理员判断改为按数据库角色重新判定,防止权限码持有者绕过
- 系统监控:单进程采集资源、请求与异常指标,分钟级聚合,
  阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义
- 试用期计提:按回款日判定并写入快照,年度结清与调整单联动;
  人员模型补试用期、离职日期与工资版本历史字段
- 计提模板:公式骨架受控,页面维护名称与参数绑定
- T+ 采集:自动化配置表与进度查询

内置角色禁停用保护对齐模块注释;核算规则版本提为常量并
补充批量核算失败堆栈日志。

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
2026-11-03 11:20:55 +08:00

329 lines
13 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「预警产生/消除留痕」的差距记录在
计划开放项,待业务确认是否物化
- 同步记录与采集开关分别展示;读取关闭不抹掉已完成的采集记录
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
- **最近动态独立分页**(2026-10-23):操作日志可能很长,聚合响应里
只能塞前 20 条;拆成独立端点后翻页只重取这张列表
"""
from datetime import date, datetime, timedelta
from decimal import Decimal
from sqlalchemy import func, inspect, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.exceptions import BadRequestError
from app.core.month import validate_month
from app.models import TPlusSyncBatch
from app.repositories import dashboard_repository, operation_log_repository
from app.repositories.query_utils import LogFilters
from app.schemas.dashboard import ActivityItem, ActivityPageOut, ActivityQuery
from app.services import approval_service
# 工作台只展示摘要,完整列表跳转对应管理页
_TODO_LIMIT = 5
_WARNING_LIMIT = 20
_TREND_MONTHS = 6
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
"""三区块一次性出数(前端一次请求渲染整页)。
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
风险预警仍是全局经营视角,不按人裁剪。
最近动态不在此返回 —— 它是分页列表,走 ``list_activities``。
"""
resolved = _normalize_month(month)
warning_params = _warning_params(session)
from app.services.authorization_service import calculate
permissions = calculate(session, current_user)["permissions"]
pending_statuses = approval_service.pending_statuses_for_roles([
role for role in ("finance", "leader") if "approvals:" + role in permissions
])
sync = _sync_status(session)
return {
"month": resolved,
"overview": _overview(session, resolved),
"todos": _todos(session, pending_statuses, sync),
"warnings": _warnings(session, warning_params, sync),
"sync": sync,
}
def _sync_status(session: Session) -> dict:
"""只读本地批次元数据,不连接源库,兼容尚未应用自动化迁移的开发库。"""
settings = get_settings()
result = {
"available": False,
"enabled": settings.t_plus_enabled,
"automation_enabled": settings.t_plus_automation_enabled,
"latest_status": None,
"last_success_at": None,
"historical_failure_total": 0,
}
if not inspect(session.connection()).has_table(TPlusSyncBatch.__tablename__):
return result
# 关联检查成功不能替代数据采集成功;仅查询原有列,避免加载 V20 新字段。
reads = TPlusSyncBatch.sync_mode.in_(("FULL", "INCREMENTAL"))
result["available"] = True
result["latest_status"] = session.scalar(
select(TPlusSyncBatch.status).where(reads).order_by(TPlusSyncBatch.id.desc()).limit(1)
)
result["last_success_at"] = session.scalar(
select(func.max(TPlusSyncBatch.finished_at)).where(
reads, TPlusSyncBatch.status == "SUCCEEDED"
)
)
result["historical_failure_total"] = session.scalar(
select(func.count()).select_from(TPlusSyncBatch).where(
reads, TPlusSyncBatch.status.in_(("FAILED", "PARTIAL"))
)
) or 0
return result
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 _month_start(month: str) -> date:
"""YYYY-MM → 该月首日(范围查询边界,避免函数列过滤)。"""
return date(int(month[:4]), int(month[5:7]), 1)
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,
start_date=_month_start(months[0]),
end_date=_month_start(_shift_month(months[-1], 1)),
)
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, pending_statuses: tuple[int, ...], sync: dict) -> dict:
"""待办事项(FR-034,澄清 #11 起步范围)。
``pending_statuses`` 已按当前用户角色解析:财务只看待财务复核、
领导只看待领导审核;admin 查看全部在途单。
"""
orders = dashboard_repository.list_pending_approvals(
session, statuses=pending_statuses, limit=_TODO_LIMIT
)
receipts = dashboard_repository.list_unassigned_receipts(session, limit=_TODO_LIMIT)
return {
"pending_approval_total": dashboard_repository.count_pending_approvals(
session, statuses=pending_statuses
),
"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
],
"sync_available": sync["available"],
"sync_failure_total": sync["historical_failure_total"],
}
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, sync: 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": [],
"total": 0,
"receipt_overdue_total": 0,
"approval_timeout_total": 0,
"sync_available": sync["available"],
}
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
]
receipt_overdue_total = dashboard_repository.count_overdue_shipments(
session, cutoff=receipt_cutoff
)
approval_timeout_total = dashboard_repository.count_overdue_approvals(
session, cutoff=approval_cutoff
)
return {
"enabled": True,
"receipt_overdue_days": params["receipt_overdue_days"],
"approval_timeout_days": params["approval_timeout_days"],
"items": items,
# items 有展示上限(_WARNING_LIMIT),徽标计数必须用全量命中数
"total": receipt_overdue_total + approval_timeout_total,
"receipt_overdue_total": receipt_overdue_total,
"approval_timeout_total": approval_timeout_total,
"sync_available": sync["available"],
}
def list_activities(session: Session, query: ActivityQuery) -> ActivityPageOut:
"""最近动态分页(FR-036):以操作日志为唯一来源(写操作即留痕)。
模块筛选项与列表同源返回(全量模块名,不随当前页变化),
前端模块下拉不必另取一次全量。
"""
repo_query = operation_log_repository.OperationLogListQuery(
filters=LogFilters(),
offset=query.offset,
limit=query.page_size,
module=query.module,
)
logs = operation_log_repository.list_logs(session, repo_query)
total = operation_log_repository.count_logs(session, repo_query)
return ActivityPageOut(
records=[
ActivityItem(
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
],
total=total,
page_num=query.page_num,
page_size=query.page_size,
modules=operation_log_repository.distinct_modules(session),
)
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))