fix(oms): 批量审核运输业务单号重复(goods_number取号三层防线)
根因: 取号为「COUNT当日单量+1+查重后移」, 非原子、无锁、无唯一索引。 批量审核并发创建运输业务单时, 多请求在同一查重窗口取到同一序号并全部 插入成功, 且将同一单号回填到出库单business_number, 页面所见单号重复。 - GoodsNumberGenerator: 取号改为当日MAX+1+存在性校验后移; 按yyMMdd分段的 JVM锁串行化"取号+插单"; 插单后自检同号自动改号自愈; 兼容唯一索引撞号重试 - BusinessDocumentOrderDomainService: 主创建入口改走原子"取号+插单", 出库/入库单号回填移至插单成功之后 - BusinessDocumentOrderImpl: 审核拆单子单路径同改 - Mapper: countByGoodsNumberPrefix改为selectGoodsNumbersByPrefix
This commit is contained in:
+3
-3
@@ -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);
|
||||
}
|
||||
|
||||
+2
-5
@@ -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表
|
||||
|
||||
+21
-19
@@ -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) {
|
||||
|
||||
+90
-4
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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) <= 12
|
||||
|
||||
Reference in New Issue
Block a user