简介:这是一套基于若依框架二次开发的WMS仓库管理系统源码,面向需要快速搭建仓储管理平台的后端与全栈开发者,尤其适合具备Spring Boot与Vue基础、希望直接落地企业级进销存场景的技术人员。系统前端采用Vue与Element UI,后端基于Spring Boot、Spring Security、Redis与Jwt,支持动态权限菜单与多终端认证,并内置代码生成器可一键生成前后端代码。功能覆盖首页库存预警与到期提醒、仓库/库区/货架、物料、客户/供应商/承运商等基础数据维护,以及入库、出库、移库、库存看板与库存记录等核心模块,入库单与出库单均支持lodop和网页打印。资源包共582个文件,以472个java源码、55个xml配置、31个vm模板及少量properties、yml、sql、bat脚本为主,压缩包约807KB,结构完整便于二次开发。目前已有920人学习下载,可作为仓储管理系统学习与项目落地的实用参考。
1. 从一张打不出来的入库单说起:若依 WMS 到底解决什么问题
很多做仓储系统的开发者都遇到过这种场景:业务方在后台点下“打印入库单”,浏览器弹出一个预览窗口,页边距不对、表格串行、条码糊成一团,换台打印机结果又不一样。更麻烦的是,仓库现场用的往往是针式打印机配专用打印控件,而办公室文员只想在 Chrome 里直接点打印。同一套 WMS,两种打印诉求,这就是标题里“支持 lodop 和网页打印”要同时存在的原因。
基于若依的 WMS 仓库管理系统,本质是把若依这套成熟的后台权限与代码生成骨架,套上仓储业务的核心模型:仓库、库区、货架三级库位,加上入库、出库两条主流程,最后把单据落到纸上。它适合两类人:一类是手里已经有若依项目、想快速长出仓储模块的后端;另一类是被 Excel 管库存折磨、想自建一套能打印单据的小团队。这篇文章不讲空概念,只讲这套系统怎么搭、库位怎么设计、两种打印怎么共存、哪些参数一改就翻车。
2. 仓库/库区/货架三级库位怎么建模才不返工
仓储系统最容易在数据模型上埋雷。很多人一开始只建一张“仓库表”,等业务说“我要按货架拣货”时才发现整条出入库链路都要改。三级库位不是把三张表简单串起来,而是要提前想清楚编码规则、层级约束和库存挂载点。
2.1 三级库位的表结构与编码规则
常见做法是三张表加一张库存表。仓库表存物理仓库,库区表用warehouse_id关联仓库,货架表用area_id关联库区,库存表则挂在货架维度上。编码建议用分段拼接,比如仓库WH01、库区WH01-A、货架WH01-A-01,这样人工看编码就能定位物理位置,扫码枪扫出来也能直接解析。
-- 仓库表 CREATE TABLE wms_warehouse ( id BIGINT PRIMARY KEY AUTO_INCREMENT, warehouse_code VARCHAR(32) NOT NULL COMMENT '仓库编码,如 WH01', warehouse_name VARCHAR(64) NOT NULL, address VARCHAR(255) DEFAULT NULL, status TINYINT DEFAULT 1 COMMENT '1启用 0停用', UNIQUE KEY uk_warehouse_code (warehouse_code) ) COMMENT '仓库'; -- 库区表 CREATE TABLE wms_area ( id BIGINT PRIMARY KEY AUTO_INCREMENT, warehouse_id BIGINT NOT NULL, area_code VARCHAR(32) NOT NULL COMMENT '库区编码,如 WH01-A', area_name VARCHAR(64) NOT NULL, area_type TINYINT DEFAULT 1 COMMENT '1存储区 2拣货区 3暂存区', UNIQUE KEY uk_area_code (area_code), KEY idx_warehouse (warehouse_id) ) COMMENT '库区'; -- 货架表 CREATE TABLE wms_shelf ( id BIGINT PRIMARY KEY AUTO_INCREMENT, area_id BIGINT NOT NULL, shelf_code VARCHAR(32) NOT NULL COMMENT '货架编码,如 WH01-A-01', shelf_name VARCHAR(64) NOT NULL, layer_num INT DEFAULT 1 COMMENT '层数', UNIQUE KEY uk_shelf_code (shelf_code), KEY idx_area (area_id) ) COMMENT '货架';逻辑说明:warehouse_code、area_code、shelf_code都加了唯一索引,防止重复编码导致库存对不上。area_type用来区分存储区和拣货区,后续出库策略可以优先从拣货区扣减。参数上,layer_num如果业务不涉及货架层,可以先默认 1,但字段留着,避免以后加层要改表。
2.2 库存挂载点与库位联动
库存到底挂仓库还是挂货架,是第二个分叉点。如果只挂仓库,拣货时无法指导具体位置;如果挂货架,入库上架就必须选到货架。我的建议是库存挂货架,但允许货架为空表示“未上架暂存”。这样入库单审核后先入暂存,上架动作再落到具体货架,流程更贴近真实仓库。
// 库存实体关键字段(若依风格,省略 getter/setter) public class WmsStock { private Long id; private Long warehouseId; private Long areaId; private Long shelfId; // 可为空,表示暂存 private Long goodsId; private String batchNo; private Integer quantity; private Integer lockQty; // 锁定数量,出库单占用 }逻辑说明:lockQty是出库流程的关键。出库单创建时先锁库存,审核后再实际扣减,避免两个订单同时抢同一批货。参数上,quantity和lockQty都用整型,如果业务有拆零需求,改成DECIMAL(18,3)并统一单位。
2.3 用若依代码生成器生成三级 CRUD
若依自带代码生成器,三级表可以先生成基础 CRUD,再手工加联动。生成时把warehouse_id、area_id配成下拉,货架下拉要根据库区动态过滤。
// 前端:库区切换时刷新货架下拉 handleAreaChange(areaId) { if (!areaId) { this.shelfOptions = []; return; } listShelf({ areaId: areaId }).then(res => { this.shelfOptions = res.rows; this.form.shelfId = null; // 清空旧选择,防止跨库区脏数据 }); }逻辑说明:这段代码解决的是“切换库区后货架还留着上一个库区数据”的经典 bug。参数上,areaId为空时直接清空选项,避免全量查询。生成器出来的页面默认是平铺表单,三级联动需要手动改change事件,别指望生成器一步到位。
3. 入库单与出库单:从建单到审核的状态机
出入库是 WMS 的主干。很多系统把“保存”和“审核”混在一起,导致库存一保存就变,退货和冲销无从下手。正确做法是单据有明确状态,库存变动只发生在审核节点。
3.1 单据状态设计与库存变动时机
常见状态是:草稿、待审核、已审核、已作废。草稿和待审核不动库存,已审核才写库存流水。出库单在待审核阶段可以锁库存,防止超卖。
public enum OrderStatus { DRAFT(0, "草稿"), PENDING(1, "待审核"), AUDITED(2, "已审核"), CANCELED(3, "已作废"); // 构造与 getter 省略 }逻辑说明:状态用枚举而不是魔法数字,前端传 0/1/2/3 时后端能校验合法性。参数上,作废只能从草稿或待审核进入,已审核单据要走红冲流程,不能直接改状态。
3.2 入库单审核的库存写入逻辑
入库审核时,按明细逐条写库存,同时写一条库存流水。流水表是后续对账和追溯的唯一依据,不能省。
@Transactional(rollbackFor = Exception.class) public void auditInbound(Long orderId) { WmsInboundOrder order = inboundMapper.selectById(orderId); if (order.getStatus() != OrderStatus.PENDING.getCode()) { throw new ServiceException("当前状态不可审核"); } List<WmsInboundItem> items = itemMapper.selectByOrderId(orderId); for (WmsInboundItem item : items) { // 按货架+商品+批次定位库存,存在则累加,不存在则新建 WmsStock stock = stockMapper.selectForUpdate( item.getShelfId(), item.getGoodsId(), item.getBatchNo()); if (stock == null) { stock = buildStock(item); stockMapper.insert(stock); } else { stock.setQuantity(stock.getQuantity() + item.getQuantity()); stockMapper.updateById(stock); } stockMapper.insertFlow(item, "IN", order.getOrderNo()); } order.setStatus(OrderStatus.AUDITED.getCode()); inboundMapper.updateById(order); }逻辑说明:selectForUpdate加行锁,防止并发审核同一货架库存导致覆盖。参数上,batchNo如果业务不启用批次,统一传DEFAULT,但字段保留。@Transactional必须加,库存和流水要么一起成功要么一起回滚。
3.3 出库单的锁库存与扣减
出库比入库多一步锁库存。建单时锁,审核时扣,作废时释放。
public void lockStock(Long orderId) { List<WmsOutboundItem> items = itemMapper.selectByOrderId(orderId); for (WmsOutboundItem item : items) { int updated = stockMapper.lockStock( item.getShelfId(), item.getGoodsId(), item.getQuantity()); if (updated == 0) { throw new ServiceException("库存不足:" + item.getGoodsName()); } } }对应的 SQL 用条件更新保证原子性:
UPDATE wms_stock SET lock_qty = lock_qty + #{qty} WHERE shelf_id = #{shelfId} AND goods_id = #{goodsId} AND quantity - lock_qty >= #{qty}逻辑说明:quantity - lock_qty >= qty是可用库存判断,放在 SQL 里比先查再改更安全。参数上,lock_qty只增不减,审核时扣quantity同时减lock_qty,作废时只减lock_qty。
4. 两种打印怎么共存:lodop 与网页打印的落地细节
打印是这套系统里最容易被低估的部分。lodop 适合针式打印机和精确套打,网页打印适合普通激光打印机和远程办公。两者不是二选一,而是按场景切换。
4.1 lodop 打印控件的接入与模板设计
lodop 需要在客户端安装打印控件,前端通过LODOP对象调用。模板用ADD_PRINT_TABLE画表格,坐标要按纸张实测。
function printInboundByLodop(order) { const LODOP = getLodop(); LODOP.PRINT_INIT("入库单打印"); LODOP.SET_PRINT_PAGESIZE(1, 2410, 1400, ""); // 241mm 宽,140mm 高,针式套打 LODOP.ADD_PRINT_TEXT(30, 30, 200, 25, "入库单"); LODOP.ADD_PRINT_TEXT(30, 400, 200, 25, "单号:" + order.orderNo); LODOP.ADD_PRINT_TABLE(80, 30, 2350, 600, buildTableHtml(order.items)); LODOP.PREVIEW(); }逻辑说明:SET_PRINT_PAGESIZE第一个参数 1 表示自定义纸张,宽高单位是 0.1mm。参数上,针式打印机套打必须实测偏移,ADD_PRINT_TEXT的前两个参数是 top 和 left,差 10 就可能压到预印格线上。buildTableHtml返回标准 table 字符串,lodop 对复杂 CSS 支持有限,别用 flex。
4.2 网页打印的 CSS 分页与打印样式
网页打印靠window.print(),核心是@media print和分页控制。
@media print { .no-print { display: none !important; } .print-table { width: 100%; border-collapse: collapse; } .print-table th, .print-table td { border: 1px solid #000; padding: 4px; font-size: 12px; } .page-break { page-break-after: always; } @page { size: A4; margin: 10mm; } }逻辑说明:.no-print用来隐藏按钮和导航,@page控制纸张和页边距。参数上,page-break-after用在明细超过一页时手动分页,但要注意表头重复需要thead配合display: table-header-group。
4.3 打印方案的选择与降级策略
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 针式打印机套打 | lodop | 坐标精确,支持自定义纸张 |
| 激光打印机普通单据 | 网页打印 | 无需装控件,跨平台 |
| 远程办公/移动端 | 网页打印 | lodop 依赖客户端 |
| 批量连续打印 | lodop | 可控性强,不易串页 |
降级策略:前端先检测 lodop 是否可用,不可用则自动切网页打印,并提示用户。
function smartPrint(order) { if (typeof getLodop === "function" && getLodop()) { printInboundByLodop(order); } else { window.print(); // 页面已渲染好打印区域 } }逻辑说明:getLodop未定义或返回空时走网页打印,保证任何环境都能出单。参数上,网页打印前要确保打印区域已渲染,否则打出空白页。
5. 避坑与排查:那些让库存对不上的细节
5.1 并发审核导致库存覆盖
现象:两个入库单同时审核同一货架,最终库存只加了一次。原因:先查后改没有加锁。解决:用selectForUpdate或条件更新,把判断和写入放同一条 SQL。
5.2 打印偏移每次都要调
现象:lodop 打印内容整体偏下 5mm。原因:纸张高度设置和打印机实际走纸有误差。解决:在SET_PRINT_PAGESIZE里微调高度,或统一加一个全局偏移量配置,别每次改模板。
5.3 网页打印分页串行
现象:明细表跨页时第二页没有表头。原因:表头没放在thead里。解决:把表头行放进thead,并加display: table-header-group。
5.4 库位删除后库存成孤儿
现象:删了货架,库存还在但查不到位置。原因:外键没约束或逻辑删除没联动。解决:货架删除前校验库存,有库存禁止删除,或改为停用而非物理删除。
5.5 批次号为空导致库存合并
现象:不同批次的货被当成同一批累加。原因:batchNo传了 null,数据库唯一索引对 null 不生效。解决:不启用批次时统一写DEFAULT,别留 null。
6. 进阶:用打印模板配置化减少改代码
打印模板硬编码在代码里,每换一种单据就要改前端,这是后期最大的维护负担。我的习惯是把模板做成可配置:模板内容存数据库,用占位符替换,lodop 和网页打印共用同一份模板数据。
// 模板渲染:占位符替换 function renderTemplate(tpl, data) { return tpl.replace(/\{\{(\w+)\}\}/g, (_, key) => data[key] ?? ""); } // 表格明细单独渲染后替换 {{items}} function renderItems(items) { return items.map(i => `<tr><td>${i.goodsName}</td><td>${i.quantity}</td></tr>` ).join(""); }逻辑说明:{{key}}是普通字段占位,{{items}}是明细区域,渲染时先替换明细再替换主字段。参数上,模板存wms_print_template表,按biz_type区分入库单、出库单,前端按类型拉取。
验证模板是否正确,不用真打印,先用预览接口返回渲染后的 HTML,在浏览器里比对字段和分页。我一般会准备一张“边界单据”:明细 1 条、明细 50 条、字段超长各一份,三份都能正常出单,模板才算过关。
最后说个血泪经验:打印这事,别等上线前才测。针式打印机的驱动、纸张、偏移,每换一台机器都可能不一样,早点拿真实打印机跑一遍,比在代码里猜坐标强得多。希望帮到你。
本文还有配套的精品资源,点击获取