简介:芸柚物流云V30是一款面向物流与供应链企业的开源管理平台,基于Java8与MySQL8构建,涵盖OMS订单管理、WMS仓储管理、TMS运输管理和BMS结算管理,同时支持全局库存与供应链订单处理,适合需要信息化升级的物流公司及Java开发人员研究二次开发。整个资源包共2000个文件,以1211个Java源码、336个JavaScript、181个XML、117个CSS、75个HTML等为主,还包括SQL脚本与PDF文档,代码结构和前端页面较为完整,压缩包大小约49MB。目前已有63人学习下载,便于快速了解平台模块分层与业务逻辑。附赠的docx说明与56between-master项目工程,可辅助理解平台使用方式与相关功能实现,开源特性也便于按需定制,适合物流系统研发、供应链学习者作为参考。
1. 芸柚物流云V30:为什么Java8与MySQL8仍是物流云平台的稳妥组合
从零搭建订单、仓储、运输、结算一体化的企业,往往不缺功能,缺的是把四块业务放进同一套数据模型里的主干。芸柚物流云V30这条线把OMS、WMS、TMS、BMS串在一个Java服务群里,底座恰好是最容易被低估的Java8与MySQL8组合。Java8的Stream、CompletableFuture、Lambda让订单状态流转和异步下发代码量明显下降;MySQL8的窗口函数、CTE、JSON类型让全局库存汇总不再需要提前引入OLAP组件。对五年以上的架构师来说,这组技术栈真正的优点是可控:排错方案多、运维成本低、招人容易。下面按OMS到BMS的链路走一遍,建表SQL、状态机设计和部署参数都摊开,落地时能直接对着改。
2. 芸柚物流云V30的OMS订单处理:主表设计与状态机实现
2.1 订单主表如何同时承载OMS订单和供应链订单
芸柚物流云V30的OMS入口并没有单独给B2B、B2C、补货各建一套表,而是统一落到一张订单主表上,用order_type区分业务类型。这种方式在最初看起来牺牲了一定查询性能,实际操作下来发现它省掉了大量跨类型同步代码,后续的全局库存扣减和财务结算也能共用同一条链路。风险在于查询时容易漏掉类型条件,所以order_type必须进复合索引,并且对外只暴露封装好的查询入口。
CREATE TABLE `oms_order` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '订单ID', `order_no` varchar(32) NOT NULL COMMENT '订单号,平台唯一', `parent_order_no` varchar(32) DEFAULT NULL COMMENT '父订单号,拆单时关联', `order_type` tinyint NOT NULL COMMENT '1销售订单 2补货单 3调拨出库单 4调拨入库单', `order_source` varchar(20) NOT NULL COMMENT '订单来源渠道:门店/电商/经销商', `customer_code` varchar(32) NOT NULL COMMENT '货主编码', `status` tinyint NOT NULL DEFAULT '10' COMMENT '订单状态,见状态机', `consignee_name` varchar(64) DEFAULT NULL COMMENT '收货人', `consignee_phone` varchar(20) DEFAULT NULL COMMENT '收货电话', `province` varchar(32) DEFAULT NULL COMMENT '省', `city` varchar(32) DEFAULT NULL COMMENT '市', `district` varchar(32) DEFAULT NULL COMMENT '区', `address` varchar(255) DEFAULT NULL COMMENT '详细地址', `total_qty` decimal(12,2) DEFAULT '0.00' COMMENT '总件数', `total_amount` decimal(12,2) DEFAULT '0.00' COMMENT '商品金额', `expected_ship_date` datetime DEFAULT NULL COMMENT '期望发货日', `planned_warehouse_code` varchar(32) DEFAULT NULL COMMENT '计划发货仓库', `created_by` varchar(32) DEFAULT NULL, `created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_status_planned_warehouse` (`status`, `planned_warehouse_code`), KEY `idx_type_parent` (`order_type`, `parent_order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';主表设计有两点值得留意。parent_order_no用来承接拆单后的父子关系,一个订单因为库存不足被拆成两张出库单时,子订单通过这个字段回溯到原始订单;order_type不加默认值,强制业务层传入,避免补货单和销售订单混在一起后影响后续计费。索引上,idx_status_planned_warehouse覆盖了调度员最常见的查询条件“某个状态下哪些订单分到某仓库”,idx_type_parent则保证按父单追溯时走索引。
2.2 用Java8枚举定义订单状态机,替代散落的if else
订单状态管理最容易失控的地方是状态值散落在service层各处,今天这里加一个判断,明天那里加一个判断,三个月后没人知道哪些状态可以流转到取消。芸柚物流云V30的OMS模块用Java8枚举把状态流转收敛到一个类里,所有状态迁移都走统一入口,非法跳转直接抛异常。
public enum OrderStatus { CREATED(10, "待审核"), WAREHOUSE_PENDING(20, "待分配仓库"), WMS_SENT(30, "已下发WMS"), PARTIAL_SHIPPED(40, "部分发货"), COMPLETED(50, "已完成"), CANCELLED(90, "已取消"); private final int code; private final String desc; private static final Map<OrderStatus, Set<OrderStatus>> TRANSITION = new EnumMap<>(OrderStatus.class); static { TRANSITION.put(CREATED, EnumSet.of(WAREHOUSE_PENDING, CANCELLED)); TRANSITION.put(WAREHOUSE_PENDING, EnumSet.of(WMS_SENT, CANCELLED)); TRANSITION.put(WMS_SENT, EnumSet.of(PARTIAL_SHIPPED, COMPLETED, CANCELLED)); TRANSITION.put(PARTIAL_SHIPPED, EnumSet.of(COMPLETED)); } public boolean canTransitTo(OrderStatus target) { Set<OrderStatus> allowed = TRANSITION.get(this); return allowed != null && allowed.contains(target); } }这段枚举的价值在于把状态图集中定义,新增状态时只需要改这一处。EnumMap比HashMap内存占用更小、遍历顺序稳定;EnumSet的contains走位运算,判断性能远高于List的线性扫描。实际使用时还要配一个StateMachine.verify(current, target)的静态工具方法,在订单update语句执行前调用,校验失败就抛业务异常,这样不依赖数据库层面的状态约束也能挡住非法流转。
2.3 订单下发WMS的异步幂等设计
订单审核通过后需要把数据推给WMS生成出库单,这个过程在网络抖动时最容易产生重复单。常见做法是Redis分布式锁加状态双重校验,锁的key用订单号,value固定为“1”,过期时间设10分钟,防止WMS接口长时间无响应导致锁不释放。拿到锁后再查一次订单状态,确认还处于WAREHOUSE_PENDING才真正发起下发。
public void dispatchToWms(OrderDO order) { String lockKey = "dispatch:order:" + order.getOrderNo(); Boolean acquired = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofMinutes(10)); if (!Boolean.TRUE.equals(acquired)) { return; } CompletableFuture<DispatchResult> future = CompletableFuture.supplyAsync(() -> wmsApiClient.dispatch(order.getOrderNo(), order.getPlannedWarehouseCode()), wmsExecutor); future.whenComplete((result, ex) -> { if (ex != null) { stringRedisTemplate.delete(lockKey); return; } orderMapper.updateStatus(order.getOrderNo(), OrderStatus.WMS_SENT.getCode()); }); }这段代码里wmsExecutor必须是自定义线程池,不能直接用ForkJoinPool.commonPool(),否则WMS接口一慢会拖垮整个应用的公共线程池。线程数建议按WMS接口的吞吐压测结果来定,常见做法是核心线程10、最大线程20、队列容量200。whenComplete在两个分支里分别处理失败释放锁和成功更新状态,注意这里没有处理“更新数据库状态成功但WMS后续回调失败”的情况,那部分要靠WMS的主动回执补偿。
2.4 订单状态与WMS作业的映射关系
| 订单状态 | 对应WMS作业 | 操作角色 | 触发时机 |
|---|---|---|---|
| 待审核 | 无 | 客服 | 创建订单后 |
| 待分配仓库 | 无 | 调度员 | 审核通过 |
| 已下发WMS | 生成出库单 | 系统自动 | 状态变更为已下发 |
| 部分发货 | 按波次分批发货 | 仓库操作员 | 出库单部分复核完成 |
| 已完成 | 回传签收 | 系统自动 | 出库单全部发运 |
这张表在项目里直接对应着状态机定义的每一种合法流转,遇到“订单已下发WMS但用户要取消”的诉求,不能简单把状态改回待审核,而是要调用WMS的取消接口,拿到取消回执后才能把状态改为已取消。
3. WMS仓储管理与全局库存管理:台账比实时更重要
3.1 库存三口径:可卖、实物、在途
很多项目把库存字段直接堆在商品表上,一个available_qty列同时被OMS扣减和WMS回填,线上死锁多,还无法追溯。芸柚物流云V30的做法是把库存拆成三套视图:OMS看到的可卖库存、WMS维护的实物库存、以及跨仓调拨的在途库存。可卖库存是WMS实物库存减去冻结和已分配量后同步到OMS侧的;在途库存是调拨单审核通过后生成的虚拟数量,调拨出库方扣减、调拨入库方增加,不允许直接销售。
3.2 库存流水表:把每次变动都写成一行
库存系统的核心不是库存汇总表,而是流水表。汇总表随时可以重建,流水一旦丢了就再也查不清差异。
CREATE TABLE `wms_inventory_ledger` ( `id` bigint NOT NULL AUTO_INCREMENT, `customer_code` varchar(20) NOT NULL COMMENT '货主编码', `warehouse_code` varchar(20) NOT NULL COMMENT '仓库编码', `sku_code` varchar(40) NOT NULL COMMENT 'SKU编码', `lot_number` varchar(40) DEFAULT NULL COMMENT '批次号', `change_type` tinyint NOT NULL COMMENT '1入库 2出库 3冻结 4解冻 5盘盈 6盘亏', `change_qty` decimal(12,2) NOT NULL COMMENT '变动数量,正数增加,负数减少', `before_qty` decimal(12,2) NOT NULL COMMENT '变动前数量', `after_qty` decimal(12,2) NOT NULL COMMENT '变动后数量', `ref_order_no` varchar(32) NOT NULL COMMENT '来源单据号', `created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_sku_wh` (`warehouse_code`, `sku_code`, `lot_number`), KEY `idx_ref_order` (`ref_order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存流水表';before_qty和after_qty冗余进来是刻意为之,线上库存出现差异时,直接通过流水就能还原当时现场,不需要把当前库存和流水做差值。change_type用int类型,方便后续加效期调整、残次品转正等新类型。写入流水和更新汇总表必须在同一个数据库事务里,顺序固定为“先写流水、后更新汇总”,否则并发下会出现汇总数量与流水对不上的情况。
3.3 用MySQL8 CTE核对全局库存
全局库存汇总的核对,在MySQL8之前要写多条临时表SQL或干脆导出到Excel。MySQL8的CTE语法可以把核对逻辑写成一条可读性很高的SQL,作为定时巡检任务,每天凌晨算出每个仓库每个SKU的账面库存与流水累计值。
WITH ins AS ( SELECT warehouse_code, sku_code, SUM(change_qty) AS total_in_qty FROM wms_inventory_ledger WHERE change_type IN (1, 5) GROUP BY warehouse_code, sku_code ), outs AS ( SELECT warehouse_code, sku_code, SUM(change_qty) AS total_out_qty FROM wms_inventory_ledger WHERE change_type IN (2, 6) GROUP BY warehouse_code, sku_code ) SELECT i.warehouse_code, i.sku_code, i.total_in_qty + IFNULL(o.total_out_qty, 0) AS calc_qty FROM ins i LEFT JOIN outs o ON i.warehouse_code = o.warehouse_code AND i.sku_code = o.sku_code;LEFT JOIN是为了把只有入库没有出库的SKU也带出来,IFNULL处理没有出库记录时合计值为NULL的情况。实际生产环境不会每次都全量跑这条SQL,常见做法是把它落到每日凌晨2点的调度任务里,只核对当天发生过变动的SKU,再把差异结果写入对账表,由库存会计在上班前看到报告。
3.4 库存分配时的锁顺序
全局库存的分配请求来源不止OMS订单,还有经销商手工下单、补货申请,多个来源同时扣减同一个SKU时容易死锁。避免死锁的手段是把更新语句固定为“仓库编码+SKU编码”的维度,让所有请求都按同一个键顺序加锁。
-- 原子扣减可用库存,条件带上 available_qty >= 需求量 UPDATE wms_available_inventory SET available_qty = available_qty - #{needQty}, allocated_qty = allocated_qty + #{needQty} WHERE warehouse_code = #{warehouseCode} AND sku_code = #{skuCode} AND available_qty >= #{needQty};affected rows返回0时说明可用库存不足,业务层立刻返回缺货提示,不要再重试。allocated_qty代表已经被订单占用但还没出库的数量,这张订单后续取消时要把allocated_qty减回去、available_qty加回来,并写一条change_type=4的流水。
3.5 WMS作业闭环
| 作业环节 | 单据类型 | 库存变化 |
|---|---|---|
| 收货 | 收货单 | 待检库存增加 |
| 上架 | 上架单 | 实物库存增加,待检库存减少 |
| 分配 | 分配单 | 可用库存减少,分配库存增加 |
| 拣货复核 | 出库单 | 分配库存减少 |
| 发货 | 出库单 | 实物库存减少 |
4. TMS运输管理与BMS结算的联动:运单即费用
4.1 运单生成与节点回传
WMS出库单确认发运后,TMS模块以出库单号为维度生成运单。运单表的核心字段包含订单号、出库单号、承运商编码、运输方式、包裹数、体积重量、实际重量、发货仓、目的城市,以及一个transport_status用来记录当前节点。TMS的价值在于节点回传,揽收、中转、派送、签收每一次回传都更新状态并写入时间戳,BMS费用计提依赖的就是这个状态变化时间。
4.2 运费计算:重量分档与金额精度
运费计提是BMS和TMS衔接最紧密的地方,标准运费按承运商报价规则计算。这里最典型的坑是用double算金额,Java8里运费计算必须走BigDecimal,续重进位规则也要单独确认,有的报价按1kg进位,有的按0.5kg进位。
public BigDecimal calcFreight(CarrierFreightRule rule, double weightKg, double volumeWeightKg) { double billWeight = Math.max(weightKg, volumeWeightKg); if (billWeight <= rule.getFirstWeight()) { return rule.getFirstPrice(); } int extraKg = (int) Math.ceil(billWeight - rule.getFirstWeight()); return rule.getFirstPrice() .add(rule.getAdditionalPricePerKg() .multiply(BigDecimal.valueOf(extraKg))); }运单上的计费重量是实际重量和体积重量取大,体积重量由长宽高除以抛比系数得到,通常抛比是5000或6000,这个系数每个承运商不一样,要放进CarrierFreightRule表而不是写死在代码里。Math.ceil向上取整保证了2.1kg按3kg续重计费,完整实现里还要考虑最低收费和燃油附加费,这两个费用是账单明细行,不合并进单价。
4.3 BMS结算流水的生成时机
BMS在运单签收后生成应收流水和应付流水,应收面向客户、应付面向承运商。生成时机放在签收而不是发运,是因为签收前可能发生改址、退回、丢失赔偿,太早计提会给对账造成额外工作量。结算流水表里要同时保存订单号、运单号、费用类型、含税金额、税率、开票状态,方便财务按订单维度合并开票。
| 对账维度 | 应收流水 | 应付流水 |
|---|---|---|
| 单据维度 | 订单号/运单号 | 运单号 |
| 金额维度 | 客户报价 | 承运商报价 |
| 时间维度 | 签收时间 | 签收时间 |
| 差异处理 | 改单重新计提 | 赔偿单独建流水 |
5. 落地环节:Java8连接MySQL8的配置、驱动与验证参数
5.1 在CentOS7.9上离线安装MySQL8的关键步骤
离线环境部署MySQL8时,常见做法是提前下载rpm包传到内网服务器,解压后按顺序安装。需要注意mysql-community-server的rpm依赖libaio、perl等基础包,缺了会直接报依赖错误。初始化后第一件事是查看临时密码,再修改root密码。
# 按顺序安装rpm包,common和libs要先装 rpm -ivh mysql-community-common-*.rpm rpm -ivh mysql-community-libs-*.rpm rpm -ivh mysql-community-client-*.rpm rpm -ivh mysql-community-server-*.rpm # 初始化并启动 mysqld --initialize --user=mysql systemctl start mysqld # 从错误日志拿临时密码 grep 'temporary password' /var/log/mysqld.log安装顺序不能乱,common提供公共库文件,libs提供连接库,服务端依赖它们才能完成安装。mysqld --initialize只执行一次,重复执行会报数据目录已存在,需要先清空/var/lib/mysql下的内容。
5.2 Java8连MySQL8的时区与认证插件坑
Java8项目连接MySQL8时最典型的问题是驱动版本和认证插件不匹配。MySQL8默认的caching_sha2_password认证插件,老版本mysql-connector-java 5.x无法识别,会报Access denied for user 'root'@'localhost',而Navicat等客户端因为走了新版驱动所以连接正常。解决方案是在JDBC URL里把serverTimezone=Asia/Shanghai带上,同时确认驱动是8.0.x版本。
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/yunyou?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai spring.datasource.username=root spring.datasource.password=your_password spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver如果业务系统暂时换不了驱动,也可以用SQL把用户认证插件改回mysql_native_password,但新库建议还是用新驱动,改插件只适合临时过渡。
5.3 芸柚物流云V30的四个生产参数
| 参数位置 | 参数名 | 推荐值 | 说明 |
|---|---|---|---|
| JVM | -Xms -Xmx | 物理内存的50% | 堆设太大留给OS缓存的内存不足 |
| 连接池 | initialSize | 5 | 启动时预建连接数 |
| 连接池 | maxActive | 50 | 按接口QPS压测调整 |
| MySQL | innodb_lock_wait_timeout | 5 | 库存分配死锁快速失败 |
线上验证整体链路时,我一般先用一条SQL确认订单、库存、运单能串起来:
SELECT o.order_no, o.status, w.warehouse_code, t.transport_status FROM oms_order o LEFT JOIN wms_outbound w ON w.order_no = o.order_no LEFT JOIN tms_waybill t ON t.order_no = o.order_no WHERE o.created_time BETWEEN #{startTime} AND #{endTime} LIMIT 20;查询结果里任何一个关联字段为空,说明对应环节的单据没有生成成功,结合打印日志里WMS下发回执或TMS回调的报文排查即可。
本文还有配套的精品资源,点击获取