"""客户维度KPI看板 API — P0-2""" from fastapi import APIRouter, Depends, Query, HTTPException from sqlalchemy.orm import Session from sqlalchemy import func, desc from typing import Optional from datetime import datetime, timedelta from app.database import get_db from app.deps import get_entity_id from app.auth_middleware import require_auth, require_role from app.models import KPIDefinition, KPIValue, KPIAlert, User from app.utils.alert_entity_scope import month_period_clause from app.utils.kpi_value_scope import actual_only_clause import logging logger = logging.getLogger("cma.customer") router = APIRouter(prefix="/api/cma/customer-dashboard", tags=["客户看板"], dependencies=[Depends(require_role("ceo", "finance", "business", "it"))], ) # ── 客户维度「标准指标族」九卡(与前端 CustomerDashboard.vue 的 CUSTOMER_TEMPLATES 逐项对应) # (卡片名, 默认单位, 预设目标值, 实例层 kpi_code 或 None) # 第四列为 None = 该卡在 KPI 库**无可用定义**(页面须显示「未定义·待建」,不得显示 0/无数据)。 STANDARD_INDICATORS = [ ("客户满意度", "分", 90, "C_SATISFACTION"), ("客户留存率", "%", 85, None), ("新客户增长率", "%", 20, None), ("客户渠补率", "%", 95, None), ("客户投诉率", "%", 5, "C_COMPLAINT_RATE"), ("客户平均贡献值", "元", 10000, None), ("客户转化率", "%", 30, None), ("客户活跃度", "%", 70, None), ("NPS净推荐值", "分", 60, None), ] def parse_period(period_type: str, start_date: str = None, end_date: str = None): """解析时间区间""" today = datetime.now() if period_type == "month": start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0) end = today elif period_type == "quarter": q = (today.month - 1) // 3 start = today.replace(month=q*3+1, day=1, hour=0, minute=0, second=0, microsecond=0) end = today elif period_type == "year": start = today.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) end = today elif period_type == "custom" and start_date and end_date: start = datetime.strptime(start_date, "%Y-%m-%d") end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) else: start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0) end = today return start, end @router.get("") def list_customer_kpis( period: str = Query("month"), start_date: str = Query(None), end_date: str = Query(None), db: Session = Depends(get_db), current_user: User = Depends(require_auth), entity_id: int = Depends(get_entity_id), ): """获取客户维度KPI列表(含最新值、预警、趋势)""" start, end = parse_period(period, start_date, end_date) period_str = start.strftime("%Y-%m") # 只查 customer 维度的 KPI kpis = db.query(KPIDefinition).filter( KPIDefinition.status == "active", KPIDefinition.dimension == "customer", KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21 ).order_by(KPIDefinition.kpi_code).all() result = [] for k in kpis: base_query = db.query(KPIValue).filter(KPIValue.kpi_id == k.id) if period == "month": latest = base_query.filter(KPIValue.period == period_str).order_by(KPIValue.id.desc()).first() elif period == "quarter": q_month = (datetime.now().month - 1) // 3 months = [f"{datetime.now().year}-{m:02d}" for m in range(q_month*3+1, q_month*3+4)] values = base_query.filter(KPIValue.period.in_(months)).all() latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{months[0]}~{months[-1]}"})() if latest_val else None elif period == "year": values = base_query.filter(actual_only_clause(), KPIValue.period.like(f"{period_str[:4]}%")).all() latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None latest = type('obj', (object,), {"actual_value": latest_val, "period": period_str[:4]})() if latest_val else None elif period == "custom" and start_date and end_date: periods = [] d = start while d <= end: periods.append(d.strftime("%Y-%m")) d += timedelta(days=32) d = d.replace(day=1) values = base_query.filter(KPIValue.period.in_(set(periods))).all() latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None else: latest = base_query.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first() # 最新预警 alert = db.query(KPIAlert).filter( KPIAlert.kpi_id == k.id, KPIAlert.status == "pending", ).order_by(KPIAlert.id.desc()).first() # 趋势(环比变化率) trend = None achievement_rate = None period_values = [] if latest and latest.actual_value: prev_period_str = None if period == "month": year_s, month_s = period_str.split("-") y_s, m_s = int(year_s), int(month_s) m_s -= 1 if m_s <= 0: m_s += 12 y_s -= 1 prev_period_str = f"{y_s}-{m_s:02d}" if prev_period_str: prev_val = db.query(KPIValue).filter( KPIValue.kpi_id == k.id, KPIValue.period == prev_period_str, ).order_by(KPIValue.id.desc()).first() if prev_val and prev_val.actual_value and prev_val.actual_value > 0: trend = round((latest.actual_value - prev_val.actual_value) / prev_val.actual_value * 100, 2) elif prev_val and prev_val.actual_value and prev_val.actual_value == 0: trend = 100.0 if latest.actual_value > 0 else 0 # 达成率 if latest and latest.actual_value and k.target_value and k.target_value > 0: achievement_rate = round(latest.actual_value / k.target_value * 100, 1) # 最近6期趋势数据 period_q = db.query(KPIValue).filter( KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period), ).order_by(KPIValue.period.desc()).limit(6).all() period_values = [ {"period": v.period, "value": v.actual_value} for v in reversed(period_q) if v.actual_value is not None ] result.append({ "id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name, "dimension": k.dimension, "category": k.category, "unit": k.unit, "target_value": k.target_value, "actual_value": latest.actual_value if latest else None, "period": latest.period if latest else None, "alert_level": alert.alert_level if alert else "none", "alert_message": alert.alert_message if alert else None, "frequency": k.frequency, "responsible_dept": k.responsible_dept, "responsible_user": k.responsible_user, "trend": trend, "achievement_rate": achievement_rate, "period_values": period_values, "kpi_name": k.kpi_name, }) return {"data": result, "period": period, "total": len(result)} @router.get("/standard-indicators") def standard_indicators( db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): """客户维度「标准指标族」九卡三态(真值 / 无数据 / 未定义·待建)——只读。 CPA P0-b(2026-10-04):页面 9 张预设卡此前对「KPI 库无定义」的项渲染成「无数据」, 与「定义在库但无值」不可区分。本端点逐卡给出三态,页面据此渲染标签,**禁止空显示为 0**。 映射为**显式白名单**(不做名称模糊匹配),仅当实例层 KPI 与预设卡语义完全一致才连: 客户满意度 → C_SATISFACTION(实例层同名,active,有值) 客户投诉率 → C_COMPLAINT_RATE(实例层同名,inactive ⇒ 无值) 其余 7 张保持未定义(依据任务 finance-20261003 现状表 §一「7 个在 KPI 库无可用定义」): 客户留存率 ≠ 客户保留率(C_RETENTION_RATE)、客户渠补率 ≠ 渠补率(C_REBATE_RATE)、 新客户增长率 ≠ 新客户数(C_NEW_CLIENTS)、客户平均贡献值/客户转化率/客户活跃度/NPS净推荐值 库内皆无。 """ codes = [c for _, _, _, c in STANDARD_INDICATORS if c] defs = {} if codes: # 用 raw SQL 读定义层:**不用 KPIDefinition ORM**——该模型的 `direction` 列由他单 # (CMA-KPI-20261003 KPI字典分层治理)在途新增,生产库尚未落列,ORM 查询会 1054; # raw SQL 只取本端点需要的列,与在途 DDL 解耦(与 zxt_masterbill 同一形态)。 from sqlalchemy import bindparam, text as _text stmt = _text( "SELECT id, kpi_code, unit, target_value, status FROM kpi_definitions " "WHERE entity_id = :eid AND dimension = 'customer' AND kpi_code IN :codes" ).bindparams(bindparam("codes", expanding=True)) rows = db.execute(stmt, {"eid": entity_id, "codes": codes}).fetchall() defs = {r[1]: {"id": r[0], "unit": r[2], "target_value": r[3], "status": r[4]} for r in rows} items = [] summary = {"value": 0, "no_data": 0, "undefined": 0, "total": len(STANDARD_INDICATORS)} state_labels = {"value": "真值", "no_data": "无数据", "undefined": "未定义·待建"} for name, unit, target, code in STANDARD_INDICATORS: kpi = defs.get(code) if code else None if kpi is None: state = "undefined" items.append({ "name": name, "unit": unit, "target_value": target, "state": state, "state_label": state_labels[state], "kpi_code": None, "kpi_id": None, "kpi_status": None, "actual_value": None, "period": None, "why": "KPI 库无该定义", }) else: val = db.query(KPIValue).filter( KPIValue.kpi_id == kpi["id"], KPIValue.entity_id == entity_id, KPIValue.actual_value.isnot(None), month_period_clause(KPIValue.period), ).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first() state = "value" if val else "no_data" items.append({ "name": name, "unit": kpi["unit"] or unit, "target_value": kpi["target_value"] if kpi["target_value"] is not None else target, "state": state, "state_label": state_labels[state], "kpi_code": code, "kpi_id": kpi["id"], "kpi_status": kpi["status"], "actual_value": val.actual_value if val else None, "period": val.period if val else None, "why": None if val else "定义在库(status=%s)但无任何月期间值" % kpi["status"], }) summary[state] += 1 return { "entity_id": entity_id, "items": items, "summary": summary, "note": ("state: value=定义在库且有值 / no_data=定义在库但无值 / " "undefined=KPI 库无定义(未定义·待建);禁止把 undefined/no_data 显示为 0"), } @router.get("/trend/{kpi_id}") def get_kpi_trend( kpi_id: int, months: int = Query(12, ge=3, le=24), db: Session = Depends(get_db), ): """获取单个KPI的历史趋势数据""" kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() if not kpi: raise HTTPException(404, "KPI不存在") # 获取最近N期数据 values = db.query(KPIValue).filter( KPIValue.kpi_id == kpi_id, month_period_clause(KPIValue.period), ).order_by(KPIValue.period.desc()).limit(months).all() trend_data = [ {"period": v.period, "value": v.actual_value} for v in reversed(values) if v.actual_value is not None ] # 计算预警水平和触发时间 alerts = db.query(KPIAlert).filter( KPIAlert.kpi_id == kpi_id, ).order_by(KPIAlert.created_at.desc()).limit(10).all() alert_logs = [ { "level": a.alert_level, "message": a.alert_message, "time": a.created_at.isoformat() if a.created_at else None, "status": a.status, } for a in alerts ] return { "kpi": { "id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name, "target_value": kpi.target_value, "unit": kpi.unit, "threshold_green": kpi.threshold_green, "threshold_yellow": kpi.threshold_yellow, "threshold_red": kpi.threshold_red, }, "trend_data": trend_data, "alerts": alert_logs, } @router.get("/summary") def get_customer_summary( period: str = Query("month"), db: Session = Depends(get_db), ): """客户维度概要统计""" total = db.query(func.count(KPIDefinition.id)).filter( KPIDefinition.status == "active", KPIDefinition.dimension == "customer", ).scalar() or 0 # 预警统计 pending_alerts = db.query(func.count(KPIAlert.id)).filter( KPIAlert.status == "pending", KPIAlert.kpi_id.in_( db.query(KPIDefinition.id).filter( KPIDefinition.status == "active", KPIDefinition.dimension == "customer", ) ), ).scalar() or 0 # 二级类别分布 cat_stats = db.query( KPIDefinition.category, func.count(KPIDefinition.id), ).filter( KPIDefinition.status == "active", KPIDefinition.dimension == "customer", ).group_by(KPIDefinition.category).all() return { "total": total, "pending_alerts": pending_alerts, "category_stats": [{"category": c[0], "count": c[1]} for c in cat_stats], }