From c4e91f28efd96f168bd117ef91964f2f722ab3b9 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Tue, 21 Jul 2026 18:22:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AI=E4=BA=8B=E5=89=8D=E9=A2=84=E8=AD=A6?= =?UTF-8?q?=20=E2=80=94=20=E7=8E=B0=E9=87=91=E6=B5=81=E9=A2=84=E6=B5=8B+?= =?UTF-8?q?=E9=A2=84=E8=AD=A6=E6=89=A9=E5=B1=95+=E5=87=86=E7=A1=AE?= =?UTF-8?q?=E7=8E=87+=E6=83=85=E6=99=AF=E5=BB=BA=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/alert_rules.py | 141 +++++++++- backend/app/api/predict.py | 107 ++++++- backend/app/models/__init__.py | 42 +++ backend/app/utils/cash_forecast_engine.py | 322 ++++++++++++++++++++++ frontend/src/api/index.ts | 9 + frontend/src/router/index.ts | 1 + frontend/src/views/AlertList.vue | 44 ++- frontend/src/views/PredictAccuracy.vue | 195 +++++++++++++ frontend/src/views/PredictDashboard.vue | 246 +++++++++++++++++ 9 files changed, 1101 insertions(+), 6 deletions(-) create mode 100644 backend/app/utils/cash_forecast_engine.py create mode 100644 frontend/src/views/PredictAccuracy.vue diff --git a/backend/app/api/alert_rules.py b/backend/app/api/alert_rules.py index b78543b2..aeaf333e 100644 --- a/backend/app/api/alert_rules.py +++ b/backend/app/api/alert_rules.py @@ -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} diff --git a/backend/app/api/predict.py b/backend/app/api/predict.py index 207caf62..3940b51e 100644 --- a/backend/app/api/predict.py +++ b/backend/app/api/predict.py @@ -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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0ecdb8a4..db246fd2 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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()) diff --git a/backend/app/utils/cash_forecast_engine.py b/backend/app/utils/cash_forecast_engine.py new file mode 100644 index 00000000..588f38d3 --- /dev/null +++ b/backend/app/utils/cash_forecast_engine.py @@ -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 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 791bdc82..1b997374 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -113,6 +113,9 @@ export const alertRulesApi = { checkAll: () => api.post('/alert-rules/check-all'), calculateDynamic: () => api.post('/alert-rules/calculate-dynamic'), 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 = { @@ -174,6 +177,12 @@ export const predictApi = { sensitivity: (data: any) => api.post('/predict/sensitivity', data), scenario: (data: any) => api.post('/predict/scenario', 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 = { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 94718309..da85022b 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -27,6 +27,7 @@ const routes = [ { 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: '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: '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'] } }, diff --git a/frontend/src/views/AlertList.vue b/frontend/src/views/AlertList.vue index cb10b1bc..7c82a8be 100644 --- a/frontend/src/views/AlertList.vue +++ b/frontend/src/views/AlertList.vue @@ -39,11 +39,11 @@ - + @@ -147,6 +147,8 @@ 查询 + 🔮 检查预测值 + 💡 生成情景建议 @@ -254,7 +256,7 @@ - + @@ -264,6 +266,14 @@ + + + + + + + 预测值超限时,系统会检查未来7天现金流预测是否触及阈值 + @@ -366,6 +376,7 @@ const calcLoading = ref(false) const ruleForm = ref({ kpi_id: null as number | null, rule_type: 'static', + trigger_on: 'actual', enabled: 1, params: { green: '', yellow: '', red: '', sensitivity: 1.0, threshold_pct: 10 }, }) @@ -467,6 +478,7 @@ function editRule(row: any) { ruleForm.value = { kpi_id: row.kpi_id, rule_type: row.rule_type, + trigger_on: row.trigger_on || 'actual', enabled: row.enabled, params: { green: '', yellow: '', red: '', sensitivity: 1.0, threshold_pct: 10, ...(params || {}) }, } @@ -478,6 +490,7 @@ async function saveRule() { try { const data = { rule_type: ruleForm.value.rule_type, + trigger_on: ruleForm.value.trigger_on, enabled: ruleForm.value.enabled, params: ruleForm.value.params, } @@ -514,6 +527,29 @@ async function generateRules() { 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() { calcLoading.value = true try { diff --git a/frontend/src/views/PredictAccuracy.vue b/frontend/src/views/PredictAccuracy.vue new file mode 100644 index 00000000..2b2fc2fb --- /dev/null +++ b/frontend/src/views/PredictAccuracy.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/frontend/src/views/PredictDashboard.vue b/frontend/src/views/PredictDashboard.vue index c50a43b5..15149df4 100644 --- a/frontend/src/views/PredictDashboard.vue +++ b/frontend/src/views/PredictDashboard.vue @@ -216,6 +216,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + 🔮 开始预测 + + + + + + +
经营现金流趋势{{ cfResult.trends.operating_cash_flow_trend_pct }}%
+
应收账款趋势{{ cfResult.trends.receivables_trend_pct }}%
+
应付账款趋势{{ cfResult.trends.payables_trend_pct }}%
+
+
+ + + + +
+
+ + + + + +
+ + + ✅ {{ act }} + +
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
@@ -359,6 +464,141 @@ async function doScenario() { } catch { ElMessage.error('情景模拟失败') } scenarioLoading.value = false } + +// ── 现金流预测(AI事前预警) ── +const cfLoading = ref(false) +const cfEntityId = ref(1) +const cfCurrentCash = ref(30) +const cfDays = ref(30) +const cfResult = ref({}) +const cfChartRef = ref(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 `${d.date}
现金余额: ${d.predicted_cash.toFixed(2)}万
置信区间: ${d.lower_bound.toFixed(2)} ~ ${d.upper_bound.toFixed(2)}万
状态: ${statusLabel}` + } + }, + legend: { bottom: 0, icon: 'circle', itemWidth: 8, itemHeight: 8 }, + }) + }) +}