"""增长质量诊断 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.deps import get_entity_id 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 = Depends(get_entity_id), 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 = Depends(get_entity_id), 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"], }