34、扩展仓储管理系统 (跨境汽车零部件模拟) - /物流与仓储组件/extended-warehouse-management
76个工业组件库示例汇总
扩展仓储管理系统 (跨境汽车零部件模拟)
概述
这是一个高级的仓储管理系统 (WMS) 模拟组件,专为展示跨境汽车零部件的复杂物流场景而设计。它模拟了从海外供应商发货,经过海运/空运、清关、质检,到最终入库上架,以及接收国内订单、拣货、包装、报关并最终发运的完整流程。
此组件通过可视化界面动态展示关键流程节点的状态变化、库存变化以及相关 KPI,旨在帮助理解跨境仓储操作中的挑战和管理要点。
请注意: 这是一个概念性的模拟演示,流程时间和库存管理逻辑被高度简化,旨在教学和演示,并非精确的运营优化工具。
主要功能
- 端到端流程模拟:
- 入库: 模拟从海外(如德国、日本等)生成入库运输单,经历"运输中" -> “已到港” -> “清关中”(模拟延迟) -> “待检验”(模拟延迟) -> “待入库” -> "已入库"的全过程。
- 出库: 模拟接收国内工厂或售后中心的出库订单,经历"待处理" -> (库存检查与分配) -> “待拣货” -> “拣货中”(模拟延迟) -> “包装中”(模拟延迟) -> “待报关”(模拟延迟) -> "已发运"的全过程。
- 库存管理与可视化:
- 按 SKU (零件号) 追踪库存,包含物料描述和模拟价值。
- 模拟库存状态(可用、已分配、待检等)和批次信息。
- 提供库存列表展示,支持按零件号搜索。
- 提供简化的仓库布局可视化,示意性展示各区域的库存占用情况。
- 仪表盘与 KPI:
- 实时显示关键绩效指标 (KPI),包括模拟的总库存价值、总 SKU 数量、待处理入/出库单量、清关中/待检批次数。
- 模拟控制:
- 启动/暂停/恢复/重置: 完全控制模拟的运行状态。
- 速度调节: 加快或减慢模拟时间流逝速度。
- 手动触发: 可在模拟运行时手动生成新的入库单或出库单,以观察特定情况下的系统响应。
- 交互式界面:
- 表格视图: 清晰展示入库单、出库单和库存详情。
- 详情展示: 点击单据旁的"详情"按钮可查看该单据包含的具体物料和当前状态。
- 事件日志: 实时记录模拟过程中发生的关键事件,如单据生成、状态变更、入库/分配操作等。
- 风格: 采用苹果科技工业风格,界面简洁、专业,并具备响应式布局。
如何使用
- 打开页面: 在现代浏览器中打开
index.html
文件。 - 控制模拟:
- 点击 “开始模拟” (
startSimBtn
) 启动。系统会自动开始生成入库和出库单,并按模拟时间推进流程。 - 点击 “暂停模拟” (
pauseSimBtn
) 暂停,按钮变为"恢复模拟"。 - 点击 “恢复模拟” (原"开始模拟"按钮) 继续运行。
- 点击 “重置模拟” (
resetSimBtn
) 清除所有数据和状态,返回初始界面。 - 拖动 “速度” (
simSpeedSlider
) 滑块调整模拟速度。 - 点击 “模拟新入库单” (
addInboundBtn
) 或 “模拟新出库单” (addOutboundBtn
) 手动添加随机单据。
- 点击 “开始模拟” (
- 观察与交互:
- 在 “概览仪表盘” 查看实时 KPI。
- 在 “入库管理” 和 “出库管理” 区域的表格中跟踪单据状态,点击"详情"查看具体物料。
- 在 “库存视图与管理” 区域:
- 查看 “仓库概览 (模拟)” 中各区域的库存填充情况。
- 浏览 “库存列表”,查看各 SKU 在不同库位、不同批次、不同状态下的数量。
- 使用搜索框 (
inventorySearchInput
) 按零件号过滤库存列表。
- 在 “事件日志” 区域查看模拟过程中的详细步骤和信息。
文件结构
extended-warehouse-management/
├── index.html # 组件的 HTML 结构
├── styles.css # CSS 样式定义 (苹果科技工业风格)
├── script.js # JavaScript 模拟逻辑与交互
└── README.md # 本说明文件
技术栈
- HTML5: 结构
- CSS3: 样式 (Flexbox, Grid, Variables, Responsive Design)
- JavaScript (ES6+): 核心模拟逻辑 (状态机, 时间推进), DOM 操作, 事件处理
注意事项
- 简化模型: 库存分配 (FIFO)、库位选择、运输时间、处理时间均为高度简化或随机生成。未考虑实际库容、拣货路径优化、并发冲突等复杂因素。
- 非持久化: 所有数据仅在浏览器当前会话中有效,刷新页面将丢失所有模拟状态。
- 性能: 大量单据和库存条目可能会影响渲染性能,UI 更新频率在代码中可以调整。
- 可视化: 仓库布局可视化仅为示意,未精确反映物理布局或空间占用。
效果展示
源码
index.html
<!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">
</head>
<body><div class="wms-container"><header class="wms-header"><h1>扩展仓储管理系统 (跨境汽车零部件)</h1><div class="header-controls"><button id="startSimBtn" class="control-button">开始</button><button id="pauseSimBtn" class="control-button" disabled>暂停</button><button id="resetSimBtn" class="control-button" disabled>重置</button><div class="speed-control"><label for="simSpeedSlider">速度:</label><input type="range" id="simSpeedSlider" min="0.5" max="5" step="0.5" value="1"><span id="simSpeedValue">1x</span></div></div></header><main class="wms-main-content"><!-- Section 1: Dashboard --><section class="dashboard-section panel"><h2>概览仪表盘</h2><div class="dashboard-grid"><div class="kpi-card"><span class="kpi-title">总库存价值 ()</span><span class="kpi-value" id="kpiTotalValue">¥ --</span></div><div class="kpi-card"><span class="kpi-title">总 SKU 数量</span><span class="kpi-value" id="kpiTotalSKU">--</span></div><div class="kpi-card"><span class="kpi-title">待处理入库单</span><span class="kpi-value" id="kpiPendingInbound">--</span></div><div class="kpi-card"><span class="kpi-title">待处理出库单</span><span class="kpi-value" id="kpiPendingOutbound">--</span></div><div class="kpi-card"><span class="kpi-title">清关中物料 (批次)</span><span class="kpi-value" id="kpiCustomsHold">--</span></div><div class="kpi-card"><span class="kpi-title">待检验物料 (批次)</span><span class="kpi-value" id="kpiQIHold">--</span></div></div></section><!-- Section 2: Inbound Management --><section class="inbound-section panel"><h2>入库管理</h2><div class="section-controls"><button id="addInboundBtn" class="small-button">新入库单</button></div><div class="table-container scrollable"><table id="inboundTable"><thead><tr><th>入库单号</th><th>来源地</th><th>预计到达</th><th>状态</th><th>操作</th></tr></thead><tbody><!-- Inbound shipment rows will be added here --></tbody></table></div><div id="inboundDetail" class="detail-view"><!-- Details of selected inbound shipment --><p>选择一个入库单查看详情。</p></div></section><!-- Section 3: Inventory View --><section class="inventory-section panel"><h2>库存视图与管理</h2><div class="section-controls inventory-controls"><input type="text" id="inventorySearchInput" placeholder="搜索零件号或库位..."><button id="inventorySearchBtn" class="small-button">搜索</button></div><div class="inventory-layout"><div class="warehouse-vis scrollable" id="warehouseVisualization"><!-- Warehouse layout visualization (e.g., using divs for zones/racks) --><p>仓库布局可视化区域</p></div><div class="inventory-list scrollable"><h3>库存列表</h3><div class="table-container"><table id="inventoryTable"><thead><tr><th>零件号</th><th>库位</th><th>数量</th><th>状态</th><th>批次号</th></tr></thead><tbody id="inventoryListBody"><!-- Inventory items will be listed here --></tbody></table></div></div></div></section><!-- Section 4: Outbound Management --><section class="outbound-section panel"><h2>出库管理</h2><div class="section-controls"><button id="addOutboundBtn" class="small-button">新出库单</button></div><div class="table-container scrollable"><table id="outboundTable"><thead><tr><th>出库单号</th><th>目的地</th><th>请求日期</th><th>状态</th><th>操作</th></tr></thead><tbody><!-- Outbound order rows will be added here --></tbody></table></div><div id="outboundDetail" class="detail-view"><!-- Details of selected outbound order --><p>选择一个出库单查看详情。</p></div></section><!-- Section 5: Event Log --><section class="event-log-section panel scrollable"><h2>事件日志</h2><ul id="eventLogList"><!-- Log messages will appear here --><li>系统已初始化。</li></ul></section></main><footer class="wms-footer"><span id="simulationStatus">状态: 空闲</span></footer></div><script src="script.js"></script>
</body>
</html>
styles.css
:root {--background-color: #f5f5f7;--panel-background: #ffffff;--header-background: #e9ecef; /* Slightly darker header */--footer-background: #e9ecef;--text-primary: #1d1d1f;--text-secondary: #515154;--text-light: #86868b;--border-color: #d2d2d7;--accent-blue: #007aff;--accent-blue-hover: #005ecf;--accent-green: #34c759;--accent-yellow: #ffcc00;--accent-red: #ff3b30;--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--border-radius: 8px;--panel-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0,0,0,0.05);--spacing-unit: 16px;
}*,
*::before,
*::after {box-sizing: border-box;margin: 0;padding: 0;
}body {font-family: var(--font-family);background-color: var(--background-color);color: var(--text-primary);line-height: 1.5;margin: 0;padding: var(--spacing-unit);font-size: 14px; /* Slightly smaller base font */
}.wms-container {display: flex;flex-direction: column;max-width: 1600px; /* Limit max width for very large screens */margin: 0 auto;background-color: var(--panel-background);border-radius: var(--border-radius);box-shadow: var(--panel-shadow);overflow: hidden; /* Contain child elements */
}.wms-header {background-color: var(--header-background);padding: calc(var(--spacing-unit) * 0.75) var(--spacing-unit);border-bottom: 1px solid var(--border-color);display: flex;justify-content: space-between;align-items: center;flex-wrap: wrap; /* Allow controls to wrap on small screens */
}.wms-header h1 {font-size: 1.2em;font-weight: 600;color: var(--text-primary);margin: 0;margin-right: var(--spacing-unit);
}.header-controls {display: flex;align-items: center;gap: calc(var(--spacing-unit) * 0.75);flex-wrap: wrap; /* Ensure controls wrap nicely */
}.control-button,
.small-button {padding: 8px 16px;font-size: 0.9em;font-weight: 500;border: none;border-radius: 6px;cursor: pointer;transition: background-color 0.2s ease, color 0.2s ease, opacity 0.2s ease;background-color: var(--accent-blue);color: white;
}.control-button:hover,
.small-button:hover {background-color: var(--accent-blue-hover);
}.control-button:disabled,
.small-button:disabled {background-color: #a8a8aa; /* Muted background for disabled */cursor: not-allowed;opacity: 0.7;
}.small-button {padding: 6px 12px;font-size: 0.85em;
}.speed-control {display: flex;align-items: center;gap: 8px;font-size: 0.9em;color: var(--text-secondary);
}#simSpeedSlider {width: 100px;cursor: pointer;
}#simSpeedValue {font-weight: 500;min-width: 25px; /* Ensure space for value */text-align: right;
}.wms-main-content {display: grid;/* Define grid layout - Adapt based on desired flow */grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); /* Flexible columns */gap: var(--spacing-unit);padding: var(--spacing-unit);/* Limit overall height and allow scrolling within main if needed, but prefer scrolling within sections *//* max-height: calc(100vh - 150px); /* Example: adjust based on header/footer/padding *//* overflow-y: auto; */
}.panel {background-color: var(--panel-background);border: 1px solid var(--border-color);border-radius: var(--border-radius);padding: var(--spacing-unit);display: flex;flex-direction: column;/* Give panels a default min-height, but let them grow */min-height: 250px;max-height: 500px; /* Set a max height for panels */overflow: hidden; /* Hide overflow, use scrollable inner divs */
}.panel h2 {font-size: 1.1em;font-weight: 600;margin-bottom: var(--spacing-unit);padding-bottom: calc(var(--spacing-unit) / 2);border-bottom: 1px solid var(--border-color);color: var(--text-primary);
}.scrollable {overflow-y: auto;flex-grow: 1; /* Allow scrollable areas to fill available space *//* Custom scrollbar styling (optional, webkit specific) */&::-webkit-scrollbar {width: 6px;}&::-webkit-scrollbar-track {background: #f1f1f1;border-radius: 3px;}&::-webkit-scrollbar-thumb {background: #c1c1c1;border-radius: 3px;}&::-webkit-scrollbar-thumb:hover {background: #a8a8a8;}
}/* Dashboard */
.dashboard-grid {display: grid;grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));gap: var(--spacing-unit);
}.kpi-card {background-color: #f9f9fb; /* Slightly off-white */border: 1px solid var(--border-color);border-radius: 6px;padding: calc(var(--spacing-unit) * 0.75);display: flex;flex-direction: column;justify-content: space-between;
}.kpi-title {font-size: 0.85em;color: var(--text-secondary);margin-bottom: 8px;
}.kpi-value {font-size: 1.4em;font-weight: 600;color: var(--text-primary);
}/* Inbound / Outbound Sections */
.section-controls {margin-bottom: var(--spacing-unit);display: flex;gap: 10px;align-items: center;
}.inventory-controls {justify-content: flex-start;
}#inventorySearchInput {padding: 6px 10px;border: 1px solid var(--border-color);border-radius: 6px;font-size: 0.9em;flex-grow: 1; /* Allow search input to take space */max-width: 300px;
}.table-container {flex-grow: 1; /* Make table container fill space */overflow: auto; /* Scroll if table content overflows */margin-bottom: var(--spacing-unit);position: relative; /* For potential absolute elements if needed */
}table {width: 100%;border-collapse: collapse;font-size: 0.9em;
}thead {position: sticky; /* Make header sticky within scrollable container */top: 0;background-color: #f9f9fb;z-index: 1;
}th,
td {padding: 8px 10px;text-align: left;border-bottom: 1px solid var(--border-color);white-space: nowrap; /* Prevent wrapping in cells initially */
}th {font-weight: 600;color: var(--text-secondary);
}tbody tr:hover {background-color: #f0f0f0;
}/* Status indicators in tables (example) */
td .status {display: inline-block;padding: 3px 8px;border-radius: 12px;font-size: 0.8em;font-weight: 500;text-align: center;color: white;
}.status-shipping { background-color: var(--accent-blue); }
.status-customs { background-color: var(--accent-yellow); color: var(--text-primary); }
.status-qi { background-color: var(--accent-yellow); color: var(--text-primary); }
.status-received { background-color: var(--accent-green); }
.status-picking { background-color: var(--accent-blue); }
.status-packing { background-color: var(--accent-blue); }
.status-shipped { background-color: var(--accent-green); }
.status-available { background-color: var(--accent-green); }
.status-hold { background-color: var(--accent-red); }.detail-view {font-size: 0.9em;color: var(--text-secondary);padding-top: var(--spacing-unit);border-top: 1px dashed var(--border-color);margin-top: auto; /* Push detail view to bottom */
}/* Inventory Section Specifics */
.inventory-layout {display: flex;flex-direction: column; /* Stack vis and list */flex-grow: 1;overflow: hidden;
}.warehouse-vis {background-color: #eef0f2; /* Different background for vis area */border: 1px solid var(--border-color);border-radius: 6px;min-height: 150px;margin-bottom: var(--spacing-unit);padding: var(--spacing-unit);/* Styles for warehouse zones/racks simulation go here */
}.inventory-list {flex-grow: 1; /* Let list take remaining space */overflow: hidden; /* Contains the inner table container */display: flex;flex-direction: column;
}.inventory-list h3 {font-size: 1em;font-weight: 600;margin-bottom: calc(var(--spacing-unit) / 2);
}/* Event Log */
.event-log-section {max-height: 300px; /* Limit height for log */
}#eventLogList {list-style: none;padding: 0;font-size: 0.85em;color: var(--text-secondary);flex-grow: 1;
}#eventLogList li {padding: 4px 0;border-bottom: 1px dotted #e5e5e5;white-space: normal;
}
#eventLogList li:last-child {border-bottom: none;
}.wms-footer {background-color: var(--footer-background);padding: calc(var(--spacing-unit) / 2) var(--spacing-unit);border-top: 1px solid var(--border-color);text-align: right;font-size: 0.85em;color: var(--text-secondary);
}/* Responsive Adjustments */
@media (max-width: 900px) {.wms-main-content {grid-template-columns: 1fr; /* Stack panels on smaller screens */}.panel {max-height: 400px; /* Allow slightly more height when stacked */}.wms-header {flex-direction: column;align-items: flex-start;}.wms-header h1 {margin-bottom: calc(var(--spacing-unit) * 0.5);}.header-controls {width: 100%;justify-content: flex-start;}
}@media (max-width: 600px) {body {padding: calc(var(--spacing-unit) / 2);}.wms-container {border-radius: 0;}.panel {padding: calc(var(--spacing-unit) * 0.75);}.kpi-value {font-size: 1.2em;}th, td {padding: 6px 8px;font-size: 0.85em;}
}
script.js
document.addEventListener('DOMContentLoaded', () => {// --- DOM Elements ---const startSimBtn = document.getElementById('startSimBtn');const pauseSimBtn = document.getElementById('pauseSimBtn');const resetSimBtn = document.getElementById('resetSimBtn');const simSpeedSlider = document.getElementById('simSpeedSlider');const simSpeedValue = document.getElementById('simSpeedValue');const addInboundBtn = document.getElementById('addInboundBtn');const addOutboundBtn = document.getElementById('addOutboundBtn');// Dashboard KPIsconst kpiTotalValue = document.getElementById('kpiTotalValue');const kpiTotalSKU = document.getElementById('kpiTotalSKU');const kpiPendingInbound = document.getElementById('kpiPendingInbound');const kpiPendingOutbound = document.getElementById('kpiPendingOutbound');const kpiCustomsHold = document.getElementById('kpiCustomsHold');const kpiQIHold = document.getElementById('kpiQIHold');// Inbound Sectionconst inboundTableBody = document.querySelector('#inboundTable tbody');const inboundDetailView = document.getElementById('inboundDetail');// Inventory Sectionconst inventorySearchInput = document.getElementById('inventorySearchInput');const inventorySearchBtn = document.getElementById('inventorySearchBtn');const warehouseVis = document.getElementById('warehouseVisualization');const inventoryListBody = document.getElementById('inventoryListBody');// Outbound Sectionconst outboundTableBody = document.querySelector('#outboundTable tbody');const outboundDetailView = document.getElementById('outboundDetail');// Event Log & Footer Statusconst eventLogList = document.getElementById('eventLogList');const simulationStatusSpan = document.getElementById('simulationStatus');// --- Simulation State ---let simulationRunning = false;let simulationPaused = false;let simulationSpeed = 1;let simTimeSeconds = 0;let lastTimestamp = 0;let animationFrameId = null;let nextInboundId = 1;let nextOutboundId = 1;let nextBatchId = 1; // For tracking specific arrivalslet inboundShipments = []; // { id, source, etaDays, arrivalDay, status, items: [{ sku, qty }], batchId }let outboundOrders = []; // { id, destination, requestDay, status, items: [{ sku, qty }] }let inventory = {}; // { sku: { partNumber, description, value, locations: { locationId: { batchId, qty, status } } } }let warehouseLocations = []; // { id, zone, capacity, currentLoad }// --- Simulation Configuration ---const config = {timeScale: 3600, // 1 sim second = 1 hour (adjust as needed)inboundInterval: 24 * 2, // Avg time between new inbound shipments (in sim hours)outboundInterval: 24 * 1, // Avg time between new outbound orders (in sim hours)customsClearanceTime: { min: 12, max: 48 }, // Sim hoursqiInspectionTime: { min: 4, max: 24 }, // Sim hourspickingTimePerOrder: { min: 1, max: 4 }, // Sim hourspackingTimePerOrder: { min: 1, max: 2 }, // Sim hourscustomsDocTime: { min: 2, max: 8 }, // Sim hours for outboundlocations: ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'], // Simplified locationssources: ['德国', '日本', '美国', '墨西哥'],destinations: ['国内工厂A', '国内工厂B', '售后中心'],// Example Auto Parts SKU dataskus: {'ENG001': { partNumber: 'ENG001', description: '发动机缸体', value: 5000 },'BRK005': { partNumber: 'BRK005', description: '刹车盘', value: 300 },'TRN002': { partNumber: 'TRN002', description: '变速箱总成', value: 8000 },'ECU010': { partNumber: 'ECU010', description: '发动机控制单元', value: 1500 },'SEN030': { partNumber: 'SEN030', description: '氧传感器', value: 200 },'WHL007': { partNumber: 'WHL007', description: '轮毂', value: 500 },}};// --- Utility Functions ---function getRandomInt(min, max) {return Math.floor(Math.random() * (max - min + 1)) + min;}function getRandomElement(arr) {return arr[Math.floor(Math.random() * arr.length)];}function formatSimTime(totalSeconds) {const totalSimHours = totalSeconds * config.timeScale / 3600;const days = Math.floor(totalSimHours / 24);const hours = Math.floor(totalSimHours % 24);return `第 ${days + 1} 天 ${String(hours).padStart(2, '0')}:00`;}function addLog(message, type = 'info') {const li = document.createElement('li');const timeStr = formatSimTime(simTimeSeconds);li.innerHTML = `<span class="log-time">[${timeStr}]</span> ${message}`;li.classList.add(`log-${type}`);eventLogList.insertBefore(li, eventLogList.firstChild); // Add to top// Limit log length (optional)if (eventLogList.children.length > 100) {eventLogList.removeChild(eventLogList.lastChild);}}// --- Core Simulation Functions ---function createInboundShipment() {const etaDays = getRandomInt(5, 20);const currentSimDay = Math.floor(simTimeSeconds * config.timeScale / (3600 * 24));const newShipment = {id: `IN-${String(nextInboundId++).padStart(4, '0')}`,source: getRandomElement(config.sources),etaDays: etaDays,arrivalDay: currentSimDay + etaDays, // Expected arrival simulation daystatus: '运输中', // Initial statusitems: [],batchId: `B${nextBatchId++}`,processTimer: 0, // Timer for current process step durationprocessCompleteTime: -1 // Time needed for current step};// Add random itemsconst numItemTypes = getRandomInt(1, 4);const skuKeys = Object.keys(config.skus);for (let i = 0; i < numItemTypes; i++) {const sku = getRandomElement(skuKeys);if (!newShipment.items.some(item => item.sku === sku)) { // Avoid duplicate SKUs in one shipment for simplicitynewShipment.items.push({sku: sku,qty: getRandomInt(10, 100)});}}if (newShipment.items.length > 0) {inboundShipments.push(newShipment);addLog(`生成入库单 ${newShipment.id} (来自: ${newShipment.source}, 预计到达: 第 ${newShipment.arrivalDay + 1} 天)`);renderInboundTable();}}function createOutboundOrder() {const currentSimDay = Math.floor(simTimeSeconds * config.timeScale / (3600 * 24));const newOrder = {id: `OUT-${String(nextOutboundId++).padStart(4, '0')}`,destination: getRandomElement(config.destinations),requestDay: currentSimDay,status: '待处理', // Initial statusitems: [],processTimer: 0,processCompleteTime: -1};// Add random items from available inventory (simplified)const numItemTypes = getRandomInt(1, 3);const availableSkus = Object.keys(inventory).filter(sku => getTotalAvailableQty(sku) > 0);if (availableSkus.length === 0) {addLog("库存不足,无法创建出库单", "warn");return; // Cannot create order if no stock}for (let i = 0; i < numItemTypes; i++) {const sku = getRandomElement(availableSkus);if (!newOrder.items.some(item => item.sku === sku)) {const maxQtyNeeded = getRandomInt(5, 50);const availableQty = getTotalAvailableQty(sku);const qtyToOrder = Math.min(maxQtyNeeded, availableQty);if (qtyToOrder > 0) {newOrder.items.push({ sku, qty: qtyToOrder });}}}if (newOrder.items.length > 0) {outboundOrders.push(newOrder);addLog(`生成出库单 ${newOrder.id} (发往: ${newOrder.destination})`);renderOutboundTable();}}function updateInboundStatus(deltaTimeHours) {const currentSimDay = Math.floor(simTimeSeconds * config.timeScale / (3600 * 24));inboundShipments.forEach(shipment => {switch (shipment.status) {case '运输中':if (currentSimDay >= shipment.arrivalDay) {shipment.status = '已到港';addLog(`入库单 ${shipment.id} 已到达港口`, "success");// Optionally trigger next step immediately or wait a frameshipment.status = '清关中';shipment.processCompleteTime = getRandomInt(config.customsClearanceTime.min, config.customsClearanceTime.max);shipment.processTimer = 0;addLog(`入库单 ${shipment.id} 开始清关 (预计 ${shipment.processCompleteTime} 小时)`);}break;case '清关中':shipment.processTimer += deltaTimeHours;if (shipment.processTimer >= shipment.processCompleteTime) {shipment.status = '待检验';shipment.processCompleteTime = getRandomInt(config.qiInspectionTime.min, config.qiInspectionTime.max);shipment.processTimer = 0;addLog(`入库单 ${shipment.id} 清关完成,等待质检 (预计 ${shipment.processCompleteTime} 小时)`);}break;case '待检验':shipment.processTimer += deltaTimeHours;if (shipment.processTimer >= shipment.processCompleteTime) {// Simplified: Assume all pass inspectionshipment.status = '待入库'; // Ready to be put awayshipment.processCompleteTime = -1; // Reset timershipment.processTimer = 0;addLog(`入库单 ${shipment.id} 质检完成,准备入库`);// Attempt to put away stockputAwayStock(shipment);}break;case '待入库': // This state might be brief if putAwayStock is efficientputAwayStock(shipment);break;case '已入库':// Final state, do nothingbreak;}});// Clean up completed shipments (optional, maybe keep for history)// inboundShipments = inboundShipments.filter(s => s.status !== '已入库');}function updateOutboundStatus(deltaTimeHours) {outboundOrders.forEach(order => {switch(order.status) {case '待处理':// Check if stock is available and allocateif (canFulfillOrder(order)) {if(allocateStock(order)) {order.status = '待拣货';addLog(`出库单 ${order.id} 库存已分配,等待拣货`);} else {addLog(`出库单 ${order.id} 分配库存失败 (并发?), 稍后重试`, "warn");// Stock might have been taken by another concurrent process}} else {// addLog(`出库单 ${order.id} 库存不足,等待补货`, "info");}break;case '待拣货':order.status = '拣货中';order.processCompleteTime = getRandomInt(config.pickingTimePerOrder.min, config.pickingTimePerOrder.max);order.processTimer = 0;addLog(`出库单 ${order.id} 开始拣货 (预计 ${order.processCompleteTime} 小时)`);break;case '拣货中':order.processTimer += deltaTimeHours;if (order.processTimer >= order.processCompleteTime) {order.status = '包装中';order.processCompleteTime = getRandomInt(config.packingTimePerOrder.min, config.packingTimePerOrder.max);order.processTimer = 0;addLog(`出库单 ${order.id} 拣货完成,开始包装 (预计 ${order.processCompleteTime} 小时)`);}break;case '包装中':order.processTimer += deltaTimeHours;if (order.processTimer >= order.processCompleteTime) {order.status = '待报关';order.processCompleteTime = getRandomInt(config.customsDocTime.min, config.customsDocTime.max);order.processTimer = 0;addLog(`出库单 ${order.id} 包装完成,准备报关文件 (预计 ${order.processCompleteTime} 小时)`);}break;case '待报关':order.processTimer += deltaTimeHours;if (order.processTimer >= order.processCompleteTime) {order.status = '已发运';order.processCompleteTime = -1;order.processTimer = 0;addLog(`出库单 ${order.id} 报关完成并发运`, "success");// De-allocate stock is implicitly done when it leaves}break;case '已发运':// Final statebreak;}});// Clean up completed orders (optional)// outboundOrders = outboundOrders.filter(o => o.status !== '已发运');}// --- Inventory Management Functions ---function initializeInventory() {inventory = {};Object.keys(config.skus).forEach(sku => {inventory[sku] = { ...config.skus[sku], locations: {} };});}function findAvailableLocation(sku, qty) {// Very simplified: find the first location in config list// In reality, this involves complex logic (zone rules, capacity, existing SKU locations)return config.locations[0];}function putAwayStock(shipment) {let allPutAway = true;shipment.items.forEach(item => {const sku = item.sku;const qty = item.qty;const batchId = shipment.batchId;if (!inventory[sku]) { // Should not happen if init is correctconsole.error(`SKU ${sku} not found in inventory master!`);allPutAway = false;return;}// Simplified: Put everything into one locationconst locationId = findAvailableLocation(sku, qty);if (!locationId) {addLog(`无法为入库单 ${shipment.id} 的 ${sku} 找到合适的库位!`, "error");allPutAway = false;return; // Cannot put away}if (!inventory[sku].locations[locationId]) {inventory[sku].locations[locationId] = []; // Store list of batches in location}// Check if batch already exists in location (shouldn't for new arrivals)let existingBatch = inventory[sku].locations[locationId].find(b => b.batchId === batchId);if (!existingBatch) {inventory[sku].locations[locationId].push({batchId: batchId,qty: qty,status: '可用' // Mark as available after QI});addLog(`零件 ${sku} (批次 ${batchId}) 数量 ${qty} 已入库至 ${locationId}`);} else {// This case might happen if putAway is called multiple times for same shipment// For simplicity, assume it's done onceconsole.warn(`批次 ${batchId} 的 ${sku} 已存在于 ${locationId},跳过重复入库。`);}});if (allPutAway) {shipment.status = '已入库';shipment.processCompleteTime = -1;addLog(`入库单 ${shipment.id} 所有物料已成功入库`, "success");} else {// Keep status '待入库' if some items failedaddLog(`入库单 ${shipment.id} 部分物料入库失败,保持待入库状态`, "warn");}renderInventoryList(); // Update inventory display}function getTotalAvailableQty(sku) {let totalQty = 0;if (inventory[sku]) {Object.values(inventory[sku].locations).forEach(locationBatches => {locationBatches.forEach(batch => {if (batch.status === '可用') {totalQty += batch.qty;}});});}return totalQty;}function canFulfillOrder(order) {return order.items.every(item => getTotalAvailableQty(item.sku) >= item.qty);}function allocateStock(order) {// Simplified FIFO allocation: find oldest available batches and mark as '已分配'let allocationSuccessful = true;const allocations = []; // Track changes to revert on failurefor (const item of order.items) {let qtyToAllocate = item.qty;const sku = item.sku;let allocatedForThisItem = false;if (!inventory[sku]) {allocationSuccessful = false; break;}// Iterate through locations and batches (assuming some order, e.g., location ID then batch ID)const sortedLocations = Object.keys(inventory[sku].locations).sort();for (const locId of sortedLocations) {const batches = inventory[sku].locations[locId];// Sort batches? Assuming array order is arrival order for FIFOfor (const batch of batches) {if (batch.status === '可用' && qtyToAllocate > 0) {const allocQty = Math.min(qtyToAllocate, batch.qty);if (allocQty > 0) {batch.status = '已分配'; // Mark the batch or portion (simplified: mark whole batch)batch.allocatedTo = order.id; // Link allocation to orderqtyToAllocate -= allocQty;allocations.push({ sku, locId, batchId: batch.batchId, qty: allocQty }); // Track successif (qtyToAllocate === 0) {allocatedForThisItem = true;break; // Allocation for this SKU complete}// If partial allocation from batch, need more complex state (not implemented here)}}}if (allocatedForThisItem) break; // Move to next SKU}if (!allocatedForThisItem || qtyToAllocate > 0) {allocationSuccessful = false; // Could not allocate fully for this SKUbreak;}}if (!allocationSuccessful) {// Revert allocationsaddLog(`出库单 ${order.id} 分配失败,回滚库存状态`, "error");allocations.forEach(({ sku, locId, batchId }) => {const batch = inventory[sku]?.locations[locId]?.find(b => b.batchId === batchId);if (batch && batch.allocatedTo === order.id) {batch.status = '可用'; // Revert statusdelete batch.allocatedTo;}});return false;}addLog(`出库单 ${order.id} 库存分配成功: ${JSON.stringify(allocations)}`);renderInventoryList(); // Update displayreturn true;}// --- Rendering Functions ---function renderInboundTable() {inboundTableBody.innerHTML = ''; // Clear existinginboundShipments.slice().reverse().slice(0, 50).forEach(shipment => { // Show latest 50const row = inboundTableBody.insertRow();row.dataset.id = shipment.id;row.innerHTML = `<td>${shipment.id}</td><td>${shipment.source}</td><td>第 ${shipment.arrivalDay + 1} 天</td><td><span class="status status-${getStatusClass(shipment.status)}">${shipment.status}</span></td><td><button class="small-button view-detail-btn" data-type="inbound" data-id="${shipment.id}">详情</button></td>`;});updateKPIs();}function renderOutboundTable() {outboundTableBody.innerHTML = '';outboundOrders.slice().reverse().slice(0, 50).forEach(order => {const row = outboundTableBody.insertRow();row.dataset.id = order.id;row.innerHTML = `<td>${order.id}</td><td>${order.destination}</td><td>第 ${order.requestDay + 1} 天</td><td><span class="status status-${getStatusClass(order.status)}">${order.status}</span></td><td><button class="small-button view-detail-btn" data-type="outbound" data-id="${order.id}">详情</button></td>`;});updateKPIs();}function renderInventoryList(filterSku = '') {inventoryListBody.innerHTML = '';let totalValue = 0;let totalSkuCount = 0;Object.keys(inventory).sort().forEach(sku => {const itemData = inventory[sku];let skuHasVisibleStock = false;if (!filterSku || itemData.partNumber.toLowerCase().includes(filterSku.toLowerCase())) {totalSkuCount++; // Count SKU if no filter or matches filterObject.keys(itemData.locations).sort().forEach(locId => {itemData.locations[locId].forEach(batch => {if (batch.qty > 0) { // Only show locations with stockskuHasVisibleStock = true;const row = inventoryListBody.insertRow();row.innerHTML = `<td>${itemData.partNumber}</td><td>${locId}</td><td>${batch.qty}</td><td><span class="status status-${getStatusClass(batch.status)}">${batch.status}</span></td><td>${batch.batchId}</td>`;if(batch.status === '可用') {totalValue += batch.qty * itemData.value;}}});});}// If filter applied but this SKU had no visible stock, don't increment total SKU count if it was only based on masterif(filterSku && !skuHasVisibleStock) {// Decrement if it was counted initially but had no stock matching filter? Or adjust logic.// Simpler: Just filter the display, KPI shows all SKUs present in master list.}});// Update total SKU count based on master data for simplicity unless filtered?kpiTotalSKU.textContent = Object.keys(inventory).length;kpiTotalValue.textContent = `¥ ${totalValue.toLocaleString()}`;renderWarehouseVis(); // Update visualization}function renderWarehouseVis() {// Simplified visualization: Show zones and relative fill levelswarehouseVis.innerHTML = '<h3>仓库概览 (模拟)</h3>';const zones = {}; // { zoneId: { totalQty: 0, availableQty: 0 } }Object.values(inventory).forEach(itemData => {Object.entries(itemData.locations).forEach(([locId, batches]) => {const zone = locId.substring(0, 1); // Assume zone is first char (A, B, C)if (!zones[zone]) zones[zone] = { totalQty: 0, availableQty: 0 };batches.forEach(batch => {zones[zone].totalQty += batch.qty;if (batch.status === '可用') {zones[zone].availableQty += batch.qty;}});});});const visContainer = document.createElement('div');visContainer.style.display = 'flex';visContainer.style.gap = '10px';visContainer.style.marginTop = '10px';Object.keys(zones).sort().forEach(zoneId => {const zoneData = zones[zoneId];const zoneDiv = document.createElement('div');zoneDiv.style.border = '1px solid var(--border-color)';zoneDiv.style.padding = '10px';zoneDiv.style.borderRadius = '4px';zoneDiv.style.textAlign = 'center';zoneDiv.style.minWidth = '80px';zoneDiv.innerHTML = `<div style="font-weight: bold;">区域 ${zoneId}</div><div style="font-size: 0.8em; color: var(--text-secondary);">可用: ${zoneData.availableQty}</div><div style="font-size: 0.8em; color: var(--text-light);">总计: ${zoneData.totalQty}</div>`;// Add a simple fill indicator (optional)const fillRatio = zoneData.totalQty > 0 ? Math.min(1, zoneData.totalQty / (config.locations.filter(l=>l.startsWith(zoneId)).length * 100)) : 0; // Assuming capacity 100 per locconst fillBar = document.createElement('div');fillBar.style.height = '5px';fillBar.style.width = '100%';fillBar.style.marginTop = '5px';fillBar.style.backgroundColor = '#e0e0e0';fillBar.style.borderRadius = '3px';fillBar.style.overflow = 'hidden';const innerFill = document.createElement('div');innerFill.style.height = '100%';innerFill.style.width = `${fillRatio * 100}%`;innerFill.style.backgroundColor = 'var(--accent-blue)';fillBar.appendChild(innerFill);zoneDiv.appendChild(fillBar);visContainer.appendChild(zoneDiv);});warehouseVis.appendChild(visContainer);}function updateKPIs() {kpiPendingInbound.textContent = inboundShipments.filter(s => s.status !== '已入库' && s.status !== '已取消').length;kpiPendingOutbound.textContent = outboundOrders.filter(o => o.status !== '已发运' && o.status !== '已取消').length;kpiCustomsHold.textContent = inboundShipments.filter(s => s.status === '清关中').length;kpiQIHold.textContent = inboundShipments.filter(s => s.status === '待检验').length;// Other KPIs like utilization need more tracking data}function getStatusClass(status) {switch (status) {case '运输中': return 'shipping';case '已到港': return 'received'; // Use green temporarilycase '清关中': return 'customs';case '待检验': return 'qi';case '待入库': return 'blue'; // Use blue for intermediate stepcase '已入库': return 'available';case '可用': return 'available';case '待处理': return 'hold';case '待拣货': return 'yellow';case '拣货中': return 'picking';case '包装中': return 'packing';case '待报关': return 'customs';case '已发运': return 'shipped';case '已分配': return 'hold';default: return '';}}function showDetailView(event) {const button = event.target.closest('.view-detail-btn');if (!button) return;const type = button.dataset.type;const id = button.dataset.id;if (type === 'inbound') {const shipment = inboundShipments.find(s => s.id === id);if (shipment) {inboundDetailView.innerHTML = `<h4>入库单详情: ${shipment.id}</h4><p><strong>来源:</strong> ${shipment.source}</p><p><strong>状态:</strong> ${shipment.status}</p><p><strong>批次号:</strong> ${shipment.batchId}</p><p><strong>预计到达:</strong> 第 ${shipment.arrivalDay + 1} 天</p>${shipment.processCompleteTime > 0 ? `<p><strong>当前步骤剩余:</strong> ${Math.max(0, shipment.processCompleteTime - shipment.processTimer).toFixed(1)} 小时</p>` : ''}<h5>包含物品:</h5><ul>${shipment.items.map(item => `<li>${item.sku} (${config.skus[item.sku].description}): ${item.qty} 件</li>`).join('')}</ul>`;}} else if (type === 'outbound') {const order = outboundOrders.find(o => o.id === id);if (order) {outboundDetailView.innerHTML = `<h4>出库单详情: ${order.id}</h4><p><strong>发往:</strong> ${order.destination}</p><p><strong>状态:</strong> ${order.status}</p><p><strong>请求日期:</strong> 第 ${order.requestDay + 1} 天</p>${order.processCompleteTime > 0 ? `<p><strong>当前步骤剩余:</strong> ${Math.max(0, order.processCompleteTime - order.processTimer).toFixed(1)} 小时</p>` : ''}<h5>包含物品:</h5><ul>${order.items.map(item => `<li>${item.sku} (${config.skus[item.sku].description}): ${item.qty} 件</li>`).join('')}</ul>`;}}}// --- Simulation Loop ---function simulationStep(timestamp) {if (!simulationRunning || simulationPaused) {if(simulationRunning) animationFrameId = requestAnimationFrame(simulationStep);return;}if (!lastTimestamp) lastTimestamp = timestamp;const realDeltaTimeMs = timestamp - lastTimestamp;lastTimestamp = timestamp;// Calculate elapsed simulation time in hours based on speedconst simDeltaTimeSeconds = (realDeltaTimeMs / 1000) * simulationSpeed;const simDeltaTimeHours = simDeltaTimeSeconds * config.timeScale / 3600;simTimeSeconds += simDeltaTimeSeconds;// --- Update simulation logic based on simDeltaTimeHours ---updateInboundStatus(simDeltaTimeHours);updateOutboundStatus(simDeltaTimeHours);// --- Trigger periodic events (based on sim time, not real time) ---// Use modulo or track last event time// This needs refinement for accurate interval timing with variable speed// --- Update UI ---simulationStatusSpan.textContent = `状态: ${simulationPaused ? '已暂停' : '运行中'} | ${formatSimTime(simTimeSeconds)}`;// Only render tables if needed (e.g., based on time interval or state changes)// For simplicity, render frequently for nowrenderInboundTable();renderOutboundTable();renderInventoryList(); // Could be expensive, optimize laterupdateKPIs();// Request next frameanimationFrameId = requestAnimationFrame(simulationStep);}// --- Event Listeners ---startSimBtn.addEventListener('click', () => {if (simulationRunning && !simulationPaused) return;if (simulationPaused) { // ResumesimulationPaused = false;lastTimestamp = performance.now();addLog("模拟已恢复");pauseSimBtn.disabled = false;startSimBtn.textContent = '开始模拟'; // Reset button text if neededstartSimBtn.disabled = true;} else { // Start freshresetSimulationState();initializeInventory();simulationRunning = true;simulationPaused = false;simTimeSeconds = 0;lastTimestamp = performance.now();addLog("模拟已开始", "success");startSimBtn.disabled = true;pauseSimBtn.disabled = false;resetSimBtn.disabled = false;// Trigger initial eventscreateInboundShipment();createOutboundOrder();// Setup timed events (Needs better handling with speed changes)// For now, trigger inside loop or use setTimeout scaled by speed}animationFrameId = requestAnimationFrame(simulationStep);});pauseSimBtn.addEventListener('click', () => {if (!simulationRunning || simulationPaused) return;simulationPaused = true;cancelAnimationFrame(animationFrameId); // Stop updatesaddLog("模拟已暂停", "warn");startSimBtn.disabled = false;startSimBtn.textContent = '恢复模拟';pauseSimBtn.disabled = true;simulationStatusSpan.textContent = `状态: 已暂停 | ${formatSimTime(simTimeSeconds)}`;});resetSimBtn.addEventListener('click', () => {simulationRunning = false;simulationPaused = false;cancelAnimationFrame(animationFrameId);resetSimulationState();initializeInventory();addLog("模拟已重置", "info");startSimBtn.disabled = false;startSimBtn.textContent = '开始模拟';pauseSimBtn.disabled = true;resetSimBtn.disabled = true;simulationStatusSpan.textContent = `状态: 空闲 | ${formatSimTime(0)}`;renderInboundTable();renderOutboundTable();renderInventoryList();updateKPIs();eventLogList.innerHTML = '<li>模拟系统已初始化。</li>'; // Clear log});simSpeedSlider.addEventListener('input', (e) => {simulationSpeed = parseFloat(e.target.value);simSpeedValue.textContent = `${simulationSpeed}x`;// Interval timers should be reset here if using setInterval});addInboundBtn.addEventListener('click', () => {if (simulationRunning) {createInboundShipment();} else {addLog("请先开始模拟", "warn");}});addOutboundBtn.addEventListener('click', () => {if (simulationRunning) {createOutboundOrder();} else {addLog("请先开始模拟", "warn");}});inventorySearchBtn.addEventListener('click', () => {const searchTerm = inventorySearchInput.value;renderInventoryList(searchTerm);});inventorySearchInput.addEventListener('keyup', (e) => {if (e.key === 'Enter') {const searchTerm = inventorySearchInput.value;renderInventoryList(searchTerm);}});// Delegate clicks for detail buttonsdocument.querySelector('.wms-main-content').addEventListener('click', showDetailView);// --- Initialization ---function resetSimulationState() {simTimeSeconds = 0;lastTimestamp = 0;inboundShipments = [];outboundOrders = [];inventory = {}; // Re-initialize laternextInboundId = 1;nextOutboundId = 1;nextBatchId = 1;// Clear any running timers or intervals if used}function initializeApp() {initializeInventory();simulationStatusSpan.textContent = '状态: 空闲';renderInboundTable();renderOutboundTable();renderInventoryList();updateKPIs();simSpeedValue.textContent = `${simulationSpeed}x`;simSpeedSlider.value = simulationSpeed;}initializeApp();});