feat: AI事前预警 — 现金流预测+预警扩展+准确率+情景建议
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user