Files
cma-management/backend/app/api/predict.py
T
Hermes CI Fix 6b6043536a feat: 预测性成本智能升级 — 历史回归弹性校准 + 预测偏差告警
升级1: 宏观敏感性弹性历史校准
- 内置宏观历史数据(oil/usd/cpi 2026-01~07月度)
- 变化率弹性: 同period匹配KPI历史vs因素历史算弹性
- 合理性校验: |弹性|超出[0.01,0.5]视为噪声回退规则(诚实标注)

升级2: 预测偏差告警闭环
- 新表 kpi_forecast_log(预测历史)+模型KpiForecastLog
- 预测时落库(同KPI同预测期覆盖)
- alert_rules 支持 rule_type=forecast_deviation(threshold_pct)
- POST /alert-rules/run-forecast-deviation: 预测vs实际偏差>阈值生成预警(去重, 超2倍阈值红色)
- 端到端验证: 模拟实际500vs预测399.55→偏差20.1%>5%→红色预警生成

回归: pytest 40 passed(predict+alerts)
2026-08-25 00:55:50 +08:00

831 lines
36 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.
"""预测模拟API — 管理会计OS"""
import logging
from fastapi import APIRouter, HTTPException, Depends, Request, Query
from app.utils.predict_engine import (
cvp_analysis, npv, irr,
sensitivity_analysis, scenario_analysis,
)
from app.utils.cash_forecast_engine import (
forecast_cash_flow, save_forecast_to_db,
calculate_accuracy, generate_scenario_suggestion,
)
from app.database import get_db
from app.deps import get_entity_id, resolve_entity_for_request
from sqlalchemy.orm import Session
logger = logging.getLogger("cma.predict")
router = APIRouter(prefix="/api/cma/predict", tags=["预测模拟"])
@router.post("/cvp")
def api_cvp_analysis(data: dict):
"""CVP本量利分析"""
try:
result = cvp_analysis(
unit_price=float(data.get("unit_price", 0)),
unit_variable_cost=float(data.get("unit_variable_cost", 0)),
fixed_cost=float(data.get("fixed_cost", 0)),
target_profit=float(data["target_profit"]) if data.get("target_profit") else None,
actual_volume=float(data["actual_volume"]) if data.get("actual_volume") else None,
)
return result
except Exception as e:
raise HTTPException(400, f"CVP计算失败: {str(e)}")
@router.post("/investment")
def api_investment_analysis(data: dict):
"""投资决策分析(NPV/IRR/回收期)"""
try:
initial = float(data.get("initial_investment", 0))
rate = float(data.get("discount_rate", 10))
cash_flows = [float(cf) for cf in data.get("cash_flows", [])]
if not cash_flows:
raise HTTPException(400, "现金流列表不能为空")
npv_result = npv(initial, cash_flows, rate)
irr_result = irr(initial, cash_flows)
return {
"npv_analysis": npv_result,
"irr_analysis": irr_result,
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(400, f"投资决策计算失败: {str(e)}")
@router.post("/sensitivity")
def api_sensitivity_analysis(data: dict):
"""敏感性分析"""
try:
result = sensitivity_analysis(
base_revenue=float(data.get("base_revenue", 0)),
base_cost=float(data.get("base_cost", 0)),
base_profit=float(data["base_profit"]) if data.get("base_profit") else None,
step=int(data.get("step", 5)),
max_step=int(data.get("max_step", 20)),
)
return result
except Exception as e:
raise HTTPException(400, f"敏感性分析失败: {str(e)}")
@router.post("/scenario")
def api_scenario_analysis(data: dict):
"""情景模拟"""
try:
optimistic = data.get("optimistic", {})
pessimistic = data.get("pessimistic", {})
base = data.get("base", {})
if not all([optimistic, pessimistic, base]):
raise HTTPException(400, "需要提供乐观/中性/悲观三个情景的参数")
result = scenario_analysis(
optimistic={
"revenue": float(optimistic.get("revenue", 0)),
"cost": float(optimistic.get("cost", 0)),
},
pessimistic={
"revenue": float(pessimistic.get("revenue", 0)),
"cost": float(pessimistic.get("cost", 0)),
},
base={
"revenue": float(base.get("revenue", 0)),
"cost": float(base.get("cost", 0)),
},
)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(400, f"情景模拟失败: {str(e)}")
@router.post("/cvp-detailed")
def api_cvp_detailed(data: dict):
"""CVP本量利详细分析 — 含改善方案推演和保本图数据 (CMA P2)"""
try:
fixed_cost = float(data.get("fixed_cost", 617))
variable_cost_rate = float(data.get("variable_cost_rate", 0.4862))
unit_price = float(data.get("unit_price", 228))
current_volume = float(data.get("current_volume", 5300))
contribution_margin_rate = 1 - variable_cost_rate
breakeven_revenue = round(fixed_cost / contribution_margin_rate, 2)
breakeven_units = round(breakeven_revenue * 10000 / unit_price, 0)
current_revenue = round(current_volume * unit_price / 10000, 2)
current_profit = round(current_revenue * (1 - variable_cost_rate) - fixed_cost, 2)
safety_margin = round((current_revenue - breakeven_revenue) / current_revenue * 100, 2) if current_revenue > 0 else 0
scenarios = [
{"name": "降固定费用至300万", "fixed_cost": 300, "variable_cost_rate": variable_cost_rate,
"breakeven_revenue": round(300 / contribution_margin_rate, 2),
"breakeven_units": round(300 / contribution_margin_rate * 10000 / unit_price, 0)},
{"name": "降变动成本率至30%", "fixed_cost": fixed_cost, "variable_cost_rate": 0.3,
"breakeven_revenue": round(fixed_cost / 0.7, 2),
"breakeven_units": round(fixed_cost / 0.7 * 10000 / unit_price, 0)},
{"name": "两者同时改善", "fixed_cost": 300, "variable_cost_rate": 0.3,
"breakeven_revenue": round(300 / 0.7, 2),
"breakeven_units": round(300 / 0.7 * 10000 / unit_price, 0)},
]
# 保本图数据点
chart_data = []
max_volume = int(max(breakeven_units * 2, current_volume * 3))
step = max(1, int(max_volume / 20))
for vol in range(0, int(max_volume) + step, step):
rev = round(vol * unit_price / 10000, 2)
tc = round(fixed_cost + rev * variable_cost_rate, 2)
chart_data.append({"volume": vol, "revenue": rev, "total_cost": tc, "profit": round(rev - tc, 2)})
return {
"fixed_cost": fixed_cost,
"variable_cost_rate": round(variable_cost_rate * 100, 2),
"unit_price": unit_price,
"contribution_margin_rate": round(contribution_margin_rate * 100, 2),
"breakeven_revenue": breakeven_revenue,
"breakeven_units": int(breakeven_units),
"current_revenue": current_revenue,
"current_profit": current_profit,
"current_volume": int(current_volume),
"safety_margin": safety_margin,
"scenarios": scenarios,
"chart_data": chart_data,
}
except Exception as e:
raise HTTPException(400, f"CVP详细分析失败: {str(e)}")
# ── 现金流预测(AI事前预警) ────────────────────────────────────
@router.post("/cash-forecast")
def api_cash_forecast(request: Request, data: dict, db: Session = Depends(get_db)):
"""现金流预测 — 根据历史KPI推算未来30天现金流"""
try:
entity_id = resolve_entity_for_request(request, int(data.get("entity_id", 1)))
days = int(data.get("days", 30))
current_cash = float(data["current_cash"]) if data.get("current_cash") else None
result = forecast_cash_flow(entity_id, db, days, current_cash)
# 保存到数据库
try:
save_forecast_to_db(entity_id, result, db)
except Exception as e:
logger.warning(f"保存预测结果失败: {e}")
return result
except Exception as e:
raise HTTPException(400, f"现金流预测失败: {str(e)}")
@router.get("/cash-forecast/history")
def api_cash_forecast_history(
entity_id: int = Depends(get_entity_id),
days: int = 30,
db: Session = Depends(get_db),
):
"""获取已保存的现金流预测历史"""
from app.models import CashForecast
forecasts = db.query(CashForecast).filter(
CashForecast.entity_id == entity_id,
).order_by(CashForecast.forecast_date.desc()).limit(days).all()
return {
"data": [{
"id": f.id,
"forecast_date": f.forecast_date.isoformat(),
"predicted_cash": f.predicted_cash,
"lower_bound": f.lower_bound,
"upper_bound": f.upper_bound,
"alert_status": f.alert_status,
} for f in forecasts]
}
@router.get("/accuracy")
def api_forecast_accuracy(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""预测准确率报表 — 上期预测 vs 本期实际"""
try:
results = calculate_accuracy(entity_id, db)
# 计算整体MAE/MAPE
if results:
total_mae = sum(r["mae"] for r in results) / len(results)
total_mape = sum(r["mape"] for r in results) / len(results)
else:
total_mae = 0
total_mape = 0
return {
"data": results,
"summary": {
"total_periods": len(results),
"avg_mae": round(total_mae, 2),
"avg_mape": round(total_mape, 2),
},
}
except Exception as e:
raise HTTPException(400, f"获取准确率失败: {str(e)}")
@router.get("/scenario-suggestions")
def api_scenario_suggestions(alert_type: str = None):
"""获取情景建议模板"""
types = ["cash_low", "cash_critical", "cost_high", "revenue_drop"]
results = []
for at in types:
if alert_type and at != alert_type:
continue
sug = generate_scenario_suggestion(at, "")
results.append({"alert_type": at, **sug})
return {"data": results}
@router.post("/scenario-suggestion/generate")
def api_generate_suggestion(data: dict):
"""根据预警信息动态生成情景建议"""
try:
alert_type = data.get("alert_type", "cash_low")
kpi_name = data.get("kpi_name", "未知KPI")
extra = data.get("extra", {})
sug = generate_scenario_suggestion(alert_type, kpi_name, extra)
return sug
except Exception as e:
raise HTTPException(400, f"生成建议失败: {str(e)}")
# ── 实物期权计算器 ─────────────────────────────────────────────
import math
def _norm_cdf(x: float) -> float:
"""标准正态分布CDF — Abramowitz & Stegun 近似 (max error ≈ 1.5×10⁻⁷)"""
a1, a2, a3, a4, a5 = 0.254829592, -0.284496736, 1.421413741, -1.453152027, 1.061405429
p = 0.3275911
sign = 1.0
if x < 0:
sign = -1.0
x_abs = abs(x) / math.sqrt(2.0)
t = 1.0 / (1.0 + p * x_abs)
y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * math.exp(-x_abs * x_abs)
return 0.5 * (1.0 + sign * y)
def _black_scholes_call(S0: float, X: float, t: float, r: float, sigma: float) -> dict:
"""BSM看涨期权定价(扩张期权/延迟期权)"""
sqrt_t = math.sqrt(t)
d1 = (math.log(S0 / X) + (r + 0.5 * sigma ** 2) * t) / (sigma * sqrt_t)
d2 = d1 - sigma * sqrt_t
nd1 = _norm_cdf(d1)
nd2 = _norm_cdf(d2)
call_value = max(S0 * nd1 - X * math.exp(-r * t) * nd2, 0.0)
return {"value": round(call_value, 4), "d1": round(d1, 4), "d2": round(d2, 4), "Nd1": round(nd1, 4), "Nd2": round(nd2, 4)}
def _black_scholes_put(S0: float, X: float, t: float, r: float, sigma: float) -> dict:
"""BSM看跌期权定价(放弃期权/收缩期权)"""
sqrt_t = math.sqrt(t)
d1 = (math.log(S0 / X) + (r + 0.5 * sigma ** 2) * t) / (sigma * sqrt_t)
d2 = d1 - sigma * sqrt_t
nd1 = _norm_cdf(-d1)
nd2 = _norm_cdf(-d2)
put_value = max(X * math.exp(-r * t) * nd2 - S0 * nd1, 0.0)
return {"value": round(put_value, 4), "d1": round(d1, 4), "d2": round(d2, 4), "N(-d1)": round(nd1, 4), "N(-d2)": round(nd2, 4)}
def _binomial_tree_call(S0: float, X: float, t: float, r: float, sigma: float, n: int = 100) -> float:
"""二叉树欧式看涨期权定价(延迟期权)"""
dt = t / n
u = math.exp(sigma * math.sqrt(dt))
d = 1.0 / u
p = (math.exp(r * dt) - d) / (u - d)
discount = math.exp(-r * dt)
prices = [S0 * (u ** (n - j)) * (d ** j) for j in range(n + 1)]
values = [max(p - X, 0.0) for p in prices]
for i in range(n - 1, -1, -1):
for j in range(i + 1):
values[j] = discount * (p * values[j] + (1 - p) * values[j + 1])
return max(values[0], 0.0)
def _binomial_tree_american_put(S0: float, X: float, t: float, r: float, sigma: float, n: int = 100) -> float:
"""二叉树美式看跌期权定价(可随时放弃的放弃期权)"""
dt = t / n
u = math.exp(sigma * math.sqrt(dt))
d = 1.0 / u
p = (math.exp(r * dt) - d) / (u - d)
discount = math.exp(-r * dt)
prices = [S0 * (u ** (n - j)) * (d ** j) for j in range(n + 1)]
values = [max(X - p, 0.0) for p in prices]
for i in range(n - 1, -1, -1):
for j in range(i + 1):
hold = discount * (p * values[j] + (1 - p) * values[j + 1])
exercise = X - (S0 * (u ** (i - j)) * (d ** j))
values[j] = max(hold, exercise)
return max(values[0], 0.0)
@router.post("/real-option")
def api_real_option(data: dict):
"""实物期权计算器"""
try:
opt_type = data.get("opt_type", "expansion") # expansion|abandon|delay|shrink
model = data.get("model", "bs") # bs|binomial
S0 = float(data.get("S0", 100.0))
X = float(data.get("X", 80.0))
t = float(data.get("t", 3.0))
r = float(data.get("r", 0.0174))
sigma = float(data.get("sigma", 0.30))
expansion_factor = float(data.get("expansion_factor", 1.5))
salvage_value = float(data.get("salvage_value", S0 * 0.3))
n_steps = int(data.get("n_steps", 100))
# 输入校验
if S0 <= 0 or X <= 0 or t <= 0 or sigma <= 0:
raise HTTPException(400, "参数必须为正数")
if sigma > 2.0:
raise HTTPException(400, "波动率σ不能超过200%")
result = {"option_type": opt_type, "model": model, "S0": S0, "X": X, "t": t, "r": r, "sigma": sigma}
# 计算期权价值
if opt_type in ("expansion", "delay") and model == "bs":
bs = _black_scholes_call(S0, X, t, r, sigma)
result["option_value"] = bs["value"]
result["intermediate"] = {k: v for k, v in bs.items() if k != "value"}
elif opt_type == "expansion" and model == "binomial":
adj_X = X / expansion_factor
bt_val = _binomial_tree_call(S0, adj_X, t, r, sigma, n_steps)
option_value = max(bt_val * expansion_factor, 0.0)
result["option_value"] = round(option_value, 4)
result["intermediate"] = {"expansion_factor": expansion_factor, "adjusted_X": round(adj_X, 4), "tree_value": round(bt_val, 4)}
elif opt_type == "delay" and model == "binomial":
option_value = _binomial_tree_call(S0, X, t, r, sigma, n_steps)
result["option_value"] = round(option_value, 4)
# Also compute BS for reference
bs = _black_scholes_call(S0, X, t, r, sigma)
result["intermediate"] = {"n_steps": n_steps, "bs_reference": round(bs["value"], 4)}
elif opt_type in ("abandon", "shrink") and model == "bs":
effective_X = salvage_value if opt_type == "abandon" else X
bs = _black_scholes_put(S0, effective_X, t, r, sigma)
result["option_value"] = bs["value"]
result["intermediate"] = {k: v for k, v in bs.items() if k != "value"}
if opt_type == "abandon":
result["intermediate"]["salvage_value"] = effective_X
elif opt_type == "abandon" and model == "binomial":
bt_val = _binomial_tree_american_put(S0, salvage_value, t, r, sigma, n_steps)
result["option_value"] = round(bt_val, 4)
result["intermediate"] = {"n_steps": n_steps, "salvage_value": salvage_value}
else:
raise HTTPException(400, f"不支持的组合: {opt_type} + {model}")
# 决策建议
val = result["option_value"]
if val > 0:
result["suggestion"] = "期权价值 > 0,管理弹性有价值,建议保留决策弹性,在有利时机行权"
result["suggestion_type"] = "positive"
else:
result["suggestion"] = "期权价值 ≈ 0,弹性无明显价值,建议按传统NPV决策,无需等待"
result["suggestion_type"] = "neutral"
# 扩展NPV(假设传统NPV = S0 - X
npv_without = S0 - X
expanded_npv = npv_without + val
result["npv_without_flexibility"] = round(npv_without, 4)
result["expanded_npv"] = round(expanded_npv, 4)
if expanded_npv > 0:
result["decision"] = "✅ 扩展NPV > 0,含弹性后项目整体值得投资"
else:
result["decision"] = "❌ 扩展NPV ≤ 0,含弹性后项目仍不值得投资"
# 敏感性分析数据(σ从10%~90%变化)
sensitivity = []
for s_pct in range(5, 96, 5):
s = s_pct / 100.0
if opt_type in ("expansion", "delay"):
if model == "bs":
v = _black_scholes_call(S0, X, t, r, s)["value"]
else:
bt = _binomial_tree_call(S0, X, t, r, s, n_steps)
v = bt * expansion_factor if opt_type == "expansion" else bt
else:
eff_X = salvage_value if opt_type == "abandon" else X
if model == "bs":
v = _black_scholes_put(S0, eff_X, t, r, s)["value"]
else:
v = _binomial_tree_american_put(S0, eff_X, t, r, s, n_steps)
sensitivity.append({"sigma": s_pct, "option_value": round(v, 4)})
result["sensitivity"] = sensitivity
# 警告提示
warnings = []
if t * sigma * sigma * 0.5 > r:
warnings.append("高波动+长时间,延迟价值显著")
if S0 < X:
warnings.append("价外期权,期权价值较低")
if S0 > X * 1.5:
warnings.append("深度价内,几乎确定行权")
if sigma < 0.10:
warnings.append("波动率过低,期权价值趋近于0")
if t > 10:
warnings.append("长期期权,贴现因子影响大")
result["warnings"] = warnings
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(400, f"实物期权计算失败: {str(e)}")
# ── 增长质量诊断 ─────────────────────────────────────────────
def _score_revenue_structure(entity: dict) -> int:
"""营收结构评分:渠补率越低越好"""
rebate_rate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
if rebate_rate > 80: return 1
if rebate_rate > 60: return 2
if rebate_rate > 40: return 3
if rebate_rate > 20: return 4
return 5
def _score_profit_structure(entity: dict) -> int:
"""利润结构评分:真实毛利率越高越好"""
gross_margin = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
if gross_margin < 0: return 1
if gross_margin < 10: return 2
if gross_margin < 20: return 3
if gross_margin < 30: return 4
return 5
def _score_cash_assets(entity: dict) -> int:
"""现金资产评分:现金比率越高越好"""
cash_ratio = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
if cash_ratio < 10: return 1
if cash_ratio < 30: return 2
if cash_ratio < 50: return 3
if cash_ratio < 100: return 4
return 5
def _score_growth_driver(entity: dict) -> int:
"""增长驱动评分:费用增速相对收入增速越低越好"""
expense_growth = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
revenue_growth = float(entity.get("revenueGrowthRate", entity.get("revenue_growth_rate", 1)))
if revenue_growth <= 0: revenue_growth = 1 # prevent div by zero
ratio = expense_growth / revenue_growth
if ratio > 1.5: return 1
if ratio > 1.2: return 2
if ratio > 1.0: return 3
if ratio > 0.8: return 4
return 5
def _score_org_efficiency(entity: dict) -> int:
"""组织效率评分:管理费/净收入越低越好"""
mgmt_ratio = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
if mgmt_ratio > 300: return 1
if mgmt_ratio > 200: return 2
if mgmt_ratio > 100: return 3
if mgmt_ratio > 50: return 4
return 5
def _diagnosis_text(overall: float, dimensions: dict, entity_name: str) -> str:
"""根据评分生成诊断结论"""
lines = []
low_dims = {k: v for k, v in dimensions.items() if v["score"] <= 2}
mid_dims = {k: v for k, v in dimensions.items() if 2 < v["score"] < 4}
dim_labels = {
"revenueStructure": "营收结构",
"profitStructure": "利润结构",
"cashAssets": "现金资产",
"growthDriver": "增长驱动",
"orgEfficiency": "组织效率",
}
if overall < 2:
lines.append(f"{entity_name}的增长质量评分仅{overall}分,属于「越增长越重」类型。")
lines.append("增长主要依赖资源投入而非核心能力积累,可持续性堪忧。")
elif overall < 3:
lines.append(f"{entity_name}的增长质量评分{overall}分,需重点关注。")
lines.append("部分维度存在风险,增长质量有待改善。")
elif overall < 4:
lines.append(f"{entity_name}的增长质量评分{overall}分,处于中等水平。")
lines.append("多数维度表现尚可,仍有优化空间。")
else:
lines.append(f"{entity_name}的增长质量评分{overall}分,「越增长越轻」。")
lines.append("增长模式健康,具备持续增长能力。")
if low_dims:
low_names = [dim_labels.get(k, k) for k in low_dims]
lines.append(f"⚠️ 需重点关注:{'、'.join(low_names)}评分偏低(≤2分)。")
if mid_dims:
mid_names = [dim_labels.get(k, k) for k in mid_dims]
lines.append(f"💡 可优化:{'、'.join(mid_names)}有提升空间。")
# 具体建议(硬编码的关键诊断)
if dimensions.get("revenueStructure", {}).get("score", 5) <= 2:
lines.append("• 营收依赖渠道返利,建议降低渠补率、拓展直销渠道。")
if dimensions.get("orgEfficiency", {}).get("score", 5) <= 2:
lines.append("• 管理费率高企,建议精简费用结构、优化运营效率。")
if dimensions.get("cashAssets", {}).get("score", 5) <= 2:
lines.append("• 现金比率极低,存在断流风险,建议加强现金流管理。")
if dimensions.get("growthDriver", {}).get("score", 5) <= 2:
lines.append("• 费用增速远超收入增速,增长不可持续,需控制费用膨胀。")
return "\n".join(lines)
def _generate_improvement_suggestions(dimension: str, score: int, entity: dict) -> list:
"""为指定维度生成改善建议"""
suggestions = []
if dimension == "revenueStructure":
rebate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
if score <= 2:
target_rebate = max(rebate - 10, 0)
savings = f"释放现金{round(rebate - target_rebate, 1)}%/月"
suggestions.append(f"渠补谈判:{rebate}%→{target_rebate}%{savings}")
suggestions.append("客户分散:拓展直销渠道,降低渠道依赖")
suggestions.append("渠补制度:分级管理,差异化返利")
else:
suggestions.append("维持现有渠补政策")
elif dimension == "profitStructure":
gm = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
if score <= 2:
suggestions.append(f"成本优化:毛利率仅{gm}%,需分析成本构成")
suggestions.append("产品结构:提高高毛利产品占比")
suggestions.append("定价策略:评估提价空间")
else:
suggestions.append("维持毛利率水平")
elif dimension == "cashAssets":
cr = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
if score <= 2:
suggestions.append(f"现金管理:现金比率仅{cr}%,存在断流风险")
suggestions.append("应收账款:加快回款周期")
suggestions.append("融资安排:准备短期授信额度")
else:
suggestions.append("维持现金流健康")
elif dimension == "growthDriver":
eg = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
if score <= 2:
suggestions.append(f"费用管控:费用增速{eg}倍于收入,需严控费用")
suggestions.append("预算管理:建立费用增长红线机制")
suggestions.append("投资回报:评估每项投入的ROI")
else:
suggestions.append("维持费用增长与收入增长匹配")
elif dimension == "orgEfficiency":
mr = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
if score <= 2:
suggestions.append(f"管理效率:管理费/净收入{mr}%,急需降本增效")
suggestions.append("组织精简:评估管理层级压缩空间")
suggestions.append("流程优化:推进数字化降本")
else:
suggestions.append("维持管理效率水平")
return suggestions
def _get_dim_detail_indicators(dimension: str, entity: dict) -> list:
"""获取维度的明细诊断指标"""
indicators = []
if dimension == "revenueStructure":
rebate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
net_ratio = round(100 - rebate, 1)
indicators.append({"label": "渠补率", "value": f"{rebate}%",
"verdict": "收入依赖渠道返利" if rebate > 50 else "渠道依赖程度中等",
"status": "danger" if rebate > 50 else "warning" if rebate > 20 else "success"})
indicators.append({"label": "净收入占比", "value": f"{net_ratio}%",
"verdict": f"仅{net_ratio}%归公司" if net_ratio < 30 else "净收入占比合理",
"status": "danger" if net_ratio < 30 else "success"})
elif dimension == "profitStructure":
gm = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
indicators.append({"label": "真实毛利率", "value": f"{gm}%",
"verdict": "毛利偏低" if gm < 15 else "毛利正常",
"status": "danger" if gm < 10 else "warning" if gm < 20 else "success"})
elif dimension == "cashAssets":
cr = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
indicators.append({"label": "现金比率", "value": f"{cr}%",
"verdict": "断流风险" if cr < 5 else "现金紧张" if cr < 30 else "现金充足",
"status": "danger" if cr < 5 else "warning" if cr < 30 else "success"})
elif dimension == "growthDriver":
eg = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
rg = float(entity.get("revenueGrowthRate", entity.get("revenue_growth_rate", 1)))
ratio = eg / rg if rg > 0 else 99
indicators.append({"label": "费用增速/收入增速", "value": f"{ratio:.1f}倍",
"verdict": "费用增速过快" if ratio > 1.5 else "费用可控" if ratio > 1 else "增长健康",
"status": "danger" if ratio > 1.5 else "warning" if ratio > 1 else "success"})
elif dimension == "orgEfficiency":
mr = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
indicators.append({"label": "管理费/净收入", "value": f"{mr}%",
"verdict": "管理费极高" if mr > 200 else "管理费偏高" if mr > 100 else "管理费正常",
"status": "danger" if mr > 200 else "warning" if mr > 100 else "success"})
return indicators
@router.post("/growth-quality")
def api_growth_quality(request: Request, data: dict):
"""增长质量诊断 — 五维度评分+综合评分+诊断结论"""
try:
entity_id = resolve_entity_for_request(request, data.get("entity_id"))
ENTITY_DATA = {
1: {"entity":"陕西酣客文化传媒","rebateRate":82.8,"trueGrossMargin":18.6,"cashRatio":0.6,"expenseGrowthRate":2.2,"revenueGrowthRate":1.0,"mgmtRatio":447},
2: {"entity":"陕西博海网络科技","rebateRate":0,"trueGrossMargin":13.1,"cashRatio":6.7,"expenseGrowthRate":0.8,"revenueGrowthRate":1.0,"mgmtRatio":1.4},
}
entity = ENTITY_DATA.get(entity_id, data.get("entity", data))
entity_name = entity.get("entity", entity.get("name", "该企业"))
period = entity.get("period", data.get("period", "当前"))
# 五维度评分
dim_scores = {
"revenueStructure": _score_revenue_structure(entity),
"profitStructure": _score_profit_structure(entity),
"cashAssets": _score_cash_assets(entity),
"growthDriver": _score_growth_driver(entity),
"orgEfficiency": _score_org_efficiency(entity),
}
overall = round(sum(dim_scores.values()) / 5, 1)
# 综合等级
if overall >= 4:
level = "🟢 越增长越轻"
level_type = "excellent"
elif overall >= 3:
level = "🟡 增长质量中等"
level_type = "medium"
elif overall >= 2:
level = "🟠 需关注"
level_type = "warning"
else:
level = "🔴 越增长越重"
level_type = "danger"
# 诊断结论
dimensions_payload = {}
detail_payload = {}
for dim, score in dim_scores.items():
dimensions_payload[dim] = {"score": score, "weight": 20}
detail_payload[dim] = {
"score": score,
"indicators": _get_dim_detail_indicators(dim, entity),
"suggestions": _generate_improvement_suggestions(dim, score, entity),
}
diagnosis = _diagnosis_text(overall, dimensions_payload, entity_name)
# 对比数据(如果请求中包含多个实体)
compare = data.get("compare", None)
compare_result = None
if compare:
compare_entity = compare
compare_name = compare_entity.get("entity", compare_entity.get("name", "对比企业"))
cdims = {
"revenueStructure": _score_revenue_structure(compare_entity),
"profitStructure": _score_profit_structure(compare_entity),
"cashAssets": _score_cash_assets(compare_entity),
"growthDriver": _score_growth_driver(compare_entity),
"orgEfficiency": _score_org_efficiency(compare_entity),
}
compare_overall = round(sum(cdims.values()) / 5, 1)
compare_result = {
"entity_name": compare_name,
"overall": compare_overall,
"dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()},
"level": ("🟢 越增长越轻" if compare_overall >= 4 else
"🟡 增长质量中等" if compare_overall >= 3 else
"🟠 需关注" if compare_overall >= 2 else "🔴 越增长越重"),
}
return {
"entity_name": entity_name,
"period": period,
"overall": overall,
"level": level,
"level_type": level_type,
"dimensions": dimensions_payload,
"detail": detail_payload,
"diagnosis": diagnosis,
"compare": compare_result,
}
except Exception as e:
raise HTTPException(400, f"增长质量诊断失败: {str(e)}")
# ── KPI趋势预测(预测性成本智能 MVP) ────────────────────────────
from app.utils.kpi_forecast_engine import ( # noqa: E402
MODELS, forecast_kpi, forecast_finance_kpis,
MACRO_FACTORS, factor_sensitivity_for_kpi, factor_sensitivity_with_history,
adjusted_next_with_factor, save_forecast_logs,
)
@router.get("/kpi-forecast")
def api_kpi_forecast(
kpi_code: str,
periods: int = 3,
model: str = "linear",
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""单个财务KPI预测 — 线性回归/移动平均,多租户隔离(entity_id 权限校验)"""
if periods < 0 or periods > 24:
raise HTTPException(400, "periods 必须在 0~24 之间")
if model not in MODELS:
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
result = forecast_kpi(entity_id, kpi_code, db, periods=periods, model=model)
if result is None:
raise HTTPException(
404,
f"KPI {kpi_code} 在企业 entity_id={entity_id} 下不存在,或历史数据不足(至少2条)",
)
return result
@router.get("/kpi-forecast/finance")
def api_kpi_forecast_finance(
periods: int = 3,
model: str = "linear",
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""批量预测该企业全部财务维度KPI(历史≥3条),按可预测性排序"""
if periods < 0 or periods > 24:
raise HTTPException(400, "periods 必须在 0~24 之间")
if model not in MODELS:
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
results = forecast_finance_kpis(entity_id, db, periods=periods, model=model)
try:
save_forecast_logs(entity_id, results, db, model=model) # 升级2a: 预测落库(供偏差告警)
except Exception as e:
logger.warning(f"预测落库失败(不影响返回): {e}")
return {
"entity_id": entity_id,
"model": model,
"periods": periods,
"total": len(results),
"data": results,
}
@router.get("/kpi-forecast/sensitivity")
def api_kpi_forecast_sensitivity(
pct: float = Query(10, description="宏观因素变动幅度% (±)"),
periods: int = Query(3),
model: str = Query("linear"),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""宏观敏感性因素联动(IMA 2026.7)— 财务KPI × 宏观因素(油价/汇率/CPI)敏感性矩阵
输出:每个KPI的预测值 + 各因素 ±pct% 情景下的调整后预测值
MVP:弹性系数为规则推断(按KPI类别),诚实标注"模型弹性"非历史回归"""
if abs(pct) > 50:
raise HTTPException(400, "pct 必须在 ±50 以内")
if model not in MODELS:
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
results = forecast_finance_kpis(entity_id, db, periods=periods, model=model)
matrix = []
for r in results:
kpi_info = r.get("kpi", {})
# v2: 有历史数据用变化率弹性校准,无数据回退规则推断
sens = factor_sensitivity_with_history(
kpi_info.get("name", ""), kpi_info.get("code", ""), r.get("history", []))
next_val = r.get("next_target")
factor_effects = []
for s in sens:
up_val = adjusted_next_with_factor(next_val, pct, s["direction"], s["elasticity"])
down_val = adjusted_next_with_factor(next_val, -pct, s["direction"], s["elasticity"])
factor_effects.append({
"factor_key": s["factor_key"],
"factor_name": s["factor_name"],
"factor_unit": s["factor_unit"],
"direction": s["direction"],
"elasticity": s["elasticity"],
"elasticity_source": s.get("elasticity_source", "rule"),
"matched_periods": s.get("matched_periods"),
"rule_direction": s.get("rule_direction"),
"adj_up": up_val,
"adj_down": down_val,
})
matrix.append({
"kpi": kpi_info,
"category": sens[0]["category"] if sens else "profit",
"next_target": next_val,
"confidence": r.get("confidence"),
"trend": r.get("trend"),
"factors": factor_effects,
})
return {
"entity_id": entity_id,
"model": model,
"periods": periods,
"pct": pct,
"factors": MACRO_FACTORS,
"total": len(matrix),
"data": matrix,
}