feat:库存bug

This commit is contained in:
dzc committed 2026-10-09 10:56:52 +08:00
1 parent 80b4d1e9cc
commit b4109f55d2
11 files changed
+331 -232

No files matched your search

@@ -68,11 +68,6 @@ public class CanteenSpuUpdateStockReqDTO {
@NotNull(message = "库存变化数量不能为空")
private Integer incrCount;
/**
* 本次业务开始前的库存快照,仅幂等库存接口使用。
*/
private Integer beforeStock;
/**
* 每日菜谱用餐日;分池扣减时必填(可与同批其他行不同)。
*/
@@ -71,16 +71,14 @@ public interface CanteenDailyMenuMapper extends PjcloudBaseMapper<CanteenDailyMe
@Param("categoryId") Long categoryId, DataScope dataScope);
/**
* 条件更新线上净销量(delta 为剩余侧增量:下单负数);expected 为操作前剩余。
* 条件更新线上净销量(delta 为剩余侧增量:下单负数);扣减自带「不扣负、不超配额」边界。
*/
int updateOnlineStockDelta(@Param("merchantId") Long merchantId, @Param("mealDate") java.time.LocalDate mealDate,
@Param("hourType") Integer hourType, @Param("spuId") Long spuId, @Param("delta") int delta,
@Param("expected") Integer expected);
@Param("hourType") Integer hourType, @Param("spuId") Long spuId, @Param("delta") int delta);
/**
* 条件更新线下净销量(delta 为剩余侧增量:下单负数);expected 为操作前剩余。
* 条件更新线下净销量(delta 为剩余侧增量:下单负数);扣减自带「不扣负、不超配额」边界。
*/
int updateOfflineStockDelta(@Param("merchantId") Long merchantId, @Param("mealDate") java.time.LocalDate mealDate,
@Param("hourType") Integer hourType, @Param("spuId") Long spuId, @Param("delta") int delta,
@Param("expected") Integer expected);
@Param("hourType") Integer hourType, @Param("spuId") Long spuId, @Param("delta") int delta);
}
@@ -34,31 +34,4 @@ public interface CanteenSpuMapper extends PjcloudBaseMapper<CanteenSpuEntity> {
.ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount));
return update(null, updateWrapper);
}
/**
* 仅在数据库当前库存仍等于业务快照时更新库存和销量,避免不同业务号并发操作同一 SPU 时丢失更新。
*
* @param id 商品 SPU 编号
* @param incrCount 库存增量,负数扣减、正数恢复
* @param expectedStock 本次业务读取到的操作前库存
* @return 影响行数;返回 0 表示库存已变化或扣减后会小于 0
*/
default int updateStockIfMatch(Long id, Integer incrCount, Integer expectedStock) {
if (id == null || incrCount == null || incrCount == 0 || incrCount == Integer.MIN_VALUE
|| expectedStock == null || expectedStock < 0) {
return 0;
}
String sql;
if (incrCount > 0) {
sql = " stock = stock + " + incrCount + ", sales_count = sales_count - " + incrCount;
} else {
sql = " stock = stock - " + Math.abs(incrCount) + ", sales_count = sales_count + " + Math.abs(incrCount);
}
LambdaUpdateWrapper<CanteenSpuEntity> updateWrapper = new LambdaUpdateWrapper<CanteenSpuEntity>()
.setSql(sql)
.eq(CanteenSpuEntity::getId, id)
.eq(CanteenSpuEntity::getStock, expectedStock)
.ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount));
return update(null, updateWrapper);
}
}
@@ -115,29 +115,6 @@ public class CanteenDailyMenuStockSupport {
return offline ? adStockOf(menu) : onlineRemain(menu);
}
/**
* 将调用方 beforeStock 归一到通道 CAS 口径(与 {@link #channelRemain} / SQL expected 一致)。
* <p>
* 线下:标准为 AD stock(总库存−线上已订)。兼容 Hard 库存校验走 App 填充时误传的
* 「剩余总库存」(总库存−线上已订−线下已售):该值可接受,但 CAS 仍用 AD stock。
*
* @return 归一后的 expected;无法识别则 null(应按库存已变化处理)
*/
public static Integer normalizeChannelBeforeStock(CanteenDailyMenuEntity menu, boolean offline,
Integer beforeStock) {
if (menu == null || beforeStock == null || beforeStock < 0) {
return null;
}
int channel = channelRemain(menu, offline);
if (beforeStock == channel) {
return channel;
}
if (offline && beforeStock == sharedRemain(menu)) {
return channel;
}
return null;
}
/** App:stock = 线上剩余 */
public void fillAppOnlineRemain(List<DailyMenuAppVO> rows, Long merchantId, LocalDate mealDate) {
if (CollUtil.isEmpty(rows)) {
@@ -454,7 +454,7 @@ public class CanteenOnlineSpuServiceImpl extends ServiceImpl<CanteenOnlineSpuMap
}
/**
* 同一业务号的扣减和补偿在食堂服务内串行执行;补偿通过当前库存与原库存快照判断扣减是否已提交。
* 同一业务号的扣减和补偿在食堂服务内串行执行;扣减是否已提交由 Redis 状态机判断。
*/
@Override
@Lock4j(keys = {"#dto.bizId"})
@@ -464,8 +464,7 @@ public class CanteenOnlineSpuServiceImpl extends ServiceImpl<CanteenOnlineSpuMap
boolean deductOperation = "DEDUCT".equals(operation);
boolean rollbackOperation = "ROLLBACK".equals(operation);
if (StrUtil.isBlank(dto.getBizId()) || CollUtil.isEmpty(dto.getItems())
|| (!deductOperation && !rollbackOperation)
|| dto.getItems().stream().anyMatch(item -> item.getBeforeStock() == null)) {
|| (!deductOperation && !rollbackOperation)) {
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_BIZ_PARAM_BAD));
}
String stateKey = ORDER_STOCK_BIZ_KEY_PREFIX + dto.getBizId();
@@ -476,39 +475,25 @@ public class CanteenOnlineSpuServiceImpl extends ServiceImpl<CanteenOnlineSpuMap
if (rollbackOperation && "ROLLED_BACK".equals(state)) {
return R.ok();
}
// 充足性以服务端现值判断,不再比对调用方快照:不同业务号交替下单/退款时快照必然失配,
// 会把正常操作误判为“库存已变化”;防超卖由 updateStock 同条 UPDATE 的 stock >= |增量| 条件保证。
for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
CanteenOnlineSpuEntity spu = baseMapper.selectById(item.getId());
if (spu == null) {
return R.failed(MsgUtils.getMessage(ErrorCodes.COMMON_PRODUCT_NOT_FOUND));
}
int expectedStock = item.getBeforeStock();
int targetStock = Math.addExact(expectedStock, item.getIncrCount());
if (deductOperation) {
if (spu.getStock() == targetStock) {
continue;
}
if (spu.getStock() != expectedStock) {
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_CHANGED, spu.getName()));
}
} else {
if (spu.getStock() == expectedStock) {
continue;
}
if (spu.getStock() != targetStock) {
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_COMPENSATE_FAIL));
}
if (deductOperation && Math.addExact(spu.getStock(), item.getIncrCount()) < 0) {
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_INSUFFICIENT));
}
}
for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
CanteenOnlineSpuEntity spu = baseMapper.selectById(item.getId());
int expectedStock = item.getBeforeStock();
int targetStock = Math.addExact(expectedStock, item.getIncrCount());
int delta = deductOperation ? item.getIncrCount() : -item.getIncrCount();
boolean needUpdate = deductOperation
? spu.getStock() == expectedStock
: spu.getStock() == targetStock;
if (needUpdate && baseMapper.updateStock(item.getId(), delta) == 0) {
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL);
// 只有确认 DEDUCT 已提交的 ROLLBACK 才反向回补;状态缺失的 ROLLBACK 说明扣减从未生效,
// 只写终态阻断延迟到达的 DEDUCT,不能按库存值猜测并恢复(与基础池协议语义一致)。
if (deductOperation || "DEDUCTED".equals(state)) {
if (baseMapper.updateStock(item.getId(), delta) == 0) {
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL);
}
}
}
stringRedisTemplate.opsForValue().set(stateKey, deductOperation ? "DEDUCTED" : "ROLLED_BACK");
@@ -133,7 +133,7 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
if (dto == null || StrUtil.isBlank(dto.getBizId()) || CollUtil.isEmpty(dto.getItems())
|| (!applyOperation && !rollbackOperation)
|| dto.getItems().stream().anyMatch(item -> item == null || item.getId() == null
|| item.getIncrCount() == null || item.getIncrCount() == 0 || item.getBeforeStock() == null)) {
|| item.getIncrCount() == null || item.getIncrCount() == 0)) {
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_BIZ_PARAM_BAD));
}
normalizeDailyStockChannel(dto);
@@ -160,24 +160,13 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
if (spu == null || spu.getStock() == null) {
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_SPU_BAD));
}
int expectedStock = item.getBeforeStock();
if (expectedStock < 0) {
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_SNAPSHOT_BAD));
}
int targetStock = Math.addExact(expectedStock, item.getIncrCount());
if (targetStock < 0) {
// 充足性以服务端现值判断,不再比对调用方快照:不同业务号交替扣减/回补时快照必然失配,
// 曾把正常下单确定性误拒为“库存已变化”;防超卖由更新 SQL 的下界条件保证。
if (Math.addExact(spu.getStock(), item.getIncrCount()) < 0) {
return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_PRODUCT_STOCK_LOW,
StrUtil.blankToDefault(spu.getName(), "商品")));
}
if (applyOperation && item.getIncrCount() < 0) {
// 负增量扣减必须以快照做前置校验:不得把“库存恰好等于目标值”当成本业务已经执行,
// 该值可能由另一个业务号并发扣减得到,且并发扣减基于同一剩余量判断会超卖。
// 正增量恢复是可交换操作,不做快照校验,见下方更新循环注释。
if (spu.getStock() != expectedStock) {
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_CHANGED,
StrUtil.blankToDefault(spu.getName(), "商品")));
}
} else if (rollbackOperation && "APPLIED".equals(state)) {
if (rollbackOperation && "APPLIED".equals(state)) {
int rollbackDelta = -item.getIncrCount();
if (rollbackDelta < 0 && spu.getStock() < Math.abs(rollbackDelta)) {
return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_PRODUCT_STOCK_LOW,
@@ -187,10 +176,9 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
}
for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
int expectedStock = item.getBeforeStock();
int delta = applyOperation ? item.getIncrCount() : -item.getIncrCount();
if (rollbackOperation && "APPLIED".equals(state)) {
// 补偿不能再要求库存等于本业务 APPLY 后的快照:期间可能已有其他订单合法改变同一 SPU。
// 补偿是可交换操作:期间其他订单可能已合法改变同一 SPU 的库存,
// 同一业务号由锁和 APPLIED 状态保证只反向一次,数据库条件更新只负责阻止库存扣成负数。
if (baseMapper.updateStock(item.getId(), delta) == 0) {
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_COMPENSATE_FAIL);
@@ -199,15 +187,11 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
}
// 状态缺失的 ROLLBACK 说明没有可确认的成功 APPLY,只写入终态阻断延迟请求,不能按库存值猜测并恢复。
if (applyOperation) {
// 正增量恢复库存是可交换操作:并发下单或退款改变的库存不影响本业务恢复量的正确性,
// 直接原子累加即可,幂等仍由 bizId 分布式锁和 Redis 状态保证,
// 避免退款恢复被其他业务号的并发库存变更误判为冲突(“库存已变化”)。
// 负增量扣减保留快照 CAS 条件更新,防止不同业务号基于同一剩余量重复判断导致超卖。
int updated = delta > 0 ? baseMapper.updateStock(item.getId(), delta)
: baseMapper.updateStockIfMatch(item.getId(), delta, expectedStock);
if (updated == 0) {
throw new I18nBizException(delta > 0 ? ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL
: ErrorCodes.CANTEEN_STOCK_CHANGED, "");
// 库存变更统一为无条件原子累加:正负增量都是可交换操作,幂等由 bizId 分布式锁和 Redis 状态保证,
// 防超卖由 updateStock 同条 UPDATE 的 stock >= |增量| 条件保证(返回 0 行即库存不足)。
// 不再使用调用方快照做 CAS:不同业务号交替扣减/回补时快照必然失配,曾把正常下单误判为“库存已变化”。
if (baseMapper.updateStock(item.getId(), delta) == 0) {
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL, "");
}
}
}
@@ -222,9 +206,18 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
return "ONLINE".equals(ch) || "OFFLINE".equals(ch);
}
/** 空通道默认 OFFLINE,兼容未升级的安卓消费机。 */
/**
* 空通道且行携带餐段信息时默认 OFFLINE,兼容未升级的安卓消费机(其请求必带 mealDate/hourType)。
* 不带餐段的空通道请求保持原样走基础 SPU 协议:Hardware 的基础池扣减/退款不带 stockChannel 与餐段,
* 无条件默认 OFFLINE 会把它们错误路由进分池路径并被餐段校验拒绝。
*/
private static void normalizeDailyStockChannel(CanteenSpuUpdateStockReqDTO dto) {
if (dto != null && StrUtil.isBlank(dto.getStockChannel())) {
if (dto == null || StrUtil.isNotBlank(dto.getStockChannel()) || CollUtil.isEmpty(dto.getItems())) {
return;
}
boolean hasDailyRows = dto.getItems().stream()
.anyMatch(item -> item != null && (item.getMealDate() != null || item.getHourType() != null));
if (hasDailyRows) {
dto.setStockChannel("OFFLINE");
}
}
@@ -260,7 +253,7 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
}
boolean offline = !"ONLINE".equals(StrUtil.trim(dto.getStockChannel()));
List<CanteenSpuUpdateStockReqDTO.Item> items = expandDailyMenuLinkedStockItems(dto.getMerchantId(), offline,
dto.getItems(), false);
dto.getItems());
if (CollUtil.isEmpty(items)) {
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_ITEMS_EMPTY));
}
@@ -289,9 +282,9 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
for (CanteenSpuUpdateStockReqDTO.Item item : items) {
int updated = offline
? canteenDailyMenuMapper.updateOfflineStockDelta(dto.getMerchantId(), item.getMealDate(),
item.getHourType(), item.getId(), item.getIncrCount(), null)
item.getHourType(), item.getId(), item.getIncrCount())
: canteenDailyMenuMapper.updateOnlineStockDelta(dto.getMerchantId(), item.getMealDate(),
item.getHourType(), item.getId(), item.getIncrCount(), null);
item.getHourType(), item.getId(), item.getIncrCount());
if (updated == 0) {
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_INSUFFICIENT);
}
@@ -317,7 +310,7 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
}
// 套餐行展开为组成单品同日同餐段销量联动;ROLLBACK 对同一 items 再展开一次,保证对称回补
List<CanteenSpuUpdateStockReqDTO.Item> items = expandDailyMenuLinkedStockItems(dto.getMerchantId(), offline,
dto.getItems(), true);
dto.getItems());
Map<String, Integer> remainCursor = new HashMap<>();
for (CanteenSpuUpdateStockReqDTO.Item item : items) {
CanteenDailyMenuEntity menu = findDailyMenuRow(dto.getMerchantId(), item.getMealDate(), item.getHourType(),
@@ -328,32 +321,15 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
String key = dailyStockItemKey(item);
int current = remainCursor.computeIfAbsent(key,
k -> CanteenDailyMenuStockSupport.channelRemain(menu, offline));
int expectedStock = item.getBeforeStock();
if (expectedStock < 0) {
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_SNAPSHOT_BAD));
}
// 线下:兼容误传「剩余总库存」快照,归一到 AD stock 再与 current / SQL expected 对齐
Integer normalizedExpected = CanteenDailyMenuStockSupport.normalizeChannelBeforeStock(menu, offline,
expectedStock);
if (applyOperation && item.getIncrCount() < 0 && normalizedExpected == null) {
CanteenSpuEntity spu = baseMapper.selectById(item.getId());
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_CHANGED,
StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
}
int casExpected = normalizedExpected != null ? normalizedExpected : expectedStock;
item.setBeforeStock(casExpected);
// 可售不足:线下用剩余总库存判断(真正可卖边界);线上仍用通道剩余
// 可售不足:线下用剩余总库存判断(真正可卖边界);线上仍用通道剩余。
// 不再比对调用方快照:不同业务号交替扣减/回补时快照必然失配,曾把消费机正常下单
// 确定性误拒为“库存已变化”;防超卖由 delta SQL 的“不超配额、不扣负”边界保证。
int sellable = offline ? CanteenDailyMenuStockSupport.sharedRemain(menu) : current;
if (item.getIncrCount() < 0 && Math.abs(item.getIncrCount()) > sellable) {
CanteenSpuEntity spu = baseMapper.selectById(item.getId());
return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_PRODUCT_STOCK_LOW,
StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
}
if (applyOperation && item.getIncrCount() < 0 && current != casExpected) {
CanteenSpuEntity spu = baseMapper.selectById(item.getId());
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_CHANGED,
StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
}
if (rollbackOperation && "APPLIED".equals(state)) {
int rollbackDelta = -item.getIncrCount();
if (rollbackDelta < 0 && current < Math.abs(rollbackDelta)) {
@@ -372,24 +348,23 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
if (rollbackOperation && "APPLIED".equals(state)) {
updated = offline
? canteenDailyMenuMapper.updateOfflineStockDelta(dto.getMerchantId(), item.getMealDate(),
item.getHourType(), item.getId(), delta, null)
item.getHourType(), item.getId(), delta)
: canteenDailyMenuMapper.updateOnlineStockDelta(dto.getMerchantId(), item.getMealDate(),
item.getHourType(), item.getId(), delta, null);
item.getHourType(), item.getId(), delta);
if (updated == 0) {
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_COMPENSATE_FAIL);
}
continue;
}
if (applyOperation) {
Integer expected = delta > 0 ? null : item.getBeforeStock();
// delta SQL 自带“不超配额、不扣负”边界,不再携带快照 CAS 条件
updated = offline
? canteenDailyMenuMapper.updateOfflineStockDelta(dto.getMerchantId(), item.getMealDate(),
item.getHourType(), item.getId(), delta, expected)
item.getHourType(), item.getId(), delta)
: canteenDailyMenuMapper.updateOnlineStockDelta(dto.getMerchantId(), item.getMealDate(),
item.getHourType(), item.getId(), delta, expected);
item.getHourType(), item.getId(), delta);
if (updated == 0) {
throw new I18nBizException(delta > 0 ? ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL
: ErrorCodes.CANTEEN_STOCK_CHANGED, "");
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL, "");
}
}
}
@@ -404,17 +379,10 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
* 单品售出会通过展示/校验侧的「可组成套餐数」压低套餐可售,此处不反向改写套餐 sold,避免与订单销量口径打架。
*/
private List<CanteenSpuUpdateStockReqDTO.Item> expandDailyMenuLinkedStockItems(Long merchantId, boolean offline,
List<CanteenSpuUpdateStockReqDTO.Item> source, boolean fillMissingBeforeStock) {
List<CanteenSpuUpdateStockReqDTO.Item> source) {
if (CollUtil.isEmpty(source)) {
return List.of();
}
Map<String, Integer> callerBefore = new HashMap<>();
for (CanteenSpuUpdateStockReqDTO.Item item : source) {
if (item != null && item.getId() != null && item.getMealDate() != null && item.getHourType() != null
&& item.getBeforeStock() != null) {
callerBefore.put(dailyStockItemKey(item), item.getBeforeStock());
}
}
Set<Long> spuIds = source.stream().filter(Objects::nonNull).map(CanteenSpuUpdateStockReqDTO.Item::getId)
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, CanteenSpuEntity> spuMap = spuIds.isEmpty() ? Map.of()
@@ -478,15 +446,6 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
if (item.getIncrCount() == null || item.getIncrCount() == 0) {
continue;
}
Integer before = callerBefore.get(dailyStockItemKey(item));
if (before == null && fillMissingBeforeStock) {
CanteenDailyMenuEntity menu = findDailyMenuRow(merchantId, item.getMealDate(), item.getHourType(),
item.getId());
before = menu == null ? 0 : CanteenDailyMenuStockSupport.channelRemain(menu, offline);
}
if (before != null) {
item.setBeforeStock(before);
}
result.add(item);
}
return result;
@@ -496,7 +455,6 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
CanteenSpuUpdateStockReqDTO.Item copy = new CanteenSpuUpdateStockReqDTO.Item();
copy.setId(src.getId());
copy.setIncrCount(src.getIncrCount());
copy.setBeforeStock(src.getBeforeStock());
copy.setMealDate(src.getMealDate());
copy.setHourType(src.getHourType());
return copy;
@@ -347,16 +347,17 @@
<!--
delta 与旧接口一致:下单扣减传负数、售后回传正数。
实际改写销量:sold = sold - delta(扣减时 sold 增加)。
expected 为操作前「剩余」快照(配额 - 销量)。
正增量恢复不带任何边界条件:SET 已把销量钳制到 0 为止(菜谱重导或按订单重算销量后
sold 可能小于恢复量,退款/补偿不能被统计口径阻断),配额为 0 等脏数据同样不得阻断;
负增量扣减保留原「不扣成负销量、不超配额」边界,语义不变。
不再携带 beforeStock 快照 CAS 条件:不同业务号交替扣减/回补时快照必然失配,
曾把消费机正常下单确定性误拒为“库存已变化”;防超卖由上述边界条件保证。
-->
<!--
共享总库存:offline_stock=总库存,online_stock=移动端上限。
delta 为库存协议增量(扣减为负):sold = sold - delta。
移动端剩余 = LEAST(上限−已订, 总库存−已订−已售);
AD stock / expected = 总库存−已订;扣减边界仍为 已订+已售 ≤ 总库存。
扣减边界为 已订+已售 ≤ 总库存。
-->
<update id="updateOnlineStockDelta">
UPDATE canteen_daily_menu
@@ -371,12 +372,6 @@
AND IFNULL(online_sold, 0) - #{delta} &lt;= IFNULL(online_stock, 600)
AND IFNULL(online_sold, 0) + IFNULL(offline_sold, 0) - #{delta} &lt;= IFNULL(offline_stock, 600)
</if>
<if test="expected != null">
AND LEAST(
GREATEST(IFNULL(online_stock, 600) - IFNULL(online_sold, 0), 0),
GREATEST(IFNULL(offline_stock, 600) - IFNULL(online_sold, 0) - IFNULL(offline_sold, 0), 0)
) = #{expected}
</if>
</update>
<update id="updateOfflineStockDelta">
@@ -391,8 +386,5 @@
AND IFNULL(offline_sold, 0) - #{delta} &gt;= 0
AND IFNULL(online_sold, 0) + IFNULL(offline_sold, 0) - #{delta} &lt;= IFNULL(offline_stock, 600)
</if>
<if test="expected != null">
AND GREATEST(IFNULL(offline_stock, 600) - IFNULL(online_sold, 0), 0) = #{expected}
</if>
</update>
</mapper>
@@ -0,0 +1,177 @@
package com.bsd.pjcloud.canteen.service.impl;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
import com.bsd.pjcloud.canteen.entity.CanteenOnlineSpuEntity;
import com.bsd.pjcloud.canteen.mapper.CanteenCategoryMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenOnlineCategoryMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenOnlineComboItemMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenOnlineSpuMapper;
import com.bsd.pjcloud.canteen.service.CanteenOnlineSpuChoiceGroupService;
import com.bsd.pjcloud.canteen.service.CanteenOnlineSpuService;
import com.bsd.pjcloud.canteen.util.CanteenMerchantScopeHelper;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.core.util.SpringContextHolder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.context.MessageSource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.Locale;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 在线菜品旧协议(canteen_online_spu)DEDUCT/ROLLBACK 幂等状态机测试。
* 协议已不再携带 beforeStock 快照做 CAS:充足性以服务端现值判断,防超卖由
* updateStock 同条 UPDATE 的 stock >= |增量| 条件保证,不同业务号交替下单/退款不得误报“库存已变化”。
*/
class CanteenOnlineSpuServiceImplTest {
/** 在线菜品 Mapper,用于验证库存增量是否真实执行。 */
private CanteenOnlineSpuMapper onlineSpuMapper;
/** Redis 字符串操作,用于模拟和验证库存业务状态迁移。 */
private ValueOperations<String, String> valueOperations;
/** 被测在线菜品服务。 */
private CanteenOnlineSpuService service;
@BeforeEach
void setUp() {
onlineSpuMapper = mock(CanteenOnlineSpuMapper.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
service = new CanteenOnlineSpuServiceImpl(redisTemplate, mock(CanteenCategoryMapper.class),
mock(CanteenOnlineCategoryMapper.class), mock(CanteenDailyMenuMapper.class), onlineSpuMapper,
mock(CanteenOnlineSpuChoiceGroupService.class), mock(CanteenOnlineComboItemMapper.class),
mock(CanteenMerchantScopeHelper.class));
}
/** 撤销最小 MessageSource 注入,避免静态 SpringContextHolder 影响后续测试类。 */
@AfterEach
void tearDown() {
ReflectionTestUtils.setField(SpringContextHolder.class, "beanFactory", null);
}
/**
* DEDUCT 不再比对快照:其他业务号已改变当前库存(交替下单/退款场景)时,
* 只要服务端现值仍够扣就必须放行,不得误报“库存已变化”。
*/
@Test
void deductShouldIgnoreConcurrentStockChange() {
when(valueOperations.get("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9")).thenReturn(null);
when(onlineSpuMapper.selectById(1L)).thenReturn(onlineSpu(8));
when(onlineSpuMapper.updateStock(1L, -2)).thenReturn(1);
assertTrue(service.updateOnlineSpuSkuStockIdempotent(stockRequest("DEDUCT", -2)).isOk());
verify(onlineSpuMapper).updateStock(1L, -2);
verify(valueOperations).set("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9", "DEDUCTED");
}
/**
* DEDUCT 防超卖兜底:服务端现值不足以覆盖扣减量时仍必须拒绝。
*/
@Test
void deductShouldRejectInsufficientStock() {
registerMinimalMessageSource();
when(valueOperations.get("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9")).thenReturn(null);
when(onlineSpuMapper.selectById(1L)).thenReturn(onlineSpu(1));
R result = service.updateOnlineSpuSkuStockIdempotent(stockRequest("DEDUCT", -2));
assertFalse(result.isOk());
verify(onlineSpuMapper, never()).updateStock(anyLong(), anyInt());
}
/**
* 状态缺失的 ROLLBACK 说明 DEDUCT 从未生效:只写终态阻断延迟到达的 DEDUCT,
* 不得凭空恢复库存。
*/
@Test
void rollbackWithoutDeductShouldOnlyMarkTerminalState() {
when(valueOperations.get("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9")).thenReturn(null);
when(onlineSpuMapper.selectById(1L)).thenReturn(onlineSpu(8));
assertTrue(service.updateOnlineSpuSkuStockIdempotent(stockRequest("ROLLBACK", -2)).isOk());
verify(onlineSpuMapper, never()).updateStock(anyLong(), anyInt());
verify(valueOperations).set("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9", "ROLLED_BACK");
}
/**
* 确认 DEDUCT 已提交的 ROLLBACK 必须反向回补库存并写入终态。
*/
@Test
void rollbackAfterDeductShouldRestoreStock() {
when(valueOperations.get("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9")).thenReturn("DEDUCTED");
when(onlineSpuMapper.selectById(1L)).thenReturn(onlineSpu(8));
when(onlineSpuMapper.updateStock(1L, 2)).thenReturn(1);
assertTrue(service.updateOnlineSpuSkuStockIdempotent(stockRequest("ROLLBACK", -2)).isOk());
verify(onlineSpuMapper).updateStock(1L, 2);
verify(valueOperations).set("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9", "ROLLED_BACK");
}
/**
* 为纯 Mockito 环境注入最小 MessageSource,使 R.failed 分支的 MsgUtils 文案解析可用;
* 文案内容不影响断言,直接回显错误码。
*/
private void registerMinimalMessageSource() {
MessageSource messageSource = mock(MessageSource.class);
when(messageSource.getMessage(anyString(), any(), any(Locale.class)))
.thenAnswer(invocation -> invocation.getArgument(0, String.class));
ConfigurableListableBeanFactory factory = mock(ConfigurableListableBeanFactory.class);
when(factory.getBean("pjcloudMessageSource")).thenReturn(messageSource);
new SpringContextHolder().postProcessBeanFactory(factory);
}
/**
* 构造单个在线菜品的幂等库存请求。
*
* @param operation DEDUCT 或 ROLLBACK
* @param increment DEDUCT 使用的库存增量(扣减为负数)
* @return 可直接调用库存服务的请求
*/
private CanteenSpuUpdateStockReqDTO stockRequest(String operation, int increment) {
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
item.setId(1L);
item.setIncrCount(increment);
CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
dto.setBizId("HARD_RESERVE_ORDER:9");
dto.setOperation(operation);
dto.setItems(List.of(item));
return dto;
}
/**
* 构造指定当前库存的在线菜品实体。
*
* @param stock 当前在线菜品库存
* @return 仅包含本用例所需字段的实体
*/
private CanteenOnlineSpuEntity onlineSpu(int stock) {
CanteenOnlineSpuEntity entity = new CanteenOnlineSpuEntity();
entity.setId(1L);
entity.setStock(stock);
entity.setName("测试菜品");
return entity;
}
}
@@ -37,6 +37,8 @@ import static org.mockito.Mockito.when;
/**
* 每日菜谱基础 SPU 库存幂等状态机测试,重点覆盖退款任务本地回滚后的再次执行。
* 库存协议已不再携带 beforeStock 快照做 CAS:充足性以服务端现值判断,
* 防超卖由更新 SQL 的下界条件保证,不同业务号交替扣减/回补不得被误判为“库存已变化”。
*/
class CanteenSpuServiceImplTest {
@@ -46,7 +48,7 @@ class CanteenSpuServiceImplTest {
/** 基础 SPU Mapper,用于验证库存增量是否真实执行。 */
private CanteenSpuMapper spuMapper;
/** 每日菜谱 Mapper,用于验证分池正增量恢复的剩余匹配条件。 */
/** 每日菜谱 Mapper,用于验证分池增量是否真实执行。 */
private CanteenDailyMenuMapper dailyMenuMapper;
/** Redis 字符串操作,用于模拟和验证库存业务状态迁移。 */
@@ -79,11 +81,10 @@ class CanteenSpuServiceImplTest {
/**
* 已确认应用后完成补偿的售后库存允许再次 APPLY;否则钱包成功后的任务重试会丢失库存恢复。
* 正增量恢复走无条件原子累加,不再与快照做 CAS 对比。
*/
@Test
void compensatedRefundInventoryShouldAllowRetry() {
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", 2, 10);
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", 2);
when(valueOperations.get("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9"))
.thenReturn("COMPENSATED");
when(spuMapper.selectById(1L)).thenReturn(spu(10));
@@ -97,12 +98,12 @@ class CanteenSpuServiceImplTest {
}
/**
* 正增量恢复是可交换操作:快照与当前库存不一致(并发下单/退款已改变库存)时仍应直接累加成功,
* 正增量恢复是可交换操作:并发下单/退款已改变当前库存时仍应直接累加成功,
* 不得被误判为“库存已变化”阻断退款。
*/
@Test
void restoreApplyShouldIgnoreConcurrentStockChange() {
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", 2, 10);
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", 2);
when(spuMapper.selectById(1L)).thenReturn(spu(8));
when(spuMapper.updateStock(1L, 2)).thenReturn(1);
@@ -114,24 +115,40 @@ class CanteenSpuServiceImplTest {
}
/**
* 负增量扣减仍要求当前库存等于快照,防止不同业务号基于同一剩余量重复判断导致超卖。
* 负增量扣减不再比对快照:当天其他业务号(消费机售出/退款回补)已改变库存时,
* 只要服务端现值仍够扣就必须放行——曾经快照口径失配导致消费机下单被确定性误拒“库存已变化”。
*/
@Test
void deductApplyShouldStillRejectChangedStock() {
registerMinimalMessageSource();
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", -2, 10);
void deductApplyShouldIgnoreConcurrentStockChange() {
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", -2);
when(spuMapper.selectById(1L)).thenReturn(spu(8));
when(spuMapper.updateStock(1L, -2)).thenReturn(1);
assertTrue(service.updateSkuStockIdempotent(dto).isOk());
verify(spuMapper).updateStock(1L, -2);
verify(valueOperations).set("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9", "APPLIED",
Duration.ofDays(7));
}
/**
* 负增量扣减防超卖兜底:服务端现值不足以覆盖扣减量时仍必须拒绝。
*/
@Test
void deductApplyShouldRejectInsufficientStock() {
registerMinimalMessageSource();
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", -2);
when(spuMapper.selectById(1L)).thenReturn(spu(1));
R result = service.updateSkuStockIdempotent(dto);
assertFalse(result.isOk());
verify(spuMapper, never()).updateStock(anyLong(), anyInt());
verify(spuMapper, never()).updateStockIfMatch(anyLong(), anyInt(), anyInt());
}
/**
* 每日菜谱分池正增量恢复:当前剩余与快照不一致(菜谱重导/销量重算后)也不得阻断退款,
* 且传给 SQL 的剩余匹配条件为 null(销量回退由 SQL 钳制到 0 为止)。
* 每日菜谱分池正增量恢复:菜谱重导/销量重算导致剩余口径变化后也不得阻断退款
* (销量回退由 SQL 钳制到 0 为止)。
*/
@Test
void dailyRestoreApplyShouldSkipRemainMatch() {
@@ -139,7 +156,6 @@ class CanteenSpuServiceImplTest {
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
item.setId(100L);
item.setIncrCount(2);
item.setBeforeStock(5);
item.setMealDate(mealDate);
item.setHourType(1);
CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
@@ -149,29 +165,27 @@ class CanteenSpuServiceImplTest {
dto.setMerchantId(1L);
dto.setItems(List.of(item));
// 配额 10、已售 0:剩余 10 与快照 5 不一致,正增量恢复仍应放行
// 配额 10、已售 0,正增量恢复应放行
when(dailyMenuMapper.selectOne(any())).thenReturn(dailyMenu(10, 0));
when(dailyMenuMapper.updateOnlineStockDelta(1L, mealDate, 1, 100L, 2, null)).thenReturn(1);
when(dailyMenuMapper.updateOnlineStockDelta(1L, mealDate, 1, 100L, 2)).thenReturn(1);
assertTrue(service.updateSkuStockIdempotent(dto).isOk());
verify(dailyMenuMapper).updateOnlineStockDelta(1L, mealDate, 1, 100L, 2, null);
verify(dailyMenuMapper).updateOnlineStockDelta(1L, mealDate, 1, 100L, 2);
verify(valueOperations).set("canteen:spu:order-stock:daily:ONLINE:HARD_DAILY_MENU_AFTER_SALE:9",
"APPLIED", Duration.ofDays(7));
}
/**
* OFFLINE 扣减:调用方 beforeStock 误传「剩余总库存」时,应归一到 AD stock 再 CAS,不得误报库存已变化。
* 例:总库存 10、线上已订 2、线下已售 3 → shared=5、AD=8;beforeStock=5 时 SQL expected 应为 8。
* 本次消费机事故回归用例:总库存 10、线上已订 2、线下已售 3(当天已有其他订单成交痕迹),
* OFFLINE 扣减 1 仍应放行——不得再因快照口径失配误报“库存已变化”。
*/
@Test
void offlineApplyShouldNormalizeSharedRemainSnapshotToAdStock() {
registerMinimalMessageSource();
void offlineApplyShouldAllowDeductAfterOtherBizSoldToday() {
LocalDate mealDate = LocalDate.of(2026, 10, 8);
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
item.setId(100L);
item.setIncrCount(-1);
item.setBeforeStock(5);
item.setMealDate(mealDate);
item.setHourType(1);
CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
@@ -190,7 +204,7 @@ class CanteenSpuServiceImplTest {
menu.setOnlineSold(2);
menu.setOfflineSold(3);
when(dailyMenuMapper.selectOne(any())).thenReturn(menu);
when(dailyMenuMapper.updateOfflineStockDelta(1L, mealDate, 1, 100L, -1, 8)).thenReturn(1);
when(dailyMenuMapper.updateOfflineStockDelta(1L, mealDate, 1, 100L, -1)).thenReturn(1);
CanteenSpuEntity entity = new CanteenSpuEntity();
entity.setId(100L);
entity.setSpuType(1);
@@ -200,26 +214,50 @@ class CanteenSpuServiceImplTest {
assertTrue(service.updateSkuStockIdempotent(dto).isOk());
verify(dailyMenuMapper).updateOfflineStockDelta(1L, mealDate, 1, 100L, -1, 8);
verify(dailyMenuMapper).updateOfflineStockDelta(1L, mealDate, 1, 100L, -1);
verify(valueOperations).set("canteen:spu:order-stock:daily:OFFLINE:HARD_DAILY_MENU_ORDER:77",
"APPLIED", Duration.ofDays(7));
}
/**
* 构造每日菜谱菜谱行实体。
*
* @param onlineStock 线上配额
* @param onlineSold 线上已售
* @return 仅包含本用例所需字段的实体
* 每日菜谱分池扣减防超卖兜底:共享剩余总库存(总库存−线上已订−线下已售)不足时仍必须拒绝。
*/
private CanteenDailyMenuEntity dailyMenu(int onlineStock, int onlineSold) {
@Test
void dailyOfflineDeductShouldRejectWhenSharedRemainInsufficient() {
registerMinimalMessageSource();
LocalDate mealDate = LocalDate.of(2026, 10, 8);
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
item.setId(100L);
item.setIncrCount(-1);
item.setMealDate(mealDate);
item.setHourType(1);
CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
dto.setBizId("HARD_DAILY_MENU_ORDER:78");
dto.setOperation("APPLY");
dto.setStockChannel("OFFLINE");
dto.setMerchantId(1L);
dto.setItems(List.of(item));
// 总库存 10、线上已订 2、线下已售 8:共享剩余 0,扣 1 应拒绝
CanteenDailyMenuEntity menu = new CanteenDailyMenuEntity();
menu.setMerchantId(1L);
menu.setSpuId(100L);
menu.setHourType(1);
menu.setOnlineStock(onlineStock);
menu.setOnlineSold(onlineSold);
return menu;
menu.setOfflineStock(10);
menu.setOnlineStock(10);
menu.setOnlineSold(2);
menu.setOfflineSold(8);
when(dailyMenuMapper.selectOne(any())).thenReturn(menu);
CanteenSpuEntity entity = new CanteenSpuEntity();
entity.setId(100L);
entity.setSpuType(1);
entity.setMerchantId(1L);
entity.setName("测试菜品");
when(spuMapper.selectBatchIds(any())).thenReturn(List.of(entity));
assertFalse(service.updateSkuStockIdempotent(dto).isOk());
verify(dailyMenuMapper, never()).updateOfflineStockDelta(anyLong(), any(), anyInt(), anyLong(), anyInt());
}
/**
@@ -227,7 +265,7 @@ class CanteenSpuServiceImplTest {
*/
@Test
void confirmedApplyRollbackShouldBecomeCompensated() {
CanteenSpuUpdateStockReqDTO dto = stockRequest("ROLLBACK", 2, 10);
CanteenSpuUpdateStockReqDTO dto = stockRequest("ROLLBACK", 2);
when(valueOperations.get("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9"))
.thenReturn("APPLIED");
when(spuMapper.selectById(1L)).thenReturn(spu(12));
@@ -257,15 +295,13 @@ class CanteenSpuServiceImplTest {
* 构造单个基础 SPU 的幂等库存请求。
*
* @param operation APPLY 或 ROLLBACK
* @param increment 原 APPLY 使用的库存增量
* @param beforeStock 当前业务尝试的操作前库存快照
* @param increment 库存增量(扣减为负数)
* @return 可直接调用库存服务的请求
*/
private CanteenSpuUpdateStockReqDTO stockRequest(String operation, int increment, int beforeStock) {
private CanteenSpuUpdateStockReqDTO stockRequest(String operation, int increment) {
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
item.setId(1L);
item.setIncrCount(increment);
item.setBeforeStock(beforeStock);
CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
dto.setBizId("HARD_DAILY_MENU_AFTER_SALE:9");
dto.setOperation(operation);
@@ -273,6 +309,23 @@ class CanteenSpuServiceImplTest {
return dto;
}
/**
* 构造每日菜谱菜谱行实体。
*
* @param onlineStock 线上配额
* @param onlineSold 线上已售
* @return 仅包含本用例所需字段的实体
*/
private CanteenDailyMenuEntity dailyMenu(int onlineStock, int onlineSold) {
CanteenDailyMenuEntity menu = new CanteenDailyMenuEntity();
menu.setMerchantId(1L);
menu.setSpuId(100L);
menu.setHourType(1);
menu.setOnlineStock(onlineStock);
menu.setOnlineSold(onlineSold);
return menu;
}
/**
* 构造当前库存快照。
*
@@ -2972,7 +2972,6 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
item.setIncrCount(Math.multiplyExact(car.getProductCount(), factor));
item.setMealDate(mealDate);
item.setHourType(hourType);
item.setBeforeStock(inventorySnapshot.get(dailyStockKey(mealDate, hourType, car.getProductId())));
items.add(item);
}
}
@@ -3049,7 +3048,6 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
item.setId(entry.getKey());
item.setIncrCount(entry.getValue());
item.setBeforeStock(inventorySnapshot.get(entry.getKey()));
return item;
}).toList());
R<?> result = onlineCanteenSpuService.updateSkuStockIdempotent(stockDto);
@@ -5155,7 +5153,6 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
item.setId(entry.getKey());
item.setIncrCount(entry.getValue());
item.setBeforeStock(inventorySnapshot.get(entry.getKey()));
return item;
}).toList());
R<?> result = remoteCanteenSpuService.updateSkuStockIdempotent(dto);
@@ -5183,7 +5180,6 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
item.setId(entry.getKey());
item.setIncrCount(entry.getValue());
item.setBeforeStock(inventorySnapshot.get(entry.getKey()));
item.setMealDate(mealDate);
item.setHourType(hourType);
return item;
@@ -5233,8 +5229,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
}
/**
* 线下快照与食堂 channelRemain(OFFLINE) 一致:AD stock = 总库存 − 线上已订。
* 不用 App 填充的 remainOfflineStock(在 listForOrderValidate 里是「剩余总库存」,会导致 beforeStock 对不齐)。
* 线下剩余(本地充足性预检口径):与食堂 channelRemain(OFFLINE) 一致,AD stock = 总库存 − 线上已订。
* 不用 App 填充的 remainOfflineStock(在 listForOrderValidate 里是「剩余总库存」,口径偏小会误拒下单)。
*/
private static int dailyOfflineRemain(DailyMenuAppVO row) {
if (row == null) {
@@ -112,7 +112,6 @@ class HardOrderServiceImplInventoryTest {
assertEquals("APPLY", request.getOperation());
assertEquals("HARD_DAILY_MENU_ORDER:100", request.getBizId());
assertEquals(-2, request.getItems().get(0).getIncrCount());
assertEquals(5, request.getItems().get(0).getBeforeStock());
}
/**
@@ -164,7 +163,6 @@ class HardOrderServiceImplInventoryTest {
assertEquals("APPLY", request.getOperation());
assertEquals("HARD_DAILY_MENU_AFTER_SALE:500", request.getBizId());
assertEquals(2, request.getItems().get(0).getIncrCount());
assertEquals(3, request.getItems().get(0).getBeforeStock());
}
/**
@@ -189,7 +187,7 @@ class HardOrderServiceImplInventoryTest {
/**
* 验证设备购买链路(/hard/hard-api/ad/create-order 的 BELOW+CANTEEN_SPU 购物车)对当日菜谱在册商品
* 扣每日菜谱 OFFLINE 池:请求带 stockChannel=OFFLINE、日期餐段和线下剩余快照,不再扣 canteen_spu.stock。
* 扣每日菜谱 OFFLINE 池:请求带 stockChannel=OFFLINE 和日期餐段,不再扣 canteen_spu.stock。
*/
@Test
void deviceCreateOrderDeductsDailyMenuOfflinePoolWhenOnMenu() {
@@ -211,7 +209,6 @@ class HardOrderServiceImplInventoryTest {
CanteenSpuUpdateStockReqDTO.Item item = request.getItems().get(0);
assertEquals(11L, item.getId());
assertEquals(-1, item.getIncrCount());
assertEquals(4, item.getBeforeStock());
assertEquals(LocalDate.of(2026, 9, 13), item.getMealDate());
assertEquals(2, item.getHourType());
verify(remoteCanteenSpuService, never()).getInnerSpuListByIds(any());
@@ -237,7 +234,6 @@ class HardOrderServiceImplInventoryTest {
CanteenSpuUpdateStockReqDTO request = captor.getValue();
assertNull(request.getStockChannel());
assertEquals(-1, request.getItems().get(0).getIncrCount());
assertEquals(9996, request.getItems().get(0).getBeforeStock());
}
/**
@@ -266,16 +262,13 @@ class HardOrderServiceImplInventoryTest {
assertEquals("HARD_DAILY_MENU_ORDER:100", dailyRequest.getBizId());
assertEquals(11L, dailyRequest.getItems().get(0).getId());
assertEquals(-1, dailyRequest.getItems().get(0).getIncrCount());
assertEquals(4, dailyRequest.getItems().get(0).getBeforeStock());
assertEquals("HARD_DAILY_MENU_ORDER:100", basicRequest.getBizId());
assertEquals(12L, basicRequest.getItems().get(0).getId());
assertEquals(-2, basicRequest.getItems().get(0).getIncrCount());
assertEquals(5, basicRequest.getItems().get(0).getBeforeStock());
}
/**
* 验证线上预定每日菜谱订单退款恢复 ONLINE 池:下单扣的是 online_sold,退款必须还同一池,
* 快照取线上剩余并携带日期餐段。
* 验证线上预定每日菜谱订单退款恢复 ONLINE 池:下单扣的是 online_sold,退款必须还同一池,并携带日期餐段。
*/
@Test
void reserveRefundRestoresDailyMenuOnlineStock() {
@@ -296,13 +289,12 @@ class HardOrderServiceImplInventoryTest {
CanteenSpuUpdateStockReqDTO.Item item = request.getItems().get(0);
assertEquals(11L, item.getId());
assertEquals(2, item.getIncrCount());
assertEquals(8, item.getBeforeStock());
assertEquals(LocalDate.of(2026, 9, 13), item.getMealDate());
assertEquals(2, item.getHourType());
}
/**
* 验证机器收银等其他类型的每日菜谱订单退款仍恢复 OFFLINE 池,快照取线下剩余。
* 验证机器收银等其他类型的每日菜谱订单退款仍恢复 OFFLINE 池。
*/
@Test
void cashierDailyMenuRefundRestoresOfflineStock() {
@@ -321,7 +313,6 @@ class HardOrderServiceImplInventoryTest {
assertEquals("OFFLINE", request.getStockChannel());
assertEquals("HARD_DAILY_MENU_AFTER_SALE:500", request.getBizId());
assertEquals(2, request.getItems().get(0).getIncrCount());
assertEquals(4, request.getItems().get(0).getBeforeStock());
}
/**
@@ -409,6 +400,8 @@ class HardOrderServiceImplInventoryTest {
/**
* 构造指定线上/线下剩余的每日菜谱行。
* 线下剩余按 hard 预检口径(AD stock = offlineStock − onlineSold)同步设置配额字段,
* 不能只填 remainOfflineStock(App 展示口径,预检不信任)。
*
* @param onlineRemain 线上池剩余
* @param offlineRemain 线下池剩余
@@ -420,6 +413,8 @@ class HardOrderServiceImplInventoryTest {
row.setName("测试菜品");
row.setRemainOnlineStock(onlineRemain);
row.setRemainOfflineStock(offlineRemain);
row.setOfflineStock(offlineRemain);
row.setOnlineSold(0);
return row;
}