|
|
import os |
|
|
import json |
|
|
import math |
|
|
import random |
|
|
import io |
|
|
import csv |
|
|
import logging |
|
|
import numpy as np |
|
|
import pandas as pd |
|
|
from datetime import datetime, timedelta |
|
|
from flask import Flask, render_template_string, request, jsonify, send_file |
|
|
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO) |
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
app = Flask(__name__) |
|
|
app.secret_key = os.urandom(24) |
|
|
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_mock_data(months=24, trend=0.02, seasonality_strength=0.2, base=1000): |
|
|
"""生成模拟销量数据:趋势 + 季节性 + 随机噪声""" |
|
|
data = [] |
|
|
start_date = datetime.now() - timedelta(days=months*30) |
|
|
|
|
|
for i in range(months): |
|
|
|
|
|
date = start_date + timedelta(days=i*30) |
|
|
date_str = date.strftime("%Y-%m") |
|
|
|
|
|
|
|
|
trend_factor = 1 + (i * trend) |
|
|
|
|
|
|
|
|
|
|
|
month_idx = date.month - 1 |
|
|
season_factor = 1 + seasonality_strength * math.sin(2 * math.pi * month_idx / 12) |
|
|
|
|
|
|
|
|
noise = random.uniform(0.9, 1.1) |
|
|
|
|
|
|
|
|
volume = int(base * trend_factor * season_factor * noise) |
|
|
|
|
|
data.append({ |
|
|
"date": date_str, |
|
|
"volume": volume |
|
|
}) |
|
|
|
|
|
return data |
|
|
|
|
|
def holt_winters_forecast(series, n_preds=6, alpha=0.3, beta=0.1, gamma=0.1, season_len=12): |
|
|
""" |
|
|
简化的 Holt-Winters (Triple Exponential Smoothing) 实现 |
|
|
series: list of historical values |
|
|
n_preds: number of months to predict |
|
|
""" |
|
|
series = np.array(series) |
|
|
n = len(series) |
|
|
|
|
|
|
|
|
if n < season_len * 2: |
|
|
season_len = max(2, n // 2) |
|
|
|
|
|
|
|
|
level = series[0] |
|
|
trend = series[1] - series[0] if n > 1 else 0 |
|
|
seasonals = [series[i] / (series[0] if series[0] != 0 else 1) for i in range(season_len)] |
|
|
|
|
|
result = [] |
|
|
|
|
|
|
|
|
levels = [level] |
|
|
trends = [trend] |
|
|
|
|
|
|
|
|
for i in range(n): |
|
|
val = series[i] |
|
|
s_idx = i % season_len |
|
|
prev_level = levels[-1] |
|
|
prev_trend = trends[-1] |
|
|
prev_seasonal = seasonals[s_idx] |
|
|
|
|
|
|
|
|
if prev_seasonal == 0: prev_seasonal = 1 |
|
|
if prev_level == 0: prev_level = 1 |
|
|
|
|
|
|
|
|
new_level = alpha * (val / prev_seasonal) + (1 - alpha) * (prev_level + prev_trend) |
|
|
|
|
|
|
|
|
new_trend = beta * (new_level - prev_level) + (1 - beta) * prev_trend |
|
|
|
|
|
|
|
|
new_seasonal = gamma * (val / new_level) + (1 - gamma) * prev_seasonal |
|
|
|
|
|
levels.append(new_level) |
|
|
trends.append(new_trend) |
|
|
seasonals[s_idx] = new_seasonal |
|
|
|
|
|
|
|
|
fitted = (prev_level + prev_trend) * prev_seasonal |
|
|
result.append(fitted) |
|
|
|
|
|
|
|
|
forecast = [] |
|
|
last_level = levels[-1] |
|
|
last_trend = trends[-1] |
|
|
|
|
|
for i in range(n_preds): |
|
|
m = i + 1 |
|
|
s_idx = (n + i) % season_len |
|
|
pred = (last_level + m * last_trend) * seasonals[s_idx] |
|
|
forecast.append(int(pred)) |
|
|
|
|
|
return result, forecast |
|
|
|
|
|
def calculate_inventory_metrics(history_series, forecast_series, lead_time_days, service_level, unit_cost, holding_cost_percent): |
|
|
"""计算库存核心指标""" |
|
|
if not history_series: |
|
|
return {} |
|
|
|
|
|
|
|
|
avg_monthly_demand = np.mean(history_series[-6:]) |
|
|
avg_daily_demand = avg_monthly_demand / 30 |
|
|
|
|
|
|
|
|
|
|
|
std_dev_monthly = np.std(history_series[-6:]) |
|
|
std_dev_daily = std_dev_monthly / math.sqrt(30) |
|
|
|
|
|
|
|
|
|
|
|
z_map = { |
|
|
0.90: 1.28, |
|
|
0.95: 1.645, |
|
|
0.98: 2.05, |
|
|
0.99: 2.33 |
|
|
} |
|
|
|
|
|
z_score = 1.645 |
|
|
closest_sl = min(z_map.keys(), key=lambda x: abs(x - service_level)) |
|
|
z_score = z_map[closest_sl] |
|
|
|
|
|
|
|
|
|
|
|
safety_stock = z_score * std_dev_daily * math.sqrt(lead_time_days) |
|
|
|
|
|
|
|
|
rop = (avg_daily_demand * lead_time_days) + safety_stock |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
annual_demand = np.sum(forecast_series) * (12 / len(forecast_series)) if len(forecast_series) > 0 else 0 |
|
|
order_cost = 50 |
|
|
holding_cost_per_unit = unit_cost * holding_cost_percent |
|
|
|
|
|
if holding_cost_per_unit > 0: |
|
|
eoq = math.sqrt((2 * annual_demand * order_cost) / holding_cost_per_unit) |
|
|
else: |
|
|
eoq = annual_demand / 12 |
|
|
|
|
|
return { |
|
|
"safety_stock": int(safety_stock), |
|
|
"rop": int(rop), |
|
|
"eoq": int(eoq), |
|
|
"avg_daily_demand": round(avg_daily_demand, 2), |
|
|
"turnover_rate": round(annual_demand / ((safety_stock + eoq/2) * unit_cost), 1) if unit_cost > 0 and (safety_stock + eoq/2) > 0 else 0 |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/') |
|
|
def index(): |
|
|
return render_template_string(TEMPLATE) |
|
|
|
|
|
@app.route('/api/generate', methods=['POST']) |
|
|
def api_generate(): |
|
|
try: |
|
|
params = request.json |
|
|
trend = float(params.get('trend', 0.02)) |
|
|
seasonality = float(params.get('seasonality', 0.2)) |
|
|
base = int(params.get('base', 1000)) |
|
|
|
|
|
data = generate_mock_data(months=24, trend=trend, seasonality_strength=seasonality, base=base) |
|
|
return jsonify({"status": "success", "data": data}) |
|
|
except Exception as e: |
|
|
logger.error(f"Generate error: {e}") |
|
|
return jsonify({"status": "error", "message": str(e)}), 500 |
|
|
|
|
|
@app.route('/api/forecast', methods=['POST']) |
|
|
def api_forecast(): |
|
|
try: |
|
|
req = request.json |
|
|
history = req.get('history', []) |
|
|
params = req.get('params', {}) |
|
|
|
|
|
if not history: |
|
|
return jsonify({"status": "error", "message": "No history data provided"}), 400 |
|
|
|
|
|
|
|
|
volumes = [d['volume'] for d in history] |
|
|
dates = [d['date'] for d in history] |
|
|
|
|
|
|
|
|
fitted, forecast = holt_winters_forecast(volumes, n_preds=6) |
|
|
|
|
|
|
|
|
try: |
|
|
last_date = datetime.strptime(dates[-1], "%Y-%m") |
|
|
except ValueError: |
|
|
|
|
|
last_date = datetime.now() |
|
|
|
|
|
future_dates = [] |
|
|
for i in range(6): |
|
|
d = last_date + timedelta(days=(i+1)*30) |
|
|
future_dates.append(d.strftime("%Y-%m")) |
|
|
|
|
|
|
|
|
lead_time = int(params.get('lead_time', 14)) |
|
|
service_level = float(params.get('service_level', 0.95)) |
|
|
unit_cost = float(params.get('unit_cost', 50)) |
|
|
|
|
|
metrics = calculate_inventory_metrics( |
|
|
volumes, forecast, |
|
|
lead_time_days=lead_time, |
|
|
service_level=service_level, |
|
|
unit_cost=unit_cost, |
|
|
holding_cost_percent=0.2 |
|
|
) |
|
|
|
|
|
return jsonify({ |
|
|
"forecast_dates": future_dates, |
|
|
"forecast_values": forecast, |
|
|
"metrics": metrics, |
|
|
"fitted": fitted |
|
|
}) |
|
|
except Exception as e: |
|
|
logger.error(f"Forecast error: {e}") |
|
|
return jsonify({"status": "error", "message": str(e)}), 500 |
|
|
|
|
|
@app.route('/api/upload', methods=['POST']) |
|
|
def api_upload(): |
|
|
try: |
|
|
if 'file' not in request.files: |
|
|
return jsonify({"status": "error", "message": "No file part"}), 400 |
|
|
file = request.files['file'] |
|
|
if file.filename == '': |
|
|
return jsonify({"status": "error", "message": "No selected file"}), 400 |
|
|
|
|
|
if file: |
|
|
|
|
|
|
|
|
try: |
|
|
if file.filename.endswith('.csv'): |
|
|
df = pd.read_csv(file) |
|
|
elif file.filename.endswith(('.xls', '.xlsx')): |
|
|
df = pd.read_excel(file) |
|
|
else: |
|
|
return jsonify({"status": "error", "message": "Unsupported file format. Please use CSV or Excel."}), 400 |
|
|
except Exception as e: |
|
|
return jsonify({"status": "error", "message": f"File parse error: {str(e)}"}), 400 |
|
|
|
|
|
|
|
|
df.columns = [c.lower() for c in df.columns] |
|
|
|
|
|
|
|
|
date_col = next((c for c in df.columns if 'date' in c or 'time' in c or '日期' in c or '时间' in c), None) |
|
|
vol_col = next((c for c in df.columns if 'vol' in c or 'qty' in c or 'amount' in c or '销量' in c or '数量' in c), None) |
|
|
|
|
|
if not date_col or not vol_col: |
|
|
return jsonify({"status": "error", "message": "Could not identify 'Date' or 'Volume' columns. Please name them clearly."}), 400 |
|
|
|
|
|
|
|
|
try: |
|
|
df[date_col] = pd.to_datetime(df[date_col]) |
|
|
df = df.sort_values(date_col) |
|
|
df[date_col] = df[date_col].dt.strftime('%Y-%m') |
|
|
except Exception: |
|
|
return jsonify({"status": "error", "message": "Date column format invalid"}), 400 |
|
|
|
|
|
data = [] |
|
|
for _, row in df.iterrows(): |
|
|
try: |
|
|
vol = int(row[vol_col]) |
|
|
data.append({ |
|
|
"date": str(row[date_col]), |
|
|
"volume": vol |
|
|
}) |
|
|
except ValueError: |
|
|
continue |
|
|
|
|
|
return jsonify({"status": "success", "data": data}) |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Upload error: {e}") |
|
|
return jsonify({"status": "error", "message": str(e)}), 500 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
TEMPLATE = """ |
|
|
<!DOCTYPE html> |
|
|
<html lang="zh-CN" class="dark"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>智能库存预测引擎 (Inventory Forecast Engine)</title> |
|
|
<script src="https://cdn.tailwindcss.com"></script> |
|
|
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> |
|
|
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script> |
|
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> |
|
|
<script> |
|
|
tailwind.config = { |
|
|
darkMode: 'class', |
|
|
theme: { |
|
|
extend: { |
|
|
colors: { |
|
|
primary: '#3B82F6', |
|
|
secondary: '#10B981', |
|
|
dark: '#111827', |
|
|
darker: '#0B0F19', |
|
|
card: '#1F2937' |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
</script> |
|
|
<style> |
|
|
body { background-color: #0B0F19; color: #E5E7EB; font-family: 'Inter', sans-serif; } |
|
|
.glass-panel { |
|
|
background: rgba(31, 41, 55, 0.7); |
|
|
backdrop-filter: blur(10px); |
|
|
border: 1px solid rgba(75, 85, 99, 0.4); |
|
|
} |
|
|
input[type="range"] { |
|
|
accent-color: #3B82F6; |
|
|
} |
|
|
/* Loading overlay */ |
|
|
.loading-overlay { |
|
|
position: fixed; top:0; left:0; width:100%; height:100%; |
|
|
background: rgba(0,0,0,0.7); z-index: 100; |
|
|
display: flex; justify-content: center; align-items: center; |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body class="min-h-screen flex flex-col"> |
|
|
<div id="app" class="flex-grow flex flex-col"> |
|
|
<!-- Loading --> |
|
|
<div v-if="loading" class="loading-overlay"> |
|
|
<div class="text-center"> |
|
|
<i class="fas fa-spinner fa-spin text-4xl text-blue-500 mb-2"></i> |
|
|
<p class="text-gray-300">处理中...</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Header --> |
|
|
<header class="border-b border-gray-800 bg-darker/80 backdrop-blur sticky top-0 z-50"> |
|
|
<div class="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center"> |
|
|
<div class="flex items-center gap-3"> |
|
|
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/20"> |
|
|
<i class="fas fa-cubes text-white text-lg"></i> |
|
|
</div> |
|
|
<div> |
|
|
<h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-indigo-400">智能库存预测引擎</h1> |
|
|
<p class="text-xs text-gray-500">Inventory Forecast Engine Pro</p> |
|
|
</div> |
|
|
</div> |
|
|
<div class="flex gap-4"> |
|
|
<label class="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-sm transition flex items-center gap-2 border border-gray-700 cursor-pointer"> |
|
|
<i class="fas fa-upload text-blue-400"></i> 上传数据 |
|
|
<input type="file" class="hidden" @change="handleFileUpload" accept=".csv,.xls,.xlsx"> |
|
|
</label> |
|
|
<button @click="generateData" class="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-sm transition flex items-center gap-2 border border-gray-700"> |
|
|
<i class="fas fa-sync-alt" :class="{'animate-spin': loading}"></i> 重新生成 |
|
|
</button> |
|
|
<button @click="runForecast" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg text-sm font-medium transition shadow-lg shadow-blue-600/20 flex items-center gap-2"> |
|
|
<i class="fas fa-calculator"></i> 运行预测 |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</header> |
|
|
|
|
|
<!-- Main Content --> |
|
|
<main class="flex-grow p-6 max-w-7xl mx-auto w-full grid grid-cols-12 gap-6"> |
|
|
|
|
|
<!-- Sidebar Controls --> |
|
|
<div class="col-span-12 lg:col-span-3 space-y-6"> |
|
|
<!-- Data Settings --> |
|
|
<div class="glass-panel rounded-xl p-5"> |
|
|
<h3 class="text-sm font-semibold text-gray-300 mb-4 flex items-center gap-2"> |
|
|
<i class="fas fa-database text-blue-400"></i> 数据模拟参数 |
|
|
</h3> |
|
|
<div class="space-y-4"> |
|
|
<div> |
|
|
<label class="text-xs text-gray-400 block mb-1">基础月销量 (Base)</label> |
|
|
<input v-model.number="params.base" type="number" class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm focus:border-blue-500 outline-none text-white"> |
|
|
</div> |
|
|
<div> |
|
|
<label class="text-xs text-gray-400 block mb-1">增长趋势 (Trend)</label> |
|
|
<div class="flex items-center gap-2"> |
|
|
<input v-model.number="params.trend" type="range" min="-0.05" max="0.1" step="0.01" class="flex-grow h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer"> |
|
|
<span class="text-xs w-12 text-right text-mono text-gray-300">${ (params.trend * 100).toFixed(0) }%</span> |
|
|
</div> |
|
|
</div> |
|
|
<div> |
|
|
<label class="text-xs text-gray-400 block mb-1">季节性强度 (Seasonality)</label> |
|
|
<div class="flex items-center gap-2"> |
|
|
<input v-model.number="params.seasonality" type="range" min="0" max="0.8" step="0.1" class="flex-grow h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer"> |
|
|
<span class="text-xs w-12 text-right text-mono text-gray-300">${ params.seasonality }</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Inventory Settings --> |
|
|
<div class="glass-panel rounded-xl p-5"> |
|
|
<h3 class="text-sm font-semibold text-gray-300 mb-4 flex items-center gap-2"> |
|
|
<i class="fas fa-sliders-h text-green-400"></i> 库存策略配置 |
|
|
</h3> |
|
|
<div class="space-y-4"> |
|
|
<div> |
|
|
<label class="text-xs text-gray-400 block mb-1">目标服务水平 (Service Level)</label> |
|
|
<select v-model.number="invParams.service_level" class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm focus:border-green-500 outline-none text-white"> |
|
|
<option :value="0.90">90% (低风险)</option> |
|
|
<option :value="0.95">95% (标准)</option> |
|
|
<option :value="0.98">98% (高可用)</option> |
|
|
<option :value="0.99">99% (关键业务)</option> |
|
|
</select> |
|
|
</div> |
|
|
<div> |
|
|
<label class="text-xs text-gray-400 block mb-1">采购提前期 (Lead Time Days)</label> |
|
|
<div class="flex items-center gap-2"> |
|
|
<input v-model.number="invParams.lead_time" type="range" min="1" max="60" step="1" class="flex-grow h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer"> |
|
|
<span class="text-xs w-12 text-right text-mono text-gray-300">${ invParams.lead_time }d</span> |
|
|
</div> |
|
|
</div> |
|
|
<div> |
|
|
<label class="text-xs text-gray-400 block mb-1">单件成本 ($)</label> |
|
|
<input v-model.number="invParams.unit_cost" type="number" class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm focus:border-green-500 outline-none text-white"> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Info Card --> |
|
|
<div class="glass-panel rounded-xl p-5 bg-gradient-to-br from-blue-900/20 to-purple-900/20 border-blue-500/20"> |
|
|
<h4 class="text-sm font-bold text-blue-300 mb-2">商业价值说明</h4> |
|
|
<p class="text-xs text-gray-400 leading-relaxed"> |
|
|
本系统使用 <strong>Holt-Winters 三次指数平滑算法</strong> 预测未来销量,并基于正态分布理论计算<strong>安全库存</strong>与<strong>再订货点 (ROP)</strong>。帮助商家在维持服务水平的同时,最小化资金占用。 |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Main Charts & Metrics --> |
|
|
<div class="col-span-12 lg:col-span-9 flex flex-col gap-6"> |
|
|
|
|
|
<!-- Error Message --> |
|
|
<div v-if="error" class="bg-red-900/50 border border-red-500/50 p-4 rounded-lg flex items-center gap-3"> |
|
|
<i class="fas fa-exclamation-circle text-red-500"></i> |
|
|
<span class="text-red-200 text-sm">${ error }</span> |
|
|
<button @click="error = null" class="ml-auto text-red-400 hover:text-red-200"><i class="fas fa-times"></i></button> |
|
|
</div> |
|
|
|
|
|
<!-- KPI Cards --> |
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4" v-if="metrics"> |
|
|
<div class="glass-panel p-4 rounded-xl border-l-4 border-blue-500 relative overflow-hidden group"> |
|
|
<div class="absolute right-0 top-0 p-3 opacity-10 group-hover:opacity-20 transition"> |
|
|
<i class="fas fa-shield-alt text-4xl"></i> |
|
|
</div> |
|
|
<div class="text-xs text-gray-400 mb-1">建议安全库存</div> |
|
|
<div class="text-2xl font-bold text-white">${ metrics.safety_stock } <span class="text-xs font-normal text-gray-500">件</span></div> |
|
|
<div class="text-xs text-blue-400 mt-1">Buffer Stock</div> |
|
|
</div> |
|
|
|
|
|
<div class="glass-panel p-4 rounded-xl border-l-4 border-yellow-500 relative overflow-hidden group"> |
|
|
<div class="absolute right-0 top-0 p-3 opacity-10 group-hover:opacity-20 transition"> |
|
|
<i class="fas fa-bell text-4xl"></i> |
|
|
</div> |
|
|
<div class="text-xs text-gray-400 mb-1">再订货点 (ROP)</div> |
|
|
<div class="text-2xl font-bold text-white">${ metrics.rop } <span class="text-xs font-normal text-gray-500">件</span></div> |
|
|
<div class="text-xs text-yellow-400 mt-1">Reorder Point</div> |
|
|
</div> |
|
|
|
|
|
<div class="glass-panel p-4 rounded-xl border-l-4 border-green-500 relative overflow-hidden group"> |
|
|
<div class="absolute right-0 top-0 p-3 opacity-10 group-hover:opacity-20 transition"> |
|
|
<i class="fas fa-shopping-cart text-4xl"></i> |
|
|
</div> |
|
|
<div class="text-xs text-gray-400 mb-1">经济订货量 (EOQ)</div> |
|
|
<div class="text-2xl font-bold text-white">${ metrics.eoq } <span class="text-xs font-normal text-gray-500">件</span></div> |
|
|
<div class="text-xs text-green-400 mt-1">Optimal Order Qty</div> |
|
|
</div> |
|
|
|
|
|
<div class="glass-panel p-4 rounded-xl border-l-4 border-purple-500 relative overflow-hidden group"> |
|
|
<div class="absolute right-0 top-0 p-3 opacity-10 group-hover:opacity-20 transition"> |
|
|
<i class="fas fa-sync text-4xl"></i> |
|
|
</div> |
|
|
<div class="text-xs text-gray-400 mb-1">预估周转率</div> |
|
|
<div class="text-2xl font-bold text-white">${ metrics.turnover_rate }x <span class="text-xs font-normal text-gray-500">/年</span></div> |
|
|
<div class="text-xs text-purple-400 mt-1">Turnover Rate</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Main Chart --> |
|
|
<div class="glass-panel p-5 rounded-xl flex-grow flex flex-col min-h-[400px]"> |
|
|
<h3 class="text-lg font-semibold text-gray-200 mb-4 flex justify-between items-center"> |
|
|
<span><i class="fas fa-chart-line text-blue-500 mr-2"></i> 销量预测与库存分析</span> |
|
|
<span class="text-xs font-normal text-gray-500 bg-gray-800 px-2 py-1 rounded">Holt-Winters Model</span> |
|
|
</h3> |
|
|
<div id="mainChart" class="flex-grow w-full h-full"></div> |
|
|
</div> |
|
|
</div> |
|
|
</main> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
const { createApp, ref, onMounted, watch, nextTick } = Vue; |
|
|
|
|
|
createApp({ |
|
|
delimiters: ['${', '}'], // Changed to avoid Jinja2 conflict |
|
|
setup() { |
|
|
const loading = ref(false); |
|
|
const error = ref(null); |
|
|
const chartInstance = ref(null); |
|
|
|
|
|
// State |
|
|
const historyData = ref([]); |
|
|
const forecastData = ref(null); |
|
|
const metrics = ref(null); |
|
|
|
|
|
// Parameters |
|
|
const params = ref({ |
|
|
base: 1000, |
|
|
trend: 0.02, |
|
|
seasonality: 0.3 |
|
|
}); |
|
|
|
|
|
const invParams = ref({ |
|
|
service_level: 0.95, |
|
|
lead_time: 14, |
|
|
unit_cost: 50 |
|
|
}); |
|
|
|
|
|
// Methods |
|
|
const initChart = () => { |
|
|
const el = document.getElementById('mainChart'); |
|
|
if (el) { |
|
|
chartInstance.value = echarts.init(el); |
|
|
window.addEventListener('resize', () => chartInstance.value.resize()); |
|
|
} |
|
|
}; |
|
|
|
|
|
const updateChart = () => { |
|
|
if (!chartInstance.value) return; |
|
|
|
|
|
const dates = historyData.value.map(d => d.date); |
|
|
const values = historyData.value.map(d => d.volume); |
|
|
|
|
|
let series = [ |
|
|
{ |
|
|
name: '历史销量', |
|
|
type: 'line', |
|
|
data: values, |
|
|
smooth: true, |
|
|
symbolSize: 6, |
|
|
itemStyle: { color: '#3B82F6' }, |
|
|
areaStyle: { |
|
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ |
|
|
{ offset: 0, color: 'rgba(59, 130, 246, 0.5)' }, |
|
|
{ offset: 1, color: 'rgba(59, 130, 246, 0.0)' } |
|
|
]) |
|
|
} |
|
|
} |
|
|
]; |
|
|
|
|
|
let xAxisData = [...dates]; |
|
|
|
|
|
if (forecastData.value) { |
|
|
const fDates = forecastData.value.dates; |
|
|
const fValues = forecastData.value.values; |
|
|
|
|
|
// 连接历史最后一点和预测第一点,为了视觉连贯 |
|
|
const lastHistDate = dates[dates.length-1]; |
|
|
const lastHistVal = values[values.length-1]; |
|
|
|
|
|
// 构造预测数据序列 (前补 null) |
|
|
const nulls = Array(values.length - 1).fill(null); |
|
|
// 把历史最后一点作为预测起始点 |
|
|
const plotForecast = [lastHistVal, ...fValues]; |
|
|
const fullForecastData = [...nulls, ...plotForecast]; |
|
|
|
|
|
// 扩展 X 轴 |
|
|
xAxisData = [...dates, ...fDates]; |
|
|
|
|
|
series.push({ |
|
|
name: 'AI 预测销量', |
|
|
type: 'line', |
|
|
data: fullForecastData, |
|
|
smooth: true, |
|
|
symbolSize: 6, |
|
|
lineStyle: { type: 'dashed', width: 3 }, |
|
|
itemStyle: { color: '#10B981' } |
|
|
}); |
|
|
|
|
|
if (metrics.value) { |
|
|
const avgDemand = metrics.value.avg_daily_demand * 30; |
|
|
|
|
|
series.push({ |
|
|
name: '月均需求趋势', |
|
|
type: 'line', |
|
|
data: Array(xAxisData.length).fill(avgDemand), |
|
|
showSymbol: false, |
|
|
lineStyle: { color: '#6B7280', width: 1, type: 'dotted' }, |
|
|
z: -1 |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
const option = { |
|
|
backgroundColor: 'transparent', |
|
|
tooltip: { |
|
|
trigger: 'axis', |
|
|
backgroundColor: 'rgba(17, 24, 39, 0.9)', |
|
|
borderColor: '#374151', |
|
|
textStyle: { color: '#E5E7EB' } |
|
|
}, |
|
|
legend: { |
|
|
data: ['历史销量', 'AI 预测销量'], |
|
|
textStyle: { color: '#9CA3AF' }, |
|
|
bottom: 0 |
|
|
}, |
|
|
grid: { |
|
|
left: '3%', |
|
|
right: '4%', |
|
|
bottom: '10%', |
|
|
top: '10%', |
|
|
containLabel: true |
|
|
}, |
|
|
xAxis: { |
|
|
type: 'category', |
|
|
boundaryGap: false, |
|
|
data: xAxisData, |
|
|
axisLine: { lineStyle: { color: '#4B5563' } }, |
|
|
axisLabel: { color: '#9CA3AF' } |
|
|
}, |
|
|
yAxis: { |
|
|
type: 'value', |
|
|
splitLine: { lineStyle: { color: '#374151' } }, |
|
|
axisLabel: { color: '#9CA3AF' } |
|
|
}, |
|
|
series: series |
|
|
}; |
|
|
|
|
|
chartInstance.value.setOption(option); |
|
|
}; |
|
|
|
|
|
const generateData = async () => { |
|
|
loading.value = true; |
|
|
error.value = null; |
|
|
try { |
|
|
const res = await fetch('/api/generate', { |
|
|
method: 'POST', |
|
|
headers: {'Content-Type': 'application/json'}, |
|
|
body: JSON.stringify(params.value) |
|
|
}); |
|
|
const data = await res.json(); |
|
|
if(data.status === 'error') throw new Error(data.message); |
|
|
historyData.value = data.data; |
|
|
|
|
|
// 自动运行预测 |
|
|
await runForecast(); |
|
|
} catch (e) { |
|
|
console.error(e); |
|
|
error.value = "生成数据失败: " + e.message; |
|
|
} finally { |
|
|
loading.value = false; |
|
|
} |
|
|
}; |
|
|
|
|
|
const runForecast = async () => { |
|
|
if (historyData.value.length === 0) return; |
|
|
|
|
|
try { |
|
|
const res = await fetch('/api/forecast', { |
|
|
method: 'POST', |
|
|
headers: {'Content-Type': 'application/json'}, |
|
|
body: JSON.stringify({ |
|
|
history: historyData.value, |
|
|
params: invParams.value |
|
|
}) |
|
|
}); |
|
|
const data = await res.json(); |
|
|
|
|
|
if(data.status === 'error') throw new Error(data.message); |
|
|
|
|
|
forecastData.value = { |
|
|
dates: data.forecast_dates, |
|
|
values: data.forecast_values |
|
|
}; |
|
|
metrics.value = data.metrics; |
|
|
|
|
|
nextTick(() => { |
|
|
updateChart(); |
|
|
}); |
|
|
} catch (e) { |
|
|
console.error(e); |
|
|
error.value = "预测失败: " + e.message; |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleFileUpload = async (event) => { |
|
|
const file = event.target.files[0]; |
|
|
if (!file) return; |
|
|
|
|
|
if (file.size > 15 * 1024 * 1024) { |
|
|
error.value = "文件过大,请上传小于 15MB 的文件"; |
|
|
return; |
|
|
} |
|
|
|
|
|
const formData = new FormData(); |
|
|
formData.append('file', file); |
|
|
|
|
|
loading.value = true; |
|
|
error.value = null; |
|
|
|
|
|
try { |
|
|
const res = await fetch('/api/upload', { |
|
|
method: 'POST', |
|
|
body: formData |
|
|
}); |
|
|
const data = await res.json(); |
|
|
|
|
|
if (data.status === 'error') { |
|
|
throw new Error(data.message); |
|
|
} |
|
|
|
|
|
historyData.value = data.data; |
|
|
await runForecast(); |
|
|
|
|
|
} catch (e) { |
|
|
console.error(e); |
|
|
error.value = "上传失败: " + e.message; |
|
|
} finally { |
|
|
loading.value = false; |
|
|
// Reset input |
|
|
event.target.value = ''; |
|
|
} |
|
|
}; |
|
|
|
|
|
// Watchers for real-time updates |
|
|
watch(invParams, () => { |
|
|
runForecast(); |
|
|
}, { deep: true }); |
|
|
|
|
|
// Lifecycle |
|
|
onMounted(() => { |
|
|
initChart(); |
|
|
generateData(); |
|
|
}); |
|
|
|
|
|
return { |
|
|
loading, |
|
|
error, |
|
|
params, |
|
|
invParams, |
|
|
metrics, |
|
|
generateData, |
|
|
runForecast, |
|
|
handleFileUpload |
|
|
}; |
|
|
} |
|
|
}).mount('#app'); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
""" |
|
|
|
|
|
if __name__ == '__main__': |
|
|
app.run(host='0.0.0.0', port=7860, debug=True) |
|
|
|