feat: AI事前预警 — 现金流预测+预警扩展+准确率+情景建议
This commit is contained in:
@@ -25,6 +25,7 @@ class AlertRule(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
kpi_id = Column(Integer, nullable=False, comment="关联KPI ID")
|
kpi_id = Column(Integer, nullable=False, comment="关联KPI ID")
|
||||||
rule_type = Column(String(30), nullable=False, comment="static/dynamic/trend_up/trend_down")
|
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禁用")
|
enabled = Column(Integer, default=1, comment="1启用 0禁用")
|
||||||
params = Column(JSON, nullable=True, comment="规则参数")
|
params = Column(JSON, nullable=True, comment="规则参数")
|
||||||
# static: {"green": ">=90", "yellow": ">=80", "red": "<80"}
|
# 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")
|
kpi_id = data.get("kpi_id")
|
||||||
rule_type = data.get("rule_type", "static")
|
rule_type = data.get("rule_type", "static")
|
||||||
|
trigger_on = data.get("trigger_on", "actual")
|
||||||
|
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||||
if not kpi:
|
if not kpi:
|
||||||
@@ -108,6 +110,7 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
|
|||||||
rule = AlertRule(
|
rule = AlertRule(
|
||||||
kpi_id=kpi_id,
|
kpi_id=kpi_id,
|
||||||
rule_type=rule_type,
|
rule_type=rule_type,
|
||||||
|
trigger_on=trigger_on,
|
||||||
enabled=data.get("enabled", 1),
|
enabled=data.get("enabled", 1),
|
||||||
params=data.get("params"),
|
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:
|
if not rule:
|
||||||
raise HTTPException(404, "预警规则不存在")
|
raise HTTPException(404, "预警规则不存在")
|
||||||
|
|
||||||
for field in ("rule_type", "enabled", "params"):
|
for field in ("rule_type", "trigger_on", "enabled", "params"):
|
||||||
if field in data:
|
if field in data:
|
||||||
setattr(rule, field, data[field])
|
setattr(rule, field, data[field])
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -477,3 +480,139 @@ def _eval_threshold(value: float, threshold_str: str, invert: bool = False) -> b
|
|||||||
return False
|
return False
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return False
|
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"""
|
"""预测模拟API — 管理会计OS"""
|
||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
from app.utils.predict_engine import (
|
from app.utils.predict_engine import (
|
||||||
cvp_analysis, npv, irr,
|
cvp_analysis, npv, irr,
|
||||||
sensitivity_analysis, scenario_analysis,
|
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")
|
logger = logging.getLogger("cma.predict")
|
||||||
router = APIRouter(prefix="/api/cma/predict", tags=["预测模拟"])
|
router = APIRouter(prefix="/api/cma/predict", tags=["预测模拟"])
|
||||||
@@ -154,6 +160,105 @@ def api_cvp_detailed(data: dict):
|
|||||||
raise HTTPException(400, f"CVP详细分析失败: {str(e)}")
|
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
|
import math
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ class KPIAlert(Base):
|
|||||||
kpi_value_id = Column(Integer, ForeignKey("kpi_values.id"), nullable=True)
|
kpi_value_id = Column(Integer, ForeignKey("kpi_values.id"), nullable=True)
|
||||||
alert_level = Column(String(20), default="yellow", comment="green/yellow/red")
|
alert_level = Column(String(20), default="yellow", comment="green/yellow/red")
|
||||||
alert_message = Column(String(500), nullable=False)
|
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")
|
status = Column(String(20), default="pending", comment="pending/processing/resolved")
|
||||||
assignee = Column(String(100), nullable=True, comment="处理人")
|
assignee = Column(String(100), nullable=True, comment="处理人")
|
||||||
resolution = Column(Text, nullable=True, comment="处理结果")
|
resolution = Column(Text, nullable=True, comment="处理结果")
|
||||||
@@ -362,3 +365,42 @@ class OKRTemplate(Base):
|
|||||||
is_active = Column(Integer, default=1)
|
is_active = Column(Integer, default=1)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=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())
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -113,6 +113,9 @@ export const alertRulesApi = {
|
|||||||
checkAll: () => api.post('/alert-rules/check-all'),
|
checkAll: () => api.post('/alert-rules/check-all'),
|
||||||
calculateDynamic: () => api.post('/alert-rules/calculate-dynamic'),
|
calculateDynamic: () => api.post('/alert-rules/calculate-dynamic'),
|
||||||
dynamicThresholds: (params?: any) => api.get('/alert-rules/dynamic-thresholds', { params }),
|
dynamicThresholds: (params?: any) => api.get('/alert-rules/dynamic-thresholds', { params }),
|
||||||
|
// AI事前预警
|
||||||
|
checkForecast: () => api.post('/alert-rules/check-forecast'),
|
||||||
|
generateSuggestions: () => api.post('/alert-rules/generate-suggestions'),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userApi = {
|
export const userApi = {
|
||||||
@@ -174,6 +177,12 @@ export const predictApi = {
|
|||||||
sensitivity: (data: any) => api.post('/predict/sensitivity', data),
|
sensitivity: (data: any) => api.post('/predict/sensitivity', data),
|
||||||
scenario: (data: any) => api.post('/predict/scenario', data),
|
scenario: (data: any) => api.post('/predict/scenario', data),
|
||||||
growthQuality: (data: any) => api.post('/predict/growth-quality', data),
|
growthQuality: (data: any) => api.post('/predict/growth-quality', data),
|
||||||
|
// AI事前预警
|
||||||
|
cashForecast: (data: any) => api.post('/predict/cash-forecast', data),
|
||||||
|
cashForecastHistory: (params?: any) => api.get('/predict/cash-forecast/history', { params }),
|
||||||
|
forecastAccuracy: (params?: any) => api.get('/predict/accuracy', { params }),
|
||||||
|
scenarioSuggestions: (params?: any) => api.get('/predict/scenario-suggestions', { params }),
|
||||||
|
generateSuggestion: (data: any) => api.post('/predict/scenario-suggestion/generate', data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deviationPushApi = {
|
export const deviationPushApi = {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const routes = [
|
|||||||
{ path: 'deviations', name: 'DeviationDashboard', component: () => import('@/views/DeviationDashboard.vue'), meta: { title: '差异分析', roles: ['ceo', 'finance', 'business', 'it'] } },
|
{ path: 'deviations', name: 'DeviationDashboard', component: () => import('@/views/DeviationDashboard.vue'), meta: { title: '差异分析', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||||
{ path: 'cost', name: 'CostDashboard', component: () => import('@/views/CostDashboard.vue'), meta: { title: '成本分析', roles: ['ceo', 'finance', 'it'] } },
|
{ path: 'cost', name: 'CostDashboard', component: () => import('@/views/CostDashboard.vue'), meta: { title: '成本分析', roles: ['ceo', 'finance', 'it'] } },
|
||||||
{ path: 'predict', name: 'PredictDashboard', component: () => import('@/views/PredictDashboard.vue'), meta: { title: '预测模拟', roles: ['ceo', 'finance', 'it'] } },
|
{ path: 'predict', name: 'PredictDashboard', component: () => import('@/views/PredictDashboard.vue'), meta: { title: '预测模拟', roles: ['ceo', 'finance', 'it'] } },
|
||||||
|
{ path: 'predict/accuracy', name: 'PredictAccuracy', component: () => import('@/views/PredictAccuracy.vue'), meta: { title: '预测准确率', roles: ['ceo', 'finance', 'it'] } },
|
||||||
{ path: 'real-options', name: 'RealOptions', component: () => import('@/views/RealOptions.vue'), meta: { title: '实物期权计算器', roles: ['ceo', 'finance'] } },
|
{ path: 'real-options', name: 'RealOptions', component: () => import('@/views/RealOptions.vue'), meta: { title: '实物期权计算器', roles: ['ceo', 'finance'] } },
|
||||||
{ path: 'action-plans', name: 'ActionPlans', component: () => import('@/views/ActionPlanLibrary.vue'), meta: { title: '改善行动', roles: ['ceo', 'finance', 'business', 'it'] } },
|
{ path: 'action-plans', name: 'ActionPlans', component: () => import('@/views/ActionPlanLibrary.vue'), meta: { title: '改善行动', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||||
{ path: 'reports', name: 'ReportCenter', component: () => import('@/views/ReportCenter.vue'), meta: { title: '管理报表', roles: ['ceo', 'finance', 'business'] } },
|
{ path: 'reports', name: 'ReportCenter', component: () => import('@/views/ReportCenter.vue'), meta: { title: '管理报表', roles: ['ceo', 'finance', 'business'] } },
|
||||||
|
|||||||
@@ -39,11 +39,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="alerts" v-loading="loading" style="width:100%" border stripe size="small">
|
<el-table :data="alerts" v-loading="loading" style="width:100%" border stripe size="small">
|
||||||
<el-table-column prop="alert_message" label="预警信息" min-width="320">
|
<el-table-column prop="alert_message" label="预警信息" min-width="300">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div style="display:flex;align-items:center;gap:4px;">
|
<div style="display:flex;align-items:center;gap:4px;">
|
||||||
<el-tag v-if="isTrendAlert(row.alert_message)" size="small" type="info" effect="plain">趋势</el-tag>
|
<el-tag v-if="row.alert_type === 'forecast'" size="small" type="warning" effect="plain">🔮预测</el-tag>
|
||||||
<el-tag v-else size="small" type="primary" effect="plain">静态</el-tag>
|
<el-tag v-else size="small" type="primary" effect="plain">实际</el-tag>
|
||||||
<span>{{ row.alert_message }}</span>
|
<span>{{ row.alert_message }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -147,6 +147,8 @@
|
|||||||
<el-option label="已禁用" :value="0" />
|
<el-option label="已禁用" :value="0" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button type="primary" size="small" @click="loadRules">查询</el-button>
|
<el-button type="primary" size="small" @click="loadRules">查询</el-button>
|
||||||
|
<el-button size="small" @click="checkForecastAlerts" :loading="fcLoading">🔮 检查预测值</el-button>
|
||||||
|
<el-button size="small" @click="generateAlertSuggestions" :loading="sgLoading">💡 生成情景建议</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="rules" v-loading="rulesLoading" style="width:100%" border stripe size="small">
|
<el-table :data="rules" v-loading="rulesLoading" style="width:100%" border stripe size="small">
|
||||||
@@ -254,7 +256,7 @@
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 编辑预警规则弹窗 -->
|
<!-- 编辑预警规则弹窗 -->
|
||||||
<el-dialog v-model="showRuleForm" title="编辑预警规则" width="500px">
|
<el-dialog v-model="showRuleForm" title="编辑预警规则" width="520px">
|
||||||
<el-form :model="ruleForm" label-width="110px" size="small">
|
<el-form :model="ruleForm" label-width="110px" size="small">
|
||||||
<el-form-item label="规则类型">
|
<el-form-item label="规则类型">
|
||||||
<el-select v-model="ruleForm.rule_type" style="width:100%">
|
<el-select v-model="ruleForm.rule_type" style="width:100%">
|
||||||
@@ -264,6 +266,14 @@
|
|||||||
<el-option label="下降趋势预警" value="trend_down" />
|
<el-option label="下降趋势预警" value="trend_down" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="触发条件">
|
||||||
|
<el-select v-model="ruleForm.trigger_on" style="width:100%">
|
||||||
|
<el-option label="实际值超限" value="actual" />
|
||||||
|
<el-option label="预测值超限" value="forecast" />
|
||||||
|
<el-option label="两者都触发" value="both" />
|
||||||
|
</el-select>
|
||||||
|
<span style="color:#909399;font-size:12px;margin-top:2px;display:block;">预测值超限时,系统会检查未来7天现金流预测是否触及阈值</span>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="是否启用">
|
<el-form-item label="是否启用">
|
||||||
<el-switch v-model="ruleForm.enabled" :active-value="1" :inactive-value="0" />
|
<el-switch v-model="ruleForm.enabled" :active-value="1" :inactive-value="0" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -366,6 +376,7 @@ const calcLoading = ref(false)
|
|||||||
const ruleForm = ref({
|
const ruleForm = ref({
|
||||||
kpi_id: null as number | null,
|
kpi_id: null as number | null,
|
||||||
rule_type: 'static',
|
rule_type: 'static',
|
||||||
|
trigger_on: 'actual',
|
||||||
enabled: 1,
|
enabled: 1,
|
||||||
params: { green: '', yellow: '', red: '', sensitivity: 1.0, threshold_pct: 10 },
|
params: { green: '', yellow: '', red: '', sensitivity: 1.0, threshold_pct: 10 },
|
||||||
})
|
})
|
||||||
@@ -467,6 +478,7 @@ function editRule(row: any) {
|
|||||||
ruleForm.value = {
|
ruleForm.value = {
|
||||||
kpi_id: row.kpi_id,
|
kpi_id: row.kpi_id,
|
||||||
rule_type: row.rule_type,
|
rule_type: row.rule_type,
|
||||||
|
trigger_on: row.trigger_on || 'actual',
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
params: { green: '', yellow: '', red: '', sensitivity: 1.0, threshold_pct: 10, ...(params || {}) },
|
params: { green: '', yellow: '', red: '', sensitivity: 1.0, threshold_pct: 10, ...(params || {}) },
|
||||||
}
|
}
|
||||||
@@ -478,6 +490,7 @@ async function saveRule() {
|
|||||||
try {
|
try {
|
||||||
const data = {
|
const data = {
|
||||||
rule_type: ruleForm.value.rule_type,
|
rule_type: ruleForm.value.rule_type,
|
||||||
|
trigger_on: ruleForm.value.trigger_on,
|
||||||
enabled: ruleForm.value.enabled,
|
enabled: ruleForm.value.enabled,
|
||||||
params: ruleForm.value.params,
|
params: ruleForm.value.params,
|
||||||
}
|
}
|
||||||
@@ -514,6 +527,29 @@ async function generateRules() {
|
|||||||
generating.value = false
|
generating.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkForecastAlerts() {
|
||||||
|
fcLoading.value = true
|
||||||
|
try {
|
||||||
|
const r: any = await alertRulesApi.checkForecast()
|
||||||
|
ElMessage.success(r.message || '预测值检查完成')
|
||||||
|
loadAlerts()
|
||||||
|
} catch (e) { ElMessage.error('检查失败') }
|
||||||
|
fcLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateAlertSuggestions() {
|
||||||
|
sgLoading.value = true
|
||||||
|
try {
|
||||||
|
const r: any = await alertRulesApi.generateSuggestions()
|
||||||
|
ElMessage.success(r.message || '情景建议已生成')
|
||||||
|
loadAlerts()
|
||||||
|
} catch (e) { ElMessage.error('生成失败') }
|
||||||
|
sgLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const fcLoading = ref(false)
|
||||||
|
const sgLoading = ref(false)
|
||||||
|
|
||||||
async function calcDynamic() {
|
async function calcDynamic() {
|
||||||
calcLoading.value = true
|
calcLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h3>预测准确率报表</h3>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-select v-model="entityId" size="small" style="width:200px;" @change="loadAccuracy">
|
||||||
|
<el-option label="陕西酣客(白酒经销)" :value="1" />
|
||||||
|
<el-option label="陕西博海科技(IT服务)" :value="2" />
|
||||||
|
</el-select>
|
||||||
|
<el-button size="small" @click="loadAccuracy" :loading="loading" type="primary">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 汇总卡片 -->
|
||||||
|
<el-row :gutter="16" style="margin-bottom:16px;">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="s-label">平均绝对误差 (MAE)</div>
|
||||||
|
<div class="s-value" :class="summary.avg_mae < 5 ? 'green' : summary.avg_mae < 10 ? 'orange' : 'red'">
|
||||||
|
{{ summary.avg_mae?.toFixed(2) || '--' }}
|
||||||
|
<span style="font-size:12px;color:#909399;">万元</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="s-label">平均百分比误差 (MAPE)</div>
|
||||||
|
<div class="s-value" :class="summary.avg_mape < 15 ? 'green' : summary.avg_mape < 30 ? 'orange' : 'red'">
|
||||||
|
{{ summary.avg_mape?.toFixed(2) || '--' }}
|
||||||
|
<span style="font-size:12px;color:#909399;">%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="s-label">统计周期数</div>
|
||||||
|
<div class="s-value" style="color:#409eff;">
|
||||||
|
{{ summary.total_periods || 0 }}
|
||||||
|
<span style="font-size:12px;color:#909399;">个月</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 准确率趋势图 -->
|
||||||
|
<el-card style="margin-bottom:16px;">
|
||||||
|
<template #header>
|
||||||
|
<span>📈 预测准确率趋势(MAE/MAPE)</span>
|
||||||
|
</template>
|
||||||
|
<div ref="accuracyChartRef" style="height:360px;width:100%;"></div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 每月明细 -->
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<span>📋 每月预测 vs 实际对比</span>
|
||||||
|
<span style="margin-left:12px;font-size:12px;color:#999;" v-if="accuracyData.length">共 {{ accuracyData.length }} 期</span>
|
||||||
|
</template>
|
||||||
|
<el-table :data="accuracyData" v-loading="loading" border stripe size="small" style="width:100%;">
|
||||||
|
<el-table-column prop="period" label="期间" width="100" />
|
||||||
|
<el-table-column prop="forecast_value" label="预测值(万)" width="130" align="right">
|
||||||
|
<template #default="{ row }">{{ row.forecast_value?.toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="actual_value" label="实际值(万)" width="130" align="right">
|
||||||
|
<template #default="{ row }">{{ row.actual_value?.toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="差异(万)" width="120" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :style="{ color: Math.abs(row.forecast_value - row.actual_value) < 5 ? '#67c23a' : '#f56c6c', fontWeight:600 }">
|
||||||
|
{{ (row.forecast_value - row.actual_value) > 0 ? '+' : '' }}{{ (row.forecast_value - row.actual_value)?.toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="mae" label="MAE(万)" width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :style="{ color: row.mae < 5 ? '#67c23a' : row.mae < 10 ? '#e6a23c' : '#f56c6c' }">{{ row.mae?.toFixed(2) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="mape" label="MAPE(%)" width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :style="{ color: row.mape < 15 ? '#67c23a' : row.mape < 30 ? '#e6a23c' : '#f56c6c' }">{{ row.mape?.toFixed(2) }}%</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="评级" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.mape < 15 ? 'success' : row.mape < 30 ? 'warning' : 'danger'" size="small">
|
||||||
|
{{ row.mape < 15 ? '准确' : row.mape < 30 ? '一般' : '偏差大' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, nextTick, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { predictApi } from '../api/index'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const entityId = ref(1)
|
||||||
|
const accuracyData = ref<any[]>([])
|
||||||
|
const summary = ref<any>({})
|
||||||
|
const accuracyChartRef = ref<HTMLElement | null>(null)
|
||||||
|
let accuracyChart: any = null
|
||||||
|
|
||||||
|
async function loadAccuracy() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const r: any = await predictApi.forecastAccuracy({ entity_id: entityId.value })
|
||||||
|
accuracyData.value = r.data || []
|
||||||
|
summary.value = r.summary || {}
|
||||||
|
nextTick(() => renderAccuracyChart())
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('加载预测准确率失败')
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAccuracyChart() {
|
||||||
|
if (!accuracyChartRef.value || !accuracyData.value.length) return
|
||||||
|
import('echarts').then(echarts => {
|
||||||
|
if (accuracyChart) accuracyChart.dispose()
|
||||||
|
accuracyChart = echarts.init(accuracyChartRef.value!)
|
||||||
|
|
||||||
|
const periods = accuracyData.value.map((d: any) => d.period)
|
||||||
|
const maeData = accuracyData.value.map((d: any) => d.mae)
|
||||||
|
const mapeData = accuracyData.value.map((d: any) => d.mape)
|
||||||
|
const forecastVals = accuracyData.value.map((d: any) => d.forecast_value)
|
||||||
|
const actualVals = accuracyData.value.map((d: any) => d.actual_value)
|
||||||
|
|
||||||
|
accuracyChart.setOption({
|
||||||
|
grid: [{ left: 60, right: 30, top: 20, bottom: 50 }, { left: 60, right: 30, top: 320, bottom: 50 }],
|
||||||
|
xAxis: [
|
||||||
|
{ type: 'category', data: periods, axisLabel: { rotate: 45, fontSize: 10 }, gridIndex: 0 },
|
||||||
|
{ type: 'category', data: periods, axisLabel: { rotate: 45, fontSize: 10 }, gridIndex: 1 },
|
||||||
|
],
|
||||||
|
yAxis: [
|
||||||
|
{ type: 'value', name: '误差', gridIndex: 0 },
|
||||||
|
{ type: 'value', name: '现金(万)', gridIndex: 1 },
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'MAE', type: 'bar', data: maeData, xAxisIndex: 0, yAxisIndex: 0,
|
||||||
|
itemStyle: { color: '#409eff', borderRadius: [4, 4, 0, 0] },
|
||||||
|
barWidth: '30%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'MAPE(%)', type: 'line', data: mapeData, xAxisIndex: 0, yAxisIndex: 0,
|
||||||
|
lineStyle: { color: '#e6a23c', width: 2 },
|
||||||
|
symbol: 'circle', symbolSize: 6,
|
||||||
|
itemStyle: { color: '#e6a23c' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '预测值', type: 'line', data: forecastVals, xAxisIndex: 1, yAxisIndex: 1,
|
||||||
|
lineStyle: { color: '#409eff', width: 2 },
|
||||||
|
symbol: 'diamond', symbolSize: 8,
|
||||||
|
itemStyle: { color: '#409eff' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '实际值', type: 'line', data: actualVals, xAxisIndex: 1, yAxisIndex: 1,
|
||||||
|
lineStyle: { color: '#67c23a', width: 2 },
|
||||||
|
symbol: 'circle', symbolSize: 6,
|
||||||
|
itemStyle: { color: '#67c23a' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { bottom: 0, icon: 'circle', itemWidth: 8, itemHeight: 8 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadAccuracy()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; }
|
||||||
|
.page-header h3 { margin:0; }
|
||||||
|
.header-actions { display:flex; gap:8px; }
|
||||||
|
.summary-card { text-align:center; padding:8px 0; }
|
||||||
|
.s-label { font-size:13px; color:#909399; margin-bottom:8px; }
|
||||||
|
.s-value { font-size:28px; font-weight:bold; }
|
||||||
|
.s-value.green { color:#67c23a; }
|
||||||
|
.s-value.orange { color:#e6a23c; }
|
||||||
|
.s-value.red { color:#f56c6c; }
|
||||||
|
</style>
|
||||||
@@ -216,6 +216,111 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- Tab 5: 现金流预测(AI事前预警) -->
|
||||||
|
<el-tab-pane label="现金流预测" name="cashFlow">
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card>
|
||||||
|
<template #header>预测参数</template>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="企业实体">
|
||||||
|
<el-select v-model="cfEntityId" style="width:100%;">
|
||||||
|
<el-option label="陕西酣客(白酒经销)" :value="1" />
|
||||||
|
<el-option label="陕西博海科技(IT服务)" :value="2" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="当前现金余额(万元)">
|
||||||
|
<el-input-number v-model="cfCurrentCash" :min="0" :precision="1" style="width:100%;" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="预测天数">
|
||||||
|
<el-select v-model="cfDays" style="width:100%;">
|
||||||
|
<el-option label="未来7天" :value="7" />
|
||||||
|
<el-option label="未来15天" :value="15" />
|
||||||
|
<el-option label="未来30天" :value="30" />
|
||||||
|
<el-option label="未来60天" :value="60" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="doCashForecast" :loading="cfLoading" style="width:100%;">🔮 开始预测</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
<!-- 趋势指标 -->
|
||||||
|
<el-card style="margin-top:12px;" v-if="cfResult.trends">
|
||||||
|
<template #header>📊 趋势指标</template>
|
||||||
|
<div class="trend-item"><span class="t-label">经营现金流趋势</span><span class="t-value" :class="cfResult.trends.operating_cash_flow_trend_pct >= 0 ? 'green' : 'red'">{{ cfResult.trends.operating_cash_flow_trend_pct }}%</span></div>
|
||||||
|
<div class="trend-item"><span class="t-label">应收账款趋势</span><span class="t-value" :class="cfResult.trends.receivables_trend_pct >= 0 ? 'red' : 'green'">{{ cfResult.trends.receivables_trend_pct }}%</span></div>
|
||||||
|
<div class="trend-item"><span class="t-label">应付账款趋势</span><span class="t-value" :class="cfResult.trends.payables_trend_pct >= 0 ? 'green' : 'red'">{{ cfResult.trends.payables_trend_pct }}%</span></div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="18">
|
||||||
|
<!-- 预测折线图 -->
|
||||||
|
<el-card v-if="cfResult.forecast?.length">
|
||||||
|
<template #header>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<span>📈 现金流预测 — 未来{{ cfDays }}天</span>
|
||||||
|
<span>
|
||||||
|
<span style="font-size:12px;color:#909399;">基准现金:</span>
|
||||||
|
<strong>{{ cfResult.base_cash }}万</strong>
|
||||||
|
<span style="margin-left:12px;font-size:12px;color:#909399;">最低点:</span>
|
||||||
|
<strong :style="{color: cfResult.min_cash < 10 ? '#f56c6c' : cfResult.min_cash < 20 ? '#e6a23c' : '#67c23a'}">{{ cfResult.min_cash }}万</strong>
|
||||||
|
<span style="margin-left:4px;font-size:12px;color:#909399;">({{ cfResult.min_cash_date }})</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="cfChartRef" style="height:380px;width:100%;"></div>
|
||||||
|
</el-card>
|
||||||
|
<el-empty v-else-if="!cfLoading" description="设置参数后点击「开始预测」" style="padding:60px 0;" />
|
||||||
|
|
||||||
|
<!-- 预警建议 -->
|
||||||
|
<el-card style="margin-top:16px;" v-if="cfResult.suggestions?.length">
|
||||||
|
<template #header>💡 预警建议</template>
|
||||||
|
<div v-for="(sug, idx) in cfResult.suggestions" :key="idx" style="margin-bottom:12px;">
|
||||||
|
<el-alert
|
||||||
|
:title="sug.message"
|
||||||
|
:type="sug.type === 'critical' ? 'error' : 'warning'"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
style="margin-bottom:8px;"
|
||||||
|
/>
|
||||||
|
<el-tag v-for="(act, aidx) in sug.actions" :key="aidx" style="margin:2px 4px 2px 0;" size="small" effect="plain">
|
||||||
|
✅ {{ act }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 每日预测明细表 -->
|
||||||
|
<el-card style="margin-top:16px;" v-if="cfResult.forecast?.length">
|
||||||
|
<template #header>
|
||||||
|
<span>📋 每日预测明细</span>
|
||||||
|
<span style="margin-left:12px;font-size:12px;color:#999;">(共{{ cfResult.forecast.length }}天)</span>
|
||||||
|
</template>
|
||||||
|
<el-table :data="cfResult.forecast" border stripe size="small" max-height="300" style="width:100%;">
|
||||||
|
<el-table-column prop="date" label="日期" width="110" />
|
||||||
|
<el-table-column prop="predicted_cash" label="预测现金(万)" width="130" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :style="{ color: row.predicted_cash < 10 ? '#f56c6c' : row.predicted_cash < 20 ? '#e6a23c' : '#67c23a', fontWeight:600 }">{{ row.predicted_cash?.toFixed(2) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="lower_bound" label="下界(万)" width="110" align="right">
|
||||||
|
<template #default="{ row }">{{ row.lower_bound?.toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="upper_bound" label="上界(万)" width="110" align="right">
|
||||||
|
<template #default="{ row }">{{ row.upper_bound?.toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="alert_status" label="状态" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.alert_status === 'red' ? 'danger' : row.alert_status === 'yellow' ? 'warning' : 'success'" size="small">
|
||||||
|
{{ row.alert_status === 'red' ? '🔴预警' : row.alert_status === 'yellow' ? '🟡关注' : '🟢安全' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -359,6 +464,141 @@ async function doScenario() {
|
|||||||
} catch { ElMessage.error('情景模拟失败') }
|
} catch { ElMessage.error('情景模拟失败') }
|
||||||
scenarioLoading.value = false
|
scenarioLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 现金流预测(AI事前预警) ──
|
||||||
|
const cfLoading = ref(false)
|
||||||
|
const cfEntityId = ref(1)
|
||||||
|
const cfCurrentCash = ref(30)
|
||||||
|
const cfDays = ref(30)
|
||||||
|
const cfResult = ref<any>({})
|
||||||
|
const cfChartRef = ref<HTMLElement | null>(null)
|
||||||
|
let cfChart: any = null
|
||||||
|
|
||||||
|
async function doCashForecast() {
|
||||||
|
cfLoading.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
entity_id: cfEntityId.value,
|
||||||
|
days: cfDays.value,
|
||||||
|
current_cash: cfCurrentCash.value,
|
||||||
|
}
|
||||||
|
cfResult.value = await predictApi.cashForecast(payload) as any
|
||||||
|
nextTick(() => renderCfChart())
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error('现金流预测失败: ' + (e?.message || ''))
|
||||||
|
}
|
||||||
|
cfLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCfChart() {
|
||||||
|
if (!cfChartRef.value || !cfResult.value.forecast?.length) return
|
||||||
|
import('echarts').then(echarts => {
|
||||||
|
if (cfChart) cfChart.dispose()
|
||||||
|
cfChart = echarts.init(cfChartRef.value!)
|
||||||
|
|
||||||
|
const forecast = cfResult.value.forecast
|
||||||
|
const dates = forecast.map((d: any) => d.date.slice(5))
|
||||||
|
const predicted = forecast.map((d: any) => d.predicted_cash)
|
||||||
|
const lower = forecast.map((d: any) => d.lower_bound)
|
||||||
|
const upper = forecast.map((d: any) => d.upper_bound)
|
||||||
|
|
||||||
|
// 标记预警点
|
||||||
|
const alertData: any[] = []
|
||||||
|
const markAreas: any[] = []
|
||||||
|
let lastStatus: string | null = null
|
||||||
|
const alertRanges: { startIdx: number, endIdx: number, color: string }[] = []
|
||||||
|
let currentRange: { startIdx: number, color: string } | null = null
|
||||||
|
|
||||||
|
forecast.forEach((d: any, idx: number) => {
|
||||||
|
if (d.alert_status !== 'green') {
|
||||||
|
if (!currentRange) {
|
||||||
|
currentRange = { startIdx: idx, color: d.alert_status === 'red' ? '#f56c6c' : '#e6a23c' }
|
||||||
|
}
|
||||||
|
if (d.alert_status === 'red') {
|
||||||
|
alertData.push({
|
||||||
|
name: d.date,
|
||||||
|
coord: [idx, d.predicted_cash],
|
||||||
|
itemStyle: { color: '#f56c6c' },
|
||||||
|
symbol: 'pin',
|
||||||
|
symbolSize: 30,
|
||||||
|
label: { show: true, formatter: '🔴', fontSize: 16 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (currentRange) {
|
||||||
|
alertRanges.push({ startIdx: currentRange.startIdx, endIdx: idx - 1, color: currentRange.color })
|
||||||
|
currentRange = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (currentRange) {
|
||||||
|
alertRanges.push({ startIdx: currentRange.startIdx, endIdx: forecast.length - 1, color: currentRange.color })
|
||||||
|
}
|
||||||
|
|
||||||
|
cfChart.setOption({
|
||||||
|
grid: { left: 60, right: 30, top: 20, bottom: 40 },
|
||||||
|
xAxis: { type: 'category', data: dates, axisLabel: { rotate: 45, fontSize: 10 } },
|
||||||
|
yAxis: { type: 'value', name: '现金余额(万元)', min: 0 },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '现金余额',
|
||||||
|
type: 'line',
|
||||||
|
data: predicted,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: { width: 2, color: '#409eff' },
|
||||||
|
itemStyle: { color: '#409eff' },
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 4,
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(64,158,255,0.25)' },
|
||||||
|
{ offset: 1, color: 'rgba(64,158,255,0.02)' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
markArea: {
|
||||||
|
silent: true,
|
||||||
|
data: alertRanges.map(r => [{
|
||||||
|
xAxis: r.startIdx,
|
||||||
|
itemStyle: { color: r.color, opacity: 0.1 }
|
||||||
|
}, {
|
||||||
|
xAxis: r.endIdx
|
||||||
|
}])
|
||||||
|
},
|
||||||
|
markPoint: { data: alertData },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '置信区间上界',
|
||||||
|
type: 'line',
|
||||||
|
data: upper,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: { width: 1, color: '#909399', type: 'dashed' },
|
||||||
|
symbol: 'none',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '置信区间下界',
|
||||||
|
type: 'line',
|
||||||
|
data: lower,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: { width: 1, color: '#909399', type: 'dashed' },
|
||||||
|
symbol: 'none',
|
||||||
|
areaStyle: { color: 'rgba(144,147,153,0.05)' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: function(params: any) {
|
||||||
|
const idx = params[0]?.dataIndex
|
||||||
|
const d = forecast[idx]
|
||||||
|
if (!d) return ''
|
||||||
|
const statusLabel = d.alert_status === 'red' ? '🔴预警' : d.alert_status === 'yellow' ? '🟡关注' : '🟢安全'
|
||||||
|
return `<strong>${d.date}</strong><br/>现金余额: <strong>${d.predicted_cash.toFixed(2)}万</strong><br/>置信区间: ${d.lower_bound.toFixed(2)} ~ ${d.upper_bound.toFixed(2)}万<br/>状态: ${statusLabel}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: { bottom: 0, icon: 'circle', itemWidth: 8, itemHeight: 8 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -370,4 +610,10 @@ async function doScenario() {
|
|||||||
.r-value.orange { color: #e6a23c; }
|
.r-value.orange { color: #e6a23c; }
|
||||||
.r-value.red { color: #f56c6c; }
|
.r-value.red { color: #f56c6c; }
|
||||||
.r-value.purple { color: #8b5cf6; }
|
.r-value.purple { color: #8b5cf6; }
|
||||||
|
.trend-item { display:flex; justify-content:space-between; align-items:center; padding:6px 0; border-bottom:1px solid #f0f0f0; }
|
||||||
|
.trend-item:last-child { border-bottom:none; }
|
||||||
|
.t-label { font-size:13px; color:#606266; }
|
||||||
|
.t-value { font-weight:600; font-size:14px; }
|
||||||
|
.t-value.green { color:#67c23a; }
|
||||||
|
.t-value.red { color:#f56c6c; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user