仓储层的 list_*/count_* 此前以 keyword/status/offset/limit 四五个散参数 传参,调用点重复铺开、易漏传且不可扩展;按项目「参数 >4 封装 DTO」的 约定统一收敛为各模块的 Query 对象(如 RoleQuery、BusinessQuery), 服务层与测试调用同步调整,行为不变。 - repositories 层查询方法签名改为 (session, query),分页与筛选条件 由 Query 承载,_apply_filters 同步收口 - schemas 层补齐各模块 Query 模型(approval/business_role/report 等) - services 层调用点同步改为传 Query - tests 随之调整;新增用例:待办角色流转(财务通过后领导出现待办)、 参数预览顺序稳定 验证:全量 pytest 900 passed(11:08);单元 526 passed;ruff check 绿。 Co-Authored-By: Claude Code <noreply@anthropic.com>
279 lines
11 KiB
Python
279 lines
11 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, user_repository
|
||
from app.repositories.query_utils import LogFilters
|
||
from app.services import approval_service
|
||
|
||
# 工作台只展示摘要,完整列表跳转对应管理页
|
||
_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, *, current_user) -> dict:
|
||
"""四区块一次性出数(前端一次请求渲染整页)。
|
||
|
||
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
|
||
风险预警仍是全局经营视角,不按人裁剪。
|
||
"""
|
||
resolved = _normalize_month(month)
|
||
warning_params = _warning_params(session)
|
||
pending_statuses = approval_service.pending_statuses_for_roles(
|
||
user_repository.get_role_codes(current_user)
|
||
)
|
||
return {
|
||
"month": resolved,
|
||
"overview": _overview(session, resolved),
|
||
"todos": _todos(session, pending_statuses),
|
||
"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 _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": (
|
||
"绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月"
|
||
"(含未核算回款);不含经理 12.5%+5% 聚合分成与调整单"
|
||
),
|
||
}
|
||
|
||
|
||
def _todos(session: Session, pending_statuses: tuple[int, ...]) -> 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
|
||
],
|
||
# ⑥ 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": [],
|
||
"total": 0,
|
||
"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
|
||
]
|
||
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": False,
|
||
}
|
||
|
||
|
||
def _activities(session: Session) -> list[dict]:
|
||
"""最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。"""
|
||
logs = operation_log_repository.list_logs(
|
||
session,
|
||
operation_log_repository.OperationLogListQuery(
|
||
filters=LogFilters(),
|
||
offset=0,
|
||
limit=_ACTIVITY_LIMIT,
|
||
),
|
||
)
|
||
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))
|