大宗商品销采运储系统・六层架构连载(业务服务层)
前面我们完成了业务域的顶层设计(DDD 限界上下文)、通用基建(状态机管流转、规则引擎管定价)。
基建搭好,正式开工。今天进入大宗商品最核心的交易链路——采购业务。
如果你做过普通电商的采购,大概就是:选商品 → 填数量 → 下单 → 供应商发货 → 收货。
但大宗商品的采购完全是另一个维度的故事:动辄几万吨的煤炭、钢材,不可能一次性发完,也不可能一口价定死。
大宗采购的常态是:签一份年度/季度的长协合同,锁定总量,但价格随行就市(比如跟普氏指数挂钩)。然后根据生产需求,按月甚至按周下发子订单,供应商再分批到货。
这里面的架构难点在于:如何管理庞大的“合同池”?如何处理“分批到货”带来的余量扣减与状态结转?
一句话定调:
大宗采购不是“单次交易”,而是“框架+执行”的组合拳。系统必须管得住框架的总盘子,算得清每一次的流水账。
一、业务模型:为什么大宗采购必须做“框架与子单”的拆分?
如果把长协合同和每次的提货单放在一张表里,你会遇到极大的架构灾难:合同表动辄几千个字段,而且每次提货都要去锁合同主表更新余量,并发一高直接死锁。
正确的 DDD 聚合设计:
-
采购框架合同(框架层):记录供应商、总数量(如:10万吨)、总金额、有效期、基准定价规则。
-
采购子订单(执行层):每次执行时生成(如:提货 5000 吨),记录具体的提货时间、单汽车次/单火车次/单船次信息。
这属于典型的“1 对 N”关系,但并发扣减余量的难点全部集中在框架合同的主表上。

二、核心痛点:分批到货与防超发控制
假设合同签了 10000 吨,供应商今天运来 300 吨,明天运来 500 吨。系统怎么做管控?
1. 余量控制与防超发
绝不能让供应商送货的总量超过合同约定量。这就要求我们在生成子订单或登记到货单时,必须精准扣减合同余量。
常规做法(不建议):UPDATE contract SET remain_qty = remain_qty - 500 WHERE id = 1 AND remain_qty >= 500;
这种纯数据库行锁在大并发下会导致严重的阻塞。
架构级方案(结合缓存与持久层):
-
Redis 预扣:在 Redis 中缓存合同余量,利用 Lua 脚本原子扣减。
-
数据库乐观锁兜底:Redis 扣减成功后,异步同步到数据库,数据库使用
version乐观锁兜底。
2. 状态机驱动(结合第 2 篇)
采购子订单的状态不是人工修改的,而是由“到货批次”驱动的。
-
当第一批货过磅登记:子单状态由
待发货→部分到货。 -
当累计到货量 = 子单订购量:子单状态由
部分到货→已完结,并触发 MQ 通知财务生成应付账款。
三、核心伪代码与表结构设计
1. 表结构设计简略
-- 采购长协框架表CREATE TABLE t_purchase_contract (contract_no VARCHAR(32) PRIMARY KEY,supplier_id VARCHAR(32),total_qty DECIMAL(10,2),remain_qty DECIMAL(10,2), -- 剩余未执行数量status VARCHAR(20),version INT DEFAULT 0, -- 乐观锁版本...);-- 采购执行子订单表CREATE TABLE t_purchase_order (order_no VARCHAR(32) PRIMARY KEY,contract_no VARCHAR(32),order_qty DECIMAL(10,2), -- 本次订购量received_qty DECIMAL(10,2), -- 已到货量status VARCHAR(20), -- 状态机流转:已下单、部分到货、已完结...);-- 到货过磅登记表CREATE TABLE t_arrival_record (record_no VARCHAR(32) PRIMARY KEY,order_no VARCHAR(32),actual_qty DECIMAL(10,2), -- 实际过磅重量weigh_time DATETIME,...);
2. 生成子订单的并发防超发逻辑(Spring Boot 伪代码)
@Service
public class PurchaseOrderService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private PurchaseContractMapper contractMapper;
// 生成采购执行单
public String createSubOrder(String contractNo, BigDecimal orderQty) {
// 1. 校验合同状态及余量 (走 Redis Lua 脚本,保证原子性)
String luaScript =
"local remain = tonumber(redis.call('get', KEYS[1])) " +
"if not remain then return -1 end " +
"local need = tonumber(ARGV[1]) " +
"if remain >= need then " +
" redis.call('decrby', KEYS[1], need) " +
" return 1 " +
"else " +
" return 0 " +
"end";
Long result = redisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList("contract:remain:" + contractNo),
orderQty.toString());
if (result == null || result <= 0) {
throw new BusinessException("合同余量不足或系统繁忙,请重试");
}
// 2. Redis 扣减成功,执行本地事务落库
try {
// 2.1 写入子订单表
String orderNo = "PO" + SnowflakeIdGenerator.nextId(); // 复用前文的雪花算法
PurchaseOrder order = new PurchaseOrder();
order.setOrderNo(orderNo);
order.setContractNo(contractNo);
order.setOrderQty(orderQty);
order.setReceivedQty(BigDecimal.ZERO);
order.setStatus(OrderState.PENDING_DELIVERY);
orderMapper.insert(order);
// 2.2 更新框架合同余量(乐观锁兜底)
int rows = contractMapper.deductQty(contractNo, orderQty);
if (rows == 0) {
// 极端情况并发冲突,抛出异常,Redis 缓存需回滚(由后续补偿任务处理)
throw new BusinessException("合同余量更新冲突,请刷新重试");
}
return orderNo;
} catch (Exception e) {
// 3. 如果落库失败,把 Redis 里的扣减加回去
redisTemplate.opsForValue().increment("contract:remain:" + contractNo, orderQty.doubleValue());
throw new BusinessException("下单失败,已释放余量");
}
}
}
四、业务闭环:从过磅到入库的跨域联动
在大宗商品中,“货到了”不等于“能入库”。
真实业务场景:货车进了场站 → 上地磅称重(实际重量) → 抽样化验(质检) → 生成入库单。
这个过程需要跨域联动,依托公共中间件的 MQ:
-
履约域/场站(IoT 接入):地磅数据通过硬件接口采集,生成
到货过磅记录。 -
采购域:监听到货记录,调用状态机,更新子订单
received_qty(如果超量,触发预警)。并抛出ArrivalCompletedEvent。 -
质检域:监听到货后,下发质检任务。
-
库存域:等待质检合格后,正式生成库存批次,增加可用库存。
架构要点:千万不要在过磅成功后直接同步调用库存服务入库,因为质检可能不合格(比如硫分超标)。必须利用状态机,走完质检流程后再真正增加库存。

五、本篇小结
大宗采购的源头架构,核心在于处理好“总”与“分”的关系。
记住三句话:
-
长协合同与执行子单必须分离,框架管额度,子单管执行。
-
扣减余量使用 Redis+Lua 防超发,数据库乐观锁做兜底。
-
到货不等于入库,必须通过 MQ 与状态机,等待质检通过后才能形成可用库存。
下篇预告:采购业务(下):质检扣杂与采购入库,拆解大宗特有的过磅防作弊、扣杂算法与入库单生成。
317



被折叠的 条评论
为什么被折叠?



