From 8ec846c6df1fcb9ec5e54a733b270993fa9db852 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Tue, 25 Aug 2026 00:44:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A2=84=E6=B5=8B=E6=80=A7=E6=88=90?= =?UTF-8?q?=E6=9C=AC=E6=99=BA=E8=83=BD=C2=B7=E5=AE=8F=E8=A7=82=E6=95=8F?= =?UTF-8?q?=E6=84=9F=E6=80=A7=E5=9B=A0=E7=B4=A0=E8=81=94=E5=8A=A8(IMA=2020?= =?UTF-8?q?26.7=E5=AE=8C=E6=95=B4=E7=89=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 内置3宏观因素: 原油价格/美元汇率/CPI通胀率 - 敏感性引擎: 按KPI类别推断弹性(成本类油价0.15/利润类0.12/营收类0.08), 方向+因素涨KPI涨 - 负值KPI(亏损)方向反转修复: 油价涨→净利更亏 - API: GET /predict/kpi-forecast/sensitivity?pct=10 → KPI×因素矩阵(±pct调整后预测) - 前端: 敏感性幅度选择(±5/10/20%) + 敏感性矩阵表(同向/反向+↑↓调整值) - 诚实标注: 模型弹性(规则推断,非历史回归), 后续可用宏观历史数据回归校准 - 验证: Chrome实测页面+API矩阵, pytest 46 passed --- backend/app/api/predict.py | 56 ++++++++++++++- backend/app/utils/kpi_forecast_engine.py | 89 ++++++++++++++++++++++++ frontend/src/api/index.ts | 1 + frontend/src/views/CostIntelligence.vue | 74 +++++++++++++++++++- 4 files changed, 217 insertions(+), 3 deletions(-) diff --git a/backend/app/api/predict.py b/backend/app/api/predict.py index 5e267f45..f49fad6d 100644 --- a/backend/app/api/predict.py +++ b/backend/app/api/predict.py @@ -1,6 +1,6 @@ """预测模拟API — 管理会计OS""" import logging -from fastapi import APIRouter, HTTPException, Depends, Request +from fastapi import APIRouter, HTTPException, Depends, Request, Query from app.utils.predict_engine import ( cvp_analysis, npv, irr, sensitivity_analysis, scenario_analysis, @@ -719,6 +719,7 @@ def api_growth_quality(request: Request, data: dict): # ── KPI趋势预测(预测性成本智能 MVP) ──────────────────────────── from app.utils.kpi_forecast_engine import ( # noqa: E402 MODELS, forecast_kpi, forecast_finance_kpis, + MACRO_FACTORS, factor_sensitivity_for_kpi, adjusted_next_with_factor, ) @@ -764,3 +765,56 @@ def api_kpi_forecast_finance( "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", {}) + sens = factor_sensitivity_for_kpi(kpi_info.get("name", ""), kpi_info.get("code", "")) + 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"], + "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, + } diff --git a/backend/app/utils/kpi_forecast_engine.py b/backend/app/utils/kpi_forecast_engine.py index 246ea0c7..362fce2c 100644 --- a/backend/app/utils/kpi_forecast_engine.py +++ b/backend/app/utils/kpi_forecast_engine.py @@ -293,3 +293,92 @@ def forecast_finance_kpis(entity_id: int, db: Session, score = {"high": 3, "medium": 2, "low": 1} results.sort(key=lambda r: (score.get(r["confidence"], 0), r["history_count"]), reverse=True) return results + + +# ════════════════════════════════════════════════════════════ +# 宏观敏感性因素联动(IMA 2026.7 Predictive Cost Intelligence 完整版) +# 内置宏观因素 → 按KPI类型推断弹性系数 → 调整预测值 +# MVP:弹性系数为规则推断+可调,非历史回归(诚实标注"模型弹性") +# ════════════════════════════════════════════════════════════ + +MACRO_FACTORS = [ + {"key": "oil", "name": "原油价格", "unit": "美元/桶", + "desc": "油价↑ → 运输/能源成本↑ → 成本类KPI↑、利润类KPI↓"}, + {"key": "usd", "name": "美元汇率", "unit": "USD/CNY", + "desc": "美元↑ → 进口成本↑(成本类↑)、出口收入↑(营收类↑)"}, + {"key": "cpi", "name": "CPI通胀率", "unit": "%", + "desc": "CPI↑ → 成本↑、名义营收↑"}, +] + +# KPI 类别关键词 → 因素方向/弹性 (direction: +因素涨KPI涨, -因素涨KPI跌) +FACTOR_RULES = { + "cost": { # 成本/费用类: 宏观涨 → 成本涨 + "oil": {"direction": "+", "elasticity": 0.15}, + "usd": {"direction": "+", "elasticity": 0.10}, + "cpi": {"direction": "+", "elasticity": 0.10}, + }, + "revenue": { # 营收类: 通胀涨→名义营收涨 + "oil": {"direction": "-", "elasticity": 0.05}, + "usd": {"direction": "+", "elasticity": 0.08}, + "cpi": {"direction": "+", "elasticity": 0.08}, + }, + "profit": { # 利润类: 宏观涨 → 成本挤压利润 + "oil": {"direction": "-", "elasticity": 0.12}, + "usd": {"direction": "-", "elasticity": 0.08}, + "cpi": {"direction": "-", "elasticity": 0.08}, + }, + "cash": { # 现金流类 + "oil": {"direction": "-", "elasticity": 0.06}, + "usd": {"direction": "-", "elasticity": 0.04}, + "cpi": {"direction": "-", "elasticity": 0.05}, + }, +} + +# 类别关键词匹配(长词优先) +CATEGORY_KEYWORDS = [ + ("profit", ["净利润", "净利", "利润", "毛利", "ROE", "ROI", "EVA", "收益率", "报酬率"]), + ("revenue", ["营收", "收入", "销售额", "销售", "产值", "客单"]), + ("cost", ["费用率", "成本率", "费用", "成本", "费率", "应付", "返利", "渠补", "税"]), + ("cash", ["现金流", "现金", "回款", "FCF", "资金"]), +] + + +def infer_kpi_category(kpi_name: str, kpi_code: str = "") -> str: + """按KPI名称/编码推断类别: profit/revenue/cost/cash,兜底 profit(保守)""" + n = (kpi_name or "") + " " + (kpi_code or "") + for cat, kws in CATEGORY_KEYWORDS: + if any(kw in n for kw in kws): + return cat + return "profit" + + +def factor_sensitivity_for_kpi(kpi_name: str, kpi_code: str = "") -> list: + """返回该KPI对3个宏观因素的敏感性(方向+弹性)""" + cat = infer_kpi_category(kpi_name, kpi_code) + rules = FACTOR_RULES.get(cat, FACTOR_RULES["profit"]) + out = [] + for f in MACRO_FACTORS: + r = rules.get(f["key"], {"direction": "-", "elasticity": 0.05}) + out.append({ + "factor_key": f["key"], + "factor_name": f["name"], + "factor_unit": f["unit"], + "factor_desc": f["desc"], + "direction": r["direction"], + "elasticity": r["elasticity"], + "category": cat, + }) + return out + + +def adjusted_next_with_factor(next_target: Optional[float], pct: float, + direction: str, elasticity: float) -> Optional[float]: + """因素变动 pct% → 调整后预测值: 方向+ 因素涨预测涨; 方向- 因素涨预测跌 + 负值KPI(亏损)方向反转: 方向- 时因素涨 → 更亏(更负)""" + if next_target is None: + return None + factor_change = pct * 0.01 # ±5% → 0.05 + sign = 1.0 if direction == "+" else -1.0 + if next_target < 0: + sign = -sign # 负值(亏损): 因素涨 → 更亏 + return round(next_target * (1 + sign * factor_change * elasticity), 2) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 9d3faf3c..8285a84f 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -223,6 +223,7 @@ export const predictApi = { // KPI趋势预测(预测性成本智能) kpiForecast: (params?: any) => api.get('/predict/kpi-forecast', { params }), kpiForecastFinance: (params?: any) => api.get('/predict/kpi-forecast/finance', { params }), + kpiForecastSensitivity: (params?: any) => api.get('/predict/kpi-forecast/sensitivity', { params }), } export const deviationPushApi = { diff --git a/frontend/src/views/CostIntelligence.vue b/frontend/src/views/CostIntelligence.vue index 32b5d9cc..84b36d9c 100644 --- a/frontend/src/views/CostIntelligence.vue +++ b/frontend/src/views/CostIntelligence.vue @@ -15,7 +15,13 @@ - 刷新预测 + 敏感性幅度 + + + + + + 刷新预测
基于 kpi_values 历史数据 · 线性回归/移动平均 · 置信度诚实标注 @@ -92,6 +98,39 @@ + + + + + + + + + + + + + + + +
+ 弹性系数按KPI类别推断(成本类对油价最敏感0.15、利润类0.12、营收类0.08),MVP规则模型,后续可用宏观历史数据回归校准。 +
+
+
@@ -114,6 +153,18 @@ const selected = ref(null) const chartRef = ref(null) let chart: any = null +// ── 宏观敏感性联动 ── +const sensPct = ref(10) +const sensList = ref([]) +const sensFactors = ref([]) + +function catLabel(c: string) { + return { profit: '利润', revenue: '营收', cost: '成本', cash: '现金流' }[c] || c +} +function factorOf(row: any, key: string) { + return (row.factors || []).find((f: any) => f.factor_key === key) +} + function lastHistory(row: any) { const h = row.history || [] return h.length ? h[h.length - 1].value : null @@ -164,6 +215,20 @@ async function loadData() { } } +async function loadSensitivity() { + try { + const r: any = await predictApi.kpiForecastSensitivity({ pct: sensPct.value, periods: periods.value, model: model.value }) + sensList.value = r.data || [] + sensFactors.value = r.factors || [] + } catch (e: any) { + console.error('敏感性加载失败', e) + } +} + +async function loadAll() { + await Promise.all([loadData(), loadSensitivity()]) +} + function onSelect(row: any) { selected.value = row renderChart() @@ -207,7 +272,7 @@ function resizeChart() { } onMounted(() => { - loadData() + loadAll() window.addEventListener('resize', resizeChart) }) onBeforeUnmount(() => { @@ -267,6 +332,11 @@ onBeforeUnmount(() => { font-size: 12px; color: #C0C4CC; } +.sens-val { + font-size: 12px; + color: #606266; + margin-left: 4px; +} .trend-chart { height: 520px; width: 100%;