简介:面向仓储信息化教学与课程设计场景,这套基于射频识别技术的仓库管理系统源码包围绕到货检验、入库分配库位、库存变动记录、库存查询与出库等环节,演示如何用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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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, List<String> epcList, String bizNo) { // 1. 去重:同一批次内先去掉重复 EPC Set<String> unique = new LinkedHashSet<>(epcList); // 2. 过滤时间窗内已处理过的标签,避免信号重叠重复计数 List<String> 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. 校验实收是否等于应收,允许配置是否允许多收 List<InboundOrderItem> 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 List<CheckDiff> compare(Long areaId, List<String> scannedTags) { // 1. 查出该库区所有货架对应的账面库存 List<Stock> bookList = stockMapper.listByArea(areaId); Map<Long, Integer> bookMap = bookList.stream() .collect(Collectors.toMap(Stock::getShelfId, Stock::getQty)); // 2. 扫描到的标签转成货架,再累加实盘数量 Map<Long, Integer> realMap = new HashMap<>(); for (String epc : scannedTags) { Long shelfId = shelfMapper.selectIdByEpc(epc); if (shelfId != null) { realMap.merge(shelfId, 1, Integer::sum); } } // 3. 求差集,生成盘盈盘亏 List<CheckDiff> diffs = new ArrayList<>(); Set<Long> 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 { /** 主动触发一次盘点,返回标签列表 */ List<String> scan(String deviceCode, int timeoutMs); /** 读取器探活 */ boolean alive(String deviceCode); }新增一种读写器只需要实现这个接口,在配置里指定实现类,业务层完全无感。这一步是这套项目架构最值钱的地方,别绕过它直接在 controller 里写设备调用。
第三步是加接口时的自检。我一般会对着这张表核对每个新接口:
| 检查点 | 做法 | 不做的后果 |
|---|---|---|
| 是否操作库存 | 有则必须挂事务 | 单账不一致 |
| 是否接收标签 | 必须走时间去重 | 数量翻倍 |
| 是否改单据状态 | 校验前置状态 | 状态乱跳 |
| 是否有并发写 | 走乐观锁并重试 | 扣成负数 |
| 是否可追溯 | 写读取日志 | 出问题查不动 |
最后一条经验是我自己的习惯:每次改完入库或出库逻辑,我都会手工造三条脏数据——重复标签、库存不足、单据状态不对——跑一遍启动流程再提交。因为仓库系统真正难的不是正常流程,而是“现场扫描枪怼着标签一直响”的时候你的代码还稳不稳。这套项目把设备层和业务层拆开、把读取记录只追加不更新的设计,正是给这种现场留的退路,希望帮到你。
本文还有配套的精品资源,点击获取