Trae Assistant commited on
Commit
339b397
·
0 Parent(s):

Enhance app with file upload, localization and fixes

Browse files
Files changed (5) hide show
  1. .gitignore +8 -0
  2. Dockerfile +18 -0
  3. README.md +55 -0
  4. app.py +787 -0
  5. requirements.txt +5 -0
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .DS_Store
4
+ .venv
5
+ env/
6
+ venv/
7
+ .idea/
8
+ .vscode/
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ # Create a non-root user for security (good practice for HF Spaces)
11
+ RUN useradd -m -u 1000 user
12
+ USER user
13
+ ENV HOME=/home/user \
14
+ PATH=/home/user/.local/bin:$PATH
15
+
16
+ EXPOSE 7860
17
+
18
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: 智能库存预测引擎
3
+ emoji: 📦
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ short_description: 基于 Holt-Winters 算法的电商库存销量预测与优化系统
9
+ pinned: false
10
+ license: mit
11
+ ---
12
+
13
+ # 智能库存预测引擎 (Inventory Forecast Engine)
14
+
15
+ ## 📊 项目简介
16
+ 这是一个专为跨境电商、零售商和供应链管理者设计的**智能库存预测与优化系统**。它利用**Holt-Winters 三次指数平滑算法**,能够处理具有**趋势性**和**季节性**的销量数据,精准预测未来需求,并自动计算**安全库存 (Safety Stock)** 和 **再订货点 (ROP)**,帮助商家在降低库存成本的同时防止缺货。
17
+
18
+ ## 🚀 核心功能
19
+ * **智能销量预测**:基于历史数据,自动识别增长趋势和季节性波动,生成未来 6 个月的销量预测。
20
+ * **动态库存优化**:
21
+ * **安全库存计算**:根据目标服务水平 (Service Level) 和提前期 (Lead Time) 波动,科学计算缓冲库存。
22
+ * **再订货点 (ROP)**:动态建议何时补货,避免断货风险。
23
+ * **经济订货量 (EOQ)**:基于持有成本和订货成本,建议最佳单次采购量。
24
+ * **实时交互模拟**:
25
+ * 调整服务水平 (90% - 99%),实时查看对库存资金占用的影响。
26
+ * 调整采购提前期,模拟供应链延迟对库存水位的压力。
27
+ * **数据可视化**:集成 ECharts,直观展示历史销量、AI 预测曲线及趋势分析。
28
+
29
+ ## 🛠️ 技术栈
30
+ * **后端**: Python Flask, NumPy (算法实现)
31
+ * **前端**: Vue 3, Tailwind CSS (现代化暗黑风格 UI)
32
+ * **图表**: Apache ECharts 5
33
+ * **部署**: Docker (Gunicorn WSGI)
34
+
35
+ ## 💡 商业价值
36
+ 对于年销售额 $1M+ 的电商卖家,库存周转率每提升 1 次,可能释放数万美元的现金流。本工具通过科学算法替代 Excel 拍脑袋估算,显著降低滞销风险和缺货损失。
37
+
38
+ ## 📦 如何运行
39
+ ### 本地运行
40
+ ```bash
41
+ # 安装依赖
42
+ pip install -r requirements.txt
43
+
44
+ # 启动服务
45
+ python app.py
46
+ ```
47
+
48
+ ### Docker 运行
49
+ ```bash
50
+ docker build -t inventory-engine .
51
+ docker run -p 7860:7860 inventory-engine
52
+ ```
53
+
54
+ ---
55
+ *Generated by Trae AI Pair Programmer*
app.py ADDED
@@ -0,0 +1,787 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import math
4
+ import random
5
+ import io
6
+ import csv
7
+ import logging
8
+ import numpy as np
9
+ import pandas as pd
10
+ from datetime import datetime, timedelta
11
+ from flask import Flask, render_template_string, request, jsonify, send_file
12
+
13
+ # Configure logging
14
+ logging.basicConfig(level=logging.INFO)
15
+ logger = logging.getLogger(__name__)
16
+
17
+ app = Flask(__name__)
18
+ app.secret_key = os.urandom(24)
19
+ app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max upload
20
+
21
+ # -----------------------------------------------------------------------------
22
+ # 核心算法逻辑
23
+ # -----------------------------------------------------------------------------
24
+
25
+ def generate_mock_data(months=24, trend=0.02, seasonality_strength=0.2, base=1000):
26
+ """生成模拟销量数据:趋势 + 季节性 + 随机噪声"""
27
+ data = []
28
+ start_date = datetime.now() - timedelta(days=months*30)
29
+
30
+ for i in range(months):
31
+ # 时间索引
32
+ date = start_date + timedelta(days=i*30)
33
+ date_str = date.strftime("%Y-%m")
34
+
35
+ # 趋势项 (线性增长)
36
+ trend_factor = 1 + (i * trend)
37
+
38
+ # 季节项 (模拟年度周期)
39
+ # 使用 month 0-11 映射到 0-2pi
40
+ month_idx = date.month - 1
41
+ season_factor = 1 + seasonality_strength * math.sin(2 * math.pi * month_idx / 12)
42
+
43
+ # 随机噪声
44
+ noise = random.uniform(0.9, 1.1)
45
+
46
+ # 最终销量
47
+ volume = int(base * trend_factor * season_factor * noise)
48
+
49
+ data.append({
50
+ "date": date_str,
51
+ "volume": volume
52
+ })
53
+
54
+ return data
55
+
56
+ def holt_winters_forecast(series, n_preds=6, alpha=0.3, beta=0.1, gamma=0.1, season_len=12):
57
+ """
58
+ 简化的 Holt-Winters (Triple Exponential Smoothing) 实现
59
+ series: list of historical values
60
+ n_preds: number of months to predict
61
+ """
62
+ series = np.array(series)
63
+ n = len(series)
64
+
65
+ # 数据过短处理
66
+ if n < season_len * 2:
67
+ season_len = max(2, n // 2)
68
+
69
+ # 初始值
70
+ level = series[0]
71
+ trend = series[1] - series[0] if n > 1 else 0
72
+ seasonals = [series[i] / (series[0] if series[0] != 0 else 1) for i in range(season_len)]
73
+
74
+ result = []
75
+
76
+ # 拟合历史数据 (简单模拟,不进行复杂的参数优化,仅做演示运算)
77
+ levels = [level]
78
+ trends = [trend]
79
+
80
+ # 训练阶段
81
+ for i in range(n):
82
+ val = series[i]
83
+ s_idx = i % season_len
84
+ prev_level = levels[-1]
85
+ prev_trend = trends[-1]
86
+ prev_seasonal = seasonals[s_idx]
87
+
88
+ # 防止除零
89
+ if prev_seasonal == 0: prev_seasonal = 1
90
+ if prev_level == 0: prev_level = 1
91
+
92
+ # 更新 Level
93
+ new_level = alpha * (val / prev_seasonal) + (1 - alpha) * (prev_level + prev_trend)
94
+
95
+ # 更新 Trend
96
+ new_trend = beta * (new_level - prev_level) + (1 - beta) * prev_trend
97
+
98
+ # 更新 Seasonal
99
+ new_seasonal = gamma * (val / new_level) + (1 - gamma) * prev_seasonal
100
+
101
+ levels.append(new_level)
102
+ trends.append(new_trend)
103
+ seasonals[s_idx] = new_seasonal # 更新当前季节系数
104
+
105
+ # 记录拟合值 (One-step ahead forecast)
106
+ fitted = (prev_level + prev_trend) * prev_seasonal
107
+ result.append(fitted)
108
+
109
+ # 预测未来
110
+ forecast = []
111
+ last_level = levels[-1]
112
+ last_trend = trends[-1]
113
+
114
+ for i in range(n_preds):
115
+ m = i + 1
116
+ s_idx = (n + i) % season_len
117
+ pred = (last_level + m * last_trend) * seasonals[s_idx]
118
+ forecast.append(int(pred))
119
+
120
+ return result, forecast
121
+
122
+ def calculate_inventory_metrics(history_series, forecast_series, lead_time_days, service_level, unit_cost, holding_cost_percent):
123
+ """计算库存核心指标"""
124
+ if not history_series:
125
+ return {}
126
+
127
+ # 1. 计算日均销量 (简化:月销量 / 30)
128
+ avg_monthly_demand = np.mean(history_series[-6:]) # 取最近6个月均值
129
+ avg_daily_demand = avg_monthly_demand / 30
130
+
131
+ # 2. 计算需求标准差 (用于安全库存)
132
+ # 计算最近历史数据的波动性
133
+ std_dev_monthly = np.std(history_series[-6:])
134
+ std_dev_daily = std_dev_monthly / math.sqrt(30)
135
+
136
+ # 3. Z-score 映射 (Service Level -> Z)
137
+ # 90% -> 1.28, 95% -> 1.645, 99% -> 2.33
138
+ z_map = {
139
+ 0.90: 1.28,
140
+ 0.95: 1.645,
141
+ 0.98: 2.05,
142
+ 0.99: 2.33
143
+ }
144
+ # 默认插值或取最近
145
+ z_score = 1.645 # default 95%
146
+ closest_sl = min(z_map.keys(), key=lambda x: abs(x - service_level))
147
+ z_score = z_map[closest_sl]
148
+
149
+ # 4. 安全库存 (Safety Stock) = Z * sigma_LT
150
+ # sigma_LT = sigma_daily * sqrt(Lead Time)
151
+ safety_stock = z_score * std_dev_daily * math.sqrt(lead_time_days)
152
+
153
+ # 5. 再订货点 (ROP) = (Daily Demand * Lead Time) + Safety Stock
154
+ rop = (avg_daily_demand * lead_time_days) + safety_stock
155
+
156
+ # 6. 建议订货量 (EOQ - Economic Order Quantity)
157
+ # EOQ = sqrt( (2 * AnnualDemand * OrderCost) / HoldingCostPerUnit )
158
+ # 假设 OrderCost 固定为 $50 (演示用)
159
+ annual_demand = np.sum(forecast_series) * (12 / len(forecast_series)) if len(forecast_series) > 0 else 0
160
+ order_cost = 50
161
+ holding_cost_per_unit = unit_cost * holding_cost_percent
162
+
163
+ if holding_cost_per_unit > 0:
164
+ eoq = math.sqrt((2 * annual_demand * order_cost) / holding_cost_per_unit)
165
+ else:
166
+ eoq = annual_demand / 12 # fallback
167
+
168
+ return {
169
+ "safety_stock": int(safety_stock),
170
+ "rop": int(rop),
171
+ "eoq": int(eoq),
172
+ "avg_daily_demand": round(avg_daily_demand, 2),
173
+ "turnover_rate": round(annual_demand / ((safety_stock + eoq/2) * unit_cost), 1) if unit_cost > 0 and (safety_stock + eoq/2) > 0 else 0
174
+ }
175
+
176
+ # -----------------------------------------------------------------------------
177
+ # Routes
178
+ # -----------------------------------------------------------------------------
179
+
180
+ @app.route('/')
181
+ def index():
182
+ return render_template_string(TEMPLATE)
183
+
184
+ @app.route('/api/generate', methods=['POST'])
185
+ def api_generate():
186
+ try:
187
+ params = request.json
188
+ trend = float(params.get('trend', 0.02))
189
+ seasonality = float(params.get('seasonality', 0.2))
190
+ base = int(params.get('base', 1000))
191
+
192
+ data = generate_mock_data(months=24, trend=trend, seasonality_strength=seasonality, base=base)
193
+ return jsonify({"status": "success", "data": data})
194
+ except Exception as e:
195
+ logger.error(f"Generate error: {e}")
196
+ return jsonify({"status": "error", "message": str(e)}), 500
197
+
198
+ @app.route('/api/forecast', methods=['POST'])
199
+ def api_forecast():
200
+ try:
201
+ req = request.json
202
+ history = req.get('history', []) # list of {date, volume}
203
+ params = req.get('params', {})
204
+
205
+ if not history:
206
+ return jsonify({"status": "error", "message": "No history data provided"}), 400
207
+
208
+ # Extract time series
209
+ volumes = [d['volume'] for d in history]
210
+ dates = [d['date'] for d in history]
211
+
212
+ # Run Forecast
213
+ fitted, forecast = holt_winters_forecast(volumes, n_preds=6)
214
+
215
+ # Generate future dates
216
+ try:
217
+ last_date = datetime.strptime(dates[-1], "%Y-%m")
218
+ except ValueError:
219
+ # Try another format if %Y-%m fails, or default
220
+ last_date = datetime.now()
221
+
222
+ future_dates = []
223
+ for i in range(6):
224
+ d = last_date + timedelta(days=(i+1)*30)
225
+ future_dates.append(d.strftime("%Y-%m"))
226
+
227
+ # Calculate Inventory Metrics
228
+ lead_time = int(params.get('lead_time', 14))
229
+ service_level = float(params.get('service_level', 0.95))
230
+ unit_cost = float(params.get('unit_cost', 50))
231
+
232
+ metrics = calculate_inventory_metrics(
233
+ volumes, forecast,
234
+ lead_time_days=lead_time,
235
+ service_level=service_level,
236
+ unit_cost=unit_cost,
237
+ holding_cost_percent=0.2 # 20% annual holding cost
238
+ )
239
+
240
+ return jsonify({
241
+ "forecast_dates": future_dates,
242
+ "forecast_values": forecast,
243
+ "metrics": metrics,
244
+ "fitted": fitted # Optional: show how well it fit history
245
+ })
246
+ except Exception as e:
247
+ logger.error(f"Forecast error: {e}")
248
+ return jsonify({"status": "error", "message": str(e)}), 500
249
+
250
+ @app.route('/api/upload', methods=['POST'])
251
+ def api_upload():
252
+ try:
253
+ if 'file' not in request.files:
254
+ return jsonify({"status": "error", "message": "No file part"}), 400
255
+ file = request.files['file']
256
+ if file.filename == '':
257
+ return jsonify({"status": "error", "message": "No selected file"}), 400
258
+
259
+ if file:
260
+ # Handle large files by processing stream or reading efficiently
261
+ # For simplicity with pandas, we read into memory, but limit is 16MB via config
262
+ try:
263
+ if file.filename.endswith('.csv'):
264
+ df = pd.read_csv(file)
265
+ elif file.filename.endswith(('.xls', '.xlsx')):
266
+ df = pd.read_excel(file)
267
+ else:
268
+ return jsonify({"status": "error", "message": "Unsupported file format. Please use CSV or Excel."}), 400
269
+ except Exception as e:
270
+ return jsonify({"status": "error", "message": f"File parse error: {str(e)}"}), 400
271
+
272
+ # Normalize columns
273
+ df.columns = [c.lower() for c in df.columns]
274
+
275
+ # Look for date and volume columns
276
+ date_col = next((c for c in df.columns if 'date' in c or 'time' in c or '日期' in c or '时间' in c), None)
277
+ 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)
278
+
279
+ if not date_col or not vol_col:
280
+ return jsonify({"status": "error", "message": "Could not identify 'Date' or 'Volume' columns. Please name them clearly."}), 400
281
+
282
+ # Sort by date
283
+ try:
284
+ df[date_col] = pd.to_datetime(df[date_col])
285
+ df = df.sort_values(date_col)
286
+ df[date_col] = df[date_col].dt.strftime('%Y-%m')
287
+ except Exception:
288
+ return jsonify({"status": "error", "message": "Date column format invalid"}), 400
289
+
290
+ data = []
291
+ for _, row in df.iterrows():
292
+ try:
293
+ vol = int(row[vol_col])
294
+ data.append({
295
+ "date": str(row[date_col]),
296
+ "volume": vol
297
+ })
298
+ except ValueError:
299
+ continue # skip invalid rows
300
+
301
+ return jsonify({"status": "success", "data": data})
302
+
303
+ except Exception as e:
304
+ logger.error(f"Upload error: {e}")
305
+ return jsonify({"status": "error", "message": str(e)}), 500
306
+
307
+ # -----------------------------------------------------------------------------
308
+ # Vue Template
309
+ # -----------------------------------------------------------------------------
310
+
311
+ TEMPLATE = """
312
+ <!DOCTYPE html>
313
+ <html lang="zh-CN" class="dark">
314
+ <head>
315
+ <meta charset="UTF-8">
316
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
317
+ <title>智能库存预测引擎 (Inventory Forecast Engine)</title>
318
+ <script src="https://cdn.tailwindcss.com"></script>
319
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
320
+ <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
321
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
322
+ <script>
323
+ tailwind.config = {
324
+ darkMode: 'class',
325
+ theme: {
326
+ extend: {
327
+ colors: {
328
+ primary: '#3B82F6',
329
+ secondary: '#10B981',
330
+ dark: '#111827',
331
+ darker: '#0B0F19',
332
+ card: '#1F2937'
333
+ }
334
+ }
335
+ }
336
+ }
337
+ </script>
338
+ <style>
339
+ body { background-color: #0B0F19; color: #E5E7EB; font-family: 'Inter', sans-serif; }
340
+ .glass-panel {
341
+ background: rgba(31, 41, 55, 0.7);
342
+ backdrop-filter: blur(10px);
343
+ border: 1px solid rgba(75, 85, 99, 0.4);
344
+ }
345
+ input[type="range"] {
346
+ accent-color: #3B82F6;
347
+ }
348
+ /* Loading overlay */
349
+ .loading-overlay {
350
+ position: fixed; top:0; left:0; width:100%; height:100%;
351
+ background: rgba(0,0,0,0.7); z-index: 100;
352
+ display: flex; justify-content: center; align-items: center;
353
+ }
354
+ </style>
355
+ </head>
356
+ <body class="min-h-screen flex flex-col">
357
+ <div id="app" class="flex-grow flex flex-col">
358
+ <!-- Loading -->
359
+ <div v-if="loading" class="loading-overlay">
360
+ <div class="text-center">
361
+ <i class="fas fa-spinner fa-spin text-4xl text-blue-500 mb-2"></i>
362
+ <p class="text-gray-300">处理中...</p>
363
+ </div>
364
+ </div>
365
+
366
+ <!-- Header -->
367
+ <header class="border-b border-gray-800 bg-darker/80 backdrop-blur sticky top-0 z-50">
368
+ <div class="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
369
+ <div class="flex items-center gap-3">
370
+ <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">
371
+ <i class="fas fa-cubes text-white text-lg"></i>
372
+ </div>
373
+ <div>
374
+ <h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-indigo-400">智能库存预测引擎</h1>
375
+ <p class="text-xs text-gray-500">Inventory Forecast Engine Pro</p>
376
+ </div>
377
+ </div>
378
+ <div class="flex gap-4">
379
+ <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">
380
+ <i class="fas fa-upload text-blue-400"></i> 上传数据
381
+ <input type="file" class="hidden" @change="handleFileUpload" accept=".csv,.xls,.xlsx">
382
+ </label>
383
+ <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">
384
+ <i class="fas fa-sync-alt" :class="{'animate-spin': loading}"></i> 重新生成
385
+ </button>
386
+ <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">
387
+ <i class="fas fa-calculator"></i> 运行预测
388
+ </button>
389
+ </div>
390
+ </div>
391
+ </header>
392
+
393
+ <!-- Main Content -->
394
+ <main class="flex-grow p-6 max-w-7xl mx-auto w-full grid grid-cols-12 gap-6">
395
+
396
+ <!-- Sidebar Controls -->
397
+ <div class="col-span-12 lg:col-span-3 space-y-6">
398
+ <!-- Data Settings -->
399
+ <div class="glass-panel rounded-xl p-5">
400
+ <h3 class="text-sm font-semibold text-gray-300 mb-4 flex items-center gap-2">
401
+ <i class="fas fa-database text-blue-400"></i> 数据模拟参数
402
+ </h3>
403
+ <div class="space-y-4">
404
+ <div>
405
+ <label class="text-xs text-gray-400 block mb-1">基础月销量 (Base)</label>
406
+ <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">
407
+ </div>
408
+ <div>
409
+ <label class="text-xs text-gray-400 block mb-1">增长趋势 (Trend)</label>
410
+ <div class="flex items-center gap-2">
411
+ <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">
412
+ <span class="text-xs w-12 text-right text-mono text-gray-300">${ (params.trend * 100).toFixed(0) }%</span>
413
+ </div>
414
+ </div>
415
+ <div>
416
+ <label class="text-xs text-gray-400 block mb-1">季节性强度 (Seasonality)</label>
417
+ <div class="flex items-center gap-2">
418
+ <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">
419
+ <span class="text-xs w-12 text-right text-mono text-gray-300">${ params.seasonality }</span>
420
+ </div>
421
+ </div>
422
+ </div>
423
+ </div>
424
+
425
+ <!-- Inventory Settings -->
426
+ <div class="glass-panel rounded-xl p-5">
427
+ <h3 class="text-sm font-semibold text-gray-300 mb-4 flex items-center gap-2">
428
+ <i class="fas fa-sliders-h text-green-400"></i> 库存策略配置
429
+ </h3>
430
+ <div class="space-y-4">
431
+ <div>
432
+ <label class="text-xs text-gray-400 block mb-1">目标服务水平 (Service Level)</label>
433
+ <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">
434
+ <option :value="0.90">90% (低风险)</option>
435
+ <option :value="0.95">95% (标准)</option>
436
+ <option :value="0.98">98% (高可用)</option>
437
+ <option :value="0.99">99% (关键业务)</option>
438
+ </select>
439
+ </div>
440
+ <div>
441
+ <label class="text-xs text-gray-400 block mb-1">采购提前期 (Lead Time Days)</label>
442
+ <div class="flex items-center gap-2">
443
+ <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">
444
+ <span class="text-xs w-12 text-right text-mono text-gray-300">${ invParams.lead_time }d</span>
445
+ </div>
446
+ </div>
447
+ <div>
448
+ <label class="text-xs text-gray-400 block mb-1">单件成本 ($)</label>
449
+ <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">
450
+ </div>
451
+ </div>
452
+ </div>
453
+
454
+ <!-- Info Card -->
455
+ <div class="glass-panel rounded-xl p-5 bg-gradient-to-br from-blue-900/20 to-purple-900/20 border-blue-500/20">
456
+ <h4 class="text-sm font-bold text-blue-300 mb-2">商业价值说明</h4>
457
+ <p class="text-xs text-gray-400 leading-relaxed">
458
+ 本系统使用 <strong>Holt-Winters 三次指数平滑算法</strong> 预测未来销量,并基于正态分布理论计算<strong>安全库存</strong>与<strong>再订货点 (ROP)</strong>。帮助商家在维持服务水平的同时,最小化资金占用。
459
+ </p>
460
+ </div>
461
+ </div>
462
+
463
+ <!-- Main Charts & Metrics -->
464
+ <div class="col-span-12 lg:col-span-9 flex flex-col gap-6">
465
+
466
+ <!-- Error Message -->
467
+ <div v-if="error" class="bg-red-900/50 border border-red-500/50 p-4 rounded-lg flex items-center gap-3">
468
+ <i class="fas fa-exclamation-circle text-red-500"></i>
469
+ <span class="text-red-200 text-sm">${ error }</span>
470
+ <button @click="error = null" class="ml-auto text-red-400 hover:text-red-200"><i class="fas fa-times"></i></button>
471
+ </div>
472
+
473
+ <!-- KPI Cards -->
474
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-4" v-if="metrics">
475
+ <div class="glass-panel p-4 rounded-xl border-l-4 border-blue-500 relative overflow-hidden group">
476
+ <div class="absolute right-0 top-0 p-3 opacity-10 group-hover:opacity-20 transition">
477
+ <i class="fas fa-shield-alt text-4xl"></i>
478
+ </div>
479
+ <div class="text-xs text-gray-400 mb-1">建议安全库存</div>
480
+ <div class="text-2xl font-bold text-white">${ metrics.safety_stock } <span class="text-xs font-normal text-gray-500">件</span></div>
481
+ <div class="text-xs text-blue-400 mt-1">Buffer Stock</div>
482
+ </div>
483
+
484
+ <div class="glass-panel p-4 rounded-xl border-l-4 border-yellow-500 relative overflow-hidden group">
485
+ <div class="absolute right-0 top-0 p-3 opacity-10 group-hover:opacity-20 transition">
486
+ <i class="fas fa-bell text-4xl"></i>
487
+ </div>
488
+ <div class="text-xs text-gray-400 mb-1">再订货点 (ROP)</div>
489
+ <div class="text-2xl font-bold text-white">${ metrics.rop } <span class="text-xs font-normal text-gray-500">件</span></div>
490
+ <div class="text-xs text-yellow-400 mt-1">Reorder Point</div>
491
+ </div>
492
+
493
+ <div class="glass-panel p-4 rounded-xl border-l-4 border-green-500 relative overflow-hidden group">
494
+ <div class="absolute right-0 top-0 p-3 opacity-10 group-hover:opacity-20 transition">
495
+ <i class="fas fa-shopping-cart text-4xl"></i>
496
+ </div>
497
+ <div class="text-xs text-gray-400 mb-1">经济订货量 (EOQ)</div>
498
+ <div class="text-2xl font-bold text-white">${ metrics.eoq } <span class="text-xs font-normal text-gray-500">件</span></div>
499
+ <div class="text-xs text-green-400 mt-1">Optimal Order Qty</div>
500
+ </div>
501
+
502
+ <div class="glass-panel p-4 rounded-xl border-l-4 border-purple-500 relative overflow-hidden group">
503
+ <div class="absolute right-0 top-0 p-3 opacity-10 group-hover:opacity-20 transition">
504
+ <i class="fas fa-sync text-4xl"></i>
505
+ </div>
506
+ <div class="text-xs text-gray-400 mb-1">预估周转率</div>
507
+ <div class="text-2xl font-bold text-white">${ metrics.turnover_rate }x <span class="text-xs font-normal text-gray-500">/年</span></div>
508
+ <div class="text-xs text-purple-400 mt-1">Turnover Rate</div>
509
+ </div>
510
+ </div>
511
+
512
+ <!-- Main Chart -->
513
+ <div class="glass-panel p-5 rounded-xl flex-grow flex flex-col min-h-[400px]">
514
+ <h3 class="text-lg font-semibold text-gray-200 mb-4 flex justify-between items-center">
515
+ <span><i class="fas fa-chart-line text-blue-500 mr-2"></i> 销量预测与库存分析</span>
516
+ <span class="text-xs font-normal text-gray-500 bg-gray-800 px-2 py-1 rounded">Holt-Winters Model</span>
517
+ </h3>
518
+ <div id="mainChart" class="flex-grow w-full h-full"></div>
519
+ </div>
520
+ </div>
521
+ </main>
522
+ </div>
523
+
524
+ <script>
525
+ const { createApp, ref, onMounted, watch, nextTick } = Vue;
526
+
527
+ createApp({
528
+ delimiters: ['${', '}'], // Changed to avoid Jinja2 conflict
529
+ setup() {
530
+ const loading = ref(false);
531
+ const error = ref(null);
532
+ const chartInstance = ref(null);
533
+
534
+ // State
535
+ const historyData = ref([]);
536
+ const forecastData = ref(null);
537
+ const metrics = ref(null);
538
+
539
+ // Parameters
540
+ const params = ref({
541
+ base: 1000,
542
+ trend: 0.02,
543
+ seasonality: 0.3
544
+ });
545
+
546
+ const invParams = ref({
547
+ service_level: 0.95,
548
+ lead_time: 14,
549
+ unit_cost: 50
550
+ });
551
+
552
+ // Methods
553
+ const initChart = () => {
554
+ const el = document.getElementById('mainChart');
555
+ if (el) {
556
+ chartInstance.value = echarts.init(el);
557
+ window.addEventListener('resize', () => chartInstance.value.resize());
558
+ }
559
+ };
560
+
561
+ const updateChart = () => {
562
+ if (!chartInstance.value) return;
563
+
564
+ const dates = historyData.value.map(d => d.date);
565
+ const values = historyData.value.map(d => d.volume);
566
+
567
+ let series = [
568
+ {
569
+ name: '历史销量',
570
+ type: 'line',
571
+ data: values,
572
+ smooth: true,
573
+ symbolSize: 6,
574
+ itemStyle: { color: '#3B82F6' },
575
+ areaStyle: {
576
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
577
+ { offset: 0, color: 'rgba(59, 130, 246, 0.5)' },
578
+ { offset: 1, color: 'rgba(59, 130, 246, 0.0)' }
579
+ ])
580
+ }
581
+ }
582
+ ];
583
+
584
+ let xAxisData = [...dates];
585
+
586
+ if (forecastData.value) {
587
+ const fDates = forecastData.value.dates;
588
+ const fValues = forecastData.value.values;
589
+
590
+ // 连接历史最后一点和预测第一点,为了视觉连贯
591
+ const lastHistDate = dates[dates.length-1];
592
+ const lastHistVal = values[values.length-1];
593
+
594
+ // 构造预测数据序列 (前补 null)
595
+ const nulls = Array(values.length - 1).fill(null);
596
+ // 把历史最后一点作为预测起始点
597
+ const plotForecast = [lastHistVal, ...fValues];
598
+ const fullForecastData = [...nulls, ...plotForecast];
599
+
600
+ // 扩展 X 轴
601
+ xAxisData = [...dates, ...fDates];
602
+
603
+ series.push({
604
+ name: 'AI 预测销量',
605
+ type: 'line',
606
+ data: fullForecastData,
607
+ smooth: true,
608
+ symbolSize: 6,
609
+ lineStyle: { type: 'dashed', width: 3 },
610
+ itemStyle: { color: '#10B981' }
611
+ });
612
+
613
+ if (metrics.value) {
614
+ const avgDemand = metrics.value.avg_daily_demand * 30;
615
+
616
+ series.push({
617
+ name: '月均需求趋势',
618
+ type: 'line',
619
+ data: Array(xAxisData.length).fill(avgDemand),
620
+ showSymbol: false,
621
+ lineStyle: { color: '#6B7280', width: 1, type: 'dotted' },
622
+ z: -1
623
+ });
624
+ }
625
+ }
626
+
627
+ const option = {
628
+ backgroundColor: 'transparent',
629
+ tooltip: {
630
+ trigger: 'axis',
631
+ backgroundColor: 'rgba(17, 24, 39, 0.9)',
632
+ borderColor: '#374151',
633
+ textStyle: { color: '#E5E7EB' }
634
+ },
635
+ legend: {
636
+ data: ['历史销量', 'AI 预测销量'],
637
+ textStyle: { color: '#9CA3AF' },
638
+ bottom: 0
639
+ },
640
+ grid: {
641
+ left: '3%',
642
+ right: '4%',
643
+ bottom: '10%',
644
+ top: '10%',
645
+ containLabel: true
646
+ },
647
+ xAxis: {
648
+ type: 'category',
649
+ boundaryGap: false,
650
+ data: xAxisData,
651
+ axisLine: { lineStyle: { color: '#4B5563' } },
652
+ axisLabel: { color: '#9CA3AF' }
653
+ },
654
+ yAxis: {
655
+ type: 'value',
656
+ splitLine: { lineStyle: { color: '#374151' } },
657
+ axisLabel: { color: '#9CA3AF' }
658
+ },
659
+ series: series
660
+ };
661
+
662
+ chartInstance.value.setOption(option);
663
+ };
664
+
665
+ const generateData = async () => {
666
+ loading.value = true;
667
+ error.value = null;
668
+ try {
669
+ const res = await fetch('/api/generate', {
670
+ method: 'POST',
671
+ headers: {'Content-Type': 'application/json'},
672
+ body: JSON.stringify(params.value)
673
+ });
674
+ const data = await res.json();
675
+ if(data.status === 'error') throw new Error(data.message);
676
+ historyData.value = data.data;
677
+
678
+ // 自动运行预测
679
+ await runForecast();
680
+ } catch (e) {
681
+ console.error(e);
682
+ error.value = "生成数据失败: " + e.message;
683
+ } finally {
684
+ loading.value = false;
685
+ }
686
+ };
687
+
688
+ const runForecast = async () => {
689
+ if (historyData.value.length === 0) return;
690
+
691
+ try {
692
+ const res = await fetch('/api/forecast', {
693
+ method: 'POST',
694
+ headers: {'Content-Type': 'application/json'},
695
+ body: JSON.stringify({
696
+ history: historyData.value,
697
+ params: invParams.value
698
+ })
699
+ });
700
+ const data = await res.json();
701
+
702
+ if(data.status === 'error') throw new Error(data.message);
703
+
704
+ forecastData.value = {
705
+ dates: data.forecast_dates,
706
+ values: data.forecast_values
707
+ };
708
+ metrics.value = data.metrics;
709
+
710
+ nextTick(() => {
711
+ updateChart();
712
+ });
713
+ } catch (e) {
714
+ console.error(e);
715
+ error.value = "预测失败: " + e.message;
716
+ }
717
+ };
718
+
719
+ const handleFileUpload = async (event) => {
720
+ const file = event.target.files[0];
721
+ if (!file) return;
722
+
723
+ if (file.size > 15 * 1024 * 1024) {
724
+ error.value = "文件过大,请上传小于 15MB 的文件";
725
+ return;
726
+ }
727
+
728
+ const formData = new FormData();
729
+ formData.append('file', file);
730
+
731
+ loading.value = true;
732
+ error.value = null;
733
+
734
+ try {
735
+ const res = await fetch('/api/upload', {
736
+ method: 'POST',
737
+ body: formData
738
+ });
739
+ const data = await res.json();
740
+
741
+ if (data.status === 'error') {
742
+ throw new Error(data.message);
743
+ }
744
+
745
+ historyData.value = data.data;
746
+ await runForecast();
747
+
748
+ } catch (e) {
749
+ console.error(e);
750
+ error.value = "上传失败: " + e.message;
751
+ } finally {
752
+ loading.value = false;
753
+ // Reset input
754
+ event.target.value = '';
755
+ }
756
+ };
757
+
758
+ // Watchers for real-time updates
759
+ watch(invParams, () => {
760
+ runForecast();
761
+ }, { deep: true });
762
+
763
+ // Lifecycle
764
+ onMounted(() => {
765
+ initChart();
766
+ generateData();
767
+ });
768
+
769
+ return {
770
+ loading,
771
+ error,
772
+ params,
773
+ invParams,
774
+ metrics,
775
+ generateData,
776
+ runForecast,
777
+ handleFileUpload
778
+ };
779
+ }
780
+ }).mount('#app');
781
+ </script>
782
+ </body>
783
+ </html>
784
+ """
785
+
786
+ if __name__ == '__main__':
787
+ app.run(host='0.0.0.0', port=7860, debug=True)
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ flask
2
+ numpy
3
+ pandas
4
+ gunicorn
5
+ openpyxl