按 2026-11-02 需求补齐计划交付后端能力: - 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝; 管理员判断改为按数据库角色重新判定,防止权限码持有者绕过 - 系统监控:单进程采集资源、请求与异常指标,分钟级聚合, 阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义 - 试用期计提:按回款日判定并写入快照,年度结清与调整单联动; 人员模型补试用期、离职日期与工资版本历史字段 - 计提模板:公式骨架受控,页面维护名称与参数绑定 - T+ 采集:自动化配置表与进度查询 内置角色禁停用保护对齐模块注释;核算规则版本提为常量并 补充批量核算失败堆栈日志。 影响范围:后端全部模块、数据库迁移 V22-V28 验证:uv run ruff check 通过;922 个单元测试全部通过; 集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
329 lines
13 KiB
Python
329 lines
13 KiB
Python
"""工作台服务(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))
|