Files
cma-management/backend/app/api/customer_dashboard.py
T
Hermes CI Fix 451c994a86 feat(cpa): customer profitability MVP P0-a/b - revenue caliber single source + snapshot table + 9-card tri-state
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.
2026-10-09 17:00:37 +08:00

339 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""客户维度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],
}