RFID仓库管理系统实战:入库出库、库存台账与盘点去重设计
发布时间:2026/11/4 7:22:25 锦皓数字建站

简介面向仓储信息化教学与课程设计场景这套基于射频识别技术的仓库管理系统源码包围绕到货检验、入库分配库位、库存变动记录、库存查询与出库等环节演示如何用RFID自动采集数据提升录入速度与准确性帮助读者理解实时库存掌控与库存控制的实现思路。包内共42个文件约1.46MB以12个h头文件、10个cpp源文件构成核心业务与通信逻辑配合8个php页面、4个pro工程文件与4个ui界面文件另有doc说明文档及a、so、dll等库文件覆盖服务端、终端查询与分类入库等模块便于按目录梳理系统结构。已有931人学习下载适合课程设计、毕业设计或仓库管理原型开发时参考可从中获取RFID读写接入、数据追溯、终端查询与前后端协同的实现范例及调试线索。1. 一套“贴着货架跑”的仓库系统到底解决了什么问题我第一次接触这套基于射频识别技术的仓库管理系统是在一个只有六个库区、两班倒发货的小仓库里。当时最大的痛点不是“不会写代码”而是进出库单据和实物永远对不上账扫码枪要对着箱子上的一维码一个个点一批货五十箱点完一上午没了遇上标签磨损还得手动输入错一个数字后面全乱。这套系统给出的思路很直接——把标签贴在托盘和货架上让读写器自动批量识别人只负责搬和决策。它适合谁适合手上有中小型仓库、需要做入库出库盘点指令流、又想拿到一份能改源码的开发者。下文里 RFID 读写、入库单、出库单、库存台账、权限这几个词会反复出现都是这套系统真正在跑的东西。它技术上不是玩具后端是典型的业务管理系统分层结构有 controller、service、mapper 三层数据库里落的是货品、库区、货架、入库单、出库单、库存流水这几张核心表前端是一个管理后台的页面集合设备和业务之间靠一层接口层解耦——读写器读到的标签编号先落到读取记录里再由业务规则决定这批货该进哪个单据。这套分层的好处是硬件换了、协议变了改接口层就够了业务代码不用推倒重来。对新手来说它是一份完整跑得通的 CRUD 实战对熟手来说真正值得看的是“读取事件怎么做幂等、单据状态怎么流转”这两个设计点。2. 工程结构与技术选型从标签编号到库存台账的一条链路2.1 目录分层与模块职责这类系统的目录一般按业务域加技术层双维度切分我看到的结构大致是这样warehouse-rfid/ ├── doc/ # 数据库脚本、接口说明 │ ├── schema.sql │ └── api.md ├── src/main/java/com/demo/warehouse/ │ ├── controller/ # 对外接口货品、入库、出库、盘点、库存 │ ├── service/ # 业务规则单据状态机、库存扣减 │ ├── mapper/ # 数据库访问 │ ├── entity/ # 表实体 │ ├── dto/ # 请求/响应对象 │ ├── device/ # RFID 读写器适配层 │ └── common/ # 统一返回、异常、拦截器 ├── src/main/resources/ │ ├── application.yml │ ├── mapper/*.xml │ └── static/ # 管理后台页面 └── pom.xml整个链路是“读卡事件 → 读取记录 → 单据匹配 → 库存变更”四段。前三段允许失败和重试最后一段必须是事务性的否则就会出现“单据显示入库成功、台账没加”这种最典型的翻车。下面几节按这条链路往下拆。2.2 技术栈与选型理由后端用 Spring Boot 做容器和 REST 接口MyBatis 做 SQL 映射MySQL 存业务数据前端用普通的 HTML 模板加少量异步请求。这个组合不是最新潮的但对仓库场景很实用层次选型为什么这么选接口层Spring MVC单据类接口天然是资源式增删改查路径好设计持久层MyBatis库存查询条件多、要写联表和聚合手写 SQL 可控存储MySQL单据和台账是强一致关系数据事务必备设备层独立适配类读写器协议差异大隔离在 device 包前端模板 原生请求仓库现场电脑配置低页面越轻越好选型上的血泪经验是不要在设备读取那一步直接调库存扣减。读写器会因为信号重叠、标签密集重复上报一次搬货能读到十几条相同编号。我的做法是让它只负责“记录”由业务层按业务单号做去重和校验。2.3 数据库核心表设计表结构决定了后面代码能写成什么样。这套系统里最关键的五张表字段我按常见实现给出来-- 货品主数据 CREATE TABLE goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_code VARCHAR(32) NOT NULL UNIQUE COMMENT 货品编码, goods_name VARCHAR(128) NOT NULL, spec VARCHAR(64), unit VARCHAR(16) ); -- 货架位RFID 标签挂在货架上也用于定位 CREATE TABLE shelf ( id BIGINT PRIMARY KEY AUTO_INCREMENT, shelf_code VARCHAR(32) NOT NULL UNIQUE COMMENT 货架编码, area_code VARCHAR(32) NOT NULL COMMENT 所属库区, tag_epc VARCHAR(64) COMMENT 货架标签 EPC ); -- 库存台账一个货品在一个货架上一条记录 CREATE TABLE stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_id BIGINT NOT NULL, shelf_id BIGINT NOT NULL, qty INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_goods_shelf (goods_id, shelf_id) ); -- 入库单明细 CREATE TABLE inbound_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, goods_id BIGINT NOT NULL, expect_qty INT NOT NULL COMMENT 应收数量, real_qty INT NOT NULL DEFAULT 0 COMMENT 实收数量, tag_epc VARCHAR(64) COMMENT 托盘标签 ); -- 读取记录设备上报的原始事件只追加不更新 CREATE TABLE tag_read_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tag_epc VARCHAR(64) NOT NULL, device_code VARCHAR(32) NOT NULL, read_time DATETIME NOT NULL, biz_no VARCHAR(32) COMMENT 关联单据号可空 );stock表里的version字段是给乐观锁用的tag_read_log只追加不更新这条设计能让你在出问题时回放“哪台设备在什么时间读到了哪个标签”相当于给自己留了后悔药。inbound_order_item上特意存了托盘标签 EPC是为了让“读到的标签”和“应到货物”能对上号。2.4 配置与启动方式application.yml里除了常规数据源一般会多一段设备参数server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/warehouse_rfid?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 rfid: device-code: READER-01 # 同标签在该时间窗内重复上报视为一次 dedup-window-seconds: 5 # 单次盘点允许的最大标签数防止脏数据撑爆内存 max-tags-per-scan: 500dedup-window-seconds是整套系统里最不起眼但最要命的一个参数后面避坑那节会专门说。启动就是标准的 Spring Boot 流程# 1. 建库并导入表结构 mysql -uroot -p warehouse_rfid doc/schema.sql # 2. 编译打包 mvn clean package -DskipTests # 3. 启动 java -jar target/warehouse-rfid.jar注意打包前先确认doc/schema.sql有没有建初始库区和货架数据没有的话第一次进页面看不到可选的货架会误以为接口炸了。3. 核心业务实现入库、出库与库存扣减怎么写3.1 读取事件去重与单据匹配设备层拿到的永远是“裸标签”业务层要回答的是“这批标签属于哪张单”。下面是服务端的处理骨架Service public class TagReadService { Autowired private TagReadLogMapper logMapper; Autowired private InboundOrderMapper inboundMapper; /** * 处理一批上报标签 * param deviceCode 读写器编号 * param epcList 本次读到的标签集合 * param bizNo 前端或设备指定的单据号可为空 */ public ScanResult handleScan(String deviceCode, ListString epcList, String bizNo) { // 1. 去重同一批次内先去掉重复 EPC SetString unique new LinkedHashSet(epcList); // 2. 过滤时间窗内已处理过的标签避免信号重叠重复计数 ListString fresh new ArrayList(); for (String epc : unique) { int cnt logMapper.countRecent(epc, deviceCode, 5); if (cnt 0) { fresh.add(epc); } } // 3. 落原始日志方便追溯 for (String epc : fresh) { logMapper.insert(deviceCode, epc, bizNo); } // 4. 单据为空则只做记录不碰库存 if (bizNo null) { return ScanResult.of(fresh.size(), false); } // 5. 按单据里的预期标签做匹配命中的回填实收数量 int matched inboundMapper.matchAndFill(bizNo, fresh); return ScanResult.of(matched, true); } }逻辑说明第 1 步用LinkedHashSet保证顺序同时去掉同一批里的重复第 2 步查最近 5 秒的日志把“信号重叠导致的重复上报”挡在外面这个 5 秒对应配置里的dedup-window-seconds第 3 步无论后面成不成功原始记录都写下来这就是前面说的后悔药第 4 步是一个刻意的分叉——没绑定单据的扫描只做记录不做业务动作避免误扫把账搞乱第 5 步才真正回填实收数量。参数bizNo为空是常见情况比如现场只是随手扫一下看看标签好不好使。3.2 入库单状态流转与库存增加入库的核心不是加库存而是单据状态和库存台账要保持一致。常见做法是给单据定义一个状态机待收货 → 收货中 → 已完成。Service public class InboundService { Autowired private InboundOrderMapper orderMapper; Autowired private StockMapper stockMapper; /** * 完成入库单据置完成 台账加数 * 必须同一个事务否则会出现单已完、账没加 */ Transactional(rollbackFor Exception.class) public void finishInbound(String orderNo) { InboundOrder order orderMapper.selectByNo(orderNo); if (order null) { throw new BizException(单据不存在); } if (!RECEIVING.equals(order.getStatus())) { throw new BizException(当前状态不允许完成入库); } // 1. 校验实收是否等于应收允许配置是否允许多收 ListInboundOrderItem items orderMapper.selectItems(orderNo); for (InboundOrderItem item : items) { if (item.getRealQty() item.getExpectQty()) { throw new BizException(货品 item.getGoodsId() 实收不足); } } // 2. 更新台账存在则加数不存在则插入 for (InboundOrderItem item : items) { int rows stockMapper.increase(item.getGoodsId(), item.getShelfId(), item.getExpectQty(), item.getVersion()); if (rows 0) { // 乐观锁失败或记录不存在插入新行 stockMapper.insertIfAbsent(item.getGoodsId(), item.getShelfId(), item.getExpectQty()); } } // 3. 单据状态推到已完成 orderMapper.updateStatus(orderNo, FINISHED); } }参数说明Transactional上必须写rollbackFor Exception.class因为默认只回滚运行时异常业务里抛的受检异常会把事务留成半截状态increase方法有三个入参很关键——货品、货架、数量再加版本号做乐观锁rows 0的判断不能省它可能是版本冲突也可能是记录根本不存在这里用“插入或忽略”兜底是最省事的写法。注意单据状态更新放在最后是为了让“失败就整体回滚”的语义更直观。3.3 出库与库存扣减的乐观锁出库比入库更容易出问题因为扣减可能扣成负数。这里的写法是“先校验再扣扣不动就重试一次然后报错”Service public class OutboundService { Autowired private StockMapper stockMapper; /** * 扣减库存 * return true 扣减成功 */ public boolean deduct(Long goodsId, Long shelfId, int qty) { for (int retry 0; retry 3; retry) { Stock stock stockMapper.selectByGoodsAndShelf(goodsId, shelfId); if (stock null || stock.getQty() qty) { throw new BizException(库存不足); } // 带版本号更新版本不匹配说明有并发改动 int rows stockMapper.deduct(goodsId, shelfId, qty, stock.getVersion()); if (rows 0) { return true; } } throw new BizException(库存扣减冲突请重试); } }逻辑说明循环 3 次是给并发留的余地仓库里同一货品被两个操作员同时拣货真的会发生stock.getQty() qty的判断放在更新前是为了给出“库存不足”这种业务可读的报错而不是让数据库扣出负数deduct的 SQL 里一定带上version #{version}和qty qty - #{qty}两个条件前者防并发、后者防越扣越多。如果三次都冲突说明这块库位被频繁争抢要么加大重试要么在业务上把同一货架的拣货串行化。3.4 盘点一次扫描如何生成差异台账盘点的实现思路是“快照对比”先按当前台账生成账面数再把本次扫描结果当作实盘数两个一减就是差异。public ListCheckDiff compare(Long areaId, ListString scannedTags) { // 1. 查出该库区所有货架对应的账面库存 ListStock bookList stockMapper.listByArea(areaId); MapLong, Integer bookMap bookList.stream() .collect(Collectors.toMap(Stock::getShelfId, Stock::getQty)); // 2. 扫描到的标签转成货架再累加实盘数量 MapLong, Integer realMap new HashMap(); for (String epc : scannedTags) { Long shelfId shelfMapper.selectIdByEpc(epc); if (shelfId ! null) { realMap.merge(shelfId, 1, Integer::sum); } } // 3. 求差集生成盘盈盘亏 ListCheckDiff diffs new ArrayList(); SetLong all new HashSet(bookMap.keySet()); all.addAll(realMap.keySet()); for (Long shelfId : all) { int book bookMap.getOrDefault(shelfId, 0); int real realMap.getOrDefault(shelfId, 0); if (book ! real) { diffs.add(new CheckDiff(shelfId, book, real, real - book)); } } return diffs; }这段的重点在第二个循环标签是挂在托盘或货品上的得先通过tag_epc反查货架这一步查不到就跳过不要硬塞进去否则差异表会出现一堆无主的行。merge(shelfId, 1, Integer::sum)是在给同一货架上的标签计数一个货架多个标签时数字才对得上。差异结果不要直接改台账要先落成盘点单人工确认后再调账这个流程在小仓库里能省掉大量扯皮。4. 避坑排查设备、数据与状态机上的五个真实坑4.1 同一个标签被读成一条又一条现象一次搬二十箱货入库单实收数量变成四十多反复核对才发现多了。原因RFID 是射频读取标签密集或读头功率偏高时会在极短时间重复上报业务层如果每条都累加就会翻倍。解决在服务端按标签加时间窗去重也就是 3.1 里的countRecent逻辑同时把读头功率调低一档从源头减少重复。去重只能防业务层重复计数源头功率还是得现场调。4.2 单据说完成了台账却没变现象页面上入库单显示已完成点开库存发现数量没加。原因状态更新和库存更新不在同一个事务里或者Transactional的方法被同类内部直接调用导致代理失效、事务根本没生效。解决把“存台账”和“改状态”放进同一个 service 方法public修饰且通过代理调用再检查rollbackFor是否配置否则一旦抛受检异常事务仍是半截。这个坑属于玄学范畴代码看着对跑起来就是不对。4.3 盘点结果比账面少一大截现象盘点扫了一遍差异表里几乎全部盘亏。原因读头没覆盖到的货架、标签贴在金属货架上被屏蔽、或者扫描时漏扫了某个库区都会被算成“实盘为零”。解决盘点前先把每个库区单独扫一遍并记录设备编号差异表里附上“未扫描到的货架”提示金属环境用抗金属标签别指望普通标签贴哪都灵。4.4 出库时库存扣成了负数现象并发拣货时明明库里只有十件两个操作员同时出库后变负数。原因扣减语句没有把“数量足够”写进 SQL 的 where 条件里只在 Java 里查了一下就扣。解决扣减 SQL 加上AND qty #{qty}返回影响行数为 0 就当作失败处理同时保留版本号条件双保险。4.5 启动后页面空白或接口 404现象服务起来了访问页面空白接口报找不到路径。原因前端静态资源没打进包或者schema.sql里的初始数据没导入导致首页查不到库区直接抛异常。解决确认src/main/resources/static在打包产物里启动后先访问健康检查接口再用数据库客户端确认库区和货架有没有数据。这两件事看着基础但每次换环境都会有人栽在上面。5. 二次开发从只能收货到能对接真实业务的改造思路改造这套系统我建议按“先稳数据、再加功能、最后接硬件”的顺序推进不要一上来就动库存扣减。第一步是扩展货品维度。现在goods表只有编码、名称、规格如果业务需要批次有效期就在stock和入库明细上加batch_no、expire_date两个字段。改动会顺着渗到库存查询和盘点差异但不会破坏整体结构。第二步是设备适配层。device包里通常是一个接口加一个默认实现public interface RfidReader { /** 主动触发一次盘点返回标签列表 */ ListString scan(String deviceCode, int timeoutMs); /** 读取器探活 */ boolean alive(String deviceCode); }新增一种读写器只需要实现这个接口在配置里指定实现类业务层完全无感。这一步是这套项目架构最值钱的地方别绕过它直接在 controller 里写设备调用。第三步是加接口时的自检。我一般会对着这张表核对每个新接口检查点做法不做的后果是否操作库存有则必须挂事务单账不一致是否接收标签必须走时间去重数量翻倍是否改单据状态校验前置状态状态乱跳是否有并发写走乐观锁并重试扣成负数是否可追溯写读取日志出问题查不动最后一条经验是我自己的习惯每次改完入库或出库逻辑我都会手工造三条脏数据——重复标签、库存不足、单据状态不对——跑一遍启动流程再提交。因为仓库系统真正难的不是正常流程而是“现场扫描枪怼着标签一直响”的时候你的代码还稳不稳。这套项目把设备层和业务层拆开、把读取记录只追加不更新的设计正是给这种现场留的退路希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。