简介:这是一套面向高校课程设计、毕业设计及Java Web入门进阶者的仓库管理系统项目源码,围绕库存管理、进出货流程、供应商与订单数据维护等场景展开,可帮助读者理解一个中小型管理系统从业务建模到页面呈现的完整实现思路。压缩包共70个文件,约8.52MB,以16个Java源文件为核心,配合43张界面截图与素材图、2个PSD设计稿、2份TXT安装说明及项目配置与数据库文件,便于对照代码、界面和运行环境进行学习。目前已有4615人学习下载,具备较好的参考基础。读者可从中梳理MVC分层、数据库访问、用户登录与权限控制、统计图表展示等常见模块的组织方式,并结合安装说明完成环境配置与源码调试,适合用于课程作业改造、项目复现和Java Web开发能力训练。
1. 接手一个五六个货架的备件仓时,最头疼的不是没有系统,而是用 Excel 管了三年
每个月盘点总会差几十件,也没人说得清是哪一单出的问题。这套 Java 仓库管理系统项目源码,就是针对这类场景准备的:商品档案、仓库与库位、入库单、出库单、实时库存、库存流水、盘点调整、角色权限,一条主线全串起来,跑起来就是一个能录单、能查库存、能对账的后台。它适合三类人:刚学完 Spring Boot、想找一个业务完整的项目来练手的初中级 Java 开发;要给小厂或自家门店做一套内部工具的外包人员;以及需要交课程设计的同学。接下来按「技术栈和目录怎么排 → 怎么在本机跑起来 → 入库出库的核心链路怎么写 → 踩过哪些坑 → 怎么往上加功能」的顺序拆解。
2. 技术栈与目录结构:Spring Boot 三层骨架是如何实现的
先把定位说清楚:这不是一个只做增删改查的 CRUD 项目,它的复杂度主要在「单据状态流转 + 库存变更 + 流水留痕」这三件事的一致性上。所以看目录结构时,重点不在包名有多整齐,而在于业务逻辑有没有全部集中在 Service 层、库存变更有没有统一入口。我一般拿到这类源码,第一件事就是找service/impl下跟Stock相关的实现类,如果库存加减分散在三四个 Controller 里各写各的,那后期基本不修改;如果集中在一个StockService里,这套代码就有继续修改的价值。
2.1 技术选型与版本组合
这套源码用的是典型的国内中小企业后台组合。整套技术栈里没有冷门依赖,这一点很重要,意味着你在自己机器上大概率不用为了某个怪异的 jar 包折腾半天。
| 层次 | 技术 | 常见版本 | 说明 |
|---|---|---|---|
| 运行环境 | JDK | 1.8 / 17 | 源码编译级别默认 1.8,用 17 跑一般也没问题 |
| 构建 | Maven | 3.6+ | 单模块或父子模块两种形态都有 |
| 框架 | Spring Boot | 2.7.x | 集成了 Web、事务、参数校验 |
| 持久层 | MyBatis-Plus | 3.5.x | 单表 CRUD 零 SQL,复杂查询走 XML |
| 数据库 | MySQL | 8.0 | utf8mb4,InnoDB |
| 缓存 | Redis | 5.0+ | 存登录 token 和字典缓存,可选 |
| 认证 | JWT + 拦截器 | — | 无状态,前后端分离好接 |
| 前端 | Vue2 + 后台模板 | — | 独立工程,npm run dev起 8081 |
为什么用 MyBatis-Plus 而不是纯 MyBatis?因为仓库系统里 80% 的查询是「按条件分页查列表」,字段多、条件杂,纯 XML 写一遍where 1=1的拼接非常耗时。MyBatis-Plus 的LambdaQueryWrapper能覆盖这部分,剩下 20% 的入库聚合、库存报表再落到 XML 里手写。这种「八成靠封装、两成靠手写」的搭配,是我在中小项目里最常用的做法,比全 JPA 好控制 SQL,比全手写省一半时间。
至于 JWT 而不是 Session,是为了让前端独立部署时不用处理 cookie 跨域。代价是 token 失效控制要靠 Redis 黑名单,源码里对这块做得比较简,logout只清了前端本地存储,服务端 token 在过期前仍有效。这个取舍对小系统能接受,后面讲二次开发时我会提怎么补。
2.2 目录结构与模块划分
后端整体是标准的 controller / service / mapper / entity 四层,额外的common、config、interceptor放公共能力。
wms/ ├── pom.xml ├── src/main/java/com/example/wms/ │ ├── WmsApplication.java # 启动类,@MapperScan 在这里 │ ├── common/ │ │ ├── R.java # 统一返回体 {code,msg,data} │ │ ├── BizException.java # 业务异常,被全局处理器捕获 │ │ ├── GlobalExceptionHandler.java │ │ └── PageResult.java # 分页返回封装 │ ├── config/ │ │ ├── MybatisPlusConfig.java # 分页插件、自动填充 │ │ ├── WebMvcConfig.java # 拦截器注册、跨域 │ │ └── RedisConfig.java │ ├── interceptor/ │ │ └── AuthInterceptor.java # 解析 token,塞 UserContext │ ├── controller/ │ │ ├── GoodsController.java │ │ ├── InboundController.java │ │ ├── OutboundController.java │ │ └── StockController.java │ ├── service/ │ │ ├── StockService.java # 库存变更的唯一入口 │ │ └── impl/... │ ├── mapper/ │ └── entity/ └── src/main/resources/ ├── application.yml ├── mapper/ # 与 mapper 接口同名的 XML └── sql/wms.sql # 建库建表 + 初始数据前端是独立目录wms-ui/,src/api按模块拆请求,src/views按业务拆页面,src/utils/request.js里统一注入 token 和处理code != 200。这个结构谈不上多先进,但边界清楚:要加一个「调拨单」,后端加三件套(Controller/Service/Mapper),前端加一个 api 文件和一个 views 目录,不需要动别的模块。
有个细节值得注意:实体类没有直接用数据库字段名,而是@TableField显式映射,比如goods_name对应goodsName。这看着啰嗦,但在多人协作时能避免「驼峰下划线映射到底开没开」这类低水平问题。
2.3 数据库表设计:七张表如何支撑整个系统
表不多,但主次很分明。核心是「库存表」和「流水表」两张,其他都是围绕它们的主数据。
-- 商品档案 create table wms_goods ( id bigint primary key auto_increment, goods_code varchar(32) not null comment '商品编码,业务唯一', goods_name varchar(64) not null, spec varchar(64) comment '规格', unit varchar(16) comment '单位', category_id bigint, status tinyint default 1 comment '1启用 0停用', unique key uk_goods_code (goods_code) ) engine=InnoDB default charset=utf8mb4; -- 实时库存:一个仓库 + 一个商品 = 一行 create table wms_stock ( id bigint primary key auto_increment, warehouse_id bigint not null, goods_id bigint not null, qty int not null default 0 comment '可用数量', version int not null default 0 comment '乐观锁版本号', unique key uk_wh_goods (warehouse_id, goods_id) ) engine=InnoDB default charset=utf8mb4; -- 库存流水:只增不改,对账的唯一依据 create table wms_stock_flow ( id bigint primary key auto_increment, warehouse_id bigint not null, goods_id bigint not null, change_qty int not null comment '正数入库,负数出库', biz_type tinyint not null comment '1入库 2出库 3盘盈 4盘亏', biz_no varchar(32) comment '来源单据号', operator_id bigint, create_time datetime default current_timestamp, key idx_goods_time (goods_id, create_time) ) engine=InnoDB default charset=utf8mb4;关键设计点有两个。第一,库存没有用「出入库明细实时汇总」的方式算,而是单独存一张wms_stock。汇总算虽然永远不会不一致,但列表页一查就是几十万行 group by,完全没法用。第二,wms_stock_flow是只增不改的流水表,任何时候库存对不上,都能拿它把某商品某仓的历史变动一条条拉出来复盘。这就是我前面说的「黑匣子」:出问题时有据可查,比什么都重要。
uk_wh_goods这个唯一索引是整个并发方案的基础,后面入库用on duplicate key update、出库用带条件的update,都依赖它。如果建表时漏了这个索引,代码跑起来会多出一堆重复行,而且库存数会翻倍。
3. 环境搭建与启动:从空库到登录首页的完整跑通步骤
跑这类源码,最怕的就是「IDE 打开一堆红叉,不知道从哪下手」。我的习惯是先把依赖、数据库、配置三件事按顺序解决掉,确认后端能起来,再接前端。顺序反了就会出现「页面白屏,但其实是后端 500」这种排查方向错误。
3.1 本地环境与依赖清单
先把这几个命令敲一遍,版本对不上就别急着往下走。
java -version # 期望 1.8 或 17 mvn -v # 期望 3.6 以上 node -v # 前端用 vue2,16.x 最稳 npm -v mysql --version # 8.0Redis 如果本机没装,先看配置里有没有强依赖。多数版本的代码只在登录和字典缓存用到 Redis,可以在application.yml里先注释掉相关配置,再临时把缓存实现改成内存版,能跑起来再补。我一般会先让它跑通,再追求「依赖齐全」,不然一上手就要装三四个中间件,热情就消耗殆尽了。
IDEA 导入后要确认两处:pom.xml是否全部依赖下载完成(看 Maven 面板有没有红色波浪线),以及Project Structure里的 SDK 和 Language Level 是否一致。这两处不一致是「明明代码没问题却编译不过」的高发区。
3.2 建库、导数据、改配置
源码的resources/sql/wms.sql一般包含建库语句和初始数据,直接整份导入最省事。
# 建库(如果 sql 文件里没有) mysql -uroot -p -e "CREATE DATABASE wms DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 导入结构和初始数据 mysql -uroot -p wms < src/main/resources/sql/wms.sql # 验证:能看到商品、库存、菜单这几张表就对了 mysql -uroot -p wms -e "show tables;"接着改application.yml,重点是数据源和 Redis 两段。
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/wms?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 你的密码 redis: host: 127.0.0.1 port: 6379 database: 0 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 调试期打开,能看真实 SQLURL 里那串参数不是凑数的:serverTimezone=Asia/Shanghai不写,插入时间和查询时间会差 8 小时;useSSL=false不写,MySQL 8 在某些环境下会报连接警告甚至握手失败;allowPublicKeyRetrieval=true是针对 8.0 默认认证插件的兼容开关。这几个我每次都手写一遍,从来不指望记忆。
log-impl那一行建议调试期打开,控制台会把执行的 SQL 和参数原样打出来。这比在 Service 里到处System.out.println强得多,也是后面排查「查不到数据」的第一手材料。
3.3 后端启动与前端联调
后端启动有两种方式,命令行更快:
# 方式一:命令行 mvn clean package -DskipTests java -jar target/wms-1.0.0.jar # 方式二:IDE 里直接跑 WmsApplication # 看到 Started WmsApplication in x seconds 就算成功起来后先别开前端,用 curl 打一下登录接口,确认后端链路是通的。
curl -X POST http://127.0.0.1:8080/api/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456"}'返回体里应该有一个token字段。拿到 token 再请求一个需要鉴权的接口,比如/api/stock/page,把 token 放在Authorization头里。这一步能过,说明数据库、拦截器、统一返回都正常,再去调前端就不会抓瞎。
前端部分:
cd wms-ui npm install --registry=https://registry.npmmirror.com npm run dev # 默认起在 8081,浏览器打开后账号密码用 sql 里初始化那个前端请求地址通常在.env.development或src/utils/request.js里配置baseURL。如果后端端口不是 8080,改这里就行,别去改代码里的硬编码。
3.4 第一个可验证闭环:新增商品 → 入库 → 查库存
登录进去之后,别急着点各个菜单,按这个顺序走一遍,能最快确认业务链路是否完整:
- 在「商品管理」新增一个商品,编码随便填一个没重复的,比如
TEST001。 - 在「仓库管理」确认至少有一个仓库,没有就建一个。
- 在「入库管理」新建入库单,选刚建的商品和仓库,数量填 10,保存为草稿,再点确认。
- 回到「库存查询」,按商品编码搜
TEST001,应该看到数量 10。 - 打开「库存流水」,能看到一条
biz_type=1、change_qty=10的记录。
这五步里任何一步断了,问题范围都很明确:第 3 步失败是单据或事务问题,第 4 步查不到是库存更新没生效,第 5 步没记录是流水没写。比漫无目的地翻代码高效得多。
4. 核心链路拆解:一次入库单提交背后的库存扣减与事务控制
真正值得逐行阅读的代码就那么几个方法。入库和出库这两条链路,几乎涵盖了这套源码所有的设计取舍:状态机怎么控、事务边界划在哪、并发怎么挡、流水怎么留。看懂了这两条,其他模块都是同一套模式在重复。
4.1 Controller 层:参数校验与统一返回
Controller 的职责要尽可能单一,只做三件事:接参数、校验、调 Service。
@RestController @RequestMapping("/api/inbound") public class InboundController { @Resource private InboundService inboundService; /** * 确认入库 * @param orderId 单据主键,路径参数 * @return 统一返回体,成功时 data 为 null */ @PostMapping("/confirm/{orderId}") public R<Void> confirm(@PathVariable Long orderId) { // 操作人从拦截器塞进 ThreadLocal 的上下文里取,不从请求体传 Long operatorId = UserContext.getUserId(); inboundService.confirm(orderId, operatorId); return R.ok(); } /** * 入库单分页查询 * @param query 查询条件,含 pageNum / pageSize / orderNo / status */ @GetMapping("/page") public R<PageResult<InboundOrderVO>> page(InboundQuery query) { return R.ok(inboundService.page(query)); } }operatorId从UserContext取而不是让前端传,这一点很重要。前端传的参数永远可以被篡改,操作人属于审计字段,必须由服务端根据 token 解析出来。源码里如果哪个接口的操作人是前端传的,我一定会改掉。
R这个统一返回体承担了「前端只认一种结构」的职责,code=200表示业务成功,code=500表示业务异常,code=401表示未登录。前端在request.js的响应拦截器里按 code 分流,不用每个页面写一遍判断。
参数校验我一般用@Valid加注解而不是手写 if。但要注意,@Valid只对@RequestBody生效,@PathVariable和 query 参数得在 Controller 上加@Validated才起作用,这个坑后面还会提。
4.2 Service 层:入库单落库与库存变更的事务边界
这是整套代码的核心。三个动作必须在同一个事务里:改单据状态、加库存、写流水。少一个都会出现「单子确认了但库存没加」这种最难查的不一致。
@Service public class InboundServiceImpl implements InboundService { @Resource private InboundOrderMapper orderMapper; @Resource private InboundItemMapper itemMapper; @Resource private StockMapper stockMapper; @Resource private StockFlowMapper flowMapper; /** * 确认入库 * @param orderId 入库单主键 * @param operatorId 当前操作人,来自 token */ @Override @Transactional(rollbackFor = Exception.class) public void confirm(Long orderId, Long operatorId) { InboundOrder order = orderMapper.selectById(orderId); if (order == null) { throw new BizException("入库单不存在"); } // 状态机:只允许 草稿(0) -> 已确认(1) if (!Integer.valueOf(0).equals(order.getStatus())) { throw new BizException("当前单据状态不允许确认"); } // 带原状态做条件更新,等于用数据库行锁挡住并发重复点击 int rows = orderMapper.updateStatus(orderId, 0, 1, operatorId); if (rows == 0) { throw new BizException("单据已被处理,请刷新后重试"); } List<InboundItem> items = itemMapper.selectByOrderId(orderId); if (items.isEmpty()) { throw new BizException("入库单没有明细,不能确认"); } for (InboundItem item : items) { // 库存加:不存在则插入,存在则累加 stockMapper.upsertIncrease(item.getWarehouseId(), item.getGoodsId(), item.getQty()); // 每笔变动留痕 StockFlow flow = new StockFlow(); flow.setWarehouseId(item.getWarehouseId()); flow.setGoodsId(item.getGoodsId()); flow.setChangeQty(item.getQty()); flow.setBizType(1); flow.setBizNo(order.getOrderNo()); flow.setOperatorId(operatorId); flowMapper.insert(flow); } } }几个参数和写法要解释清楚。
rollbackFor = Exception.class不能省。Spring 默认只在遇到RuntimeException和Error时回滚,如果中途抛的是受检异常,事务不会回滚,就会出现「状态改了、库存没加」的半截数据。这是我见过频率最高的事务翻车点。
updateStatus(orderId, 0, 1, operatorId)里传了「原状态 0」和「目标状态 1」,对应 SQL 的where status = 0。如果两个线程同时点确认,只有一个能 update 成功拿到rows=1,另一个拿到 0 直接抛异常。这就是用数据库的原子性代替应用层加锁,比synchronized可靠得多,因为后者在多实例部署时完全失效。
循环里逐条upsert在明细行数不多时没问题。如果一张单子有上千行明细,我一般会改成批量插入流水 + 按商品聚合后再更新库存,减少往返次数,但聚合逻辑要保证同一商品多行明细的数量先加总,否则on duplicate key update会在同一事务内反复命中同一行。
4.3 Mapper 层:乐观锁更新与流水写入
对应的 XML 里两个 update 是整个并发方案落地的关键。
<!-- 条件更新:只有原状态匹配才更新成功,返回影响行数 --> <update id="updateStatus"> update wms_inbound_order set status = #{toStatus}, confirm_by = #{operatorId}, confirm_time = now() where id = #{orderId} and status = #{fromStatus} </update> <!-- 库存累加:依赖 uk_wh_goods 唯一索引,冲突时走 update 分支 --> <update id="upsertIncrease"> insert into wms_stock (warehouse_id, goods_id, qty, version) values (#{warehouseId}, #{goodsId}, #{qty}, 0) on duplicate key update qty = qty + #{qty}, version = version + 1 </update>on duplicate key update是 MySQL 的语法,换成其他数据库就得用merge into或者先查后改。用它有个副作用要知道:当插入冲突触发 update 时,自增主键会被消耗掉,主键会出现空洞。业务上无所谓,但如果有人拿id做连续编号展示,就会看到跳号。
version字段在这里的用途更像是变更次数记录,真正防并发靠的是qty = qty + #{qty}这条原子表达式。数据库在执行单条 update 时会加行锁,两个请求进来是串行执行的,读到的qty一定是上一个请求写完之后的值,不存在丢失更新。
4.4 出库与并发:把库存扣减写成一条带条件的 update
出库比入库麻烦,因为要防超卖。很多源码在这里犯的错是「先 select 查库存,判断够不够,再 update 扣减」,两个线程同时查到 10,各自扣 8,最后库存变成 -6。
正确写法是把判断塞进 SQL:
<!-- 扣减库存:qty >= #{qty} 是防超卖的关键,返回 0 行即库存不足 --> <update id="decrease"> update wms_stock set qty = qty - #{qty}, version = version + 1 where warehouse_id = #{warehouseId} and goods_id = #{goodsId} and qty >= #{qty} </update>Service 里只需要看返回值:
int rows = stockMapper.decrease(item.getWarehouseId(), item.getGoodsId(), item.getQty()); if (rows == 0) { // 事务会整体回滚,前面已扣的明细也会退回 throw new BizException("商品[" + item.getGoodsName() + "]库存不足"); }where qty >= #{qty}这个条件让「判断」和「扣减」在一条 SQL 里原子完成。返回 0 行说明要么库存不够,要么这行根本不存在(仓库和商品组合没建过库存记录),两种情况都该报错。这里抛异常后整个事务回滚,同一张出库单里前面已经扣成功的其他商品会自动还原,不用手写补偿逻辑。这也是为什么明细循环必须放在一个事务里的原因。
4.5 分页与查询:插件配不对 total 永远是 0
列表页全靠 MyBatis-Plus 的分页插件,配置只有几行,但漏了就是「数据查出来了,总条数显示 0,翻页翻不动」。
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor page = new PaginationInnerInterceptor(DbType.MYSQL); // 单页上限,防止前端传 pageSize=999999 把数据库拖死 page.setMaxLimit(500L); // 请求页码超过总页数时返回空列表而不是回到第一页,便于前端提示 page.setOverflow(false); interceptor.addInnerInterceptor(page); return interceptor; } }setMaxLimit这一行是我在线上项目里必加的。曾经有个页面被人手动把pageSize改成 20 万,一次性把几万行数据序列化成 JSON,接口直接超时,还把数据库连接池占满了。加个上限,几十行代码就能避免一次事故。
查询条件本身用LambdaQueryWrapper拼,注意like的位置:商品名称这类模糊查询用like,编码这种唯一键用eq更快,因为能走索引。
5. 常见问题排查:源码跑不通时按这几条顺序查
这套代码在我手里复现过不止一次,翻车的点相对固定。下面五条基本覆盖了「下载下来跑不起来」的绝大多数情况,按出现频率排序。
5.1 启动报 Invalid bound statement (not found)
现象:项目能启动,一调接口就报org.apache.ibatis.binding.BindingException: Invalid bound statement (not found),日志里能看到某个 Mapper 方法名。
原因:Mapper 接口和 XML 没对上。要么mapper-locations路径写错了,要么 XML 的namespace不是接口的全限定名,要么 XML 放在了src/main/java下而 Maven 打包时没把它带进target/classes。
解决:先确认target/classes/mapper/目录下有没有对应的 XML 文件,没有就是资源过滤没配。在pom.xml的<build>里加上资源目录配置,把src/main/java下的**/*.xml也打进包里。再检查 XML 的namespace是否精确到包名加类名,多一个空格都算错。
5.2 时间差 8 小时、中文变问号
现象:新增一条记录,数据库里存的时间比实际早 8 小时;或者商品名称里的中文入库后变成???。
原因:两个独立问题凑一块了。时间是 JDBC URL 少了serverTimezone或者服务端时区不是东八区;中文是数据库或表的字符集不是utf8mb4,或者连接串没带characterEncoding=utf8。
解决:URL 补齐serverTimezone=Asia/Shanghai&characterEncoding=utf8,同时确认建库语句用的是DEFAULT CHARACTER SET utf8mb4。如果表已经建好且字符集错了,alter table wms_goods convert to character set utf8mb4;能救回来,但已有数据里的问号救不回来,只能重录。这类问题我一般在新项目初始化阶段就写死,不给它出现的机会。
5.3 登录后所有接口 401,或者浏览器报跨域
现象:登录接口能返回 token,但后续请求全部 401;或者控制台一片红,提示被 CORS 策略拦截。
原因:401 多半是前端没把 token 放进请求头,或者拦截器里取 header 的名字和前端写的不一致(Authorizationvstoken)。跨域则是后端没配CorsFilter,或者配了但allowedOrigins写的是*同时allowCredentials为 true,浏览器直接拒绝。
解决:先在浏览器 Network 面板看请求头里到底有没有 token,没有就改request.js的请求拦截器。跨域建议在后端WebMvcConfig里显式列出前端地址,别用*;如果用了 Nginx 反向代理,同源之后跨域问题自然消失,这也是我更推荐的做法。
5.4 库存出现负数
现象:压测或者两个人同时点出库,库存表里出现-3这种值。
原因:扣减逻辑写成了「先查再改」,判断和更新之间有窗口期;或者用了update ... set qty = #{newQty}直接把算好的绝对值写回去,后写的覆盖先写的。
解决:统一改成update ... set qty = qty - #{qty} where qty >= #{qty},用返回行数判断成败,返回 0 就抛异常回滚。改完记得补一个并发测试:用 JMeter 或 ab 起 20 个线程同时打同一个出库接口,看最终库存是否等于「初始库存 - 成功单数 × 数量」,对得上才算真的修好。
5.5 分页 total 恒为 0,或者翻到第二页数据重复
现象:列表能查出数据,但总数显示 0,分页组件只有一页;或者第二页的内容和第一页一样。
原因:忘了注册分页插件,或者 SQL 里自己写了limit又叠加了插件的分页,导致参数被拼了两次。
解决:确认MybatisPlusConfig里的PaginationInnerInterceptor被 Spring 扫描到(类上有@Configuration,且启动类包路径能覆盖到)。然后检查自定义 XML 里有没有手写limit,有就去掉,交给插件处理。另外order by一定要有确定性字段,只按create_time排序时,同一秒的多条记录顺序不稳定,翻页就会出现重复或漏数据,加上id作为第二排序字段即可。
6. 二次开发改造:加一张盘点单,把库存校准做成可复用能力
仓库系统上线三个月后,业务方一定会提一个需求:「盘点」。它的本质是允许人为把库存改成一个指定值,并留下原因。这套源码里没有盘点模块,正好可以拿它练手,也能验证你对前面那套库存链路的理解是否到位。
改造思路是复用而不是新造。新建wms_check_order和wms_check_item两张表,字段结构和入库单基本一致,只是明细里多两个字段:book_qty(账面数)和real_qty(实盘数)。确认盘点时,不直接改库存,而是算出差额real_qty - book_qty,走已有的StockService变更入口,流水类型记 3(盘盈)或 4(盘亏)。
// 盘点确认的核心:差额走统一的库存变更入口,不另开一条写库路径 for (CheckItem item : items) { int diff = item.getRealQty() - item.getBookQty(); if (diff == 0) continue; // 没差异不用动库存 if (diff > 0) { stockMapper.upsertIncrease(item.getWarehouseId(), item.getGoodsId(), diff); } else { int rows = stockMapper.decrease(item.getWarehouseId(), item.getGoodsId(), -diff); if (rows == 0) throw new BizException("账面库存已变化,请重新盘点"); } flowMapper.insert(StockFlow.ofCheck(item, diff, operatorId)); }这段代码的价值在于复用:库存加减只有一套实现,盘点、调拨、退货都走它,后续加任何新单据类型都不用重写并发控制。我见过太多项目每加一种单据就复制一份库存扣减代码,半年后六份实现各不相同,改一个 bug 要改六遍。
验证方法也很直接:拿一个库存为 10 的商品建盘点单,实盘填 8,确认后库存应该是 8,流水里多一条change_qty=-2的记录;再建一张实盘填 12 的,库存变 12,流水多一条+2。两次都对了,说明差额计算和流水方向没搞反。
至于book_qty什么时候取,建议在「新建盘点单」时就快照下来,而不是确认时再查。因为盘点期间可能还有出入库,确认时查到的账面数已经变了,差额就对不上,用户会觉得系统在乱改数。这个细节不写进代码里,测试阶段很难发现,等真实业务跑起来就是一笔糊涂账。
从那以后我每次改库存相关的代码,都强制走一遍「造并发 + 对流水」的流程:先用脚本打 20 个并发请求,再把流水按商品汇总,跟库存表的数做一次比对,两个数不等就说明有地方绕过了统一入口。这个习惯帮我拦下过好几次「临时加个接口直接 update 库存」的偷懒改动,也让我对这套源码的信任度一直保持在一个可控的水平。希望帮到你。
本文还有配套的精品资源,点击获取