37、需求预测与库存优化 (快消品) - /供应链管理组件/fmcg-inventory-optimization

76个工业组件库示例汇总

需求预测与库存优化 (快消品) - 自定义组件

概述

这是一个用于模拟和可视化快消品 (FMCG) 需求预测与库存优化流程的组件。用户可以选择不同的产品和区域,调整预测参数和库存策略,然后运行模拟以查看历史销售、预测需求以及基于目标安全库存的优化建议。

组件旨在帮助理解季节性对销售的影响、需求预测的基本方法以及如何根据预测和库存策略来指导补货决策。

功能特性

  • 参数化模拟:
    • 选择不同的模拟产品(如饮料、零食、洗护)和销售区域。
    • 调整预测期长度(未来预测多少周)。
    • 调整目标安全库存覆盖天数。
    • 调整用于预测的历史数据长度。
  • 数据生成与模拟:
    • 模拟生成带有明显季节性特征和随机波动的历史周销售数据。
    • 基于历史数据和季节性指数,使用简化的季节性移动平均法生成未来需求预测。
    • 模拟当前的库存水平(基于近期销售表现随机生成)。
  • 库存优化:
    • 根据预测期的平均需求和设定的安全库存天数,计算目标库存水平。
    • 比较当前库存与目标库存,生成明确的"增加库存"、"减少库存"或"库存平衡"的建议及数量。
  • 数据可视化:
    • 使用 Chart.js 动态绘制折线图,清晰展示历史销售曲线和预测需求曲线。
    • 在专门的面板中展示关键信息:当前库存、目标库存和优化建议。
    • 使用条形图直观表示当前库存相对于目标库存的水平。
  • 交互式体验:
    • 通过滑块和下拉菜单轻松调整模拟参数。
    • 参数变化实时反馈到界面。
    • 点击按钮触发预测和优化计算。
    • 清晰的状态指示(未运行、运行中、已完成)。
  • Appsmith 兼容性:
    • 采用健壮的初始化逻辑(多次尝试、延迟)以适应 Appsmith 自定义组件的加载环境。
    • 使用 container.querySelector 进行 DOM 元素查找。
    • 全面的错误处理和用户友好的错误消息提示。

文件结构

复制代码
/供应链管理组件
└── /fmcg-inventory-optimization
    ├── index.html         # 组件的 HTML 结构
    ├── styles.css         # 组件的 CSS 样式 (苹果科技工业风格)
    ├── script.js          # 组件的核心 JavaScript 逻辑
    └── README.md          # 本说明文件

使用方法

  1. index.html 的内容复制到 HTML 编辑器中。
  2. styles.css 的内容复制到 CSS 编辑器中。
  3. script.js 的内容复制到 JavaScript 编辑器中。
  4. 调整组件大小以适应内容展示,建议宽度较宽,高度适中(例如 600px)。

交互流程:

  1. 通过配置面板选择产品、区域,并使用滑块调整预测期、安全库存天数和历史数据长度。
  2. 点击"运行预测与优化"按钮。
  3. 组件状态将变为"运行中",然后计算完成变为"已完成"。
  4. 图表区域将显示历史销售和预测需求曲线。
  5. 库存状态面板将显示当前模拟库存、计算出的目标库存以及相应的增加/减少/平衡建议。

技术栈

  • HTML5
  • CSS3 (Flexbox, Grid)
  • JavaScript (ES6+)
  • Chart.js (用于图表绘制)
  • Chart.js Date Adapter (date-fns)
  • Font Awesome (用于图标)

模拟逻辑说明

  • 历史销售: BaseSales * SeasonalityIndex * (1 + RandomNoise)
  • 需求预测: 基于过去 N 周(例如 4 周)的去季节性销量估算基础销量,然后乘以未来周期的季节性指数。
  • 当前库存: 基于最近几周的平均销量乘以一个随机系数(0.5 到 2.5 之间)来模拟。
  • 目标库存: (未来 N 天的平均预测日销量) * 安全库存天数,其中 N 等于安全库存天数。
  • 优化建议: 基于当前库存与目标库存的差异百分比(阈值设为 +/- 10%)决定。

注意事项与限制

  • 这是一个 模拟 组件,所有数据(历史销售、当前库存)都是动态生成的,并非真实数据。
  • 预测模型(季节性移动平均)相对 简化,仅用于演示目的,实际应用中需要更复杂的模型(如 ARIMA, Prophet 等)。
  • 库存优化逻辑基于单一产品的安全库存策略,未考虑多产品关联、供应链延迟、最小订单量等复杂因素。
  • 组件的性能可能受历史数据量和预测期长度影响。

未来可扩展方向

  • 集成更高级的预测模型。
  • 支持多产品、多区域同时分析和优化。
  • 考虑补货提前期、运输成本等因素进行更复杂的库存策略模拟。
  • 允许用户上传或连接外部数据源。
  • 添加更详细的 KPI 指标,如缺货率、库存周转率等。

效果展示

源码

index.html

javascript 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>需求预测与库存优化 (快消品)</title>
    <link rel="stylesheet" href="styles.css">
    <!-- Chart.js for visualization -->
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
     <!-- Font Awesome for Icons -->
     <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
</head>
<body>
    <div class="app-container">
        <!-- Header -->
        <header class="app-header">
            <div class="header-title">
                <i class="fas fa-chart-pie header-icon"></i>
                <h1>需求预测与库存优化 (快消品)</h1>
            </div>
            <div class="simulation-controls">
                 <button id="run-simulation-btn" class="btn btn-primary"><i class="fas fa-cogs"></i> 运行预测与优化</button>
                 <span id="simulation-status" class="status-badge">状态: 未运行</span>
            </div>
        </header>

        <!-- Main Content Area -->
        <main class="app-content">
            <!-- Configuration Panel -->
            <section class="panel config-panel">
                <div class="panel-header">
                    <h2><i class="fas fa-sliders-h"></i> 参数配置</h2>
                </div>
                <div class="panel-content">
                    <div class="config-group">
                        <label for="product-select">产品选择:</label>
                        <select id="product-select">
                            <option value="P01">畅销饮料 A</option>
                            <option value="P02">热门零食 B</option>
                            <option value="P03">日用洗护 C</option>
                        </select>
                    </div>
                    <div class="config-group">
                        <label for="region-select">区域选择:</label>
                        <select id="region-select">
                            <option value="R01">华东区</option>
                            <option value="R02">华南区</option>
                            <option value="R03">华北区</option>
                            <option value="R04">西南区</option>
                        </select>
                    </div>
                     <div class="config-group">
                        <label for="forecast-horizon-slider">预测期 (周): <span id="forecast-horizon-value">12</span></label>
                        <input type="range" id="forecast-horizon-slider" min="4" max="24" value="12" step="1">
                    </div>
                     <div class="config-group">
                        <label for="safety-stock-days-slider">安全库存天数: <span id="safety-stock-days-value">14</span></label>
                        <input type="range" id="safety-stock-days-slider" min="7" max="30" value="14" step="1">
                    </div>
                     <div class="config-group">
                        <label for="historical-data-weeks-slider">历史数据 (周): <span id="historical-data-weeks-value">52</span></label>
                        <input type="range" id="historical-data-weeks-slider" min="26" max="104" value="52" step="1">
                    </div>
                </div>
            </section>

            <!-- History & Forecast Panel -->
            <section class="panel chart-panel">
                <div class="panel-header">
                    <h2><i class="fas fa-chart-line"></i> 历史销售与需求预测</h2>
                     <div class="chart-legend">
                        <span class="legend-item history"><i class="fas fa-circle"></i> 历史销量</span>
                        <span class="legend-item forecast"><i class="fas fa-circle"></i> 预测需求</span>
                    </div>
                </div>
                <div class="panel-content chart-container">
                    <canvas id="sales-forecast-chart"></canvas>
                     <p id="chart-placeholder" class="placeholder">请点击"运行预测与优化"生成图表。</p>
                </div>
            </section>

            <!-- Inventory & Optimization Panel -->
            <section class="panel inventory-panel">
                <div class="panel-header">
                    <h2><i class="fas fa-boxes"></i> 库存状态与优化建议</h2>
                </div>
                <div class="panel-content scrollable">
                     <div id="inventory-details">
                         <p class="placeholder">请选择产品和区域,并运行模拟。</p>
                        <!-- Inventory details and optimization suggestions will be loaded here -->
                        <!-- Example Structure:
                        <div class="inventory-item">
                            <h4>华东区 - 畅销饮料 A</h4>
                            <p>当前库存: <span class="current-stock">1500</span> | 目标库存: <span class="target-stock">2500</span></p>
                            <p class="optimization-suggestion increase">建议增加库存: <span class="suggestion-amount">1000</span></p>
                            <div class="inventory-bar">
                                <div class="bar-current" style="width: 60%;"></div>
                                <div class="bar-target" style="left: 100%;"></div>
                            </div>
                        </div>
                        -->
                    </div>
                </div>
            </section>
        </main>
    </div>

    <script src="script.js"></script>
</body>
</html> 

styles.css

javascript 复制代码
:root {
    --primary-color: #007aff; /* Apple Blue */
    --secondary-color: #5856d6; /* Apple Purple */
    --background-color: #f2f2f7; /* Apple Light Gray */
    --panel-background-color: #ffffff;
    --text-color: #1c1c1e; /* Near Black */
    --text-color-secondary: #8e8e93; /* Gray */
    --border-color: #d1d1d6;
    --success-color: #34c759; /* Apple Green */
    --warning-color: #ff9500; /* Apple Orange */
    --error-color: #ff3b30; /* Apple Red */
    --history-color: #007aff;
    --forecast-color: #ff9500;
    --inventory-current-color: #34c759;
    --inventory-target-color: #ff3b30;
    
    --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    --border-radius: 8px;
    --panel-padding: 15px;
    --panel-header-height: 45px;
}

* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

body {
    font-family: var(--font-family);
    background-color: var(--background-color);
    color: var(--text-color);
    line-height: 1.5;
    font-size: 14px;
    overflow-x: hidden; /* Prevent horizontal scroll */
}

.app-container {
    display: flex;
    flex-direction: column;
    height: 100vh; /* Fallback for older browsers */
    height: 100dvh; /* Dynamic viewport height */
    max-height: 800px; /* Limit height as requested */
    overflow: hidden; /* Prevent whole container scroll */
    background-color: var(--panel-background-color);
    border-radius: var(--border-radius);
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
    margin: 10px; /* Add some margin around the container */
}

/* Header */
.app-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 10px var(--panel-padding);
    border-bottom: 1px solid var(--border-color);
    background-color: #f8f8f8;
    border-top-left-radius: var(--border-radius);
    border-top-right-radius: var(--border-radius);
}

.header-title {
    display: flex;
    align-items: center;
}

.header-icon {
    font-size: 1.5em;
    color: var(--primary-color);
    margin-right: 10px;
}

.app-header h1 {
    font-size: 1.2em;
    font-weight: 600;
    color: var(--text-color);
}

.simulation-controls {
    display: flex;
    align-items: center;
    gap: 15px;
}

/* Buttons */
.btn {
    padding: 8px 15px;
    border: none;
    border-radius: 6px;
    cursor: pointer;
    font-size: 0.9em;
    font-weight: 500;
    transition: background-color 0.2s ease, transform 0.1s ease;
    display: inline-flex;
    align-items: center;
    gap: 5px;
}

.btn-primary {
    background-color: var(--primary-color);
    color: #fff;
}

.btn-primary:hover {
    background-color: #005ec4;
}

.btn:active {
    transform: scale(0.98);
}

.status-badge {
    font-size: 0.85em;
    padding: 4px 8px;
    border-radius: 4px;
    background-color: var(--text-color-secondary);
    color: #fff;
}

/* Main Content Area */
.app-content {
    display: grid;
    grid-template-columns: 2fr 3fr; /* Adjust ratio as needed */
    grid-template-rows: auto 1fr; /* Config row, then charts/inventory */
    grid-template-areas:
        "config chart"
        "inventory chart";
    gap: var(--panel-padding);
    padding: var(--panel-padding);
    flex-grow: 1;
    overflow: hidden; /* Prevent content scroll */
}

/* Panels */
.panel {
    background-color: var(--panel-background-color);
    border: 1px solid var(--border-color);
    border-radius: var(--border-radius);
    display: flex;
    flex-direction: column;
    overflow: hidden; /* Important for scrollable content */
}

.panel-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 10px var(--panel-padding);
    border-bottom: 1px solid var(--border-color);
    background-color: #f8f8f8;
    height: var(--panel-header-height);
    flex-shrink: 0;
}

.panel-header h2 {
    font-size: 1em;
    font-weight: 600;
    display: flex;
    align-items: center;
    gap: 8px;
}

.panel-header h2 i {
    color: var(--text-color-secondary);
}

.panel-content {
    padding: var(--panel-padding);
    flex-grow: 1;
    overflow-y: auto; /* Allow vertical scroll within content */
}

.scrollable {
    overflow-y: auto;
}

/* Specific Panel Assignments */
.config-panel {
    grid-area: config;
}

.inventory-panel {
    grid-area: inventory;
}

.chart-panel {
    grid-area: chart;
}

/* Configuration Styles */
.config-group {
    margin-bottom: 15px;
}

.config-group label {
    display: block;
    font-size: 0.9em;
    font-weight: 500;
    margin-bottom: 5px;
    color: var(--text-color-secondary);
}

.config-group select,
.config-group input[type="range"] {
    width: 100%;
    padding: 8px;
    border: 1px solid var(--border-color);
    border-radius: 6px;
    font-size: 0.9em;
    background-color: #fff;
}

.config-group input[type="range"] {
    padding: 0;
    height: 5px;
    cursor: pointer;
    accent-color: var(--primary-color);
}

.config-group label span {
    font-weight: bold;
    color: var(--text-color);
}

/* Chart Styles */
.chart-container {
    position: relative; /* Needed for placeholder */
    height: 100%; /* Ensure canvas can take height */
    min-height: 200px; /* Prevent collapsing */
}

#sales-forecast-chart {
    display: block;
    width: 100% !important;
    height: 100% !important;
}

.chart-legend {
    display: flex;
    gap: 15px;
    font-size: 0.8em;
    align-items: center;
}

.legend-item i {
    margin-right: 4px;
    font-size: 0.8em;
}
.legend-item.history i { color: var(--history-color); }
.legend-item.forecast i { color: var(--forecast-color); }

/* Inventory Styles */
#inventory-details .placeholder {
    text-align: center;
    color: var(--text-color-secondary);
    padding-top: 20px;
}

.inventory-item {
    border: 1px solid var(--border-color);
    border-radius: var(--border-radius);
    padding: 15px;
    margin-bottom: 15px;
    background-color: #f9f9f9;
}

.inventory-item h4 {
    font-size: 1.1em;
    font-weight: 600;
    margin-bottom: 10px;
}

.inventory-item p {
    font-size: 0.9em;
    margin-bottom: 5px;
    color: var(--text-color-secondary);
}

.inventory-item .current-stock,
.inventory-item .target-stock,
.inventory-item .suggestion-amount {
    font-weight: bold;
    color: var(--text-color);
}

.optimization-suggestion.increase {
    color: var(--success-color);
    font-weight: 500;
}

.optimization-suggestion.decrease {
    color: var(--error-color);
    font-weight: 500;
}

.optimization-suggestion.balanced {
    color: var(--text-color-secondary);
    font-weight: 500;
}

.inventory-bar {
    height: 8px;
    background-color: var(--background-color);
    border-radius: 4px;
    margin-top: 10px;
    position: relative;
    overflow: hidden;
}

.inventory-bar .bar-current {
    position: absolute;
    left: 0;
    top: 0;
    height: 100%;
    background-color: var(--inventory-current-color);
    border-radius: 4px;
    transition: width 0.5s ease;
}

.inventory-bar .bar-target {
    position: absolute;
    top: -2px; /* Slightly offset to be visible */
    bottom: -2px;
    width: 2px;
    background-color: var(--inventory-target-color);
    transition: left 0.5s ease;
}

/* Placeholder */
.placeholder {
    text-align: center;
    color: var(--text-color-secondary);
    font-style: italic;
    padding: 20px;
}

/* Responsive Adjustments */
@media (max-width: 768px) {
    .app-content {
        grid-template-columns: 1fr; /* Stack panels */
        grid-template-rows: auto auto auto; /* One row per panel type */
        grid-template-areas:
            "config"
            "chart"
            "inventory";
        overflow-y: auto; /* Allow scrolling on mobile */
    }

    .app-container {
        max-height: none; /* Allow full height on mobile */
         margin: 0;
         border-radius: 0;
    }
    
    .panel-content {
         min-height: 150px; /* Give panels some min height */
    }

     .chart-container {
         min-height: 250px; /* Make chart taller on mobile */
     }

    .app-header h1 {
        font-size: 1em;
    }
} 

script.js

javascript 复制代码
/* =============================================================================
   FMCG Demand Forecasting & Inventory Optimization - Script
   ============================================================================= */

// --- Configuration & Constants ---
const WEEKS_PER_YEAR = 52;
const DAYS_PER_WEEK = 7;

// --- Simulation Data (Product & Region Specific Base Sales & Seasonality) ---
const BASE_SALES = {
    P01: { R01: 1000, R02: 1200, R03: 800, R04: 600 },
    P02: { R01: 500, R02: 400, R03: 600, R04: 300 },
    P03: { R01: 700, R02: 800, R03: 500, R04: 450 },
};
// Simplified weekly seasonality index (0-51 weeks), peak around week 20-30
const SEASONALITY_INDEX = Array.from({ length: WEEKS_PER_YEAR }, (_, i) => {
    // Simple sine wave + noise for seasonality
    const peakWeek = 26;
    const troughWeek = 0;
    const amplitude = 0.6; // Peak is 1 + 0.6 = 1.6 times base
    const phaseShift = -Math.PI / 2; // Start near trough
    const index = 1 + amplitude * Math.sin((i / WEEKS_PER_YEAR) * 2 * Math.PI + phaseShift);
    return Math.max(0.4, index + (Math.random() - 0.5) * 0.2); // Add noise, ensure min 0.4
});

// --- Global State ---
let state = {
    selectedProduct: 'P01',
    selectedRegion: 'R01',
    forecastHorizonWeeks: 12,
    safetyStockDays: 14,
    historicalDataWeeks: 52,
    isRunning: false,
    historicalSales: [], // [{ date: Date, sales: number }]
    forecast: [],        // [{ date: Date, forecast: number }]
    currentInventory: 0,
    targetInventory: 0,
    optimizationSuggestion: { type: 'none', amount: 0 },
    chartInstance: null,
};

// --- DOM Elements Cache ---
const elements = {};

// =============================================================================
// Initialization (Appsmith Optimized)
// =============================================================================

let initializationAttempts = 0;
const MAX_INITIALIZATION_ATTEMPTS = 10;
const INITIALIZATION_RETRY_DELAY = 500;

function attemptInitialization() {
    console.log(`Attempting to initialize FMCG app (Attempt ${initializationAttempts + 1}/${MAX_INITIALIZATION_ATTEMPTS})`);
    if (document.readyState === 'loading') {
        console.log("Document still loading, waiting for DOMContentLoaded.");
        document.addEventListener('DOMContentLoaded', () => setTimeout(attemptInitialization, 100));
        return;
    }

    const appContainer = document.querySelector('.app-container');
    if (!appContainer) {
        initializationAttempts++;
        if (initializationAttempts < MAX_INITIALIZATION_ATTEMPTS) {
            console.log(`App container not found, retrying in ${INITIALIZATION_RETRY_DELAY}ms...`);
            setTimeout(attemptInitialization, INITIALIZATION_RETRY_DELAY);
        } else {
            console.error(`Failed to find app container after ${MAX_INITIALIZATION_ATTEMPTS} attempts.`);
            showErrorMessage("应用容器加载失败,请刷新重试。");
        }
        return;
    }

    console.log("App container found, proceeding with initialization.");
    initializeApp(appContainer);
}

function initializeApp(container) {
    console.log("Initializing FMCG App...");
    try {
        if (!queryDOMElements(container)) {
            console.error("Initialization failed: Missing essential DOM elements.");
            showErrorMessage("界面元素加载不完整,请刷新重试。");
            return;
        }
        setupEventListeners();
        updateConfigDisplay(); // Update display based on initial state
        renderPlaceholder(); // Show initial placeholder message
        console.log("FMCG App Initialized.");
    } catch (error) {
        console.error("Error during app initialization:", error);
        showErrorMessage(`初始化出错: ${error.message}`);
    }
}

function queryDOMElements(container) {
    console.log("Querying DOM elements...");
    const ids = [
        'run-simulation-btn',
        'simulation-status',
        'product-select',
        'region-select',
        'forecast-horizon-slider',
        'forecast-horizon-value',
        'safety-stock-days-slider',
        'safety-stock-days-value',
        'historical-data-weeks-slider',
        'historical-data-weeks-value',
        'sales-forecast-chart',
        'chart-placeholder',
        'inventory-details'
    ];
    let allFound = true;
    elements.container = container; // Store container reference

    ids.forEach(id => {
        // Try finding by ID first, then fallback to more specific selectors if needed
        elements[id] = container.querySelector(`#${id}`);
        if (!elements[id]) {
            console.warn(`DOM element not found: #${id}`);
            // Attempt fallback selectors for specific elements if necessary
             if (id === 'sales-forecast-chart') elements[id] = container.querySelector('canvas');
             // Add more fallbacks if needed
             if (!elements[id]) {
                 allFound = false;
             }
        }
    });

     if (!allFound) {
         console.error("关键 DOM 元素缺失,请检查 HTML 结构。 Missing elements logged above.");
         return false;
     }
     console.log("DOM elements queried successfully.");
     return true;
}

function setupEventListeners() {
    console.log("Setting up event listeners...");
    try {
        if (elements['run-simulation-btn']) {
            elements['run-simulation-btn'].addEventListener('click', handleRunSimulation);
        } else console.error("run-simulation-btn not found for event listener");

        // Config sliders
        if (elements['forecast-horizon-slider']) {
            elements['forecast-horizon-slider'].addEventListener('input', handleConfigChange);
        } else console.error("forecast-horizon-slider not found");
        if (elements['safety-stock-days-slider']) {
            elements['safety-stock-days-slider'].addEventListener('input', handleConfigChange);
        } else console.error("safety-stock-days-slider not found");
        if (elements['historical-data-weeks-slider']) {
            elements['historical-data-weeks-slider'].addEventListener('input', handleConfigChange);
        } else console.error("historical-data-weeks-slider not found");

        // Config selects
        if (elements['product-select']) {
            elements['product-select'].addEventListener('change', handleConfigChange);
        } else console.error("product-select not found");
        if (elements['region-select']) {
            elements['region-select'].addEventListener('change', handleConfigChange);
        } else console.error("region-select not found");

        console.log("Event listeners set up.");
    } catch (error) {
        console.error("Error setting up event listeners:", error);
    }
}

// =============================================================================
// Event Handlers
// =============================================================================

function handleConfigChange(event) {
    console.log("Configuration changed:", event.target.id);
    try {
        // Update state based on which element triggered the change
        switch (event.target.id) {
            case 'product-select':
                state.selectedProduct = event.target.value;
                break;
            case 'region-select':
                state.selectedRegion = event.target.value;
                break;
            case 'forecast-horizon-slider':
                state.forecastHorizonWeeks = parseInt(event.target.value);
                break;
            case 'safety-stock-days-slider':
                state.safetyStockDays = parseInt(event.target.value);
                break;
            case 'historical-data-weeks-slider':
                state.historicalDataWeeks = parseInt(event.target.value);
                break;
        }
        updateConfigDisplay();
        // Optionally, clear results or re-run simulation automatically?
        // For now, require manual click on "Run"
        renderPlaceholder(); // Clear results on config change
    } catch (error) {
        console.error("Error handling config change:", error);
    }
}

function handleRunSimulation() {
    console.log("Run Simulation button clicked.");
    if (state.isRunning) {
        console.log("Simulation already running.");
        return;
    }
    state.isRunning = true;
    updateStatus('运行中...');
    disableControls(true);

    // Simulate the process asynchronously
    setTimeout(() => {
        try {
            console.log("Starting simulation logic...");
            // 1. Generate historical data
            state.historicalSales = generateHistoricalSales(
                state.selectedProduct,
                state.selectedRegion,
                state.historicalDataWeeks
            );
            console.log(`Generated ${state.historicalSales.length} weeks of historical data.`);

            // 2. Generate forecast
            state.forecast = generateDemandForecast(
                state.historicalSales,
                state.forecastHorizonWeeks
            );
            console.log(`Generated ${state.forecast.length} weeks of forecast data.`);

            // 3. Simulate current inventory (e.g., based on last few weeks sales)
            state.currentInventory = simulateCurrentInventory(state.historicalSales);
            console.log(`Simulated current inventory: ${state.currentInventory}`);

            // 4. Calculate target inventory & optimization
            const optimizationResult = calculateInventoryOptimization(
                state.forecast,
                state.safetyStockDays,
                state.currentInventory
            );
            state.targetInventory = optimizationResult.target;
            state.optimizationSuggestion = optimizationResult.suggestion;
            console.log(`Calculated target inventory: ${state.targetInventory}, Suggestion: ${optimizationResult.suggestion.type} ${optimizationResult.suggestion.amount}`);

            // 5. Render results
            renderResults();
            console.log("Simulation and rendering complete.");

        } catch (error) {
            console.error("Error during simulation run:", error);
            showErrorMessage(`模拟运行时出错: ${error.message}`);
            renderPlaceholder(); // Show placeholder on error
        } finally {
            state.isRunning = false;
            updateStatus('已完成');
            disableControls(false);
        }
    }, 50); // Small delay to allow UI update for status
}

// =============================================================================
// Simulation Logic
// =============================================================================

function generateHistoricalSales(productId, regionId, weeks) {
    const salesData = [];
    const base = BASE_SALES[productId]?.[regionId] || 500; // Default base sales
    const today = new Date();
    today.setHours(0, 0, 0, 0);

    for (let i = weeks; i > 0; i--) {
        const date = new Date(today);
        date.setDate(today.getDate() - i * DAYS_PER_WEEK);
        const weekOfYear = getWeekOfYear(date);
        const seasonality = SEASONALITY_INDEX[weekOfYear];
        // Add random noise
        const noise = 1 + (Math.random() - 0.5) * 0.3; // +/- 15% noise
        const sales = Math.max(0, Math.round(base * seasonality * noise));
        salesData.push({ date, sales });
    }
    return salesData;
}

function generateDemandForecast(historicalSales, forecastWeeks) {
    const forecastData = [];
    if (!historicalSales || historicalSales.length === 0) return forecastData;

    const lastHistoricalDate = historicalSales[historicalSales.length - 1].date;
    const historicalValues = historicalSales.map(d => d.sales);

    // Simple Seasonal Moving Average (using full cycle for seasonality index)
    const cycleLength = WEEKS_PER_YEAR;
    const movingAveragePeriod = 4; // Use 4-week moving average

    for (let i = 1; i <= forecastWeeks; i++) {
        const forecastDate = new Date(lastHistoricalDate);
        forecastDate.setDate(lastHistoricalDate.getDate() + i * DAYS_PER_WEEK);
        const forecastWeekOfYear = getWeekOfYear(forecastDate);
        const forecastSeasonality = SEASONALITY_INDEX[forecastWeekOfYear];

        // Calculate base using recent historical data, adjusted for seasonality
        let recentBaseSum = 0;
        let count = 0;
        for (let j = 1; j <= movingAveragePeriod; j++) {
            if (historicalSales.length >= j) {
                const pastData = historicalSales[historicalSales.length - j];
                const pastWeekOfYear = getWeekOfYear(pastData.date);
                const pastSeasonality = SEASONALITY_INDEX[pastWeekOfYear];
                if (pastSeasonality > 0.1) { // Avoid division by zero/small numbers
                    recentBaseSum += pastData.sales / pastSeasonality;
                    count++;
                }
            }
        }
        const estimatedBase = count > 0 ? recentBaseSum / count : (BASE_SALES[state.selectedProduct]?.[state.selectedRegion] || 500);

        const forecastValue = Math.max(0, Math.round(estimatedBase * forecastSeasonality));
        forecastData.push({ date: forecastDate, forecast: forecastValue });
    }
    return forecastData;
}

function simulateCurrentInventory(historicalSales) {
    // Simple simulation: Assume current inventory is related to recent sales volatility + a base level
    if (!historicalSales || historicalSales.length < 4) return 500; // Default if not enough data

    const recentSales = historicalSales.slice(-4).map(d => d.sales);
    const avgRecentSales = recentSales.reduce((a, b) => a + b, 0) / recentSales.length;
    // Simulate current inventory between 0.5 and 2.5 weeks of average recent sales
    const inventoryWeeks = 0.5 + Math.random() * 2;
    return Math.max(0, Math.round(avgRecentSales * inventoryWeeks));
}

function calculateInventoryOptimization(forecast, safetyStockDays, currentInventory) {
    if (!forecast || forecast.length === 0) {
        return { target: currentInventory, suggestion: { type: 'none', amount: 0 } };
    }

    // Calculate total forecast demand over the safety stock period
    const safetyStockWeeks = safetyStockDays / DAYS_PER_WEEK;
    const relevantForecast = forecast.slice(0, Math.ceil(safetyStockWeeks));
    const avgWeeklyForecast = relevantForecast.reduce((sum, item) => sum + item.forecast, 0) / relevantForecast.length;

    // Target inventory = Safety Stock Demand
    // Simplified: Average weekly forecast * safety stock weeks
    const target = Math.round(avgWeeklyForecast * safetyStockWeeks);

    const difference = target - currentInventory;
    let suggestionType = 'balanced';
    let suggestionAmount = 0;

    if (difference > target * 0.1) { // Need > 10% increase
        suggestionType = 'increase';
        suggestionAmount = difference;
    } else if (difference < -target * 0.1) { // Need > 10% decrease
        suggestionType = 'decrease';
        suggestionAmount = Math.abs(difference);
    } else {
        suggestionType = 'balanced';
        suggestionAmount = Math.abs(difference);
    }

    return {
        target: target,
        suggestion: {
            type: suggestionType,
            amount: Math.round(suggestionAmount)
        }
    };
}

// =============================================================================
// UI Rendering
// =============================================================================

function renderResults() {
    console.log("Rendering results...");
    try {
        if (elements['chart-placeholder']) {
             elements['chart-placeholder'].style.display = 'none';
         }
         if (elements['sales-forecast-chart']) {
             elements['sales-forecast-chart'].style.display = 'block';
         }
        renderChart();
        renderInventoryDetails();
    } catch (error) {
        console.error("Error rendering results:", error);
    }
}

function renderPlaceholder() {
     console.log("Rendering placeholder...");
     try {
         if (elements['chart-placeholder']) {
             elements['chart-placeholder'].style.display = 'block';
             elements['chart-placeholder'].textContent = '请点击"运行预测与优化"生成图表和建议。';
         }
         if (elements['sales-forecast-chart'] && state.chartInstance) {
             state.chartInstance.destroy();
             state.chartInstance = null;
             elements['sales-forecast-chart'].style.display = 'none';
         }
         if (elements['inventory-details']) {
             elements['inventory-details'].innerHTML = '<p class="placeholder">请选择产品和区域,并运行模拟。</p>';
         }
     } catch (error) {
         console.error("Error rendering placeholder:", error);
     }
 }

function renderChart() {
    console.log("Rendering chart...");
    try {
        const ctx = elements['sales-forecast-chart']?.getContext('2d');
        if (!ctx) {
            console.error("Failed to get chart canvas context.");
            return;
        }

        const historicalData = state.historicalSales.map(d => ({ x: d.date.getTime(), y: d.sales }));
        const forecastData = state.forecast.map(d => ({ x: d.date.getTime(), y: d.forecast }));

        // Combine dates for the x-axis labels
        const allDates = [...state.historicalSales.map(d => d.date), ...state.forecast.map(d => d.date)];

        if (state.chartInstance) {
            state.chartInstance.destroy();
        }

        state.chartInstance = new Chart(ctx, {
            type: 'line',
            data: {
                datasets: [
                    {
                        label: '历史销量',
                        data: historicalData,
                        borderColor: 'var(--history-color)',
                        backgroundColor: 'rgba(0, 122, 255, 0.1)',
                        borderWidth: 2,
                        pointRadius: 1,
                        tension: 0.1
                    },
                    {
                        label: '预测需求',
                        data: forecastData,
                        borderColor: 'var(--forecast-color)',
                        backgroundColor: 'rgba(255, 149, 0, 0.1)',
                        borderWidth: 2,
                        pointRadius: 1,
                        borderDash: [5, 5], // Dashed line for forecast
                        tension: 0.1
                    }
                ]
            },
            options: {
                responsive: true,
                maintainAspectRatio: false,
                scales: {
                    x: {
                        type: 'time',
                        time: {
                            unit: 'month',
                            tooltipFormat: 'yyyy-MM-dd',
                            displayFormats: {
                                month: 'yyyy-MM'
                            }
                        },
                        title: {
                            display: true,
                            text: '时间'
                        }
                    },
                    y: {
                        beginAtZero: true,
                        title: {
                            display: true,
                            text: '销量/预测量'
                        }
                    }
                },
                plugins: {
                    tooltip: {
                        mode: 'index',
                        intersect: false,
                    },
                    legend: {
                        display: false // Use custom legend in HTML
                    }
                },
                 animation: {
                     duration: 500 // Add subtle animation
                 }
            }
        });
        console.log("Chart rendered.");
    } catch (error) {
        console.error("Error rendering chart:", error);
    }
}

function renderInventoryDetails() {
    console.log("Rendering inventory details...");
    try {
        const container = elements['inventory-details'];
        if (!container) {
            console.error("inventory-details element not found for rendering.");
            return;
        }

        const product = state.selectedProduct; // Use actual product name later if needed
        const region = state.selectedRegion; // Use actual region name later

        const current = state.currentInventory;
        const target = state.targetInventory;
        const suggestion = state.optimizationSuggestion;

        let suggestionText = '';
        let suggestionClass = suggestion.type;
        if (suggestion.type === 'increase') {
            suggestionText = `建议增加库存: <span class="suggestion-amount">${suggestion.amount}</span>`;
        } else if (suggestion.type === 'decrease') {
            suggestionText = `建议减少库存: <span class="suggestion-amount">${suggestion.amount}</span>`;
        } else {
            suggestionText = `库存水平良好 (差异: <span class="suggestion-amount">${suggestion.amount}</span>)`;
        }

        // Calculate bar percentages (relative to target or max of current/target?)
        // Let's make the bar width represent target*1.5 for visual space
        const maxBarValue = Math.max(current, target) * 1.5;
        const currentPercent = maxBarValue > 0 ? (current / maxBarValue) * 100 : 0;
        const targetPercent = maxBarValue > 0 ? (target / maxBarValue) * 100 : 0;

        container.innerHTML = `
            <div class="inventory-item">
                <h4>${escapeHtml(region)} - ${escapeHtml(product)}</h4>
                <p>当前模拟库存: <span class="current-stock">${current}</span> | 目标库存 (基于${state.safetyStockDays}天安全库存): <span class="target-stock">${target}</span></p>
                <p class="optimization-suggestion ${suggestionClass}">${suggestionText}</p>
                <div class="inventory-bar" title="当前: ${current} / 目标: ${target}">
                    <div class="bar-current" style="width: ${Math.min(100, currentPercent)}%;"></div>
                    <div class="bar-target" style="left: ${Math.min(100, targetPercent)}%;" title="目标库存线"></div>
                </div>
            </div>
        `;
        console.log("Inventory details rendered.");
    } catch (error) {
        console.error("Error rendering inventory details:", error);
    }
}

function updateConfigDisplay() {
    console.log("Updating config display values...");
    try {
        if (elements['forecast-horizon-value']) {
            elements['forecast-horizon-value'].textContent = state.forecastHorizonWeeks;
        } else console.warn("forecast-horizon-value element not found");

        if (elements['safety-stock-days-value']) {
            elements['safety-stock-days-value'].textContent = state.safetyStockDays;
        } else console.warn("safety-stock-days-value element not found");

        if (elements['historical-data-weeks-value']) {
            elements['historical-data-weeks-value'].textContent = state.historicalDataWeeks;
        } else console.warn("historical-data-weeks-value element not found");
    } catch (error) {
        console.error("Error updating config display:", error);
    }
}

function updateStatus(message) {
     try {
         if (elements['simulation-status']) {
             elements['simulation-status'].textContent = `状态: ${message}`;
             if (message === '运行中...') {
                 elements['simulation-status'].style.backgroundColor = 'var(--warning-color)';
             } else if (message === '已完成') {
                  elements['simulation-status'].style.backgroundColor = 'var(--success-color)';
             } else {
                  elements['simulation-status'].style.backgroundColor = 'var(--text-color-secondary)';
             }
         } else {
              console.warn("simulation-status element not found to update status");
         }
     } catch (error) {
         console.error("Error updating status display:", error);
     }
 }

function disableControls(disabled) {
     try {
         if (elements['run-simulation-btn']) elements['run-simulation-btn'].disabled = disabled;
         if (elements['product-select']) elements['product-select'].disabled = disabled;
         if (elements['region-select']) elements['region-select'].disabled = disabled;
         if (elements['forecast-horizon-slider']) elements['forecast-horizon-slider'].disabled = disabled;
         if (elements['safety-stock-days-slider']) elements['safety-stock-days-slider'].disabled = disabled;
         if (elements['historical-data-weeks-slider']) elements['historical-data-weeks-slider'].disabled = disabled;
     } catch (error) {
         console.error("Error disabling/enabling controls:", error);
     }
 }

// =============================================================================
// Utility Functions
// =============================================================================

function getWeekOfYear(date) {
    const startOfYear = new Date(date.getFullYear(), 0, 1);
    const diff = date - startOfYear;
    const oneWeek = 1000 * 60 * 60 * 24 * 7;
    const weekNumber = Math.floor(diff / oneWeek);
    return weekNumber % WEEKS_PER_YEAR; // Ensure it wraps around for seasonality index
}

function escapeHtml(input) {
    if (input === null || input === undefined) return '';
    const str = String(input);
    const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' };
    return str.replace(/[&<>"']/g, (match) => map[match]);
}

function showErrorMessage(message) {
    console.error("Displaying error message to user:", message);
    try {
        const container = elements.container || document.body;
        let errorDiv = container.querySelector('.app-error-message');
        if (!errorDiv) {
            errorDiv = document.createElement('div');
            errorDiv.className = 'app-error-message';
            errorDiv.style.cssText = `
                position: absolute; /* Position within container */
                top: 50%;
                left: 50%;
                transform: translate(-50%, -50%);
                background-color: #f8d7da;
                color: #721c24;
                padding: 20px;
                border-radius: 5px;
                border: 1px solid #f5c6cb;
                box-shadow: 0 2px 4px rgba(0,0,0,0.1);
                max-width: 90%;
                text-align: center;
                z-index: 1000;
            `;
            container.appendChild(errorDiv);
        }
         errorDiv.innerHTML = `
            <h4 style="margin-bottom:10px; color:#721c24;">发生错误</h4>
            <p style="margin-bottom:15px; font-size:0.9em;">${escapeHtml(message)}</p>
            <button onclick="this.parentElement.style.display='none'" style="padding: 5px 10px; background:#dc3545; color:white; border:none; border-radius:4px; cursor:pointer;">关闭</button>
        `;
         errorDiv.style.display = 'block';
    } catch (error) {
        console.error("无法显示错误消息:", error);
        alert("发生严重错误,且无法显示错误提示。请检查控制台。");
    }
}

// --- Start Initialization Process ---
if (document.readyState === 'complete') {
    console.log("Document already complete, attempting initialization shortly.");
    setTimeout(attemptInitialization, 50);
} else {
    window.addEventListener('load', () => {
         console.log("Window load event fired, attempting initialization shortly.");
         setTimeout(attemptInitialization, 50);
     });
     // Fallback in case load doesn't fire or is missed
     document.addEventListener('DOMContentLoaded', () => {
        console.log("DOMContentLoaded event fired, attempting initialization shortly if not already done.");
        setTimeout(attemptInitialization, 100);
     });
} 
相关推荐
一只小风华~23 分钟前
CSS @media 媒体查询
前端·css·媒体
shix .1 小时前
最近 | 黄淮教务 | 小工具合集
前端·javascript
John_ToDebug1 小时前
Chrome 内置扩展 vs WebUI:浏览器内核开发中的选择与实践
前端·c++·chrome
烛阴2 小时前
解锁动态键:TypeScript 索引签名完全指南
前端·javascript·typescript
上单带刀不带妹2 小时前
ES6 中的 Proxy 全面讲解
前端·ecmascript·es6·proxy
WSSWWWSSW3 小时前
Seaborn数据可视化实战:Seaborn时间序列可视化入门
python·信息可视化·数据分析·matplotlib·seaborn
WSSWWWSSW3 小时前
Seaborn数据可视化实战:Seaborn入门-环境搭建与基础操作
信息可视化
云天徽上3 小时前
【数据可视化-96】使用 Pyecharts 绘制主题河流图(ThemeRiver):步骤与数据组织形式
开发语言·python·信息可视化·数据分析·pyecharts
没有梦想的咸鱼185-1037-16633 小时前
SWMM排水管网水力、水质建模及在海绵与水环境中的应用
数据仓库·人工智能·数据挖掘·数据分析