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