feat: AI事前预警 — 现金流预测+预警扩展+准确率+情景建议

This commit is contained in:
Hermes CI Fix
2026-07-21 18:22:04 +08:00
parent 270d7758f8
commit c4e91f28ef
9 changed files with 1101 additions and 6 deletions
+140 -1
View File
@@ -25,6 +25,7 @@ class AlertRule(Base):
id = Column(Integer, primary_key=True, index=True)
kpi_id = Column(Integer, nullable=False, comment="关联KPI ID")
rule_type = Column(String(30), nullable=False, comment="static/dynamic/trend_up/trend_down")
trigger_on = Column(String(20), default="actual", comment="actual/forecast/both — 实际值/预测值/两者触发")
enabled = Column(Integer, default=1, comment="1启用 0禁用")
params = Column(JSON, nullable=True, comment="规则参数")
# static: {"green": ">=90", "yellow": ">=80", "red": "<80"}
@@ -98,6 +99,7 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
"""创建预警规则"""
kpi_id = data.get("kpi_id")
rule_type = data.get("rule_type", "static")
trigger_on = data.get("trigger_on", "actual")
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
@@ -108,6 +110,7 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
rule = AlertRule(
kpi_id=kpi_id,
rule_type=rule_type,
trigger_on=trigger_on,
enabled=data.get("enabled", 1),
params=data.get("params"),
)
@@ -132,7 +135,7 @@ def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db)):
if not rule:
raise HTTPException(404, "预警规则不存在")
for field in ("rule_type", "enabled", "params"):
for field in ("rule_type", "trigger_on", "enabled", "params"):
if field in data:
setattr(rule, field, data[field])
db.commit()
@@ -477,3 +480,139 @@ def _eval_threshold(value: float, threshold_str: str, invert: bool = False) -> b
return False
except (ValueError, TypeError):
return False
# ============================================================
# 预测值检查 + 情景建议
# ============================================================
def _check_forecast_alerts(db: Session) -> int:
"""检查未来7天预测值是否超限 — 针对trigger_on='forecast''both'的规则"""
from app.utils.cash_forecast_engine import forecast_cash_flow, generate_scenario_suggestion
from app.models import CashForecast
rules = db.query(AlertRule).filter(
AlertRule.enabled == 1,
AlertRule.trigger_on.in_(["forecast", "both"]),
).all()
if not rules:
return 0
alerts_generated = 0
rule_kpi_cache = {}
for rule in rules:
try:
kpi = rule_kpi_cache.get(rule.kpi_id)
if kpi is None:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
if kpi:
rule_kpi_cache[rule.kpi_id] = kpi
if not kpi:
continue
entity_id = kpi.entity_id or 1
# 获取最新的预测
latest_forecasts = db.query(CashForecast).filter(
CashForecast.entity_id == entity_id,
).order_by(CashForecast.forecast_date.asc()).limit(7).all()
if not latest_forecasts:
# 没有已有预测,执行一次实时预测
from app.utils.cash_forecast_engine import save_forecast_to_db
result = forecast_cash_flow(entity_id, db)
try:
save_forecast_to_db(entity_id, result, db)
except:
pass
latest_forecasts = db.query(CashForecast).filter(
CashForecast.entity_id == entity_id,
).order_by(CashForecast.forecast_date.asc()).limit(7).all()
if not latest_forecasts:
continue
# 检查预测值是否超限
params = rule.params or {}
params["kpi"] = kpi
for forecast in latest_forecasts:
value = forecast.predicted_cash
if value is None:
continue
alert_level, alert_message = _check_static(value, params, kpi)
if alert_level and alert_level != "green":
# 生成情景建议
sug_type = "cash_critical" if alert_level == "red" else "cash_low"
sug = generate_scenario_suggestion(
sug_type, kpi.kpi_name,
{"expected_receivables": 20, "forecast_date": forecast.forecast_date.isoformat()}
)
suggestion_text = f"{sug['title']}{sug['description']}\\n建议行动:{''.join(sug['actions'])}"
existing = db.query(KPIAlert).filter(
KPIAlert.kpi_id == rule.kpi_id,
KPIAlert.alert_type == "forecast",
KPIAlert.alert_level == alert_level,
KPIAlert.alert_message == alert_message,
KPIAlert.status == "pending",
).first()
if not existing:
alert = KPIAlert(
kpi_id=rule.kpi_id,
alert_level=alert_level,
alert_message=alert_message,
alert_type="forecast",
suggestion=suggestion_text,
status="pending",
)
db.add(alert)
alerts_generated += 1
except Exception as e:
logger.error(f"预测值预警检查失败: rule_id={rule.id}, error={e}")
continue
db.commit()
return alerts_generated
@router.post("/check-forecast")
def run_forecast_alert_check(db: Session = Depends(get_db)):
"""执行预测值预警检查 — 检查未来7天预测值是否超限"""
generated = _check_forecast_alerts(db)
return {"message": f"预测值预警检查完成: 生成{generated}", "generated": generated}
@router.post("/generate-suggestions")
def generate_alert_suggestions(db: Session = Depends(get_db)):
"""为所有未处理的预警生成情景建议"""
from app.utils.cash_forecast_engine import generate_scenario_suggestion
pending = db.query(KPIAlert).filter(
KPIAlert.status == "pending",
KPIAlert.suggestion.is_(None),
).all()
updated = 0
for alert in pending:
try:
sug_type = "cash_critical" if alert.alert_level == "red" else "cash_low"
if alert.alert_type == "forecast":
sug_type = "cash_critical" if alert.alert_level == "red" else "cash_low"
else:
sug_type = "cash_low"
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == alert.kpi_id).first()
kpi_name = kpi.kpi_name if kpi else "未知KPI"
sug = generate_scenario_suggestion(sug_type, kpi_name, {
"alert_level": alert.alert_level,
"alert_message": alert.alert_message,
})
alert.suggestion = f"{sug['title']}{sug['description']}\\n建议行动:{''.join(sug['actions'])}"
updated += 1
except Exception as e:
logger.error(f"生成建议失败: alert_id={alert.id}, error={e}")
db.commit()
return {"message": f"已为{updated}条预警生成情景建议", "updated": updated}
+106 -1
View File
@@ -1,10 +1,16 @@
"""预测模拟API — 管理会计OS"""
import logging
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Depends
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 sqlalchemy.orm import Session
logger = logging.getLogger("cma.predict")
router = APIRouter(prefix="/api/cma/predict", tags=["预测模拟"])
@@ -154,6 +160,105 @@ def api_cvp_detailed(data: dict):
raise HTTPException(400, f"CVP详细分析失败: {str(e)}")
# ── 现金流预测(AI事前预警) ────────────────────────────────────
@router.post("/cash-forecast")
def api_cash_forecast(data: dict, db: Session = Depends(get_db)):
"""现金流预测 — 根据历史KPI推算未来30天现金流"""
try:
entity_id = 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 = 1,
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 = 1,
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
+42
View File
@@ -112,6 +112,9 @@ class KPIAlert(Base):
kpi_value_id = Column(Integer, ForeignKey("kpi_values.id"), nullable=True)
alert_level = Column(String(20), default="yellow", comment="green/yellow/red")
alert_message = Column(String(500), nullable=False)
alert_type = Column(String(30), default="actual", comment="actual/forecast — 实际值超限/预测值超限")
suggestion = Column(Text, nullable=True, comment="情景建议")
action_plan_linked_id = Column(Integer, nullable=True, comment="关联的改善计划ID")
status = Column(String(20), default="pending", comment="pending/processing/resolved")
assignee = Column(String(100), nullable=True, comment="处理人")
resolution = Column(Text, nullable=True, comment="处理结果")
@@ -362,3 +365,42 @@ class OKRTemplate(Base):
is_active = Column(Integer, default=1)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class CashForecast(Base):
"""现金流预测 — 每日未来30天预测"""
__tablename__ = "cash_forecasts"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, ForeignKey("entities.id"), nullable=False, comment="企业ID")
forecast_date = Column(DateTime, nullable=False, comment="预测日期(每天一条)")
predicted_cash = Column(Float, nullable=True, comment="预测现金余额")
lower_bound = Column(Float, nullable=True, comment="置信区间下界")
upper_bound = Column(Float, nullable=True, comment="置信区间上界")
alert_status = Column(String(20), default="green", comment="green/yellow/red")
created_at = Column(DateTime, server_default=func.now())
class ForecastAccuracy(Base):
"""预测准确率 — 上期预测 vs 本期实际"""
__tablename__ = "forecast_accuracy"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, ForeignKey("entities.id"), nullable=False, comment="企业ID")
period = Column(String(20), nullable=False, comment="期间 2026-07")
forecast_value = Column(Float, nullable=True, comment="预测值")
actual_value = Column(Float, nullable=True, comment="实际值")
mae = Column(Float, nullable=True, comment="绝对误差")
mape = Column(Float, nullable=True, comment="百分比误差")
created_at = Column(DateTime, server_default=func.now())
class ScenarioSuggestion(Base):
"""情景建议模板 — 根据不同预警类型自动生成建议"""
__tablename__ = "scenario_suggestions"
id = Column(Integer, primary_key=True, index=True)
alert_type = Column(String(30), nullable=False, comment="预警类型: cash_low/cash_critical/cost_high/revenue_drop")
title = Column(String(200), nullable=False, comment="建议标题")
description = Column(Text, nullable=True, comment="详细建议")
action_template = Column(Text, nullable=True, comment="改善行动模板")
priority = Column(String(20), default="medium", comment="high/medium/low")
sort_order = Column(Integer, default=0, comment="排序")
created_at = Column(DateTime, server_default=func.now())
+322
View File
@@ -0,0 +1,322 @@
"""现金流预测引擎 — 根据历史KPI数据推算未来30天现金流"""
import logging
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy.orm import Session
import math
import random
logger = logging.getLogger("cma.cash_forecast")
# 默认现金阈值(万元)
DEFAULT_CASH_WARNING = 20.0 # 黄灯 — 低于20万
DEFAULT_CASH_CRITICAL = 10.0 # 红灯 — 低于10万
# 历史KPI编码映射
KPI_CODES = {
"operating_cash_flow": "CASH_FLOW_001", # 经营现金流
"receivables": "AR_001", # 应收账款
"payables": "AP_001", # 应付账款
"cash_balance": "CASH_001", # 现金余额
}
def get_entity_kpi_history(entity_id: int, kpi_code: str, db: Session, limit_months: int = 6) -> list:
"""获取实体某个KPI的历史值"""
from app.models import KPIDefinition, KPIValue
kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == kpi_code,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
return []
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).limit(limit_months).all()
return values
def calc_trend(values: list) -> float:
"""计算趋势系数 — 线性回归斜率 / 均值"""
if len(values) < 2:
return 0.0
vals = [v.actual_value for v in values]
n = len(vals)
avg_x = (n - 1) / 2.0
avg_y = sum(vals) / n
num = sum((i - avg_x) * (vals[i] - avg_y) for i in range(n))
den = sum((i - avg_x) ** 2 for i in range(n))
slope = num / den if den != 0 else 0
return slope / max(abs(avg_y), 1.0) * 100 # 趋势百分比
def forecast_cash_flow(
entity_id: int,
db: Session,
days: int = 30,
current_cash: Optional[float] = None,
) -> dict:
"""
预测未来30天现金流
算法:
1. 获取历史经营现金流、应收、应付趋势
2. 推算每日现金流入/流出
3. 生成每日预测值+置信区间
"""
from app.models import KPIDefinition, KPIValue
# 获取当前现金余额
if current_cash is None:
cash_kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == KPI_CODES["cash_balance"],
KPIDefinition.entity_id == entity_id,
).first()
if cash_kpi:
latest_cash = db.query(KPIValue).filter(
KPIValue.kpi_id == cash_kpi.id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).first()
base_cash = latest_cash.actual_value if latest_cash else 30.0
else:
base_cash = 30.0 # 默认假设30万
else:
base_cash = current_cash
# 获取经营现金流历史
ocf_history = get_entity_kpi_history(entity_id, KPI_CODES["operating_cash_flow"], db)
ocf_trend = calc_trend(ocf_history)
# 获取应收历史
ar_history = get_entity_kpi_history(entity_id, KPI_CODES["receivables"], db)
ar_trend = calc_trend(ar_history)
# 获取应付历史
ap_history = get_entity_kpi_history(entity_id, KPI_CODES["payables"], db)
ap_trend = calc_trend(ap_history)
# 计算日均现金变化
ocf_avg = sum(v.actual_value for v in ocf_history) / max(len(ocf_history), 1) / 30.0 if ocf_history else 0.5
# 预测逻辑:趋势影响 + 季节性(月底回款高峰)
forecast = []
cash = base_cash
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
for day_offset in range(1, days + 1):
d = today + timedelta(days=day_offset)
day_of_month = d.day
is_month_end = day_of_month >= 25
# 每日现金变化 = 经营现金流日均值 × (1 + 趋势调整) + 季节因子
trend_factor = 1.0 + ocf_trend / 100.0
daily_change = ocf_avg * trend_factor
# 月底回款高峰
if is_month_end:
daily_change += ocf_avg * 0.3 # 月底多30%回款
# 周末效应
if d.weekday() >= 5:
daily_change *= 0.5 # 周末收支减半
cash += daily_change
# 置信区间:随时间增加而扩大
confidence_band = 1.0 + day_offset * 0.08 # 每过1天,区间扩大8%
std = max(abs(daily_change) * confidence_band, 0.5)
lower = cash - std * 0.5
upper = cash + std * 0.5
# 预警状态
if cash < DEFAULT_CASH_CRITICAL:
status = "red"
elif cash < DEFAULT_CASH_WARNING:
status = "yellow"
else:
status = "green"
forecast.append({
"date": d.strftime("%Y-%m-%d"),
"day_offset": day_offset,
"predicted_cash": round(cash, 2),
"lower_bound": round(max(lower, 0), 2),
"upper_bound": round(upper, 2),
"alert_status": status,
})
# 整体预警
min_cash = min(f["predicted_cash"] for f in forecast)
min_date = next(f["date"] for f in forecast if f["predicted_cash"] == min_cash)
suggestions = []
if min_cash < DEFAULT_CASH_CRITICAL:
suggestions.append({
"type": "critical",
"message": f"预计{min_date}现金余额降至{min_cash:.1f}万,低于警戒线{DEFAULT_CASH_CRITICAL}",
"actions": [
"立即催收大额应收账款",
"暂停非必要支出",
"准备短期融资安排",
]
})
elif min_cash < DEFAULT_CASH_WARNING:
suggestions.append({
"type": "warning",
"message": f"预计{min_date}现金余额降至{min_cash:.1f}万,低于关注线{DEFAULT_CASH_WARNING}",
"actions": [
"加快应收账款回款",
"控制采购付款节奏",
"评估短期现金流压力",
]
})
return {
"entity_id": entity_id,
"base_cash": round(base_cash, 2),
"days": days,
"forecast": forecast,
"min_cash": round(min_cash, 2),
"min_cash_date": min_date,
"trends": {
"operating_cash_flow_trend_pct": round(ocf_trend, 2),
"receivables_trend_pct": round(ar_trend, 2),
"payables_trend_pct": round(ap_trend, 2),
},
"suggestions": suggestions,
}
def save_forecast_to_db(entity_id: int, forecast_data: dict, db: Session):
"""将预测结果保存到数据库"""
from app.models import CashForecast
for f in forecast_data["forecast"]:
forecast_date = datetime.strptime(f["date"], "%Y-%m-%d")
cf = CashForecast(
entity_id=entity_id,
forecast_date=forecast_date,
predicted_cash=f["predicted_cash"],
lower_bound=f["lower_bound"],
upper_bound=f["upper_bound"],
alert_status=f["alert_status"],
)
db.add(cf)
db.commit()
def calculate_accuracy(entity_id: int, db: Session) -> list:
"""计算预测准确率 — 对比上期预测 vs 本期实际"""
from app.models import CashForecast, KPIDefinition, KPIValue
# 获取实体最近的预测
forecasts = db.query(CashForecast).filter(
CashForecast.entity_id == entity_id,
).order_by(CashForecast.forecast_date.desc()).limit(90).all()
# 获取实际的现金余额KPI值
cash_kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == KPI_CODES["cash_balance"],
KPIDefinition.entity_id == entity_id,
).first()
if not cash_kpi or not forecasts:
return []
actuals = db.query(KPIValue).filter(
KPIValue.kpi_id == cash_kpi.id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).limit(12).all()
actual_map = {}
for a in actuals:
try:
# period like "2026-07" -> month approx
actual_map[a.period] = a.actual_value
except:
pass
# 按月汇总预测值和实际值,计算准确率
from collections import defaultdict
monthly_forecast = defaultdict(list)
for f in forecasts:
month_key = f.forecast_date.strftime("%Y-%m")
monthly_forecast[month_key].append(f.predicted_cash)
results = []
for month, f_vals in sorted(monthly_forecast.items()):
if month in actual_map:
f_avg = sum(f_vals) / len(f_vals)
a_val = actual_map[month]
mae = abs(f_avg - a_val)
mape = abs((f_avg - a_val) / max(abs(a_val), 1)) * 100
results.append({
"period": month,
"forecast_value": round(f_avg, 2),
"actual_value": round(a_val, 2),
"mae": round(mae, 2),
"mape": round(mape, 2),
})
return results
def generate_scenario_suggestion(alert_type: str, kpi_name: str, extra: dict = None) -> dict:
"""根据预警类型生成情景建议"""
suggestions = {
"cash_low": {
"title": "现金流紧张缓解方案",
"description": f"现金余额低于阈值,建议加快应收账款催收、控制支出、评估短期融资。",
"actions": [
f"催收大额应收账款(预计回款{extra.get('expected_receivables', '待定')}万元)",
"暂停非紧急采购和资本性支出",
"与供应商协商延长账期",
"评估银行短期授信额度",
],
"priority": "high",
},
"cash_critical": {
"title": "现金流危机应对方案",
"description": f"现金余额接近断流,需立即采取紧急措施。",
"actions": [
"立即催收所有到期应收账款",
"暂停所有非必要支出",
"紧急联系银行安排短期贷款",
"评估资产变现可能性",
],
"priority": "high",
},
"cost_high": {
"title": "成本管控优化方案",
"description": f"成本率异常偏高,建议进行成本结构分析和优化。",
"actions": [
"逐项分析成本构成,识别异常项",
"与供应商重新谈判采购价格",
"评估流程优化降本空间",
"建立费用审批红线上限",
],
"priority": "medium",
},
"revenue_drop": {
"title": "收入下滑应对方案",
"description": f"收入出现下滑趋势,建议分析原因并制定恢复计划。",
"actions": [
"分析收入下滑原因(客户流失/价格战/需求变化)",
"制定客户留存和挽回计划",
"评估新产品/新市场机会",
"优化销售激励政策",
],
"priority": "high",
},
}
sug = suggestions.get(alert_type, {
"title": "改善建议",
"description": "根据预警情况制定改善措施。",
"actions": ["分析预警原因", "制定改善计划", "跟踪执行效果"],
"priority": "medium",
})
if extra:
sug["extra"] = extra
return sug