fix(oms): 批量审核运输业务单号重复(goods_number取号三层防线)

根因: 取号为「COUNT当日单量+1+查重后移」, 非原子、无锁、无唯一索引。
批量审核并发创建运输业务单时, 多请求在同一查重窗口取到同一序号并全部
插入成功, 且将同一单号回填到出库单business_number, 页面所见单号重复。

- GoodsNumberGenerator: 取号改为当日MAX+1+存在性校验后移; 按yyMMdd分段的
  JVM锁串行化"取号+插单"; 插单后自检同号自动改号自愈; 兼容唯一索引撞号重试
- BusinessDocumentOrderDomainService: 主创建入口改走原子"取号+插单",
  出库/入库单号回填移至插单成功之后
- BusinessDocumentOrderImpl: 审核拆单子单路径同改
- Mapper: countByGoodsNumberPrefix改为selectGoodsNumbersByPrefix
This commit is contained in:
rcx
2026-09-12 10:09:11 +08:00
parent 892cc30999
commit 84ff45b43d
5 changed files with 118 additions and 33 deletions
@@ -39,8 +39,8 @@ public interface BusinessDocumentOrderMapper extends BaseMapper<BusinessDocument
String getPushVerifyByOrgId(@Param("organizationId") Long organizationId);
/**
* 统计指定日期前缀(yyMMdd)下已生成的新格式业务单号数量
* 仅统计新格式(长度<=12),过滤历史16位旧数据,保证序号每日从001正确累计
* 查询指定日期前缀(yyMMdd)下已生成的新格式业务单号列表
* 仅新格式(长度<=12),过滤历史16位旧数据,供取号器解析当日最大序号
*/
int countByGoodsNumberPrefix(@Param("prefix") String prefix);
List<String> selectGoodsNumbersByPrefix(@Param("prefix") String prefix);
}
@@ -237,11 +237,8 @@ public class BusinessDocumentOrderImpl extends ServiceImpl<BusinessDocumentOrder
subOrder.setRecipientName(user.getUserName());
//生成子单货源单号(yyMMdd + 当日顺序号)
String orderCode = goodsNumberGenerator.generateUniqueGoodsNumber(subOrder.getDocumentDate());
subOrder.setGoodsNumber(orderCode);
businessDocumentOrderMapper.insert(subOrder);
//生成子单货源单号(yyMMdd + 当日顺序号)并落库, 撞唯一索引自动重新取号重试
goodsNumberGenerator.insertWithUniqueGoodsNumber(subOrder);
subOrderCount++; // 增加子单计数
// 保存子单的地址信息到BUSINESS_DOCUMENT_ADDRESS_MULTI表
@@ -285,24 +285,9 @@ public class BusinessDocumentOrderDomainService {
}
}
//生成货源单号(yyMMdd + 当日顺序号,每日重置)
String orderCode = goodsNumberGenerator.generateUniqueGoodsNumber(tmsOrder.getDocumentDate());
tmsOrder.setGoodsNumber(orderCode);
ReservationStockOutOrder reservationStockOutOrder = reservationStockOutOrderMapper.selectOne(new LambdaQueryWrapper<ReservationStockOutOrder>()
.eq(ReservationStockOutOrder::getOutOrderNumber, tmsOrder.getInOrderNumber())
.eq(ReservationStockOutOrder::getDelFlag, 1));
if (reservationStockOutOrder != null){
reservationStockOutOrder.setBusinessNumber(tmsOrder.getGoodsNumber());
reservationStockOutOrderMapper.updateById(reservationStockOutOrder);
}
ReservationStockInOrder reservationStockInOrder = reservationStockInOrderMapper.selectOne(new LambdaQueryWrapper<ReservationStockInOrder>()
.eq(ReservationStockInOrder::getInOrderNumber, tmsOrder.getInOrderNumber())
.eq(ReservationStockInOrder::getDelFlag, 1));
if (reservationStockInOrder != null){
reservationStockInOrder.setBusinessNumber(tmsOrder.getGoodsNumber());
reservationStockInOrderMapper.updateById(reservationStockInOrder);
}
//生成货源单号并落库见下方 insertWithUniqueGoodsNumber:
//取号与插单合并为原子重试(撞goods_number唯一索引自动重新取号),
//出库/入库业务单的单号回填也移至插单成功之后,避免插单失败留下脏回填
List<BusinessDocumentAddressMultiDTO> loadingAddressList = businessDocumentOrderDO.getLoadingAddressList();//装货地信息集合
List<BusinessDocumentAddressMultiDTO> unloadAddressList = businessDocumentOrderDO.getUnloadAddressList();//卸货地信息集合
@@ -426,7 +411,24 @@ public class BusinessDocumentOrderDomainService {
//一天后
tmsOrder.setDocumentDate(businessDocumentOrderDO.getDocumentDate());
boolean istrue = businessDocumentOrderMapper.insert(tmsOrder) > 0;
//生成货源单号(yyMMdd + 当日顺序号,每日重置)并落库, 撞唯一索引自动重新取号重试
boolean istrue = goodsNumberGenerator.insertWithUniqueGoodsNumber(tmsOrder);
//落库成功后将运输业务单号回填至关联的出库/入库业务单
ReservationStockOutOrder reservationStockOutOrder = reservationStockOutOrderMapper.selectOne(new LambdaQueryWrapper<ReservationStockOutOrder>()
.eq(ReservationStockOutOrder::getOutOrderNumber, tmsOrder.getInOrderNumber())
.eq(ReservationStockOutOrder::getDelFlag, 1));
if (reservationStockOutOrder != null){
reservationStockOutOrder.setBusinessNumber(tmsOrder.getGoodsNumber());
reservationStockOutOrderMapper.updateById(reservationStockOutOrder);
}
ReservationStockInOrder reservationStockInOrder = reservationStockInOrderMapper.selectOne(new LambdaQueryWrapper<ReservationStockInOrder>()
.eq(ReservationStockInOrder::getInOrderNumber, tmsOrder.getInOrderNumber())
.eq(ReservationStockInOrder::getDelFlag, 1));
if (reservationStockInOrder != null){
reservationStockInOrder.setBusinessNumber(tmsOrder.getGoodsNumber());
reservationStockInOrderMapper.updateById(reservationStockInOrder);
}
if (hasDetails) {
for (BusinessDeliveryDetailsLinkDTO businessDeliveryDetailsLinkDTO : businessDeliveryDetailsLinkDTOList) {
@@ -5,23 +5,44 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.mhd.common.core.exception.ServiceException;
import com.mhd.oms.domain.businessDocumentOrder.entity.BusinessDocumentOrder;
import com.mhd.oms.domain.businessDocumentOrder.repository.mapper.BusinessDocumentOrderMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 业务单据订单(goodsNumber)单号生成器
* 规则: yyMMdd(取自单据日期) + 当日顺序号(每日重置, 3位起步, 超999自动扩位)
* 示例: 260804001、260804999、2608041000
*
* 唯一性保障(不依赖唯一索引, 三层防线):
* 1) 取号: 当日已生成单号 MAX+1 作为基线 + 候选号存在性校验后移收敛
* (对齐入库单号修复先例 ReservationStockInOrderDomainService#generateReserveOrderNumber)
* 2) 落库: 按日期前缀分段的JVM锁串行化"取号+插单", 消除本实例内并发审核/
* 批量取号的check-then-act竞态(多实例部署时本层只覆盖各自实例)
* 3) 自检自愈: 插单后立即校验同号数量, >1 说明与并发插入撞号, 给本单重新
* 取号改号, 兜住跨实例残余竞态
* 若日后补建goods_number唯一索引(见 sql/20260906_oms_business_goods_number_unique.sql),
* 撞号将抛DuplicateKeyException由重试逻辑接住, 与上述防线叠加互不冲突
*/
@Slf4j
@Component
public class GoodsNumberGenerator {
private static final int MAX_RETRY = 100;
/**
* 取号+插单分段锁: 按单号日期前缀(yyMMdd)分段, 同日串行、跨日并行,
* 锁对象常驻(每日新增1个, 增量可忽略)
*/
private static final Map<String, Object> LOCK_STRIPES = new ConcurrentHashMap<>();
@Autowired
private BusinessDocumentOrderMapper businessDocumentOrderMapper;
@@ -33,8 +54,21 @@ public class GoodsNumberGenerator {
Date date = (documentDate == null) ? new Date() : documentDate;
String datePart = DateUtil.format(date, "yyMMdd");
// count 只查一次作为序号基线
long seq = (long) businessDocumentOrderMapper.countByGoodsNumberPrefix(datePart) + 1;
// 当日已生成单号的最大序号作为基线(MAX+1而非COUNT+1, 防脏数据/序号错位导致回退复用)
List<String> existNumbers = businessDocumentOrderMapper.selectGoodsNumbersByPrefix(datePart);
long seq = 0;
for (String existNumber : existNumbers) {
if (existNumber == null || existNumber.length() <= datePart.length()) {
continue;
}
String numStr = existNumber.substring(datePart.length());
if (!numStr.matches("\\d+")) {
continue; // 防脏数据(非纯数字后缀)导致解析失败
}
seq = Math.max(seq, Long.parseLong(numStr));
}
seq = seq + 1;
int retryCount = 0;
while (retryCount < MAX_RETRY) {
String orderCode = datePart + String.format("%03d", seq);
@@ -46,8 +80,60 @@ public class GoodsNumberGenerator {
if (CollectionUtils.isEmpty(exist)) {
return orderCode;
}
seq++; // ← 关键:查重失败,序号往后递增找空位,而不是重新count
seq++; // ← 关键:查重失败,序号往后递增找空位,而不是重新取基线
}
throw new ServiceException("生成唯一业务单号失败,已达到最大重试次数:" + MAX_RETRY);
}
}
/**
* 生成唯一业务单号并落库: JVM分段锁内完成"取号+插单",
* 插入撞goods_number唯一索引时自动重新取号重试(索引为可选兜底),
* 插单成功后自检同号并自愈改号。
* 供业务单创建入口(手动/审核新增、审核拆单、预约确认)统一使用。
* @return 是否插入成功(mapper.insert结果)
*/
public boolean insertWithUniqueGoodsNumber(BusinessDocumentOrder order) {
Date date = (order.getDocumentDate() == null) ? new Date() : order.getDocumentDate();
String lockKey = DateUtil.format(date, "yyMMdd");
synchronized (LOCK_STRIPES.computeIfAbsent(lockKey, k -> new Object())) {
int retryCount = 0;
while (retryCount < MAX_RETRY) {
retryCount++;
order.setGoodsNumber(generateUniqueGoodsNumber(order.getDocumentDate()));
try {
boolean inserted = businessDocumentOrderMapper.insert(order) > 0;
// 插单后自检自愈: 无唯一索引时跨实例并发窗口内的撞号双方都能落库,
// 同号数量>1说明本单撞号, 给本单重新取号改号, 防止单号重复扩散
healIfDuplicated(order);
return inserted;
} catch (DuplicateKeyException e) {
// 已建唯一索引时的兜底: 仅业务单号撞唯一索引时重试, 其他唯一约束冲突原样抛出
String message = e.getMessage();
if (message == null || !message.toLowerCase().contains("goods_number")) {
throw e;
}
log.warn("业务单号[{}]撞号, 自动重新取号重试(第{}次): {}", order.getGoodsNumber(), retryCount, message);
}
}
throw new ServiceException("生成唯一业务单号失败,已达到最大重试次数:" + MAX_RETRY);
}
}
/**
* 插单后自检自愈: 查同号数量, >1 说明有并发插入撞号(本单为后插入方),
* 保持先落库单据单号不变, 重新取号给本单改号
*/
private void healIfDuplicated(BusinessDocumentOrder order) {
List<BusinessDocumentOrder> sameNumber = businessDocumentOrderMapper.selectList(
new QueryWrapper<BusinessDocumentOrder>().eq("goods_number", order.getGoodsNumber())
);
if (sameNumber != null && sameNumber.size() > 1) {
String oldNumber = order.getGoodsNumber();
String newNumber = generateUniqueGoodsNumber(order.getDocumentDate());
order.setGoodsNumber(newNumber);
businessDocumentOrderMapper.updateById(order);
log.warn("业务单号[{}]与并发插入撞号(无唯一索引兜底), 已自愈改号为[{}], orderId={}",
oldNumber, newNumber, order.getId());
}
}
}
@@ -1278,8 +1278,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
select PUSH_VERIFY from NGWL_TEST_PRODUCT.SYS_ORGANIZATION where ORGANIZATION_ID = #{organizationId}
</select>
<select id="countByGoodsNumberPrefix" resultType="java.lang.Integer">
SELECT COUNT(1)
<select id="selectGoodsNumbersByPrefix" resultType="java.lang.String">
SELECT goods_number
FROM business_document_order
WHERE goods_number LIKE concat(#{prefix}, '%')
AND LENGTH(goods_number) &lt;= 12