Java SCM供应链项目实战:从领域建模到库存联动
2026/9/17 2:23:58 网站建设 项目流程

简介:面向Java开发者的SCM供应链项目源码包,涵盖WMS仓库管理等典型业务模块,适合学习企业级进销存、订单与库存协同开发的初中级工程师参考。包体共11161个文件,压缩后85.53MB,以class、java、jsp、js、css、xml、property等为主,其中java源文件2215个、页面与脚本文件分别提供前端交互和后台逻辑实现,便于按模块对照研读。目前已有551人学习下载,内容包含完整项目工程、配置文件及数据库相关文件,目录结构覆盖controller、service等分层设计,可帮助理解供应链场景下单据流转与库存同步的实际编码方式。对于希望快速上手SSM或Spring类框架搭建后台管理系统的开发者,这套源码能提供较直接的项目范式与排错思路。

1. JAVA SCM供应链项目代码到底要解决什么

接手的供应链项目如果只有一张采购订单表和一个库存表,那它根本跑不起来。真实SCM项目代码的难点不在CRUD,而是把供应商、采购、库存、销售、回款这条链路上的数据状态挪到同一套规则里。这个规则叫“单据流”,也就是每笔业务必须由上游单据驱动,下游回写上游状态。Java里最常用的落地方式是Spring Boot + MyBatis,配合Redis做库存预占,再靠事务和幂等把并发打干净。这篇文章不聊ERP巨型系统,只讲一个中型供应链项目代码的拆解和可复现写法,适合准备接手或者重写SCM模块的后端工程师,也适合想在前端讨论API契约时能反过来把服务端边界说清楚的人。

2. 供应链项目的领域建模:从业务流里抽出Java实体和表结构

2.1 先画出跨部门的供应链流程,再把流程转成状态机

做SCM项目代码,第一件事不是建Maven工程,而是把业务部门的语言翻译成对象和状态。常见流程是:供应商报价 → 生成采购订单 → 供应商发货 → 仓库收货入库 → 库存增加 → 客户下单 → 锁定库存 → 出库扣减 → 生成结算单。这个流程里最关键的是“库存变更”,因为每次变动都要有来源单据,否则账实不符时根本没法溯源。

我会用一张手绘图或者Excel表把流程画出来,标出每个节点上谁在操作、会产生什么单证。然后把这些单证落成Java实体:PurchaseOrderPurchaseOrderItemStockMovementSalesOrderSupplier。这里的反直觉点是:不要一开始就建一堆字典表,而是先把状态机定义清楚。例如采购订单状态用0:草稿 1:已确认 2:已发货 3:已入库 4:已关闭,库存变动类型用INBOUND/OUTBOUND/ADJUST。这样在写代码时,每个方法都带着状态流转意图。

2.2 从ER模型映射到Java类的几个关键取舍

给出一个简化后的采购订单实体,注意字段类型和注释。

public class PurchaseOrder { private Long id; private String orderNo; // 业务单号,如 PO20240601001 private Long supplierId; private Integer status; // 0草稿 1已确认 2已发货 3已入库 4已关闭 private BigDecimal totalAmount; // 金额必须用BigDecimal,不用double private LocalDateTime confirmTime; private LocalDateTime warehouseInTime; // getter/setter 省略 }

为什么订单号要用字符串而不是数据库自增ID?因为SCM系统里财务、仓库、供应商用的单号口径必须一致,自增ID会暴露业务量,也对接不稳定。状态字段用Integer而不是String,因为数据库索引对数字更友好,代码里用常量类管理,避免魔法值。金额一定用BigDecimal,这是Java的强制要求,避免二进制浮点误差。

2.3 表结构设计要防住并发和审计两个打点

采购订单和库存表SQL大致如下:

CREATE TABLE `purchase_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL COMMENT '业务单号', `supplier_id` bigint NOT NULL, `status` tinyint NOT NULL DEFAULT '0', `total_amount` decimal(18,2) NOT NULL DEFAULT '0.00', `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', `create_time` datetime NOT NULL, `update_time` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_supplier_status` (`supplier_id`,`status`) ) ENGINE=InnoDB COMMENT='采购订单表';

version是乐观锁用的,后面章节的库存扣减会用到。uk_order_no是防重复的关键,业务上同一次创建被重复提交时,数据库会直接报错,比代码里查一遍更可靠。idx_supplier_status覆盖“查某个供应商的订单列表”这个高频查询。

下面表格是字段选型时最常遇到的争议点:

字段场景推荐类型原因
金额decimal(18,2)精度可控,避免浮点误差
状态tinyint索引空间小,枚举可读性靠代码补
单据编号varchar(64)加唯一索引业务侧幂等,查询友好
库存数量intbigint避免小数库存,若需要小数用decimal
计量单位varchar(20)不做复杂单位换算,过度设计会拖慢系统

3. 用Spring Boot搭建可维护的SCM项目骨架

3.1 Maven多模块划分:为什么把mapper单独抽一层

供应链项目代码不像简单博客系统,它天然有多个调用方:采购后台、仓库PDA、报表服务。我把模块拆成scm-commonscm-dalscm-servicescm-webscm-dal里放MyBatis的Mapper和XML,scm-service放业务实现,scm-web只放Controller和VO。这样前端工程师拿到scm-web模块就可以直接看API约定,不用翻数据库访问代码。

pom.xml的模块声明通常写成这样:

<modules> <module>scm-common</module> <module>scm-dal</module> <module>scm-service</module> <module>scm-web</module> </modules>

scm-dal模块的依赖只暴露给scm-servicescm-web不允许直接引用Mapper。这样做的原因是避免前端页面需求改动时,逼着业务层跟着换,同时让单元测试能只加载scm-dal做数据库联调。

3.2 配置文件里的三个必调参数:连接池、Redis序列化、MyBatis下划线映射

一个典型的application.yml核心内容如下:

spring: datasource: url: jdbc:mysql://localhost:3306/scm_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: secret hikari: maximum-pool-size: 20 minimum-idle: 5 redis: host: localhost port: 6379 database: 3 mybatis: mapper-locations: classpath:mappers/*.xml configuration: map-underscore-to-camel-case: true

serverTimezone如果不设置,MySQL 8 和 Java 17 之间会出现时间差八小时的问题。maximum-pool-size一般设为核心数的两倍,数据库连接数不是越大越好,超过阈值反而会拖垮数据库。map-underscore-to-camel-case必须开启,这样数据库的supplier_id会自动映射到supplierId,少写一堆@Results注解。

3.3 接手Java Spring Boot项目代码时先看什么

热词里不少人在搜“前端开发工程师接收一个Java SpringBoot项目后端可以直接上手改代码吗”,我的答案是:能,但有顺序。先看pom.xml,确认Spring Boot版本和依赖;再看application.yml,确认数据库和Redis地址;然后看Controller层路由,最后才看业务实现。最忌讳的是打开service层从头读,因为供应链项目的Service往往很大,一上来就会被事务和状态流转绕晕。

在切换数据库连接环境时,注意spring.sql.init之类会自动执行脚本的配置,在测试环境开着没问题,生产环境尽量关掉,否则每次启动都会把初始化SQL再跑一遍,可能直接清掉生产表。

4. 用Java实现采购入库到库存扣减的联动逻辑

4.1 采购入库使用事务和乐观锁更新库存

供应链系统最怕库存账和账面不一致。采购入库的操作不是直接insert一条库存记录,而是先更新库存表,再写流水。用乐观锁防止并发重复入库:

@Transactional public void warehouseIn(StockMovement movement) { int updated = stockMapper.decreaseStock( movement.getSkuId(), movement.getChangeQty(), System.currentTimeMillis()); if (updated == 0) { throw new OrderServiceException("库存更新失败,请重试"); } stockMovementMapper.insert(movement); purchaseOrderMapper.updateStatus(movement.getOrderNo(), 3); }

对应Mapper XML里的更新语句:

<update id="decreaseStock"> UPDATE stock SET available_qty = available_qty - #{changeQty}, update_time = #{updateTime} WHERE sku_id = #{skuId} AND available_qty >= #{changeQty} </update>

关键点在于WHERE条件里的available_qty >= #{changeQty},这是数据库层面的乐观锁。如果两个请求同时扣同一批库存,数据库的锁机制会保证只有一个成功,另一个更新行数为0,代码里直接抛异常。@Transactional保证库存减少和流水写入在同一个事务,后一步失败时库存回滚,不会出现流水和库存对不上的情况。

4.2 用Redis做订单锁库存的Java实现

销售订单创建时,如果直接扣MySQL库存,行锁在高并发下会拖垮数据库。常见做法是用Redis先预占库存,再异步写数据库。代码里用RedisTemplateincrement做预减:

public Boolean tryLockStock(Long skuId, Integer qty, String orderNo) { String key = "scm:stock:lock:" + skuId; Long remain = redisTemplate.opsForValue().increment(key, -qty); if (remain != null && remain >= 0) { redisTemplate.opsForSet().add("scm:order:lock:" + orderNo, skuId + ":" + qty); return true; } else { // 回滚本次预占 redisTemplate.opsForValue().increment(key, qty); return false; } }

这里用increment的负值语义来预占,它有两个注意点:一是Redis的key必须配置过期时间,比如24小时,避免锁库存的key永远不释放;二是increment返回的是Long,序列化方式不对时会出现“不是integer或out of range”的报错,原因多半是RedisTemplate默认使用JDK序列化,解决方法是设置String序列化器来存数字:

RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.setHashValueSerializer(new StringRedisSerializer());

4.3 事务回滚和幂等性控制:SCM代码最容易踩的坑

表格里整理了三种常见幂等方案:

方案适用场景实现方式失败表现
唯一索引采购订单创建order_no加 unique key插入报错,可根据错误码返回“重复提交”
状态机校验入库/出库确认WHERE status = N更新更新行数为0,抛业务异常
Redis预占订单高并发锁库存increment负数 + 回滚预占回滚,返回失败

在实际项目里,这三种方案会组合使用。例如采购入库时,先查订单状态,再执行更新,更新行数为0说明已经被别人处理过,直接抛异常。不要把“查状态”和“更新状态”分开,因为两个请求可能同时通过校验,必须让更新语句自己带条件。

5. 供应链数据分析:用SQL和Java Stream做供应商与库存分析

5.1 用SQL统计供应商交付及时率

做报表时不要把所有数据查出来到Java里算,应该在SQL里完成聚合。下面是统计每家供应商准时交付率的查询:

SELECT supplier_id, COUNT(*) AS total_order_count, SUM(CASE WHEN actual_warehouse_in_time <= plan_warehouse_in_time THEN 1 ELSE 0 END) AS on_time_count, ROUND( SUM(CASE WHEN actual_warehouse_in_time <= plan_warehouse_in_time THEN 1 ELSE 0 END) / COUNT(*), 4 ) AS on_time_rate FROM purchase_order WHERE create_time >= '2025-01-01' GROUP BY supplier_id HAVING COUNT(*) >= 1 ORDER BY on_time_rate DESC;

这里的actual_warehouse_in_timeplan_warehouse_in_time是采购订单表里的两个时间字段,如果底层表没存,那这个指标就算不出来,所以设计表结构时要提前规划。HAVING字段过滤的是聚合后的结果,不是原始行,这点容易写错。供应链数据分析里还有一个常见口径:是按订单数算及时率,还是按订单金额算及时率,两者结论可能完全不同,和业务方确认口径后再写代码。

5.2 用Java Stream对库存做ABC分类

SQL负责聚合,内存负责分类。ABC分类的思路是:按库存金额从大到小排序,累计金额占比前70%的商品为A类,70%到90%为B类,剩下为C类。用Java Stream实现非常直观:

List<StockItem> items = stockMapper.selectAllStockItems(); BigDecimal totalAmount = items.stream() .map(StockItem::getStockAmount) .reduce(BigDecimal.ZERO, BigDecimal::add); List<StockItem> sorted = items.stream() .sorted(Comparator.comparing(StockItem::getStockAmount).reversed()) .toList(); BigDecimal accumulated = BigDecimal.ZERO; for (StockItem item : sorted) { accumulated = accumulated.add(item.getStockAmount()); double ratio = accumulated.doubleValue() / totalAmount.doubleValue(); if (ratio <= 0.7) { item.setAbcCategory("A"); } else if (ratio <= 0.9) { item.setAbcCategory("B"); } else { item.setAbcCategory("C"); } }

reduce(BigDecimal.ZERO, BigDecimal::add)用来求和,避免循环里写totalAmount +=时精度丢失。sorted里的reversed()容易把金额排序写成降序,比较器返回-1表示前一个小于后一个,这样排出来是升序,所以要格外注意。给每个item打标签以后,批处理更新时一次update更新一个分类,比逐条更新效率高得多。

5.3 导出海量库存数据时怎么防止内存爆炸

不要直接select * from stock再写到Excel,几万条就会把堆内存撑爆。我用的是MyBatis的流式查询,按游标每次拿500条:

try (SqlSession session = sqlSessionFactory.openSession()) { StockMapper mapper = session.getMapper(StockMapper.class); Cursor<StockItem> cursor = mapper.scanAllStock(); Iterator<StockItem> iterator = cursor.iterator(); while (iterator.hasNext()) { StockItem item = iterator.next(); writer.writeRow(item); } }

使用流式查询时,数据库连接会一直被占用,所以要注意:这个操作期间不要同时跑其他慢查询,否则连接池耗尽。导出的响应建议用StreamingResponseBody,这样HTTP响应也会边写边发,而不是先把整个文件写到内存。

6. 上线前必做的验证与排查技巧

6.1 用Arthas trace找到SCM服务里的慢方法

供应链项目代码里最隐蔽的问题不是报错,而是“某些请求偶尔卡一下”。我会用Arthas在测试环境直接跟踪方法调用耗时:

java -jar arthas-boot.jar trace com.example.scm.service.PurchaseOrderServiceImpl warehouseIn

执行后,每次调用warehouseIn都会打印该方法内部每个子方法的耗时。看到stockMapper.decreaseStock耗时超过200毫秒时,直接去数据库看执行计划。大多数慢查询都出在idx_supplier_status这类联合索引没有命中,或者available_qty >= #{changeQty}的更新语句把行锁范围扩得太大。

6.2 检查数据库连接池和Redis连接的一个快速命令

上线前我会同时观察两个指标:连接池活跃数、Redis慢日志。连接池活跃数通过actuator/prometheus接口抓,Redis慢日志在客户端里执行:

SLOWLOG GET 10 SLOWLOG LEN

如果发现慢日志里有大量scm:stock:lock:*相关的操作,说明单个锁key竞争太激烈,需要做分片,例如把skuId加随机后缀拆成100个桶。这里后一小时的数据如果持续增长,就说明缓存击穿了。

6.3 最后检查点:本地代码改动被覆盖怎么办

很多人在用Idea pull项目后,发现自己本地改的代码丢失了,这多半是pull之前没有Commit或Stash。对SCM这类多人维护的项目,建议在改前强制stash并立刻创建自己的分支。如果改动已经丢失,先用Git reflog找到之前的HEAD:

git reflog git checkout -b recovery-branch HEAD@{2}

这个命令能把被覆盖前的提交恢复到新分支,之后再手动比对差异。这个技巧在编码重启前用不到,但遇到同事推了一个错误的分支覆盖到你本地时,它能救回半天工作量。

以上是Java SCM供应链项目代码从领域建模、工程骨架、核心交易到数据分析与排查的一条完整落地路径。实际项目的复杂度总会比描述的高,但只要单据流、状态机、乐观锁这三个底子打得牢,后续加供应商协同、多仓库存也只是在这个骨架上做扩展。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询