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

Initial commit: Enhanced Inventory Optimization Studio with robust import/export

Browse files
Files changed (5) hide show
  1. Dockerfile +15 -0
  2. README.md +35 -0
  3. app.py +27 -0
  4. requirements.txt +2 -0
  5. templates/index.html +627 -0
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-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 PATH="/home/user/.local/bin:$PATH"
14
+
15
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: 智能库存优化工作室 (Inventory Opt Studio)
3
+ emoji: 📦
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: false
8
+ short_description: 供应链库存优化工具 (EOQ/ROP/安全库存计算与模拟)
9
+ ---
10
+
11
+ # 智能库存优化工作室 (Inventory Opt Studio)
12
+
13
+ 一个用于供应链库存管理的专业工具。通过计算经济订货量 (EOQ)、再订货点 (ROP) 和安全库存,帮助企业优化库存成本。包含动态库存模拟功能。
14
+
15
+ ## 功能特点
16
+
17
+ * **核心计算**: 自动计算 EOQ、ROP、安全库存。
18
+ * **成本分析**: 可视化订货成本与持有成本的结构。
19
+ * **动态模拟**: 基于蒙特卡洛模拟的库存水平走势图 (考虑需求波动)。
20
+ * **服务水平调整**: 实时调整目标服务水平 (Service Level),观察对安全库存的影响。
21
+ * **数据导入导出**: 支持 JSON 格式的配置导入导出,以及文本报告导出。
22
+ * **导出报告**: 支持导出文本报告。
23
+
24
+ ## 技术栈
25
+
26
+ * **Frontend**: Vue 3, Tailwind CSS, Chart.js
27
+ * **Backend**: Python Flask (Dockerized)
28
+ * **Environment**: Hugging Face Spaces compatible
29
+
30
+ ## 运行方式
31
+
32
+ ```bash
33
+ docker build -t inventory-opt .
34
+ docker run -p 7860:7860 inventory-opt
35
+ ```
app.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, send_from_directory
2
+ import os
3
+
4
+ app = Flask(__name__)
5
+
6
+ # Fix conflict between Jinja2 and Vue.js delimiters if we were using Jinja templates mixed with Vue
7
+ # But we'll likely use raw HTML/Vue, but just in case:
8
+ app.jinja_env.variable_start_string = '[['
9
+ app.jinja_env.variable_end_string = ']]'
10
+ app.jinja_env.block_start_string = '[%'
11
+ app.jinja_env.block_end_string = '%]'
12
+
13
+ @app.route('/')
14
+ def index():
15
+ return render_template('index.html')
16
+
17
+ @app.route('/static/<path:path>')
18
+ def send_static(path):
19
+ return send_from_directory('static', path)
20
+
21
+ @app.route('/health')
22
+ def health():
23
+ return "OK"
24
+
25
+ if __name__ == '__main__':
26
+ port = int(os.environ.get('PORT', 7860))
27
+ app.run(host='0.0.0.0', port=port)
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ Flask==3.0.0
2
+ gunicorn==21.2.0
templates/index.html ADDED
@@ -0,0 +1,627 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>智能库存优化工作室 | Inventory Opt Studio</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
9
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
10
+ <!-- Font Awesome -->
11
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
12
+
13
+ <script>
14
+ tailwind.config = {
15
+ darkMode: 'class',
16
+ theme: {
17
+ extend: {
18
+ colors: {
19
+ primary: '#10b981', // Emerald 500
20
+ secondary: '#3b82f6', // Blue 500
21
+ dark: {
22
+ bg: '#0f172a',
23
+ card: '#1e293b',
24
+ input: '#334155'
25
+ }
26
+ }
27
+ }
28
+ }
29
+ }
30
+ </script>
31
+ <style>
32
+ [v-cloak] { display: none; }
33
+ body {
34
+ background-color: #0f172a;
35
+ color: #e2e8f0;
36
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
37
+ }
38
+ .glass-card {
39
+ background: rgba(30, 41, 59, 0.7);
40
+ backdrop-filter: blur(10px);
41
+ border: 1px solid rgba(255, 255, 255, 0.1);
42
+ border-radius: 1rem;
43
+ }
44
+ .input-field {
45
+ background: #334155;
46
+ border: 1px solid #475569;
47
+ color: white;
48
+ padding: 0.5rem 0.75rem;
49
+ border-radius: 0.5rem;
50
+ width: 100%;
51
+ outline: none;
52
+ transition: all 0.2s;
53
+ }
54
+ .input-field:focus {
55
+ border-color: #10b981;
56
+ box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
57
+ }
58
+ /* Custom Scrollbar */
59
+ ::-webkit-scrollbar {
60
+ width: 8px;
61
+ }
62
+ ::-webkit-scrollbar-track {
63
+ background: #0f172a;
64
+ }
65
+ ::-webkit-scrollbar-thumb {
66
+ background: #334155;
67
+ border-radius: 4px;
68
+ }
69
+ ::-webkit-scrollbar-thumb:hover {
70
+ background: #475569;
71
+ }
72
+ </style>
73
+ </head>
74
+ <body class="min-h-screen flex flex-col">
75
+ <div id="app" as=-grow flex flex-col p-4 md:p-8 max-w-7xl mx-auto w-full">
76
+ <!-- Header -->
77
+ <header class="flex justify-between items-center mb-8">
78
+ <div class="flex items-center gap-3">
79
+ <div class="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-500 to-blue-600 flex items-center justify-center text-white text-2xl shadow-lg shadow-emerald-500/20">
80
+ <i class="fa-solid fa-boxes-stacked"></i>
81
+ </div>
82
+ <div>
83
+ <h1 class="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-gray-400">智能库存优化工作室</h1>
84
+ <p class="text-xs text-gray-400">供应链资产管理与 EOQ 模拟</p>
85
+ </div>
86
+ </div>
87
+ <div class="flex gap-2">
88
+ <input type="file" ref="fileInput" @change="handleFileImport" accept=".json" class="hidden">
89
+ <button @click="triggerImport" class="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm transition-colors" title="导入配置">
90
+ <i class="fa-solid fa-file-import mr-2"></i>导入
91
+ </button>
92
+ <button @click="exportConfig" class="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm transition-colors" title="导出配置 JSON">
93
+ <i class="fa-solid fa-download mr-2"></i>导出配置
94
+ </button>
95
+ <button @click="resetData" class="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm transition-colors">
96
+ <i class="fa-solid fa-rotate-left mr-2"></i>重置
97
+ </button>
98
+ <button @click="exportReport" class="px-4 py-2 rounded-lg bg-primary hover:bg-emerald-600 text-white text-sm transition-colors shadow-lg shadow-emerald-500/20">
99
+ <i class="fa-solid fa-file-export mr-2"></i>导出报告
100
+ </button>
101
+ </div>
102
+ </header>
103
+
104
+ <!-- Main Content -->
105
+ <div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
106
+ <!-- Left Panel: Parameters -->
107
+ <div class="lg:col-span-4 space-y-6">
108
+ <!-- Basic Demand -->
109
+ <div class="glass-card p-6">
110
+ <h3 class="text-lg font-semibold mb-4 text-emerald-400 flex items-center">
111
+ <i class="fa-solid fa-sliders mr-2"></i>核心参数
112
+ </h3>
113
+
114
+ <div class="space-y-4">
115
+ <div>
116
+ <label class="block text-sm text-gray-400 mb-1">年需求量 (D)</label>
117
+ <div class="relative">
118
+ <input type="number" v-model.number="params.annualDemand" class="input-field pl-10">
119
+ <span class="absolute left-3 top-2 text-gray-500"><i class="fa-solid fa-box"></i></span>
120
+ </div>
121
+ </div>
122
+
123
+ <div>
124
+ <label class="block text-sm text-gray-400 mb-1">单次订货成本 (S)</label>
125
+ <div class="relative">
126
+ <input type="number" v-model.number="params.orderingCost" class="input-field pl-10">
127
+ <span class="absolute left-3 top-2 text-gray-500">¥</span>
128
+ </div>
129
+ </div>
130
+
131
+ <div>
132
+ <label class="block text-sm text-gray-400 mb-1">单位持有成本/年 (H)</label>
133
+ <div class="relative">
134
+ <input type="number" v-model.number="params.holdingCost" class="input-field pl-10">
135
+ <span class="absolute left-3 top-2 text-gray-500">¥</span>
136
+ </div>
137
+ </div>
138
+
139
+ <div>
140
+ <label class="block text-sm text-gray-400 mb-1">单位产品成本 (C)</label>
141
+ <div class="relative">
142
+ <input type="number" v-model.number="params.unitCost" class="input-field pl-10">
143
+ <span class="absolute left-3 top-2 text-gray-500">¥</span>
144
+ </div>
145
+ </div>
146
+ </div>
147
+ </div>
148
+
149
+ <!-- Safety Stock Params -->
150
+ <div class="glass-card p-6">
151
+ <h3 class="text-lg font-semibold mb-4 text-blue-400 flex items-center">
152
+ <i class="fa-solid fa-shield-halved mr-2"></i>安全库存 & 服务水平
153
+ </h3>
154
+
155
+ <div class="space-y-4">
156
+ <div>
157
+ <label class="block text-sm text-gray-400 mb-1">提前期 (天) (L)</label>
158
+ <input type="number" v-model.number="params.leadTime" class="input-field">
159
+ </div>
160
+
161
+ <div>
162
+ <label class="block text-sm text-gray-400 mb-1">目标服务水平 (%)</label>
163
+ <div class="flex items-center gap-2">
164
+ <input type="range" v-model.number="params.serviceLevel" min="80" max="99.9" step="0.1" class="w-full accent-blue-500 h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer">
165
+ <span class="w-16 text-right font-mono">{{ params.serviceLevel }}%</span>
166
+ </div>
167
+ </div>
168
+
169
+ <div>
170
+ <label class="block text-sm text-gray-400 mb-1">需求波动标准差 (每日)</label>
171
+ <input type="number" v-model.number="params.demandStdDev" class="input-field">
172
+ <p class="text-xs text-gray-500 mt-1">反映日常需求的不确定性</p>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ </div>
177
+
178
+ <!-- Right Panel: Visualization -->
179
+ <div class="lg:col-span-8 space-y-6">
180
+ <!-- KPI Cards -->
181
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
182
+ <div class="glass-card p-4 border-l-4 border-emerald-500">
183
+ <div class="text-gray-400 text-sm mb-1">经济订货量 (EOQ)</div>
184
+ <div class="text-3xl font-bold text-white">{{ results.eoq }} <span class="text-sm font-normal text-gray-500">件</span></div>
185
+ <div class="text-xs text-emerald-400 mt-1">最优单次采购量</div>
186
+ </div>
187
+
188
+ <div class="glass-card p-4 border-l-4 border-blue-500">
189
+ <div class="text-gray-400 text-sm mb-1">再订货点 (ROP)</div>
190
+ <div class="text-3xl font-bold text-white">{{ results.rop }} <span class="text-sm font-normal text-gray-500">件</span></div>
191
+ <div class="text-xs text-blue-400 mt-1">库存降至此线需补货</div>
192
+ </div>
193
+
194
+ <div class="glass-card p-4 border-l-4 border-amber-500">
195
+ <div class="text-gray-400 text-sm mb-1">总库存年成本</div>
196
+ <div class="text-3xl font-bold text-white">¥{{ formatNumber(results.totalCost) }}</div>
197
+ <div class="text-xs text-amber-400 mt-1">含订货+持有成本</div>
198
+ </div>
199
+ </div>
200
+
201
+ <!-- Charts -->
202
+ <div class="glass-card p-6">
203
+ <h3 class="text-lg font-semibold mb-4 text-gray-200">库存动态模拟 (365天)</h3>
204
+ <div class="h-64 w-full">
205
+ <canvas id="simChart"></canvas>
206
+ </div>
207
+ </div>
208
+
209
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
210
+ <div class="glass-card p-6">
211
+ <h3 class="text-lg font-semibold mb-4 text-gray-200">成本结构分析</h3>
212
+ <div class="h-48 w-full">
213
+ <canvas id="costChart"></canvas>
214
+ </div>
215
+ </div>
216
+
217
+ <div class="glass-card p-6">
218
+ <h3 class="text-lg font-semibold mb-4 text-gray-200">库存健康度</h3>
219
+ <div class="space-y-4">
220
+ <div class="flex justify-between items-center">
221
+ <span class="text-sm text-gray-400">平均库存水平</span>
222
+ <span class="font-mono">{{ Math.round(results.eoq / 2 + results.safetyStock) }}</span>
223
+ </div>
224
+ <div class="w-full bg-gray-700 rounded-full h-2">
225
+ <div class="bg-blue-500 h-2 rounded-full" style="width: 60%"></div>
226
+ </div>
227
+
228
+ <div class="flex justify-between items-center">
229
+ <span class="text-sm text-gray-400">安全库存占比</span>
230
+ <span class="font-mono">{{ Math.round((results.safetyStock / (results.eoq + results.safetyStock)) * 100) }}%</span>
231
+ </div>
232
+ <div class="w-full bg-gray-700 rounded-full h-2">
233
+ <div class="bg-amber-500 h-2 rounded-full" :style="{ width: (results.safetyStock / (results.eoq + results.safetyStock) * 100) + '%' }"></div>
234
+ </div>
235
+
236
+ <div class="flex justify-between items-center">
237
+ <span class="text-sm text-gray-400">年订货次数</span>
238
+ <span class="font-mono">{{ results.ordersPerYear }}</span>
239
+ </div>
240
+ <div class="flex justify-between items-center">
241
+ <span class="text-sm text-gray-400">订货周期 (天)</span>
242
+ <span class="font-mono">{{ results.cycleDays }}</span>
243
+ </div>
244
+ </div>
245
+ </div>
246
+ </div>
247
+ </div>
248
+ </div>
249
+ </div>
250
+
251
+ <script>
252
+ const { createApp, ref, reactive, computed, watch, onMounted } = Vue;
253
+
254
+ createApp({
255
+ setup() {
256
+ // Default parameters
257
+ const params = reactive({
258
+ annualDemand: 12000,
259
+ orderingCost: 50, // Cost per order
260
+ holdingCost: 2, // Cost per unit per year
261
+ unitCost: 20,
262
+ leadTime: 5, // Days
263
+ serviceLevel: 95.0, // Percentage
264
+ demandStdDev: 10 // Daily demand standard deviation
265
+ });
266
+
267
+ const results = reactive({
268
+ eoq: 0,
269
+ safetyStock: 0,
270
+ rop: 0,
271
+ totalCost: 0,
272
+ ordersPerYear: 0,
273
+ cycleDays: 0
274
+ });
275
+
276
+ let simChartInstance = null;
277
+ let costChartInstance = null;
278
+
279
+ // Inverse Normal Distribution Approximation (Acklam's algorithm or simple approximation)
280
+ // Using a simpler approximation for Z-score
281
+ function getZScore(p) {
282
+ // Source: https://stackoverflow.com/questions/8816729/javascript-equivalent-for-inverse-normal-function-eg-excel-normsinv-or-nor
283
+ var a1 = -39.6968302866538, a2 = 220.946098424521, a3 = -275.928510446969;
284
+ var a4 = 138.357751867269, a5 = -30.6647980661472, a6 = 2.50662827745924;
285
+ var b1 = -54.4760987982241, b2 = 161.585836858041, b3 = -155.698979859887;
286
+ var b4 = 66.8013118877197, b5 = -13.2806815528857, c1 = -7.78489400243029e-03;
287
+ var c2 = -3.22396458041136e-01, c3 = -2.40075827716184, c4 = -2.54973253934373;
288
+ var c5 = 4.37466414146497, c6 = 2.93816398269878;
289
+ var d1 = 7.78469570904146e-03, d2 = 3.22467129070039e-01, d3 = 2.445134137143;
290
+ var d4 = 3.75440866190742;
291
+ var p_low = 0.02425, p_high = 1 - 0.02425;
292
+ var q, r;
293
+ if (0 < p && p < p_low) {
294
+ q = Math.sqrt(-2 * Math.log(p));
295
+ return (((((c1 * q + c2) * q + c3) * q + c4) * q + c5) * q + c6) /
296
+ ((((d1 * q + d2) * q + d3) * q + d4) * q + 1);
297
+ } else if (p_low <= p && p <= p_high) {
298
+ q = p - 0.5;
299
+ r = q * q;
300
+ return (((((a1 * r + a2) * r + a3) * r + a4) * r + a5) * r + a6) * q /
301
+ (((((b1 * r + b2) * r + b3) * r + b4) * r + b5) * r + 1);
302
+ } else if (p_high < p && p < 1) {
303
+ q = Math.sqrt(-2 * Math.log(1 - p));
304
+ return -(((((c1 * q + c2) * q + c3) * q + c4) * q + c5) * q + c6) /
305
+ ((((d1 * q + d2) * q + d3) * q + d4) * q + 1);
306
+ }
307
+ return 0;
308
+ }
309
+
310
+ const calculate = () => {
311
+ // EOQ
312
+ // Sqrt(2 * D * S / H)
313
+ const D = params.annualDemand;
314
+ const S = params.orderingCost;
315
+ const H = params.holdingCost;
316
+
317
+ if (D <= 0 || S <= 0 || H <= 0) return;
318
+
319
+ const eoq = Math.sqrt((2 * D * S) / H);
320
+ results.eoq = Math.round(eoq);
321
+
322
+ // Safety Stock
323
+ // Z * StdDev * Sqrt(LeadTime) -- Assuming daily StdDev provided and LeadTime in days
324
+ const z = getZScore(params.serviceLevel / 100);
325
+ // Standard deviation of demand during lead time = StdDevDaily * Sqrt(LeadTime)
326
+ const stdDevDLT = params.demandStdDev * Math.sqrt(params.leadTime);
327
+ results.safetyStock = Math.round(z * stdDevDLT);
328
+
329
+ // ROP
330
+ // (DailyDemand * LeadTime) + SafetyStock
331
+ const dailyDemand = D / 365;
332
+ results.rop = Math.round((dailyDemand * params.leadTime) + results.safetyStock);
333
+
334
+ // Total Cost (Inventory Related)
335
+ // Ordering Cost (D/Q * S) + Holding Cost (Q/2 * H) + SafetyStock Cost (SS * H)
336
+ const annualOrderingCost = (D / eoq) * S;
337
+ const annualHoldingCost = (eoq / 2) * H;
338
+ const safetyStockCost = results.safetyStock * H;
339
+
340
+ results.totalCost = Math.round(annualOrderingCost + annualHoldingCost + safetyStockCost);
341
+ results.ordersPerYear = Math.round((D / eoq) * 10) / 10;
342
+ results.cycleDays = Math.round(365 / results.ordersPerYear);
343
+
344
+ updateCharts();
345
+ };
346
+
347
+ const updateCharts = () => {
348
+ updateSimChart();
349
+ updateCostChart();
350
+ };
351
+
352
+ const updateSimChart = () => {
353
+ const ctx = document.getElementById('simChart');
354
+ if (!ctx) return;
355
+
356
+ // Simulation Logic
357
+ const days = 100; // Simulate 100 days
358
+ const dataPoints = [];
359
+ const labels = [];
360
+
361
+ let currentInventory = results.eoq + results.safetyStock; // Start full
362
+ let pendingOrders = []; // List of {arrivalDay: x, quantity: y}
363
+
364
+ for (let day = 1; day <= days; day++) {
365
+ labels.push(`Day ${day}`);
366
+
367
+ // Check deliveries
368
+ const arrivals = pendingOrders.filter(o => o.arrivalDay === day);
369
+ arrivals.forEach(o => currentInventory += o.quantity);
370
+ pendingOrders = pendingOrders.filter(o => o.arrivalDay !== day);
371
+
372
+ // Random Demand (Normal Distribution approx)
373
+ const dailyMean = params.annualDemand / 365;
374
+ // Box-Muller transform for normal distribution random
375
+ const u = 1 - Math.random();
376
+ const v = Math.random();
377
+ const z = Math.sqrt( -2.0 * Math.log( u ) ) * Math.cos( 2.0 * Math.PI * v );
378
+ const todayDemand = Math.max(0, Math.round(dailyMean + z * params.demandStdDev));
379
+
380
+ currentInventory -= todayDemand;
381
+ if (currentInventory < 0) currentInventory = 0; // Stockout
382
+
383
+ // Reorder Logic
384
+ // Check if we already have an order on the way?
385
+ // Simplified: Only order if inventory <= ROP and NO pending orders (or robust logic)
386
+ // Standard policy: If Inventory Position (Inv + OnOrder) <= ROP, Order Q
387
+ const inventoryPosition = currentInventory + pendingOrders.reduce((acc, o) => acc + o.quantity, 0);
388
+
389
+ if (inventoryPosition <= results.rop) {
390
+ pendingOrders.push({
391
+ arrivalDay: day + params.leadTime,
392
+ quantity: results.eoq
393
+ });
394
+ }
395
+
396
+ dataPoints.push(currentInventory);
397
+ }
398
+
399
+ if (simChartInstance) simChartInstance.destroy();
400
+
401
+ simChartInstance = new Chart(ctx, {
402
+ type: 'line',
403
+ data: {
404
+ labels: labels,
405
+ datasets: [{
406
+ label: '库存水平',
407
+ data: dataPoints,
408
+ borderColor: '#10b981',
409
+ backgroundColor: 'rgba(16, 185, 129, 0.1)',
410
+ fill: true,
411
+ tension: 0.1,
412
+ pointRadius: 0
413
+ },
414
+ {
415
+ label: '再订货点 (ROP)',
416
+ data: Array(days).fill(results.rop),
417
+ borderColor: '#3b82f6',
418
+ borderDash: [5, 5],
419
+ borderWidth: 1,
420
+ pointRadius: 0
421
+ },
422
+ {
423
+ label: '安全库存',
424
+ data: Array(days).fill(results.safetyStock),
425
+ borderColor: '#f59e0b',
426
+ borderDash: [2, 2],
427
+ borderWidth: 1,
428
+ pointRadius: 0
429
+ }]
430
+ },
431
+ options: {
432
+ responsive: true,
433
+ maintainAspectRatio: false,
434
+ interaction: {
435
+ intersect: false,
436
+ mode: 'index',
437
+ },
438
+ plugins: {
439
+ legend: {
440
+ labels: { color: '#94a3b8' }
441
+ }
442
+ },
443
+ scales: {
444
+ y: {
445
+ grid: { color: '#334155' },
446
+ ticks: { color: '#94a3b8' }
447
+ },
448
+ x: {
449
+ grid: { display: false },
450
+ ticks: { display: false } // Hide x labels for cleanliness
451
+ }
452
+ }
453
+ }
454
+ });
455
+ };
456
+
457
+ const updateCostChart = () => {
458
+ const ctx = document.getElementById('costChart');
459
+ if (!ctx) return;
460
+
461
+ // Cost Breakdown
462
+ const annualOrderingCost = (params.annualDemand / results.eoq) * params.orderingCost;
463
+ const annualHoldingCost = (results.eoq / 2) * params.holdingCost;
464
+ const safetyStockCost = results.safetyStock * params.holdingCost;
465
+
466
+ if (costChartInstance) costChartInstance.destroy();
467
+
468
+ costChartInstance = new Chart(ctx, {
469
+ type: 'doughnut',
470
+ data: {
471
+ labels: ['订货成本', '持有成本 (周期)', '持有成本 (安全库存)'],
472
+ datasets: [{
473
+ data: [annualOrderingCost, annualHoldingCost, safetyStockCost],
474
+ backgroundColor: [
475
+ '#3b82f6', // Blue
476
+ '#10b981', // Emerald
477
+ '#f59e0b' // Amber
478
+ ],
479
+ borderWidth: 0
480
+ }]
481
+ },
482
+ options: {
483
+ responsive: true,
484
+ maintainAspectRatio: false,
485
+ plugins: {
486
+ legend: {
487
+ position: 'right',
488
+ labels: { color: '#94a3b8' }
489
+ }
490
+ }
491
+ }
492
+ });
493
+ };
494
+
495
+ const formatNumber = (num) => {
496
+ return new Intl.NumberFormat('zh-CN').format(num);
497
+ };
498
+
499
+ const resetData = () => {
500
+ params.annualDemand = 12000;
501
+ params.orderingCost = 50;
502
+ params.holdingCost = 2;
503
+ params.unitCost = 20;
504
+ params.leadTime = 5;
505
+ params.serviceLevel = 95.0;
506
+ params.demandStdDev = 10;
507
+ };
508
+
509
+ const exportReport = () => {
510
+ // Simple text export
511
+ const report = `库存优化报告\n` +
512
+ `生成时间: ${new Date().toLocaleString()}\n\n` +
513
+ `[参数]\n` +
514
+ `年需求量: ${params.annualDemand}\n` +
515
+ `订货成本: ${params.orderingCost}\n` +
516
+ `持有成本: ${params.holdingCost}\n` +
517
+ `提前期: ${params.leadTime}天\n` +
518
+ `服务水平: ${params.serviceLevel}%\n\n` +
519
+ `[结果]\n` +
520
+ `EOQ (经济订货量): ${results.eoq}\n` +
521
+ `ROP (再订货点): ${results.rop}\n` +
522
+ `安全库存: ${results.safetyStock}\n` +
523
+ `总年成本: ${results.totalCost}\n` +
524
+ `年订货次数: ${results.ordersPerYear}\n`;
525
+
526
+ const blob = new Blob([report], { type: 'text/plain' });
527
+ const url = URL.createObjectURL(blob);
528
+ const a = document.createElement('a');
529
+ a.href = url;
530
+ a.download = 'inventory_report.txt';
531
+ a.click();
532
+ URL.revokeObjectURL(url);
533
+ };
534
+
535
+ // Export Config (JSON)
536
+ const exportConfig = () => {
537
+ const config = JSON.stringify(params, null, 2);
538
+ const blob = new Blob([config], { type: 'application/json' });
539
+ const url = URL.createObjectURL(blob);
540
+ const a = document.createElement('a');
541
+ a.href = url;
542
+ a.download = 'inventory_config.json';
543
+ a.click();
544
+ URL.revokeObjectURL(url);
545
+ };
546
+
547
+ const fileInput = ref(null);
548
+
549
+ const triggerImport = () => {
550
+ fileInput.value.click();
551
+ };
552
+
553
+ const handleFileImport = (event) => {
554
+ const file = event.target.files[0];
555
+ if (!file) return;
556
+
557
+ // 1. Size Check (< 5MB)
558
+ if (file.size > 5 * 1024 * 1024) {
559
+ alert('错误:文件大小不能超过 5MB');
560
+ event.target.value = ''; // Reset
561
+ return;
562
+ }
563
+
564
+ const reader = new FileReader();
565
+
566
+ reader.onload = (e) => {
567
+ const content = e.target.result;
568
+
569
+ // 2. Binary/Null Byte Check
570
+ if (content.includes('\0')) {
571
+ alert('错误:检测到二进制内容或非法字符');
572
+ event.target.value = '';
573
+ return;
574
+ }
575
+
576
+ try {
577
+ const data = JSON.parse(content);
578
+
579
+ // 3. Schema Validation (Basic)
580
+ if (typeof data.annualDemand === 'number' &&
581
+ typeof data.orderingCost === 'number' &&
582
+ typeof data.holdingCost === 'number') {
583
+
584
+ // Update params
585
+ Object.assign(params, data);
586
+ alert('配置导入成功!');
587
+ } else {
588
+ alert('错误:无效的配置文件格式');
589
+ }
590
+ } catch (err) {
591
+ alert('错误:JSON 解析失败');
592
+ }
593
+ event.target.value = ''; // Reset for next use
594
+ };
595
+
596
+ reader.onerror = () => {
597
+ alert('错误:读取文件失败');
598
+ event.target.value = '';
599
+ };
600
+
601
+ reader.readAsText(file);
602
+ };
603
+
604
+ watch(params, () => {
605
+ calculate();
606
+ }, { deep: true });
607
+
608
+ onMounted(() => {
609
+ calculate();
610
+ });
611
+
612
+ return {
613
+ params,
614
+ results,
615
+ formatNumber,
616
+ resetData,
617
+ exportReport,
618
+ exportConfig,
619
+ triggerImport,
620
+ handleFileImport,
621
+ fileInput
622
+ };
623
+ }
624
+ }).mount('#app');
625
+ </script>
626
+ </body>
627
+ </html>