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