Task: finance-20261003-cpa-customer-profitability-mvp-fullstack (P0-a / P0-b) - backend/app/utils/cpa_caliber.py (new): single source of truth for customer-level revenue aggregation from zxt_masterbill. Reused by alert_rules._customer_revenue_shares (dedupe: no second implementation). - backend/scripts/migrate_cpa_snapshot_20261004.py (new): idempotent DDL for customer_profitability_snapshot (UNIQUE entity_id+period+unit_id), --check / --apply / --rollback / --status / --show-create + journal. - backend/scripts/kpi_customer_profitability_deriver.py (new): monthly snapshot + kpi_values upsert (single transaction, idempotent). fail-closed: cogs_alloc / chan_sub / contrib / contrib_rate stay NULL and are never written as 0 or as an apportioned value. - backend/scripts/sql/cpa_kpi_defs_20261004.sql (new): C_CUST_REVENUE and C_CUST_COUNT active; 5 P1 codes registered inactive with fail-closed reason. - backend/app/api/customer_dashboard.py: new read-only endpoint GET /api/cma/customer-dashboard/standard-indicators returning the 9-card tri-state (value / no_data / undefined). Reads kpi_definitions via raw SQL to stay decoupled from the in-flight KPI direction DDL. - frontend/src/views/CustomerDashboard.vue: 9-card panel rendering the three states (never renders an empty value as 0). Anchor check 2026-01..09 (offline sqlite rehearsal, ALL_PASS=true): 73 customers / 4883.52 wan / top1 25.3835 / top2 23.4905 / top4 63.3249 / top10 81.7675 - all six match the task acceptance anchors. No production write and no deploy in this commit; release is gated.
339 lines
14 KiB
Python
339 lines
14 KiB
Python
"""客户维度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],
|
||
}
|