"""现金流预测引擎 — 根据历史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