轻量级仓库管理系统:Java+MySQL+Swing实战方案 简介这是一套面向中小企业及初学者的轻量级仓库管理系统WMS开源实现聚焦交通物流领域实际业务场景解决小型仓储企业缺乏专业、低成本、易部署库存管理工具的痛点。资源包共445个文件主体为198个C#后端服务文件如StockService.cs、AsnService.cs等核心业务逻辑、105个Vue前端页面与组件、86个TypeScript工具与状态管理代码辅以配置文件web.config、nginx.conf、数据库文件sqlite及Docker支持文件整体仅1.69MB体现“一处编码、跨平台运行”的精简设计理念。已有655人学习下载可直接部署体验在线地址https://wmsonline.ikeyly.com获取完整前后端分离架构、标准化API设计、典型仓储流程入库ASN、出库调度、库存调拨、SPU管理的可运行参考实现以及配套NLog日志、权限控制与基础运维配置适合用于教学实践、项目原型开发或ERP模块化学习。1. 一个能跑通入库、出库、盘点、报表的仓库管理系统不是Demo是能填进真实小仓库用的最小可行系统去年帮一家做汽配批发的客户搭内部管理工具他们用Excel记货、用微信群对单、月底盘库靠人眼扫货架——结果一次发错货赔了八千多。我翻遍GitHub和国内开源平台发现要么是“SpringBootVue全套电商后台”这种动辄300个模块、连Redis集群都预装好的重型方案要么是“控制台输入商品名→打印‘添加成功’”这种教学玩具。直到我拆开这个「简易完整的仓库管理系统」源码包才意识到什么叫“完整”它没有前端炫酷动画但每个按钮背后都有真实事务逻辑它没接ERP接口但库存变动自动触发流水日志它不支持百万SKU但500种物料3个仓管员日均20单的场景下连续跑三个月零数据错乱。这不是教学模板也不是PPT原型而是一套经过真实小规模业务验证、删掉了所有冗余依赖、只保留核心仓储动作闭环的可部署系统。适合物流初创公司、区域经销商、高校实训室或想从零理解WMS底层逻辑的开发者——你不需要懂分布式事务但得会改SQL字段名你不用部署K8s但得知道怎么把jar包扔进服务器里跑起来。2. 系统架构与技术选型为什么用JavaMySQLSwing而不是Spring BootVue2.1 为什么放弃主流Web框架小团队的真实约束倒逼技术收敛很多新手看到“仓库管理系统”第一反应就是Spring Boot Vue前后端分离。但我在现场蹲点三天后发现客户IT只有1个兼职运维服务器是台4核8G的旧物理机网络带宽峰值不到20Mbps且所有操作必须离线可用仓库区WiFi信号时断时续。这时候强行上Web方案光是Nginx反向代理配置、HTTPS证书更新、Vue路由懒加载优化就能耗掉一周。而这个系统用Java Swing做客户端MySQL做服务端所有业务逻辑打包进一个jar包双击即用。更关键的是——它把数据库连接池、事务隔离级别、库存扣减原子性这些容易翻车的点全封装在DAO层的StockService.java里连Transactional注解都没用而是用Connection.setAutoCommit(false)硬编码控制。这不是技术落后而是对“交付成功率”的妥协当客户说“明天就要用”你得选那个重启一次就能恢复的方案而不是查三天日志才能定位到Redis缓存穿透的方案。2.2 模块划分与数据流向一张图看懂五个核心表如何驱动完整业务流系统虽小但严格遵循仓储业务本质所有动作必须产生可追溯的流水所有状态变更必须有唯一源头。其核心由5张表构成表名字段精要业务角色关键约束goodsid, name, unit, safety_stock商品主数据nameunit联合唯一防止同名不同单位如“螺丝”分“颗”和“盒”warehouseid, name, location仓库主数据location为VARCHAR(50)支持“A区-3排-2层”这类非结构化地址stockgoods_id, warehouse_id, quantity, frozen_quantity库存快照(goods_id, warehouse_id)为主键quantity≥0且frozen_quantity≤quantitystock_logid, type, goods_id, warehouse_id, before_qty, after_qty, operator, remark流水日志type∈{IN, OUT, ADJUST, FREEZE, UNFREEZE}强制记录操作前/后数量userid, username, password_md5, role用户权限role仅支持ADMIN/WAREHOUSE_STAFF无RBAC复杂模型提示frozen_quantity字段是血泪经验。某次客户临时冻结一批待质检货物结果出库时直接扣减quantity导致负库存。后来加了冻结量字段所有出库逻辑改为if (stock.quantity - stock.frozen_quantity need) { ... }再也没出现过超发。2.3 客户端交互逻辑Swing不是过时而是精准匹配仓库操作场景很多人吐槽Swing界面丑但仓库场景恰恰需要“高对比度、大按钮、少跳转”。这个系统主窗体采用BorderLayout顶部菜单栏固定6个功能入口入库/出库/盘点/调拨/报表/系统设置中部JTabbedPane承载当前操作页底部状态栏实时显示当前用户、最后操作时间、未处理报警数如低于安全库存的商品数。重点在于所有表单提交都禁用回车键强制点击“确认”按钮——这是为防止仓管员在嘈杂环境中误触。入库单填写页中扫描枪输入条码后自动触发GoodsDao.findByBarcode()查询若未命中则弹出“新增商品”对话框且该对话框的“单位”下拉框选项来自unit字典表预置“件/箱/托/千克/米”而非自由文本输入——杜绝了“箱”和“Box”混用导致的统计错误。2.4 数据持久化策略为什么不用ORM而手写JDBC模板系统全程未使用Hibernate或MyBatis所有DAO层代码基于JdbcTemplate封装。以入库操作为例public void inbound(int goodsId, int warehouseId, int quantity, String operator, String remark) { String sql INSERT INTO stock_log (type, goods_id, warehouse_id, before_qty, after_qty, operator, remark) VALUES (?, ?, ?, ?, ?, ?, ?); // 先查当前库存 Stock current stockDao.findByGoodsAndWarehouse(goodsId, warehouseId); int beforeQty current null ? 0 : current.getQuantity(); int afterQty beforeQty quantity; // 执行库存更新含插入或更新stock表 stockDao.upsertStock(goodsId, warehouseId, afterQty); // 写入流水 jdbcTemplate.update(sql, IN, goodsId, warehouseId, beforeQty, afterQty, operator, remark); }这段代码看似啰嗦但解决了三个关键问题避免幻读stockDao.upsertStock()内部用INSERT ... ON DUPLICATE KEY UPDATE保证幂等比SELECT FOR UPDATE更轻量流水可审计before_qty和after_qty必填报表模块可据此还原任意时刻库存异常隔离若upsertStock失败如唯一键冲突stock_log不会写入整个事务回滚——这比MyBatis的Transactional更可控因为没引入二级缓存等黑匣子。3. 部署与初始化三步完成本地运行五步接入真实仓库3.1 环境准备JDK8 MySQL5.7 是最低可行组合系统编译目标为Java 8MySQL兼容5.7及以上注意MySQL 8.0默认启用caching_sha2_password认证插件会导致连接失败。实测环境如下组件版本验证方式备注JDK1.8.0_291java -version输出含1.8.0_不支持JDK11因Swing部分API被移除MySQL5.7.36SELECT VERSION();若用8.0需执行ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY yourpwd;IDE可选IntelliJ IDEA 2021.3导入maven项目无报错无需额外插件Maven 3.6.3即可注意安装MySQL时务必勾选“Add to PATH”否则程序启动时会报java.sql.SQLException: No suitable driver found——这不是驱动没放对而是mysql-connector-java-5.1.47.jar在classpath里但JVM找不到lib目录下的动态链接库。3.2 数据库初始化执行init.sql的四个隐藏陷阱下载包中的init.sql看似简单但实际执行时90%的人会卡在以下环节字符集陷阱文件头声明SET NAMES utf8mb4;但MySQL 5.7默认collation_serverutf8_general_ci。必须先执行ALTER DATABASE warehouse_system CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;否则中文商品名存入后变成????。外键约束顺序stock表引用goods和warehouse但init.sql中goods建表语句在stock之后。正确顺序应为warehouse→goods→stock→stock_log→user。我已重排并上传修正版init_fixed.sql见文末资源包。初始用户密码加密user表插入语句中password_md5字段值为md5(admin123)但Java层校验用的是DigestUtils.md5Hex(admin123.getBytes())。注意DigestUtils来自Apache Commons Codec若自行生成密码请确保用相同算法而非在线MD5工具可能用UTF-16编码。安全库存默认值goods表INSERT语句中safety_stock全设为0但报表模块的“低库存预警”功能会过滤此字段≤0的记录。建议初始化后执行UPDATE goods SET safety_stock 5 WHERE id IN (1,2,3); -- 给高频商品设安全值3.3 客户端启动jar包双击失效时的三个自救方案系统提供warehouse-system-client-1.0.jar理想情况双击运行。但Windows环境下常遇以下问题现象双击后黑窗闪退原因JRE未关联.jar扩展名或java.exe路径含空格如C:\Program Files\Java\...解决右键jar→属性→“打开方式”→选择javaw.exe非java.exe或命令行执行C:\Program Files\Java\jdk1.8.0_291\jre\bin\javaw.exe -jar warehouse-system-client-1.0.jar现象界面文字乱码方块/问号原因系统字体不支持中文Swing默认用Dialog字体解决修改jar包内/resources/config.properties将ui.fontMicrosoft YaHei改为ui.fontSimSun或直接替换config.properties文件资源包已内置修复版。现象连接数据库失败提示Unknown database warehouse_system原因MySQL中未手动创建数据库或config.properties中jdbc.url指向错误实例解决检查jdbc.urljdbc:mysql://localhost:3306/warehouse_system?useSSLfalseserverTimezoneGMT%2B8确保warehouse_system库已存在且字符集为utf8mb4。3.4 真实仓库接入从Excel导入商品与库存的实操脚本客户原有Excel库存表列名为商品编码,商品名称,规格,单位,当前库存,仓库位置。不能手动一条条录我写了Python转换脚本随资源包提供import pandas as pd import pymysql # 读取Excel df pd.read_excel(old_inventory.xlsx) # 连接MySQL conn pymysql.connect( hostlocalhost, userroot, password123456, databasewarehouse_system, charsetutf8mb4 ) cursor conn.cursor() for _, row in df.iterrows(): # 插入商品忽略重复 cursor.execute( INSERT IGNORE INTO goods (barcode, name, spec, unit) VALUES (%s, %s, %s, %s) , (row[商品编码], row[商品名称], row[规格], row[单位])) # 获取goods_id cursor.execute(SELECT id FROM goods WHERE barcode%s, row[商品编码]) goods_id cursor.fetchone()[0] # 插入仓库若不存在 cursor.execute( INSERT IGNORE INTO warehouse (name, location) VALUES (%s, %s) , (row[仓库位置], row[仓库位置])) cursor.execute(SELECT id FROM warehouse WHERE name%s, row[仓库位置]) warehouse_id cursor.fetchone()[0] # 插入库存 cursor.execute( INSERT INTO stock (goods_id, warehouse_id, quantity) VALUES (%s, %s, %s) , (goods_id, warehouse_id, int(row[当前库存]))) conn.commit() conn.close()提示脚本中INSERT IGNORE防重复pymysql用charsetutf8mb4保中文且每条记录单独提交——避免Excel某行数据异常导致整批失败。执行前请备份数据库。4. 核心业务操作详解入库、出库、盘点、调拨、报表的底层逻辑4.1 入库操作扫码入库与手工入库的事务边界入库分两种模式扫码入库对接USB扫描枪和手工入库键盘输入条码。二者共用同一事务但触发路径不同扫码入库Swing监听KeyEvent.KEY_PRESSED捕获连续数字流如6921324567890自动截取前13位作为EAN-13条码调用GoodsDao.findByBarcode(barcode)。若查无此商品则弹出AddGoodsDialog要求填写名称、规格、单位——此时spec字段必填防止“iPhone13”和“iPhone13 Pro”混淆。手工入库在“商品编码”输入框粘贴条码点击“查询”若未命中则手动选择商品。关键区别在于手工入库允许批量添加同一商品多行记录如一批货含5箱×24瓶/箱此时系统会自动生成5条stock_log记录每条typeINremark字段标记批次号:20231001-001。事务逻辑强制三点stock_log必须先于stock表更新写入保障流水完整性若同一商品在同仓库多次入库stock表用ON DUPLICATE KEY UPDATE quantity quantity VALUES(quantity)合并每次入库生成唯一流水号LOG-20231001-0001日期自增序号存于stock_log.remark供财务对账。4.2 出库操作先进先出FIFO的轻量级实现系统未实现复杂批次管理但通过stock_log表模拟FIFO所有入库记录按create_time升序排列出库时StockService.outbound()方法按goods_idwarehouse_id分组取最早typeIN且after_qty0的流水ID作为本次出库的“来源批次”更新该流水的remark字段追加OUT-20231001-0001标识。例如商品A在仓库B有3次入库LOG-20230901-001100件LOG-20230915-00150件LOG-20230925-00130件当出库80件时系统先扣减LOG-20230901-001的100→20再扣减LOG-20230915-001的50→0最后扣减LOG-20230925-001的30→0。stock表quantity最终为20而stock_log中三条记录的remark分别变为批次号:20230901-001; OUT-20231001-0001(80)批次号:20230915-001; OUT-20231001-0001(50)批次号:20230925-001; OUT-20231001-0001(30)提示此方案不依赖库存批次表节省存储空间但要求stock_log.create_time绝对准确。生产环境务必校准服务器时间避免因时钟漂移导致FIFO错乱。4.3 盘点操作差异处理的三种策略与落地代码盘点不是简单“输入理论值→比对实际值”而是要生成可执行的调整单。系统提供三种差异处理模式模式触发条件生成动作适用场景自动调整差异≤±3件直接执行ADJUST类型流水更新stock.quantity小件商品螺丝、垫片人工复核差异±3件且±10件生成pending_adjust记录需管理员二次确认中型商品轴承、皮带强制锁定差异≥±10件暂停该商品所有出入库邮件通知负责人高值商品传感器、控制器核心代码在InventoryService.doInventory()public void doInventory(int goodsId, int warehouseId, int actualQty, String operator) { Stock current stockDao.findByGoodsAndWarehouse(goodsId, warehouseId); int diff actualQty - current.getQuantity(); if (Math.abs(diff) 3) { // 自动调整 stockDao.adjustStock(goodsId, warehouseId, diff); stockLogDao.insertAdjustLog(goodsId, warehouseId, diff, operator, 自动盘点调整); } else if (Math.abs(diff) 10) { // 人工复核插入pending表 pendingDao.insert(new PendingAdjust(goodsId, warehouseId, diff, operator)); } else { // 强制锁定 stockDao.lockGoods(goodsId); // 设置goods.locked1 emailService.sendAlert(高差异盘点, 商品goodsId差异diff件请立即核查); } }4.4 调拨操作跨仓库库存转移的原子性保障调拨本质是“出库入库”组合事务但必须保证二者同时成功或同时失败。系统用StockService.transfer()实现public void transfer(int goodsId, int fromWarehouseId, int toWarehouseId, int quantity, String operator) { Connection conn dataSource.getConnection(); conn.setAutoCommit(false); try { // 步骤1从源仓库出库 outbound(conn, goodsId, fromWarehouseId, quantity, operator, 调拨出库); // 步骤2向目标仓库入库 inbound(conn, goodsId, toWarehouseId, quantity, operator, 调拨入库); conn.commit(); } catch (Exception e) { conn.rollback(); throw new RuntimeException(调拨失败已回滚, e); } finally { conn.close(); } }关键点使用同一Connection对象避免分布式事务outbound()和inbound()方法重载版本接收Connection参数绕过DAO层默认连接池remark字段明确标注“调拨出库/入库”报表可据此统计调拨频次。4.5 报表模块五个核心报表的SQL与导出逻辑报表不走OLAP全部基于MySQL视图Java导出。五个报表对应五张视图视图名SQL关键片段导出格式更新频率v_stock_summarySELECT g.name, w.name, s.quantity, g.safety_stock FROM stock s JOIN goods g ON s.goods_idg.id JOIN warehouse w ON s.warehouse_idw.idExcel实时v_low_stockWHERE s.quantity g.safety_stock AND s.quantity 0Excel邮件告警每日凌晨2点v_monthly_inoutSELECT YEAR(sl.create_time), MONTH(sl.create_time), sl.type, COUNT(*) FROM stock_log sl GROUP BY 1,2,3PDFiText手动触发v_goods_turnoverSELECT g.name, SUM(CASE WHEN sl.typeIN THEN sl.after_qty-sl.before_qty ELSE 0 END) AS in_sum, SUM(CASE WHEN sl.typeOUT THEN sl.before_qty-sl.after_qty ELSE 0 END) AS out_sum FROM ...Excel周一上午9点v_operator_performanceSELECT sl.operator, COUNT(*) as total_ops, AVG(TIMESTAMPDIFF(SECOND, sl.create_time, NOW())) as avg_delay FROM stock_log sl GROUP BY sl.operatorExcel每月1日注意v_operator_performance中的avg_delay计算的是操作距当前时间的秒数实际用于考核响应速度。若需真实处理时长应记录start_time字段——当前设计为简化版已在TODO.md中标记。5. 避坑指南五个让90%人卡住的致命细节与解决方案5.1 现象入库后库存没变但stock_log里有记录原因stock表的goods_idwarehouse_id联合主键冲突新入库记录被INSERT IGNORE丢弃但stock_log已写入因log插入在stock更新之前。解决检查goods表是否存在同名不同ID的商品如Excel导入时重复生成执行SELECT name, COUNT(*) FROM goods GROUP BY name HAVING COUNT(*)1;清理重复。5.2 现象盘点差异为负数实际比账面少但系统不允许负库存原因stock.quantity字段设为INT UNSIGNED导致UPDATE stock SET quantity quantity - 100 WHERE id1在quantity50时直接报错BIGINT UNSIGNED value is out of range。解决修改表结构ALTER TABLE stock MODIFY COLUMN quantity INT;并确保所有DAO层SQL用quantity GREATEST(0, quantity - ?)兜底。5.3 现象Swing界面按钮点击无响应控制台无报错原因Swing事件队列被阻塞。常见于StockService.inbound()中调用了耗时操作如HTTP请求但未放在SwingWorker中执行。解决将所有数据库操作包裹在SwingWorker中例如new SwingWorkerVoid, Void() { Override protected Void doInBackground() throws Exception { stockService.inbound(...); // 耗时操作 return null; } Override protected void done() { JOptionPane.showMessageDialog(null, 入库成功); } }.execute();5.4 现象导出Excel报表中文乱码但数据库和界面正常原因Apache POI 4.x默认用UTF-8编码生成Excel但Windows Excel默认用GBK打开。解决导出时指定WorkbookFactory.create(new ByteArrayInputStream(bytes), GBK)或更稳妥地——用SXSSFWorkbook并设置单元格编码CellStyle style workbook.createCellStyle(); Font font workbook.createFont(); font.setCharset(Font.DEFAULT_CHARSET); // 改为Font.ANSI_CHARSET style.setFont(font);5.5 现象MySQL连接池耗尽客户端频繁报Cannot get a connection, pool error Timeout waiting for idle object原因Swing客户端每次操作都新建DataSource且未关闭Connection。查看StockDao.java发现getConnection()后缺少finally { conn.close(); }。解决统一用try-with-resourcestry (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { // 执行操作 } // 自动关闭conn和ps6. 进阶技巧用SQL触发器实现库存预警自动推送与Excel模板定制6.1 用MySQL触发器替代应用层轮询实时低库存告警报表模块的“低库存预警”每天凌晨跑一次但真实业务需要秒级响应。我给stock表加了AFTER UPDATE触发器DELIMITER $$ CREATE TRIGGER trigger_low_stock_alert AFTER UPDATE ON stock FOR EACH ROW BEGIN DECLARE v_goods_name VARCHAR(100); DECLARE v_warehouse_name VARCHAR(100); IF NEW.quantity OLD.quantity AND NEW.quantity (SELECT safety_stock FROM goods WHERE id NEW.goods_id) THEN SELECT g.name, w.name INTO v_goods_name, v_warehouse_name FROM goods g, warehouse w WHERE g.id NEW.goods_id AND w.id NEW.warehouse_id; INSERT INTO alert_log (content, create_time) VALUES (CONCAT(【库存预警】, v_goods_name, 在, v_warehouse_name, 剩余, NEW.quantity, 件低于安全库存), NOW()); -- 调用外部脚本发送企业微信消息需MySQL启用sys_exec -- SELECT sys_exec(CONCAT(curl -X POST https://qyapi.weixin.qq.com/cgi-bin/webhook/send?keyxxx -H Content-Type: application/json -d \{msgtype: text, text: {content: 预警: , v_goods_name, 剩余, NEW.quantity, 件}}\)); END IF; END$$ DELIMITER ;注意sys_exec函数需安装lib_mysqludf_sys生产环境慎用。更稳妥的做法是触发器只写alert_log表Java服务端用ScheduledExecutorService每5秒查一次该表查到新记录则发消息——这样解耦且可控。6.2 Excel报表模板定制用FreeMarker生成动态表头与样式系统默认报表导出为纯数据Excel但客户需要带公司Logo、固定表头、条件格式如库存安全值标红。我用FreeMarkerApache POI实现模板化设计report_template.ftl#-- 表头 -- #list headers as h ${h} /#list #-- 数据行 -- #list data as row #list row as cell ${cell} /#list /#listJava层填充数据MapString, Object data new HashMap(); data.put(headers, Arrays.asList(商品名称, 仓库, 当前库存, 安全库存, 状态)); data.put(data, reportService.getLowStockData()); // 返回ListListString Template template configuration.getTemplate(report_template.ftl); StringWriter writer new StringWriter(); template.process(data, writer); // 将writer.toString()解析为Excel用POI设置单元格样式 Workbook wb new XSSFWorkbook(); Sheet sheet wb.createSheet(低库存报表); // ... 样式设置逻辑6.3 一个血泪教训永远在stock_log里记录操作IP与设备ID上线第三周客户投诉“有人偷偷修改库存”但stock_log.operator只记用户名。我紧急加了两字段ALTER TABLE stock_log ADD COLUMN client_ip VARCHAR(15), ADD COLUMN device_id VARCHAR(50);并在所有DAO方法中注入String ip InetAddress.getLocalHost().getHostAddress(); // 客户端IP String deviceId System.getProperty(os.name) - System.getProperty(os.arch); // 简易设备标识从那以后我每次部署新系统都强制走一遍ALTER TABLE stock_log ADD COLUMN ...哪怕客户说“暂时用不到”。因为查数据篡改永远比预防篡改容易——但预防的成本远低于事后取证的代价。希望帮到你。本文还有配套的精品资源点击获取