PayrollManagementSystem/backend/app/services/dashboard_service.py

329 lines
13 KiB
Python
Raw Normal View History

"""工作台服务(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))