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