feat: 增长质量诊断模块 — 五维评分+雷达图+跨期对比+趋势
This commit is contained in:
@@ -0,0 +1,549 @@
|
||||
"""增长质量诊断 API — 五维评分 + 诊断结论 + 跨期对比
|
||||
|
||||
数据来源:KPI字典 (kpi_definitions) + KPI实际值 (kpi_values)
|
||||
五维度:营收增长 / 利润质量 / 现金质量 / 增长效率 / 组织健康
|
||||
评分区间:0-100(>=80 好 / 60-79 中 / <60 差)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, Entity
|
||||
|
||||
router = APIRouter(prefix="/api/cma/growth-quality", tags=["增长质量诊断"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
# 五维度定义
|
||||
DIMENSIONS = [
|
||||
{"key": "revenueGrowth", "name": "营收增长", "weight": 20, "icon": "📈",
|
||||
"desc": "营收增速、增长持续性"},
|
||||
{"key": "profitQuality", "name": "利润质量", "weight": 20, "icon": "💰",
|
||||
"desc": "毛利率/净利率趋势、利润与收入匹配"},
|
||||
{"key": "cashQuality", "name": "现金质量", "weight": 20, "icon": "🏦",
|
||||
"desc": "经营现金流与净利润匹配度(含金量)"},
|
||||
{"key": "growthEfficiency", "name": "增长效率", "weight": 20, "icon": "🚀",
|
||||
"desc": "获客成本、单位增长投入产出"},
|
||||
{"key": "orgHealth", "name": "组织健康", "weight": 20, "icon": "⚡",
|
||||
"desc": "人效、费用结构"},
|
||||
]
|
||||
|
||||
# 关键KPI编码 → 维度用途
|
||||
KPI_CODES = {
|
||||
"F_REVENUE": "营业收入(万元)",
|
||||
"F_NET_PROFIT": "净利润(万元)",
|
||||
"F_OP_CFLOW": "经营性现金流(万元)",
|
||||
"F_GROSS_MARGIN": "毛利率(%)",
|
||||
"F_OP_PROFIT_MARGIN": "经营利润率(%)",
|
||||
"F_COST_RATIO": "费用率(%)",
|
||||
"F_REVENUE_GROWTH": "收入增长率(%)",
|
||||
"F_FCF": "自由现金流(万元)",
|
||||
"C_NEW_CLIENTS": "新客户数",
|
||||
"C_REBATE_RATE": "渠补率(%)",
|
||||
"C_SATISFACTION": "客户满意度",
|
||||
"L_TRAINING": "培训完成率",
|
||||
"P_DELIVERY": "交付及时率",
|
||||
"F_AR_DAYS": "应收账款周转天数",
|
||||
}
|
||||
|
||||
MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
|
||||
|
||||
|
||||
def _fetch_kpi_values(db: Session, entity_id: int, period: str) -> dict:
|
||||
"""拉取某实体某期间的全部KPI值 {kpi_code: actual_value}"""
|
||||
rows = (db.query(KPIDefinition.kpi_code, KPIValue.actual_value)
|
||||
.join(KPIValue, KPIValue.kpi_id == KPIDefinition.id)
|
||||
.filter(KPIDefinition.entity_id == entity_id,
|
||||
KPIValue.period == period,
|
||||
KPIValue.actual_value.isnot(None))
|
||||
.all())
|
||||
return {code: value for code, value in rows}
|
||||
|
||||
|
||||
def _fetch_history(db: Session, entity_id: int, limit: int = 12) -> List[dict]:
|
||||
"""拉取最近 N 个期间(按月,含数据)的 KPI 值,供趋势/持续性分析"""
|
||||
periods = (db.query(KPIValue.period)
|
||||
.join(KPIDefinition, KPIDefinition.id == KPIValue.kpi_id)
|
||||
.filter(KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.kpi_code.in_(["F_REVENUE", "F_NET_PROFIT", "F_OP_CFLOW"]))
|
||||
.distinct().all())
|
||||
plist = sorted({p[0] for p in periods}, reverse=True)
|
||||
# 只保留 YYYY-MM 格式,按时间排序(旧→新)
|
||||
months = sorted([p for p in plist if MONTH_RE.match(p)])
|
||||
hist = []
|
||||
for p in months[-limit:]:
|
||||
hist.append({"period": p, **{k: None for k in KPI_CODES}})
|
||||
if not hist:
|
||||
return []
|
||||
# 批量取数
|
||||
rows = (db.query(KPIDefinition.kpi_code, KPIValue.period, KPIValue.actual_value)
|
||||
.join(KPIValue, KPIValue.kpi_id == KPIDefinition.id)
|
||||
.filter(KPIDefinition.entity_id == entity_id,
|
||||
KPIValue.period.in_([h["period"] for h in hist]))
|
||||
.all())
|
||||
idx = {h["period"]: h for h in hist}
|
||||
for code, period, val in rows:
|
||||
if period in idx and code in idx[period]:
|
||||
idx[period][code] = val
|
||||
return hist
|
||||
|
||||
|
||||
def _prev_period(period: str) -> Optional[str]:
|
||||
"""计算上期(YYYY-MM → 上一月;其他格式 → None)"""
|
||||
m = MONTH_RE.match(period)
|
||||
if not m:
|
||||
return None
|
||||
y, mo = int(period[:4]), int(period[5:7])
|
||||
if mo == 1:
|
||||
return f"{y-1:04d}-12"
|
||||
return f"{y:04d}-{mo-1:02d}"
|
||||
|
||||
|
||||
def _yoy_period(period: str) -> Optional[str]:
|
||||
"""计算去年同期(YYYY-MM → 去年同月)"""
|
||||
m = MONTH_RE.match(period)
|
||||
if not m:
|
||||
return None
|
||||
return f"{int(period[:4])-1:04d}-{period[5:7]}"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 五维度评分引擎(0-100)
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _clamp(v: float, lo: float = 0.0, hi: float = 100.0) -> float:
|
||||
return max(lo, min(hi, v))
|
||||
|
||||
|
||||
def _linear(value, points: List[tuple]):
|
||||
"""分段线性插值评分: points = [(x, score), ...] 按 x 升序"""
|
||||
if value is None:
|
||||
return 50.0
|
||||
if value <= points[0][0]:
|
||||
return points[0][1]
|
||||
if value >= points[-1][0]:
|
||||
return points[-1][1]
|
||||
for (x1, s1), (x2, s2) in zip(points, points[1:]):
|
||||
if x1 <= value <= x2:
|
||||
if x2 == x1:
|
||||
return s1
|
||||
return s1 + (s2 - s1) * (value - x1) / (x2 - x1)
|
||||
return 50.0
|
||||
|
||||
|
||||
# ── 1. 营收增长:增速 + 持续性 ──
|
||||
def _score_revenue_growth(cur: dict, prev: dict, history: List[dict]) -> float:
|
||||
rev_cur = cur.get("F_REVENUE")
|
||||
rev_prev = prev.get("F_REVENUE") if prev else None
|
||||
# 优先用 KPI 直接给的收入增长率
|
||||
kpi_growth = cur.get("F_REVENUE_GROWTH")
|
||||
growth = None
|
||||
if kpi_growth is not None:
|
||||
growth = float(kpi_growth)
|
||||
elif rev_cur is not None and rev_prev:
|
||||
growth = (rev_cur - rev_prev) / rev_prev * 100 if rev_prev else None
|
||||
|
||||
score = _linear(growth, [
|
||||
(-30, 5), (-20, 15), (-10, 30), (0, 45), (5, 60), (10, 70),
|
||||
(20, 82), (30, 90), (50, 96),
|
||||
])
|
||||
# 增长持续性:近6个月中收入增长月占比
|
||||
if len(history) >= 2:
|
||||
revs = [h.get("F_REVENUE") for h in history if h.get("F_REVENUE") is not None]
|
||||
ups = 0
|
||||
for i in range(1, len(revs)):
|
||||
if revs[i] > revs[i - 1]:
|
||||
ups += 1
|
||||
persist = ups / (len(revs) - 1) if len(revs) > 1 else 0.5
|
||||
score = score * 0.7 + persist * 100 * 0.3
|
||||
return round(_clamp(score), 1)
|
||||
|
||||
|
||||
# ── 2. 利润质量:毛利率/净利率水平 + 趋势 + 收入匹配 ──
|
||||
def _score_profit_quality(cur: dict, prev: dict) -> float:
|
||||
gm = cur.get("F_GROSS_MARGIN")
|
||||
np_ = cur.get("F_NET_PROFIT")
|
||||
rev = cur.get("F_REVENUE")
|
||||
net_margin = (np_ / rev * 100) if (np_ is not None and rev) else None
|
||||
opm = cur.get("F_OP_PROFIT_MARGIN")
|
||||
|
||||
gm_score = _linear(gm, [(-10, 5), (0, 10), (10, 25), (20, 45), (30, 62),
|
||||
(40, 75), (55, 88), (70, 95)])
|
||||
nm_score = _linear(net_margin, [(-50, 0), (-20, 10), (-10, 20), (0, 35),
|
||||
(10, 60), (20, 78), (30, 90)])
|
||||
opm_score = _linear(opm, [(-20, 10), (0, 30), (10, 55), (20, 75), (35, 90)])
|
||||
|
||||
# 毛利率 40% + 净利率 40% + 经营利润率 20%
|
||||
base = gm_score * 0.4 + nm_score * 0.4 + opm_score * 0.2
|
||||
|
||||
# 利润与收入匹配:收入升但利润降 → 扣分
|
||||
if prev and rev is not None and np_ is not None:
|
||||
prev_rev = prev.get("F_REVENUE")
|
||||
prev_np = prev.get("F_NET_PROFIT")
|
||||
if prev_rev and prev_np is not None:
|
||||
rev_up = rev > prev_rev
|
||||
np_down = np_ < prev_np
|
||||
if rev_up and np_down:
|
||||
base -= 10
|
||||
elif np_down:
|
||||
base -= 5
|
||||
return round(_clamp(base), 1)
|
||||
|
||||
|
||||
# ── 3. 现金质量:含金量(OCF/净利润) + 现金流强度 ──
|
||||
def _score_cash_quality(cur: dict) -> float:
|
||||
ocf = cur.get("F_OP_CFLOW")
|
||||
np_ = cur.get("F_NET_PROFIT")
|
||||
rev = cur.get("F_REVENUE")
|
||||
fcf = cur.get("F_FCF")
|
||||
|
||||
# 含金量 = OCF / 净利润(净利润>0时)
|
||||
gold = None
|
||||
if ocf is not None and np_ is not None and np_ > 0:
|
||||
gold = ocf / np_
|
||||
# 净利润<=0:利润为负,含金量指标失效 → 低分(除非现金流强)
|
||||
gold_score = _linear(gold, [(0, 10), (0.5, 35), (0.8, 55), (1.0, 70),
|
||||
(1.2, 85), (1.5, 95)])
|
||||
if np_ is not None and np_ <= 0:
|
||||
gold_score = 15 if (ocf is None or ocf <= 0) else 35
|
||||
|
||||
# 现金流强度 = OCF / 收入
|
||||
ocf_ratio = (ocf / rev * 100) if (ocf is not None and rev) else None
|
||||
ocf_score = _linear(ocf_ratio, [(-20, 5), (0, 20), (10, 50), (20, 75),
|
||||
(30, 90), (50, 98)])
|
||||
fcf_score = _linear(fcf, [(-100, 10), (-20, 30), (0, 50), (20, 70),
|
||||
(100, 90)]) if fcf is not None else 50.0
|
||||
|
||||
score = gold_score * 0.5 + ocf_score * 0.35 + fcf_score * 0.15
|
||||
return round(_clamp(score), 1)
|
||||
|
||||
|
||||
# ── 4. 增长效率:费用率水平 + 单位增长投入产出 + 获客成本 ──
|
||||
def _score_growth_efficiency(cur: dict, prev: dict) -> float:
|
||||
cost_ratio = cur.get("F_COST_RATIO")
|
||||
cost_score = _linear(cost_ratio, [(10, 95), (20, 82), (30, 68), (40, 55),
|
||||
(55, 40), (70, 25), (90, 10)])
|
||||
|
||||
# 费用增速 vs 收入增速(用费用率变化近似)
|
||||
eff_score = 60.0
|
||||
if prev is not None and cost_ratio is not None:
|
||||
prev_cr = prev.get("F_COST_RATIO")
|
||||
if prev_cr:
|
||||
cr_change = cost_ratio - prev_cr
|
||||
eff_score = _linear(cr_change, [(-15, 95), (-5, 80), (0, 65),
|
||||
(5, 45), (15, 25), (30, 10)])
|
||||
|
||||
# 获客成本代理:收入/新客户数(越高越高效)
|
||||
rev = cur.get("F_REVENUE")
|
||||
new_clients = cur.get("C_NEW_CLIENTS")
|
||||
cac_score = 60.0
|
||||
if rev is not None and new_clients:
|
||||
per_client = rev / new_clients
|
||||
cac_score = _linear(per_client, [(0, 40), (50, 50), (200, 65),
|
||||
(500, 78), (1000, 88)])
|
||||
|
||||
score = cost_score * 0.45 + eff_score * 0.35 + cac_score * 0.2
|
||||
return round(_clamp(score), 1)
|
||||
|
||||
|
||||
# ── 5. 组织健康:费用结构 + 人效/运营质量 ──
|
||||
def _score_org_health(cur: dict) -> float:
|
||||
cost_ratio = cur.get("F_COST_RATIO")
|
||||
# 费用结构(费用率越低越健康)
|
||||
cost_score = _linear(cost_ratio, [(10, 95), (20, 82), (30, 68), (40, 55),
|
||||
(55, 40), (70, 25), (90, 10)])
|
||||
# 运营/人效质量代理:满意度、培训、交付、应收
|
||||
sat = cur.get("C_SATISFACTION")
|
||||
train = cur.get("L_TRAINING")
|
||||
deliver = cur.get("P_DELIVERY")
|
||||
ar_days = cur.get("F_AR_DAYS")
|
||||
op_vals = [v for v in [sat, train, deliver] if v is not None]
|
||||
op_score = (sum(op_vals) / len(op_vals)) if op_vals else 55.0
|
||||
ar_score = _linear(ar_days, [(15, 95), (30, 80), (45, 65), (60, 50),
|
||||
(90, 30), (120, 15)]) if ar_days is not None else 55.0
|
||||
|
||||
score = cost_score * 0.4 + op_score * 0.35 + ar_score * 0.25
|
||||
return round(_clamp(score), 1)
|
||||
|
||||
|
||||
_SCORERS = {
|
||||
"revenueGrowth": _score_revenue_growth,
|
||||
"profitQuality": _score_profit_quality,
|
||||
"cashQuality": _score_cash_quality,
|
||||
"growthEfficiency": _score_growth_efficiency,
|
||||
"orgHealth": _score_org_health,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 明细指标 + 改善建议
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _fmt(v, unit=""):
|
||||
if v is None:
|
||||
return "—"
|
||||
if isinstance(v, float) and v == int(v):
|
||||
return f"{int(v)}{unit}"
|
||||
return f"{round(v, 2)}{unit}"
|
||||
|
||||
|
||||
def _dim_indicators(dim_key: str, cur: dict, prev: dict) -> List[dict]:
|
||||
"""维度明细指标(label/value/verdict/status)"""
|
||||
inds = []
|
||||
def add(label, value, verdict, status):
|
||||
inds.append({"label": label, "value": value, "verdict": verdict, "status": status})
|
||||
|
||||
if dim_key == "revenueGrowth":
|
||||
rev, prev_rev = cur.get("F_REVENUE"), (prev or {}).get("F_REVENUE")
|
||||
growth = None
|
||||
if rev is not None and prev_rev:
|
||||
growth = (rev - prev_rev) / prev_rev * 100
|
||||
add("营业收入", _fmt(rev, "万"), "环比" + (_fmt(growth, "%") if growth is not None else "无上期数据"),
|
||||
"success" if (growth or 0) >= 0 else "danger")
|
||||
add("收入增长率(KPI)", _fmt(cur.get("F_REVENUE_GROWTH"), "%"),
|
||||
"KPI直接值" if cur.get("F_REVENUE_GROWTH") is not None else "未录入",
|
||||
"success" if (cur.get("F_REVENUE_GROWTH") or 0) >= 10 else "warning")
|
||||
elif dim_key == "profitQuality":
|
||||
rev, np_ = cur.get("F_REVENUE"), cur.get("F_NET_PROFIT")
|
||||
nm = (np_ / rev * 100) if (np_ is not None and rev) else None
|
||||
add("毛利率", _fmt(cur.get("F_GROSS_MARGIN"), "%"),
|
||||
"毛利健康" if (cur.get("F_GROSS_MARGIN") or 0) >= 30 else "毛利偏低",
|
||||
"success" if (cur.get("F_GROSS_MARGIN") or 0) >= 30 else "danger")
|
||||
add("净利率", _fmt(nm, "%"),
|
||||
"盈利" if (nm or 0) > 0 else "亏损",
|
||||
"success" if (nm or 0) > 10 else "danger")
|
||||
add("经营利润率", _fmt(cur.get("F_OP_PROFIT_MARGIN"), "%"),
|
||||
"正常" if (cur.get("F_OP_PROFIT_MARGIN") or 0) >= 15 else "偏低",
|
||||
"success" if (cur.get("F_OP_PROFIT_MARGIN") or 0) >= 15 else "warning")
|
||||
elif dim_key == "cashQuality":
|
||||
ocf, np_ = cur.get("F_OP_CFLOW"), cur.get("F_NET_PROFIT")
|
||||
gold = (ocf / np_) if (ocf is not None and np_ and np_ > 0) else None
|
||||
add("经营现金流", _fmt(ocf, "万"),
|
||||
"现金流入" if (ocf or 0) > 0 else "现金流出",
|
||||
"success" if (ocf or 0) > 0 else "danger")
|
||||
add("含金量(OCF/净利润)", _fmt(gold, "倍"),
|
||||
"含金量高" if (gold or 0) >= 1 else ("利润为负" if (np_ or 0) <= 0 else "含金量低"),
|
||||
"success" if (gold or 0) >= 1 else "danger")
|
||||
add("自由现金流", _fmt(cur.get("F_FCF"), "万"),
|
||||
"正常" if (cur.get("F_FCF") or 0) > 0 else "为负",
|
||||
"success" if (cur.get("F_FCF") or 0) > 0 else "warning")
|
||||
elif dim_key == "growthEfficiency":
|
||||
rev, nc = cur.get("F_REVENUE"), cur.get("C_NEW_CLIENTS")
|
||||
per = (rev / nc) if (rev is not None and nc) else None
|
||||
add("费用率", _fmt(cur.get("F_COST_RATIO"), "%"),
|
||||
"费用可控" if (cur.get("F_COST_RATIO") or 0) <= 30 else "费用偏高",
|
||||
"success" if (cur.get("F_COST_RATIO") or 0) <= 30 else "warning")
|
||||
add("单位客户营收(万/户)", _fmt(per),
|
||||
"获客效率高" if (per or 0) >= 200 else "获客效率一般",
|
||||
"success" if (per or 0) >= 500 else "warning")
|
||||
add("渠补率", _fmt(cur.get("C_REBATE_RATE"), "%"),
|
||||
"渠道依赖" if (cur.get("C_REBATE_RATE") or 0) > 50 else "渠道健康",
|
||||
"danger" if (cur.get("C_REBATE_RATE") or 0) > 50 else "success")
|
||||
elif dim_key == "orgHealth":
|
||||
add("费用率(结构)", _fmt(cur.get("F_COST_RATIO"), "%"),
|
||||
"结构健康" if (cur.get("F_COST_RATIO") or 0) <= 30 else "结构偏重",
|
||||
"success" if (cur.get("F_COST_RATIO") or 0) <= 30 else "warning")
|
||||
add("应收周转天数", _fmt(cur.get("F_AR_DAYS"), "天"),
|
||||
"回款快" if (cur.get("F_AR_DAYS") or 0) <= 45 else "回款偏慢",
|
||||
"success" if (cur.get("F_AR_DAYS") or 0) <= 45 else "warning")
|
||||
add("客户满意度", _fmt(cur.get("C_SATISFACTION")),
|
||||
"满意" if (cur.get("C_SATISFACTION") or 0) >= 80 else "待提升",
|
||||
"success" if (cur.get("C_SATISFACTION") or 0) >= 80 else "warning")
|
||||
add("培训完成率", _fmt(cur.get("L_TRAINING"), "%"),
|
||||
"学习投入足" if (cur.get("L_TRAINING") or 0) >= 80 else "学习投入不足",
|
||||
"success" if (cur.get("L_TRAINING") or 0) >= 80 else "warning")
|
||||
return inds
|
||||
|
||||
|
||||
def _dim_suggestions(dim_key: str, score: float, cur: dict) -> List[str]:
|
||||
"""按维度评分生成改善建议"""
|
||||
if score >= 80:
|
||||
return ["该维度表现良好,建议保持并固化为标准流程"]
|
||||
sug = []
|
||||
if dim_key == "revenueGrowth":
|
||||
sug = ["挖掘存量客户复购,稳定收入基本盘",
|
||||
"拓展新渠道/新产品线,提升营收增速",
|
||||
"跟踪F_REVENUE_GROWTH KPI按月更新,建立增长预警线"]
|
||||
elif dim_key == "profitQuality":
|
||||
sug = ["排查毛利率下滑原因(成本/价格/渠补),优先止血",
|
||||
"控制费用增速不超过收入增速,改善净利率",
|
||||
"对亏损产品线做盈亏平衡分析,必要时收缩"]
|
||||
elif dim_key == "cashQuality":
|
||||
sug = ["加强应收账款催收,缩短回款周期",
|
||||
"压缩非必要开支,提升经营现金流净额",
|
||||
"建立现金流月度滚动预测,防范断流风险"]
|
||||
elif dim_key == "growthEfficiency":
|
||||
sug = ["优化费用结构,降低费用率至30%以下",
|
||||
"评估渠道返利政策,降低渠补率与渠道依赖",
|
||||
"提高获客转化率,降低单位获客成本"]
|
||||
elif dim_key == "orgHealth":
|
||||
sug = ["精简组织与费用结构,提升人效",
|
||||
"强化培训与人才梯队建设(盯L_TRAINING)",
|
||||
"优化应收管理,缩短周转天数"]
|
||||
return sug
|
||||
|
||||
|
||||
def _level_of(overall: float) -> dict:
|
||||
if overall >= 80:
|
||||
return {"level": "好", "level_type": "success", "desc": "增长质量优秀,增长可持续"}
|
||||
if overall >= 60:
|
||||
return {"level": "中", "level_type": "warning", "desc": "增长质量中等,存在优化空间"}
|
||||
return {"level": "差", "level_type": "danger", "desc": "增长质量堪忧,需立即干预"}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 诊断主流程
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _diagnose(db: Session, entity_id: int, period: str, history: List[dict]):
|
||||
"""对单个期间执行五维诊断,返回完整诊断对象"""
|
||||
cur = _fetch_kpi_values(db, entity_id, period)
|
||||
prev_period = _prev_period(period)
|
||||
prev = _fetch_kpi_values(db, entity_id, prev_period) if prev_period else {}
|
||||
|
||||
scores = {}
|
||||
dims_payload = {}
|
||||
for dim in DIMENSIONS:
|
||||
key = dim["key"]
|
||||
scorer = _SCORERS[key]
|
||||
if key == "revenueGrowth":
|
||||
s = scorer(cur, prev, history)
|
||||
elif key in ("cashQuality", "orgHealth"):
|
||||
s = scorer(cur)
|
||||
else:
|
||||
s = scorer(cur, prev)
|
||||
scores[key] = s
|
||||
dims_payload[key] = {
|
||||
"key": key, "name": dim["name"], "icon": dim["icon"],
|
||||
"desc": dim["desc"], "weight": dim["weight"],
|
||||
"score": s,
|
||||
"indicators": _dim_indicators(key, cur, prev),
|
||||
"suggestions": _dim_suggestions(key, s, cur),
|
||||
}
|
||||
|
||||
overall = round(sum(scores.values()) / len(scores), 1)
|
||||
level = _level_of(overall)
|
||||
|
||||
# 诊断结论文本
|
||||
low_dims = [d for d in DIMENSIONS if scores[d["key"]] < 60]
|
||||
mid_dims = [d for d in DIMENSIONS if 60 <= scores[d["key"]] < 80]
|
||||
lines = [f"{period} 综合增长质量评分 {overall} 分({level['level']}):{level['desc']}。"]
|
||||
if low_dims:
|
||||
lines.append("需重点关注:" + "、".join(f"{d['name']}({scores[d['key']]}分)" for d in low_dims) + "。")
|
||||
if mid_dims:
|
||||
lines.append("可优化:" + "、".join(f"{d['name']}({scores[d['key']]}分)" for d in mid_dims) + "。")
|
||||
if not low_dims:
|
||||
lines.append("各维度均处于健康区间,增长质量扎实。")
|
||||
diagnosis = "".join(lines)
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"period": period,
|
||||
"overall": overall,
|
||||
"level": level["level"],
|
||||
"level_type": level["level_type"],
|
||||
"diagnosis": diagnosis,
|
||||
"dimensions": dims_payload,
|
||||
"kpi_available": {k: cur.get(k) is not None for k in KPI_CODES},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/periods")
|
||||
def list_periods(entity_id: int = Query(1), db: Session = Depends(get_db)):
|
||||
"""列出某实体有KPI数据的期间(按月,含数据覆盖度,用于前端默认期间选择)"""
|
||||
rows = (db.query(KPIValue.period, KPIValue.kpi_id)
|
||||
.join(KPIDefinition, KPIDefinition.id == KPIValue.kpi_id)
|
||||
.filter(KPIDefinition.entity_id == entity_id)
|
||||
.all())
|
||||
counts: dict = {}
|
||||
for period, kpi_id in rows:
|
||||
if MONTH_RE.match(period or ""):
|
||||
counts[period] = counts.get(period, 0) + 1
|
||||
periods = sorted(counts.keys(), reverse=True)
|
||||
return {"entity_id": entity_id, "periods": periods,
|
||||
"coverage": {p: counts[p] for p in periods}}
|
||||
|
||||
|
||||
@router.get("/diagnosis")
|
||||
def growth_quality_diagnosis(
|
||||
entity_id: int = Query(1, ge=1),
|
||||
period: Optional[str] = Query(None, description="期间 YYYY-MM,默认最近有数据期间"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""增长质量诊断 — 五维评分(0-100) + 诊断结论 + 跨期对比(本期/上期/去年同期)"""
|
||||
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
if not ent:
|
||||
raise HTTPException(404, f"实体 {entity_id} 不存在")
|
||||
|
||||
history = _fetch_history(db, entity_id, limit=12)
|
||||
if not history:
|
||||
raise HTTPException(400, "该实体暂无月度KPI数据,请先录入KPI实际值")
|
||||
|
||||
# 默认取最近且有足够数据覆盖的期间(>=5个KPI值,退化为最近一个)
|
||||
if not period:
|
||||
cov_rows = (db.query(KPIValue.period)
|
||||
.join(KPIDefinition, KPIDefinition.id == KPIValue.kpi_id)
|
||||
.filter(KPIDefinition.entity_id == entity_id)
|
||||
.all())
|
||||
cov: dict = {}
|
||||
for (p,) in cov_rows:
|
||||
if MONTH_RE.match(p or ""):
|
||||
cov[p] = cov.get(p, 0) + 1
|
||||
candidates = sorted([p for p in cov if cov[p] >= 5], reverse=True)
|
||||
period = candidates[0] if candidates else history[-1]["period"]
|
||||
|
||||
current = _diagnose(db, entity_id, period, history)
|
||||
|
||||
# 跨期对比:上期 + 去年同期
|
||||
prev_p = _prev_period(period)
|
||||
yoy_p = _yoy_period(period)
|
||||
prev_data = _fetch_kpi_values(db, entity_id, prev_p) if prev_p else {}
|
||||
yoy_data = _fetch_kpi_values(db, entity_id, yoy_p) if yoy_p else {}
|
||||
|
||||
comparison = {
|
||||
"current": {
|
||||
"period": period, "overall": current["overall"],
|
||||
"level": current["level"], "level_type": current["level_type"],
|
||||
"dimensions": {k: v["score"] for k, v in current["dimensions"].items()},
|
||||
},
|
||||
}
|
||||
if prev_p and prev_data:
|
||||
pdiag = _diagnose(db, entity_id, prev_p, history)
|
||||
comparison["previous"] = {
|
||||
"period": prev_p, "overall": pdiag["overall"],
|
||||
"level": pdiag["level"], "level_type": pdiag["level_type"],
|
||||
"dimensions": {k: v["score"] for k, v in pdiag["dimensions"].items()},
|
||||
}
|
||||
if yoy_p and yoy_data:
|
||||
ydiag = _diagnose(db, entity_id, yoy_p, history)
|
||||
comparison["yoy"] = {
|
||||
"period": yoy_p, "overall": ydiag["overall"],
|
||||
"level": ydiag["level"], "level_type": ydiag["level_type"],
|
||||
"dimensions": {k: v["score"] for k, v in ydiag["dimensions"].items()},
|
||||
}
|
||||
|
||||
# 趋势:近12个月综合评分
|
||||
trend = []
|
||||
for h in history:
|
||||
try:
|
||||
d = _diagnose(db, entity_id, h["period"], history)
|
||||
trend.append({"period": h["period"], "overall": d["overall"]})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return {
|
||||
"entity": {"id": ent.id, "name": ent.name, "short_name": ent.short_name},
|
||||
"period": period,
|
||||
"overall": current["overall"],
|
||||
"level": current["level"],
|
||||
"level_type": current["level_type"],
|
||||
"diagnosis": current["diagnosis"],
|
||||
"dimensions": current["dimensions"],
|
||||
"comparison": comparison,
|
||||
"trend": trend,
|
||||
"kpi_available": current["kpi_available"],
|
||||
}
|
||||
+2
-1
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from dotenv import load_dotenv
|
||||
from app.database import init_db
|
||||
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify
|
||||
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality
|
||||
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||
from scripts.erp_sync import run_sync as run_erp_sync
|
||||
from app.auth_middleware import require_auth
|
||||
@@ -52,6 +52,7 @@ app.include_router(versions.router)
|
||||
app.include_router(budget.router)
|
||||
app.include_router(cost.router)
|
||||
app.include_router(predict.router)
|
||||
app.include_router(growth_quality.router)
|
||||
app.include_router(reports.router)
|
||||
app.include_router(security.router)
|
||||
app.include_router(knowledge.router)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
# 启动 CMA 后端 (增长质量诊断开发用)
|
||||
cd /root/cma-management/backend
|
||||
exec /root/cma-management/backend/venv/bin/python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8010 --workers 1 --limit-max-requests 10000 --timeout-keep-alive 30 --no-access-log
|
||||
Vendored
-1
@@ -29,7 +29,6 @@ declare module 'vue' {
|
||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
|
||||
@@ -9,7 +9,7 @@ interface MenuItem {
|
||||
|
||||
// ── 角色路由映射 ──
|
||||
export const ROLE_ROUTES: Record<string, string[]> = {
|
||||
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/tax-compliance'],
|
||||
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/growth-quality', '/tax-compliance'],
|
||||
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/tax-compliance'],
|
||||
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/tax-compliance'],
|
||||
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/tax-compliance'],
|
||||
@@ -35,6 +35,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||
{ path: '/expenses', label: '费用审核', icon: 'Money', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
||||
{ path: '/tax-compliance', label: '税务合规', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
||||
{ path: '/cash-plan', label: '收付款计划', icon: 'Money', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
||||
{ path: '/growth-quality', label: '增长质量诊断', icon: 'TrendCharts', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
||||
{ path: '/deviations', label: '差异分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
||||
|
||||
// ── GROUP 3: 监控与评价(Check)──
|
||||
|
||||
@@ -43,6 +43,7 @@ const routes = [
|
||||
{ path: 'analysis-confidence', name: 'AnalysisConfidence', component: () => import('@/views/AnalysisConfidence.vue'), meta: { title: '分析置信度', roles: ['ceo', 'finance', 'it'] } },
|
||||
{ path: 'expenses', name: 'ExpenseManage', component: () => import('@/views/ExpenseManage.vue'), meta: { title: '费用审核', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||
{ path: 'cash-plan', name: 'CashPlan', component: () => import('@/views/CashPlan.vue'), meta: { title: '收付款计划', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||
{ path: 'growth-quality', name: 'GrowthQuality', component: () => import('@/views/GrowthQuality.vue'), meta: { title: '增长质量诊断', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||
{ path: 'tax-compliance', name: 'TaxCompliance', component: () => import('@/views/TaxCompliance.vue'), meta: { title: '税务合规', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="growth-quality-page">
|
||||
<div class="page-header">
|
||||
<h2>📈 增长质量诊断</h2>
|
||||
<div class="header-right">
|
||||
<el-select v-model="entityId" size="small" style="width:130px" @change="loadData">
|
||||
<el-option :value="1" label="酣客" />
|
||||
<el-option :value="2" label="博海" />
|
||||
</el-select>
|
||||
<el-select v-model="period" size="small" style="width:140px" @change="loadData" :loading="loadingPeriods">
|
||||
<el-option v-for="p in periods" :key="p" :value="p" :label="p" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16" v-loading="loading">
|
||||
<!-- 总体评分 -->
|
||||
<el-col :span="8">
|
||||
<el-card class="score-card" shadow="never">
|
||||
<div class="overall-score" :style="{ color: overallColor }">
|
||||
{{ overall != null ? overall.toFixed(1) : '-' }}
|
||||
</div>
|
||||
<div class="overall-label">综合增长质量评分</div>
|
||||
<el-tag :type="overallTag" size="small" style="margin-top:8px">{{ level }}</el-tag>
|
||||
<div class="diagnosis-text">{{ diagnosis }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 雷达图 -->
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never" header="五维雷达图">
|
||||
<div ref="radarRef" style="height:300px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 跨期对比 -->
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never" header="跨期对比">
|
||||
<div ref="compareRef" style="height:300px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 五维明细 -->
|
||||
<el-row :gutter="16" style="margin-top:16px">
|
||||
<el-col :span="24">
|
||||
<el-card shadow="never">
|
||||
<template #header>五维明细</template>
|
||||
<el-table :data="dimensionRows" size="small">
|
||||
<el-table-column prop="name" label="维度" width="140" />
|
||||
<el-table-column prop="score" label="评分" width="90">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.color, fontWeight: 600 }">{{ row.score }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="level" label="等级" width="80" />
|
||||
<el-table-column label="关键指标" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="ind in row.indicators" :key="ind.label" size="small" style="margin:2px" :type="ind.status || 'info'">
|
||||
{{ ind.label }}: {{ ind.value }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="suggestion" label="建议" min-width="220" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 趋势 -->
|
||||
<el-row style="margin-top:16px">
|
||||
<el-col :span="24">
|
||||
<el-card shadow="never" header="12个月趋势">
|
||||
<div ref="trendRef" style="height:260px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { api } from '@/api'
|
||||
|
||||
const entityId = ref(Number(localStorage.getItem('cma_entity_id') || 1))
|
||||
const period = ref('2026-06')
|
||||
const periods = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const loadingPeriods = ref(false)
|
||||
const overall = ref<number | null>(null)
|
||||
const level = ref('')
|
||||
const levelType = ref('info')
|
||||
const diagnosis = ref('')
|
||||
const dimensions = ref<any>({})
|
||||
const comparison = ref<any>({})
|
||||
const trend = ref<any[]>([])
|
||||
|
||||
const radarRef = ref<HTMLElement>()
|
||||
const compareRef = ref<HTMLElement>()
|
||||
const trendRef = ref<HTMLElement>()
|
||||
|
||||
const overallTag = computed(() => levelType.value as any)
|
||||
const overallColor = computed(() => {
|
||||
if (levelType.value === 'success') return '#67c23a'
|
||||
if (levelType.value === 'warning') return '#e6a23c'
|
||||
return '#f56c6c'
|
||||
})
|
||||
|
||||
const dimensionRows = computed(() => {
|
||||
const meta: Record<string, { name: string }> = {
|
||||
revenueGrowth: { name: '营收增长' },
|
||||
profitQuality: { name: '利润质量' },
|
||||
cashQuality: { name: '现金质量' },
|
||||
growthEfficiency: { name: '增长效率' },
|
||||
orgHealth: { name: '组织健康' },
|
||||
}
|
||||
return Object.entries(dimensions.value || {}).map(([key, v]: any) => ({
|
||||
key,
|
||||
name: meta[key]?.name || key,
|
||||
score: v?.score ?? 0,
|
||||
level: v?.level || (v?.score >= 80 ? '好' : v?.score >= 60 ? '中' : '差'),
|
||||
color: v?.score >= 80 ? '#67c23a' : v?.score >= 60 ? '#e6a23c' : '#f56c6c',
|
||||
indicators: v?.indicators || [],
|
||||
suggestion: v?.suggestion || '-',
|
||||
}))
|
||||
})
|
||||
|
||||
async function loadPeriods() {
|
||||
loadingPeriods.value = true
|
||||
try {
|
||||
const r: any = await api.get('/growth-quality/periods', { params: { entity_id: entityId.value } })
|
||||
periods.value = r.data || r || []
|
||||
if (periods.value.length && !periods.value.includes(period.value)) {
|
||||
period.value = periods.value[periods.value.length - 1]
|
||||
}
|
||||
} catch (_) {}
|
||||
loadingPeriods.value = false
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r: any = await api.get('/growth-quality/diagnosis', { params: { entity_id: entityId.value, period: period.value } })
|
||||
const d = r.data || r || {}
|
||||
overall.value = d.overall ?? null
|
||||
level.value = d.level || ''
|
||||
levelType.value = d.level_type || 'info'
|
||||
diagnosis.value = d.diagnosis || ''
|
||||
dimensions.value = d.dimensions || {}
|
||||
comparison.value = d.comparison || {}
|
||||
trend.value = d.trend || []
|
||||
await nextTick()
|
||||
renderCharts()
|
||||
} catch (e: any) {
|
||||
console.error('load growth quality failed', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function renderCharts() {
|
||||
// 雷达图
|
||||
if (radarRef.value) {
|
||||
const chart = echarts.init(radarRef.value)
|
||||
const dims = dimensionRows.value
|
||||
chart.setOption({
|
||||
radar: {
|
||||
indicator: dims.map((d: any) => ({ name: d.name, max: 100 })),
|
||||
radius: '65%',
|
||||
},
|
||||
series: [{
|
||||
type: 'radar',
|
||||
data: [{
|
||||
value: dims.map((d: any) => d.score),
|
||||
name: period.value,
|
||||
areaStyle: { color: 'rgba(64,158,255,0.2)' },
|
||||
lineStyle: { color: '#409EFF' },
|
||||
itemStyle: { color: '#409EFF' },
|
||||
}],
|
||||
}],
|
||||
})
|
||||
}
|
||||
// 跨期对比
|
||||
if (compareRef.value) {
|
||||
const chart = echarts.init(compareRef.value)
|
||||
const dims = dimensionRows.value
|
||||
const cur = comparison.value?.current || {}
|
||||
const prev = comparison.value?.previous || {}
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['本期', '上期'], bottom: 0 },
|
||||
radar: {
|
||||
indicator: dims.map((d: any) => ({ name: d.name, max: 100 })),
|
||||
radius: '60%',
|
||||
},
|
||||
series: [{
|
||||
type: 'radar',
|
||||
data: [
|
||||
{ value: dims.map((d: any) => cur[d.key]?.score ?? d.score), name: '本期', areaStyle: { opacity: 0.2 } },
|
||||
{ value: dims.map((d: any) => prev[d.key]?.score ?? 0), name: '上期' },
|
||||
],
|
||||
}],
|
||||
})
|
||||
}
|
||||
// 趋势
|
||||
if (trendRef.value) {
|
||||
const chart = echarts.init(trendRef.value)
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: trend.value.map((t: any) => t.period) },
|
||||
yAxis: { type: 'value', min: 0, max: 100 },
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: trend.value.map((t: any) => t.overall),
|
||||
smooth: true,
|
||||
areaStyle: { opacity: 0.15 },
|
||||
itemStyle: { color: '#409EFF' },
|
||||
}],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPeriods()
|
||||
await loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.growth-quality-page { max-width: 1400px; margin: 0 auto; padding: 16px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.score-card { text-align: center; min-height: 300px; }
|
||||
.overall-score { font-size: 56px; font-weight: 700; margin: 20px 0 4px; }
|
||||
.overall-label { font-size: 14px; color: #888; }
|
||||
.diagnosis-text { font-size: 13px; color: #666; text-align: left; margin-top: 16px; line-height: 1.6; padding: 8px; background: #f8f8f8; border-radius: 6px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user